# 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:** LOCKED 2026-05-07 — m signed off on all recommendations + §10 follow-ups, with one correction (Q4 narrowed from 4 shapes → 3; "activity" is a filter/source choice, not a render shape — folded into `list` shape with density config). Inventor → coder transition initiated. PR split chosen: A1 backend substrate, A2 frontend Custom Views. **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` (3). Activity-feed appearance is achieved by source/filter choice (`sources: ["project_event", …]`) rendered through `list` shape with `density: "compact"` + actor/time columns — *not* a separate shape. Defer `kanban`, `connections-graph`, `timeline-distinct-from-cards`. | Ship 4+ shapes including a dedicated "activity" — m's correction (2026-05-07): activity is content selection, not visualisation. Shape ⊥ source. | | **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` — three shapes.** m's correction (2026-05-07): activity is a content selection (sources + filters), not a render shape. The "compact one-line stream with type icons" appearance is `list` shape with `density: "compact"` + an actor/time column set — same component, different config. Shape is orthogonal to source: any source can render in any shape. | Shape | Status today | What it does | Source bias | |---|---|---|---| | **`list`** | shipped on `/events` (table), `/inbox` (`