From 5c263102e300fcd069ae5dac28dae4ced6869d55 Mon Sep 17 00:00:00 2001 From: m Date: Wed, 6 May 2026 17:25:44 +0200 Subject: [PATCH 1/3] =?UTF-8?q?design(t-paliad-144):=20data=20display=20mo?= =?UTF-8?q?del=20=E2=80=94=20additive=20Custom=20Views=20+=20render-shape?= =?UTF-8?q?=20switcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inventor pass on m's data-display rethink (m/paliad#5). Three coordinated sub-designs in one doc, scoped to m's locked direction (additive, subsume unified inbox, sidebar Meine Sichten group, in-page render-shape switcher, paliad-only). Recommended substrate: 4-source ViewService (deadline + appointment + project_event + approval_request) returning discriminated ViewRow. Recommended filter grammar: structured JSON spec with server-side validator. Recommended render shapes for v1: list / cards / calendar / activity (defer kanban, connections-graph, distinct-timeline). Recommended persistence: new paliad.user_views table (migration 056), RLS-bounded to caller, code- resident system defaults (no is_system flag). Phasing: Phase A ships substrate + Custom Views standalone (~3400 LoC, no system page changes); Phase B refactors /agenda/events/inbox/dashboard internals onto the substrate later. Coexists transparently with t-139 (hierarchy aggregation in flight on noether's other branch) and t-138 (approvals shipped). 27 questions answered (18 from issue body §1–§5 + 9 inventor follow-ups in §10 for m's call before coder shift). --- docs/design-data-display-model-2026-05-06.md | 867 +++++++++++++++++++ 1 file changed, 867 insertions(+) create mode 100644 docs/design-data-display-model-2026-05-06.md diff --git a/docs/design-data-display-model-2026-05-06.md b/docs/design-data-display-model-2026-05-06.md new file mode 100644 index 0000000..d1599ad --- /dev/null +++ b/docs/design-data-display-model-2026-05-06.md @@ -0,0 +1,867 @@ +# Design: Data display model — additive Custom Views layer + unified inbox subsume + render-shape switcher + +**Task:** t-paliad-144 +**Issue:** m/paliad#5 +**Author:** noether (inventor) +**Date:** 2026-05-06 +**Status:** DRAFT — awaiting m's go/no-go on §10 open questions +**Branch:** `mai/noether/inventor-data-display` +**Builds on:** t-paliad-109 (events unification, shipped) + t-paliad-138 (approvals, shipped) + t-paliad-139 (hierarchy aggregation, all 3 phases on `mai/noether/inventor-project` awaiting merge gate) + +--- + +## 0. Premise check (read this first) + +The issue body asks for a unified data-display model. Three premises in the brief that I verified against the live tree on this worktree before designing on top of them: + +| Premise | Live state | Verdict | +|---|---|---| +| `EventService` is already a 2-source union over `paliad.deadlines` + `paliad.appointments` | `internal/services/event_service.go` lines 40–193 — `ListVisibleForUser` runs the deadline path then the appointment path then merges in Go, sorted by `event_date` | **confirmed**; substrate exists in miniature today | +| `/agenda` is a separate timeline service, not the same code path | `internal/services/agenda_service.go` lines 78–128 — `AgendaService.List` independently joins deadlines + appointments. Different SQL, different projection (`AgendaItem` vs `EventListItem`), different urgency annotation. | **confirmed**; we have *two* substrates already, both 2-source. Generalising means picking one and retiring the other (or keeping both temporarily). | +| `/inbox` is a 4-eye approval surface, not a generic activity feed | `frontend/src/inbox.tsx` (61 lines) + `internal/services/approval_service.go` lines 730–810 — two-tab UI ("Zur Genehmigung", "Meine Anfragen") backed by `ListPendingForApprover` / `ListSubmittedByUser`. | **confirmed**; today's `/inbox` is approval-only, not the unified-inbox concept m's brainstorm describes | +| t-paliad-139 Phase 2 schema (migration 055) is incoming but not on main | Migration file exists at `internal/db/migrations/055_hierarchy_aggregation.up.sql`; per noether's prior memory, all 3 phases are stacked on `mai/noether/inventor-project` awaiting merge gate. | **confirmed**; this design must compose on top of 055's `paliad.project_partner_units` + `derive_grants_authority` model without forcing 139 to re-land | +| `paliad.project_events` carries audit kinds (`project_created`, `status_changed`, `project_archived`, `project_reparented`, …) | `internal/services/project_service.go` lines 491–805 — five `insertProjectEvent` call-sites today; `event_type` column is free-text. | **confirmed**; `project_events` is the natural fourth data source for "what happened on my projects?" | + +So the premises that anchor the design are sound. One correction to the issue body itself worth flagging: + +> the issue body lists `paliad.deadlines`, `paliad.appointments`, `paliad.project_events`, `paliad.approval_requests` as the four current data tables. + +That is right, but `event_service.go` only unions the **first two**. The Verlauf surface on `/projects/{id}` (project_events) and the inbox surface (approval_requests) are *each* their own bespoke endpoint today. The design below makes all four first-class `data_source` values in the substrate; flagging that the existing `EventService` will need to grow, not stay frozen. + +--- + +## 1. m's intent (as I read it) + +> "Custom views with saving them. […] If they could customize their view like 'myVerySpecialAgenda' with criteria and view options (filters, type of view — calendar vs cards vs list) and turn on parts — and then those views would be shown in the sidenavbar under a separate button. And on the page, the user can select all kinds of visuals." + +Plus the locked direction of 2026-05-06 16:42: + +- **Additive.** Fixed defaults stay; Custom Views ship alongside. +- **Subsume the unified inbox.** Approval candidates + project activity + new cases + status changes — all viewable through the same substrate, with configurable granularity. +- **Sidebar layout:** separate "Meine Sichten" group. +- **In-page render-shape switcher.** +- **paliad-only scope.** + +Three design pieces fall out of this: + +1. **A substrate** — one read API that returns rows from N data sources, filterable by one shared grammar. +2. **A render layer** — a small set of presentation components (List, Cards, Calendar, Activity) that all consume the substrate's row shape. +3. **A persistence + sidebar story** — `paliad.user_views` + a "Meine Sichten" group + URL contract `/views/{slug}`. + +§§3–5 cover those three. §6 covers cross-cutting concerns (RLS, performance, migration). §10 lists open questions for m to answer before coder shift. + +--- + +## 2. Recommended design (TL;DR) + +| Area | Recommendation | Smallest-diff alternative considered & rejected | +|---|---|---| +| **Substrate shape** | One `ViewService` (new) that union-loads from 4 data sources: `deadline`, `appointment`, `project_event` (audit), `approval_request`. Returns a discriminated `[]ViewRow` keyed by `kind`. | Single virtual SQL `view_row` table with UNION ALL across all 4 — too many polymorphic columns; harder to evolve per-source filters. | +| **Filter grammar** | Structured JSON spec validated server-side (`FilterSpec`). UI builds it via affordance widgets; the JSON is also human-editable for power users. | SQL DSL (security risk + complexity); UI-only (forces every dimension to have a widget). | +| **Render shapes for v1** | `list`, `cards`, `calendar`, `activity` (4). Defer `kanban`, `connections-graph`, `timeline-distinct-from-cards`. | Ship all 6 — `/events` already has list/cards/calendar; `cards` doubles as the day-grouped timeline today. Activity is the unified-inbox shape. The deferred three each need substantial new component work. | +| **Persistence** | New table `paliad.user_views` (id, user_id, slug, name, filter_spec jsonb, render_spec jsonb, sort_order, icon, last_used_at, …). RLS = caller's own rows only. | Per-user JSON column on `paliad.users` — kills the sidebar count badge query path (`SELECT count(*) WHERE user_id`); also no indexed sort. | +| **System defaults — code or DB?** | **Code.** Defaults stay as their own pages (`/dashboard`, `/agenda`, `/events`, `/inbox`); they are *built using the same render components* the custom-view system uses. No `is_system=true` row in `user_views`. | Seed system rows per user — drifts on schema bumps; new users miss bumps; `is_system=true` is a synonym for "config-as-data when config-as-code is cleaner". | +| **Sidebar** | New "Meine Sichten" group between "Arbeit" and "Werkzeuge". Each saved view appears as one nav entry (icon + name). One trailing "+ Neue Sicht" entry. | "Meine Sichten" as a single sidebar entry expanding to a panel — extra click cost on every navigation. | +| **In-page render-shape switcher** | A 4-button switcher on every view page (system + custom). Same component already exists on `/events` (cards/list/calendar). Generalise + add `activity`. | Per-route hardcoded shape — fights m's intent ("user can select all kinds of visuals"). | +| **URL contract** | `/views/{slug}` for custom views (slug is user-scoped). System views keep their existing URLs. Filter overrides via query params, transient (don't mutate stored spec). | UUID URLs (`/views/{uuid}`) — unsharable, unbookmarkable. | +| **`/inbox` page** | Stays as a fixed sidebar entry at the same URL. **Internally** refactored to use the new substrate as its read path, but the UI + URL stay. | Refactor /inbox away — needless break for users + email links. The locked direction is "subsume the inbox concept", which I read as substrate sharing, not URL retirement. | +| **Approval-candidate visibility** | Approval requests are their own `data_source`; an inbox-shaped view picks `sources: ["approval_request"]`. Pending pills on entity rows are a separate concern (already shipped via `entity.approval_status='pending'`). | Predicate-only — collapses two genuinely-different shapes (the request row vs the entity row). | +| **Migration / coexistence** | **Phase A:** ship substrate + render components + Custom Views + `paliad.user_views`. Existing pages untouched. **Phase B (later, separate task):** refactor system pages internally to use the substrate. | Refactor system pages in the same PR — bigger blast radius; harder to roll back. | +| **Performance v1** | Run on every load. Cursor pagination (`event_date` + `id` tiebreaker). No materialised views. Add per-source row caps later if telemetry says so. | Materialised view per saved view — refresh complexity, drift risk, doesn't help the first load. | + +The rest of this doc is the detail behind those rows. + +--- + +## 3. Section A — Substrate: data sources + filter grammar (Q1–Q3, Q13) + +### Q1 — What's the fundamental row? + +**Recommendation: discriminated `ViewRow` projection over an explicit data-source registry.** + +```go +// internal/services/view_service.go (new) + +type DataSource string + +const ( + SourceDeadline DataSource = "deadline" + SourceAppointment DataSource = "appointment" + SourceProjectEvent DataSource = "project_event" // audit / Verlauf + SourceApprovalRequest DataSource = "approval_request" // 4-eye inbox +) + +// ViewRow is the union shape served by the substrate. The shape is +// projection-stable: every source fills the common header fields; type- +// specific fields hang off `Detail` as a discriminated payload. +type ViewRow struct { + Kind DataSource `json:"kind"` // discriminator + ID uuid.UUID `json:"id"` // source-row id + Title string `json:"title"` // display title + Subtitle *string `json:"subtitle,omitempty"` // short context line + EventDate time.Time `json:"event_date"` // canonical sort key + + // Project context — every row in paliad has a project (approval_requests + // and project_events are project-attached by definition; deadlines and + // appointments may be personal but inherit project context when set). + ProjectID *uuid.UUID `json:"project_id,omitempty"` + ProjectTitle *string `json:"project_title,omitempty"` + ProjectReference *string `json:"project_reference,omitempty"` + ProjectType *string `json:"project_type,omitempty"` + + // Actor — who created this row (deadline/appointment) or who acted + // on it (project_event author, approval_request requester). + ActorID *uuid.UUID `json:"actor_id,omitempty"` + ActorName *string `json:"actor_name,omitempty"` + + // Detail carries the source-specific payload the render layer reads + // when it needs more than the header (e.g. cards render the deadline + // status pill, the calendar renders the appointment time range, the + // activity feed renders the audit description). + Detail json.RawMessage `json:"detail"` // shape determined by `kind` +} +``` + +`Detail` is a per-source typed Go struct (`DeadlineDetail`, `AppointmentDetail`, `ProjectEventDetail`, `ApprovalRequestDetail`) marshalled via `json.RawMessage` so the row stays a single struct on the wire. The frontend type-narrows on `kind`. + +Why a registry over a single virtual SQL view: + +- The four source tables have **truly disjoint columns** — deadline has `due_date` and `rule_code`, appointment has `start_at`/`end_at`/`location`, project_event has `event_type` (free text) + `metadata jsonb`, approval_request has `lifecycle_event` + `requested_at`. A `UNION ALL` materialised view ends up with ~40 nullable columns, half of them per row. +- Per-source filtering is fundamentally different — deadline filters look at `status`, appointment filters look at `appointment_type`, project_event filters look at `event_type`, approval_request filters look at `lifecycle_event` + `status`. Translating those into one CHECK-style filter grammar is harder than running per-source SQL paths and merging. +- The substrate already exists in miniature today — `event_service.go` line 114 union-loads two sources and merges in Go. Generalising to four sources is the same shape, more code, no new architectural concept. + +### Q2 — Filter grammar shape + +**Recommendation: structured JSON spec, validated server-side, exposed to the UI as predicates.** + +```json +{ + "version": 1, + "sources": ["deadline", "appointment", "project_event", "approval_request"], + + "scope": { + "projects": "all_visible", + "personal_only": false + }, + + "time": { + "horizon": "next_30d", + "field": "auto" + }, + + "predicates": { + "deadline": { + "status": ["pending"], + "approval_status": ["approved", "pending", "legacy"], + "event_types": [], + "include_untyped": true + }, + "appointment": { + "approval_status": ["approved", "pending", "legacy"], + "appointment_types": [] + }, + "project_event": { + "event_types": [ + "project_created", "status_changed", "project_archived", + "deadline_created", "appointment_created", "approval_decided" + ] + }, + "approval_request": { + "viewer_role": "approver_eligible", + "status": ["pending"], + "entity_types": ["deadline", "appointment"] + } + } +} +``` + +The shape: + +- **`sources`** — one or more `DataSource` values. Drives which per-source SQL paths run. +- **`scope.projects`** — `"all_visible"` (default — RLS-bounded) | `"my_subtree"` (semantic: caller's direct/derived staffing tree) | `[...]` (explicit list, RLS still applies). +- **`scope.personal_only`** — narrows deadline + appointment to caller-created rows; ignored for project_event + approval_request (where actor scoping is already implicit). +- **`time.horizon`** — `"any"` | `"next_7d"` | `"next_30d"` | `"next_90d"` | `"past_30d"` | `"past_90d"` | `"all"` | `{from, to}` literal range. `"auto"` for the date field means each source picks: deadline → `due_date`, appointment → `start_at`, project_event → `created_at`, approval_request → `requested_at` (or `decided_at` if status is decided). +- **`predicates.`** — per-source narrowing (status, types, eligibility). Empty / missing = no narrowing. + +Validation lives in Go: a `ValidateFilterSpec(spec)` function rejects unknown fields, unknown enum values, conflicting combos (`personal_only=true` + explicit `projects` list → error). The UI never sends raw user-typed JSON; it composes the spec from widget state. A "Show JSON" reveal is available in the editor for power users — but the same validator runs on POST. + +Three options considered: + +| Option | Power | Risk | Verdict | +|---|---|---|---| +| **JSON predicate spec (recommended)** | High — every dimension addressable | Schema drift → validator bug | ✅ | +| SQL-fragment DSL (`WHERE status='pending' AND …`) | Highest | Injection, RLS-bypass risk; needs a parser | ✗ | +| UI-only, no spec language | Lowest | Every new dimension = UI work + DB migration | ✗ | + +### Q3 — Granularity dimensions + +m's brainstorm called out: my projects / specific projects / newly added cases / newly added events / changes to events / approved-vs-unapproved / time horizon / event type / role-perspective. + +The full dimension set, mapped to the spec: + +| Dimension | Where it lives in `FilterSpec` | UI affordance | Notes | +|---|---|---|---| +| My projects | `scope.projects = "my_subtree"` | toggle | semantic, resolved at query time via t-139 derivation predicate | +| Specific projects | `scope.projects = [...]` | multi-select | RLS still applies; rows from inaccessible projects are silently filtered (Q17) | +| Personal-only | `scope.personal_only = true` | toggle | mutually exclusive with `projects` (server enforces) | +| Newly added cases | `sources: ["project_event"]` + `predicates.project_event.event_types: ["project_created"]` + `time.horizon` | source toggle + event-type chip group | same shape captures status_changed, project_archived | +| Newly added events | `sources: ["deadline","appointment"]` + `time.horizon` + `time.field = "created_at"` | source toggles + time-field selector | the `created_at` rather than `due_date`/`start_at` view | +| Changes to events | `sources: ["project_event"]` + `predicates.project_event.event_types: ["deadline_*","appointment_*"]` | event-type chips | project_events already audit deadline + appointment lifecycle (verified via existing emit sites) | +| Approval status of entities | `predicates.deadline.approval_status` + `predicates.appointment.approval_status` | tri-state chip | reflects the entity-side `approval_status` column | +| Approval lifecycle (the requests themselves) | `sources: ["approval_request"]` + `predicates.approval_request.status` + `predicates.approval_request.viewer_role` | source toggle + role chip | Q13 — the inbox shape | +| Time horizon | `time.horizon` + optional `{from, to}` | range chips + date pickers | shared across all sources | +| Event type (deadline) | `predicates.deadline.event_types` | multi-select | reuses existing `paliad.event_types` registry | +| Appointment type | `predicates.appointment.appointment_types` | multi-select | hearing/meeting/consultation/deadline_hearing | +| Project event kind | `predicates.project_event.event_types` | multi-select | free-text today; we'll need a curated list (§10 Q19) | +| Role-perspective | implicit — every query is "from caller's viewpoint" | n/a | not a filter; visibility predicate is the user identity | + +Hidden defaults vs UI affordances: + +- **Hidden** — `version`, `time.field` (`"auto"` is the default), per-source `include_untyped`, validator branches. +- **First-class UI** — sources, scope, time horizon, status, event_type/appointment_type/project_event_kind, approval status. +- **Power-only** (revealed in JSON editor) — explicit `{from, to}` ranges beyond the chip set, `time.field` override. + +### Q13 — Approval candidates: predicate or source? + +**Recommendation: source (`approval_request`).** + +Reasoning: the approval_requests table has fundamentally different columns (`lifecycle_event`, `pre_image`, `payload`, `requested_by`, `decision_kind`, `decided_at`) than deadline/appointment, and the inbox UI renders different things (requester avatar, "Approve / Reject" buttons, decision history). Forcing this into a predicate on deadline/appointment rows means either: + +- (a) hiding the request rows entirely — but then "show me pending approvals" is impossible to express, or +- (b) hydrating every deadline row with its pending-request payload — bloats the row shape, kills the "approval_status pill" abstraction. + +By making it a source: + +- `sources: ["approval_request"]` is the *inbox shape* — list of pending requests, decided requests, etc. +- `predicates.deadline.approval_status: ["pending"]` is the *entity shape* — list of deadlines that have a pending request (good for "show me my deadlines that are blocked on someone else's approval"). + +These are genuinely two views; the substrate exposes both. + +--- + +## 4. Section B — Render shapes + view authoring UX (Q4–Q6, Q11–Q12, Q16) + +### Q4 — Which render shapes are first-class for v1? + +**Recommendation: `list`, `cards`, `calendar`, `activity` — four shapes.** + +| Shape | Status today | What it does | Source bias | +|---|---|---|---| +| **`list`** | shipped on `/events` (table) and `/inbox` (`