m's lock-in 2026-05-07: agree with all recommendations on Q1-Q18 and §10 Q19-Q27, with one correction on Q4: "activity" is a content selection (sources + filters), not a render shape. Folded into `list` shape with density: "compact" + actor/time columns. Shape ⊥ source — any source can render in any shape. Render shapes for v1: list / cards / calendar (3, was 4). PR split decision (delegated to inventor): A1 backend substrate + API (no UI change, ~1800 LoC, smoke via curl) → main → A2 frontend Custom Views UI (~1600 LoC, additive on A1) → main. Status flipped DRAFT → LOCKED. Inventor → coder transition initiated.
889 lines
59 KiB
Markdown
889 lines
59 KiB
Markdown
# 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) | `[<uuid>...]` (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.<source>`** — 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` (`<ul class="inbox-list">`), `/dashboard` activity feed | One row per result; columns vary per source. Table for desktop, stacked card-rows on mobile. Density modes: `comfortable` (default, full table) / `compact` (one-line stream — the activity-feed look). | source-agnostic |
|
||
| **`cards`** | shipped on `/agenda` (day-grouped timeline) | Day-grouped chronological cards; primary date drives grouping. The unified-inbox-feel m described — *when fed activity-style content*. | source-agnostic |
|
||
| **`calendar`** | shipped on `/events?view=calendar` | Month grid (toggleable to week). Shows up to N pills per day. Click → popup with the day's rows. | works best for time-bound sources (deadline, appointment, project_event) |
|
||
|
||
How "activity feed" is expressed in this model:
|
||
- **Filter side**: `sources: ["project_event", "approval_request"]`, `time.horizon: past_30d`, `time.field: created_at`.
|
||
- **Render side**: `shape: "list"`, `list.density: "compact"`, `list.columns: ["time", "actor", "title", "project"]`.
|
||
|
||
That same `list` shape — with `density: "comfortable"` + the deadline column set — also powers `/events`. One component, two configs. Same logic for `cards`: the day-grouped Verlauf on `/projects/{id}` and a "newest cases this week" card view share the component.
|
||
|
||
Defer to v2: `kanban` (no obvious column axis across mixed sources), `connections-graph` (the events↔files visualisation referenced in the issue body — that's specifically about graph rendering, which is a 5x bigger component and works better as its own page than as a saved-view shape), `timeline-distinct-from-cards` (a horizontal Gantt would be the natural shape but adds a lot for marginal value at v1).
|
||
|
||
Why these three and not all six: each shape is a real frontend component with empty states, error states, layout, density toggles, mobile behaviour. We have three already shipped today, generalising them costs little. Adding `kanban` + `graph` is each its own component-week. Better to ship 3 polished than 6 half-baked.
|
||
|
||
### Q5 — Per-shape config
|
||
|
||
**Recommendation: shape config lives alongside filter spec in `render_spec`, keyed by shape.**
|
||
|
||
```json
|
||
{
|
||
"shape": "list",
|
||
"list": { "columns": ["date", "title", "project", "status"], "sort": "date_asc", "density": "comfortable" },
|
||
"cards": { "group_by": "day", "sort": "date_asc", "show_empty_days": false },
|
||
"calendar": { "default_view": "month", "show_weekends": true }
|
||
}
|
||
```
|
||
|
||
The user picks one `shape`; the matching config block is read at render time. Other shape configs are kept (so flipping back to a previously-used shape preserves its tweaks).
|
||
|
||
UI: the shape switcher is a **3-button row** at the top of every view page. Right of it, a small "Shape settings" gear opens a modal with the per-shape knobs. Most users never touch the gear.
|
||
|
||
Default values per shape:
|
||
|
||
- `list.columns` = source-determined (deadline view = date/title/rule/status; appointment view = date/title/location/type; activity-feel view = time/actor/title — auto-selected when sources are activity-flavoured)
|
||
- `list.density` = `"comfortable"` for entity sources, `"compact"` when sources include project_event or approval_request
|
||
- `list.sort` = `"date_asc"` for forward-looking views, `"date_desc"` for retrospective
|
||
- `cards.group_by` = `"day"`
|
||
- `calendar.default_view` = `"month"`
|
||
|
||
### Q6 — Empty state per view
|
||
|
||
**Recommendation: filter-aware empty states. Render component receives the resolved `FilterSpec` and produces a guidance line.**
|
||
|
||
Generic shape:
|
||
|
||
> **Keine Einträge gefunden.**
|
||
> Sicht: *{view name}* — {N} Filter aktiv (*Zeitraum: nächste 7 Tage, Status: offen*).
|
||
> Vorschläge: [Zeitraum erweitern] [Filter zurücksetzen]
|
||
|
||
The component derives the human-readable filter summary from the spec. For specific known patterns:
|
||
|
||
- All-empty across sources + horizon `next_7d` → "Nichts in den nächsten 7 Tagen — versuchen Sie 30 Tage."
|
||
- Sources picked but all 0 in 90d → "Keine Daten für diese Quellen — Sicht eventuell zu eng."
|
||
- Project filter set but project has no team → already handled at API layer (Q17).
|
||
|
||
Empty-state strings live in i18n; the view name + filter summary are interpolated at render time.
|
||
|
||
### Q11 — Where do you create a view?
|
||
|
||
**Recommendation: both, with the inline path as primary.**
|
||
|
||
Two creation paths:
|
||
|
||
1. **Inline "save current filters as a Sicht"** (primary) — on any view page (system or existing custom), once the user has tweaked the filter spec away from the saved baseline, a "Speichern als Sicht" button appears in the toolbar. Click → modal asks for name + icon + sidebar position + render shape (defaults to current). Save → POST `/api/user-views` → sidebar refreshes → user is now on the new view. The same modal on an existing custom view shows a "Save changes / Save as new" pair.
|
||
|
||
2. **Full editor at `/views/new`** (secondary) — for the power case where the user wants to build a Sicht from a blank slate. Same modal fields, plus a JSON view of the filter spec for power users. Edit existing at `/views/{slug}/edit`.
|
||
|
||
Why both:
|
||
|
||
- The inline path covers the 90% case ("I tweaked the inbox to show only my projects, save it") with one click.
|
||
- The full editor covers the 10% case where the user knows what they want but isn't currently looking at the right starting point ("I want a view of all approval-decided rows in the last 90 days").
|
||
|
||
Critically, **the inline path teaches the full editor** — both render the same form component.
|
||
|
||
### Q12 — Default-first onboarding
|
||
|
||
**Recommendation: empty + tutorial card on the first visit. No seeded examples.**
|
||
|
||
When a user with zero saved views clicks "Meine Sichten" or visits `/views`, they see:
|
||
|
||
> **Eigene Sichten — was ist das?**
|
||
> Eine Sicht ist eine gespeicherte Filterkombination — z.B. "Fristen meiner Projekte in den nächsten 14 Tagen". Sichten erscheinen als eigene Buttons in der Sidebar.
|
||
> [Beispiel-Sicht erstellen ▶] [Aus aktueller Seite speichern ▶]
|
||
|
||
The first button drops the user into the editor pre-populated with a sensible starter (e.g. "Activity feed for my subtree, last 30 days"). The second is contextual — only appears if the user has been on a system page recently (tracked client-side).
|
||
|
||
Why no seeded rows: seeded examples become orphan-confusion later ("did I make this Freitag-Stand thing? when?"). A dismissible tutorial card is cheaper to maintain and clearer about ownership.
|
||
|
||
### Q16 — URL contract
|
||
|
||
**Recommendation: `/views/{slug}` for custom views, slug user-scoped. System views keep their existing URLs.**
|
||
|
||
- **`/views/{slug}`** — slug is unique per `(user_id, slug)`. Slug is friendly: `freitag-stand`, `approvals-pending-mine`, `siemens-aktivitaet`. No UUIDs in URLs.
|
||
- **`/views/new`** — creation editor.
|
||
- **`/views/{slug}/edit`** — edit existing.
|
||
|
||
Filter overrides via query params:
|
||
|
||
- `/views/freitag-stand?from=2026-05-10&to=2026-05-17` — overrides the saved time horizon for this load only. Doesn't mutate the stored spec.
|
||
- `/views/freitag-stand?shape=calendar` — overrides the saved render shape for this load only.
|
||
|
||
Override params follow the same validator as the stored spec; unknown params are ignored.
|
||
|
||
System views — `/dashboard`, `/agenda`, `/events`, `/inbox` — keep their URLs. They never become `/views/dashboard` (a slug collision the validator must reject — slug `dashboard` is reserved).
|
||
|
||
---
|
||
|
||
## 5. Section C — Persistence + sidebar + system-vs-user-view shape (Q7–Q10, Q14, Q15, Q17, Q18)
|
||
|
||
### Q7 — Schema for `paliad.user_views`
|
||
|
||
**Recommendation:**
|
||
|
||
```sql
|
||
CREATE TABLE paliad.user_views (
|
||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||
|
||
-- Stable user-facing identifier. Goes into the URL. Validated:
|
||
-- ^[a-z0-9][a-z0-9-]{0,62}$ with reserved-list rejection (dashboard,
|
||
-- agenda, events, inbox, new, edit, …).
|
||
slug text NOT NULL,
|
||
|
||
-- Display name. Free-form; no enforced i18n (the user picks the language
|
||
-- they think in). Sidebar renders it verbatim; no fallback or translation.
|
||
name text NOT NULL,
|
||
|
||
-- One of a fixed set of icon keys (see frontend/src/components/Sidebar.tsx
|
||
-- icon registry). NULL → default icon (folder).
|
||
icon text,
|
||
|
||
-- Filter spec (§3 Q2). Validated on write.
|
||
filter_spec jsonb NOT NULL,
|
||
|
||
-- Render spec (§4 Q5). Validated on write.
|
||
render_spec jsonb NOT NULL,
|
||
|
||
-- Sidebar ordering. Lower-first. Server defaults to MAX+1 on insert so
|
||
-- new views land at the bottom; the editor lets the user drag-reorder.
|
||
sort_order int NOT NULL DEFAULT 0,
|
||
|
||
-- Show a row-count badge on the sidebar entry (like /inbox today).
|
||
-- Costs one COUNT(*) per saved view per badge refresh; opt-in.
|
||
show_count boolean NOT NULL DEFAULT false,
|
||
|
||
-- "Most-recently-used" landing (Q10). PATCH on every view-load (cheap).
|
||
last_used_at timestamptz,
|
||
|
||
created_at timestamptz NOT NULL DEFAULT now(),
|
||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||
|
||
UNIQUE (user_id, slug)
|
||
);
|
||
|
||
CREATE INDEX user_views_owner_idx
|
||
ON paliad.user_views (user_id, sort_order);
|
||
|
||
ALTER TABLE paliad.user_views ENABLE ROW LEVEL SECURITY;
|
||
|
||
CREATE POLICY user_views_owner_all
|
||
ON paliad.user_views FOR ALL
|
||
USING (user_id = auth.uid())
|
||
WITH CHECK (user_id = auth.uid());
|
||
|
||
-- updated_at autoset trigger reusing existing paliad.set_updated_at().
|
||
CREATE TRIGGER user_views_updated_at
|
||
BEFORE UPDATE ON paliad.user_views
|
||
FOR EACH ROW EXECUTE FUNCTION paliad.set_updated_at();
|
||
```
|
||
|
||
Notes on the shape:
|
||
|
||
- **No `is_system` flag** — system views are code-resident (Q8), not seeded rows. Keeps the table strictly user-owned.
|
||
- **`filter_spec`/`render_spec` as `jsonb`** — Postgres validates only structural well-formedness; the application layer (`ValidateFilterSpec` + `ValidateRenderSpec`) enforces semantic constraints at write time. Storing the parsed shapes as columns would force a schema migration per new dimension.
|
||
- **No cross-user sharing column** — explicit `OUT OF SCOPE` per the issue body. If sharing lands later, add a separate `user_view_shares (view_id, target_user_id, can_edit)` table.
|
||
- **Slug uniqueness scoped to user** — two users can both have a view called `freitag-stand`; URL is `/views/freitag-stand` and resolves against `auth.uid()`.
|
||
|
||
Migration shape: new file `056_user_views.up.sql`. Standalone — no dependencies on 055's schema beyond `paliad.users` (which is in 002). 056 can land before 055 lands on main if needed.
|
||
|
||
### Q8 — System views: code or DB?
|
||
|
||
**Recommendation: code-resident.** Defaults stay as their own pages; their handlers continue to render their existing TSX shells; their data path is the substrate.
|
||
|
||
```go
|
||
// internal/services/system_views.go (new)
|
||
|
||
// SystemView is a code-resident view definition. Used by the substrate
|
||
// when a system page (dashboard, agenda, events, inbox) needs to resolve
|
||
// its data through the unified pipeline.
|
||
type SystemView struct {
|
||
Slug string // "dashboard" | "agenda" | "events" | "inbox" — matches URL
|
||
Filter FilterSpec // canonical spec the page resolves to today
|
||
Render RenderSpec // canonical render shape
|
||
Reserved bool // if true, slug is unavailable for user views (true for all 4)
|
||
}
|
||
|
||
func DashboardSystemView() SystemView { /* …multi-section, special-cased… */ }
|
||
func AgendaSystemView() SystemView { /* sources: deadline+appointment, shape: cards, horizon: 30d */ }
|
||
func EventsSystemView() SystemView { /* sources: deadline+appointment, shape: list, configurable */ }
|
||
func InboxSystemView() SystemView { /* sources: approval_request, viewer_role: approver_eligible, shape: list */ }
|
||
```
|
||
|
||
Tradeoff (config-as-code vs config-as-data):
|
||
|
||
| Axis | Code (recommended) | DB seed |
|
||
|---|---|---|
|
||
| Ships with releases | ✅ atomic with code | ✗ requires per-user backfill |
|
||
| New users get latest | ✅ always | ✗ depends on seed timing |
|
||
| User-editable | ✗ — system views deliberately frozen | ✅ — but then "system" is meaningless |
|
||
| Drift risk | none | high (schema bump → seeded rows go stale) |
|
||
| Validator complexity | one path | two paths (code path + seed path) |
|
||
|
||
The locked direction is "additive — fixed defaults stay alongside Custom Views". I read that as: defaults are *not* user-editable; the user can build a custom view that mimics a default if they want a tweaked version. Config-as-code matches that intent exactly.
|
||
|
||
Dashboard is the awkward one — it's not a single saved view, it's a multi-section page (5-bucket summary + matter card + 2-column lists + activity feed). The recommendation is: keep `/dashboard` as a bespoke page composed of *several* internal queries, each of which can resolve to a `SystemView` later. Don't try to express the dashboard as one SystemView; that's the wrong abstraction.
|
||
|
||
### Q9 — Sidebar layout
|
||
|
||
**Recommendation:** new "Meine Sichten" group between "Arbeit" and "Werkzeuge".
|
||
|
||
```
|
||
Übersicht:
|
||
Dashboard
|
||
Agenda
|
||
Inbox [3]
|
||
Team
|
||
|
||
Arbeit:
|
||
Projekte
|
||
Fristen
|
||
Termine
|
||
|
||
Meine Sichten: ← new group
|
||
Freitag-Stand [12]
|
||
Approval-Pending-Mine
|
||
Siemens-Aktivität
|
||
+ Neue Sicht ← always-last entry
|
||
|
||
Werkzeuge: …
|
||
Wissen: …
|
||
Ressourcen: …
|
||
Einstellungen: …
|
||
Admin: …
|
||
```
|
||
|
||
Layout decisions:
|
||
|
||
- **Position**: between Arbeit and Werkzeuge — close to the work flow, before the tools/knowledge sections. m's brainstorm placed it as "a separate button" but didn't pin top vs bottom; this position keeps it in the work-context band.
|
||
- **Group label**: "Meine Sichten" / "My Views" — i18n key `nav.group.user_views`.
|
||
- **Empty group**: if the user has zero saved views, the group still renders, with only the "+ Neue Sicht" entry inside. That makes the feature discoverable; the alternative (hide empty group) buries it.
|
||
- **Per-entry icon**: from a fixed registry of ~20 icons (folder, calendar, clock, bell, files, users, …) reused from the existing sidebar SVG set. Default = folder.
|
||
- **Per-entry badge**: shown when `show_count=true` on the saved view. Server returns the count via `/api/user-views?include_count=true`; the same client refresh interval as `/api/inbox/count` (~60s). Badge is the count of currently-matching rows — same shape as the inbox bell today.
|
||
- **Drag-reorder**: the editor lets users drag entries; click-to-edit on hover.
|
||
- **Mobile**: the bottom-nav shows fixed entries only (Übersicht items) — saved views are accessible via the burger drawer. Otherwise the bottom-nav fills up the moment a power user has 5 saved views.
|
||
|
||
### Q10 — Default landing
|
||
|
||
**Recommendation: most-recently-used.**
|
||
|
||
When the user clicks "Meine Sichten" (the group label, not a specific entry), they navigate to `/views`, which resolves to:
|
||
|
||
- If `last_used_at` is set on any view → 302 to that view's URL.
|
||
- If no view has `last_used_at` → render the onboarding card (Q12).
|
||
|
||
`last_used_at` is updated on every view-load via a fire-and-forget PATCH `/api/user-views/{id}/touch`. Cheap; no UI latency.
|
||
|
||
Alternative (always-default to first by sort_order) was considered — feels less helpful (the user sorted by what they want to see *most easily*, but might not be visiting *most often*). Most-recently-used reflects actual workflow.
|
||
|
||
### Q14 — `/inbox` page
|
||
|
||
**Recommendation: stays as a fixed sidebar entry. Internally refactored to use the substrate.**
|
||
|
||
Three paths considered:
|
||
|
||
| Path | Pros | Cons |
|
||
|---|---|---|
|
||
| Keep `/inbox` as today, no internal change | zero migration risk | duplicate read path; "subsume" goal not met |
|
||
| **Refactor `/inbox` to use the substrate (recommended)** | one read path; future enhancements lift everyone | small migration effort |
|
||
| Retire `/inbox`, ship as a Custom View | cleanest concept | breaks every email link; users with the URL bookmarked get 404 |
|
||
|
||
The recommendation refactors `/inbox` internally but keeps the URL + sidebar entry. Concretely:
|
||
|
||
- The two-tab UI ("Zur Genehmigung" / "Meine Anfragen") on `/inbox` becomes two `SystemView` definitions:
|
||
- `InboxApproverView`: `sources: ["approval_request"]`, `predicates.approval_request: {viewer_role: "approver_eligible", status: ["pending"]}`, `render.shape: "list"`.
|
||
- `InboxRequesterView`: `sources: ["approval_request"]`, `predicates.approval_request: {viewer_role: "self_requested"}`, `render.shape: "list"`.
|
||
- The `/inbox` handler resolves to one of these depending on the active tab; the data path goes through `ViewService.Run(ctx, userID, spec)`.
|
||
- The frontend keeps the existing two-tab UI; the per-row card markup also stays (the substrate's `list` shape with `kind="approval_request"` knows how to render approval rows including approve/reject buttons).
|
||
- The `nav.inbox` sidebar entry stays; the bell badge keeps reading from `ApprovalService.PendingCountForUser`.
|
||
|
||
This satisfies the "subsume the unified-inbox concept" goal: any user can build a Custom View that picks `approval_request` as one source plus `project_event` as another, and gets the unified-inbox feel m's brainstorm described — without losing the dedicated `/inbox` shortcut.
|
||
|
||
### Q15 — Existing fixed pages: reroute or stay independent?
|
||
|
||
**Recommendation: phased.** Phase A (this design's implementation) leaves system pages independent; Phase B (separate later task) refactors them to use the substrate.
|
||
|
||
| Phase | Scope | Risk | Locked direction fit |
|
||
|---|---|---|---|
|
||
| **A — substrate + Custom Views ship; defaults untouched** | new code: ViewService, FilterSpec, RenderSpec, view_service handlers, /views/* pages, paliad.user_views | low — additive | exactly matches m's "additive" framing |
|
||
| **B — refactor /agenda, /events, /dashboard, /inbox internals to use ViewService** | rip out parallel read paths; defaults become SystemView-resolved | medium — touches every default page | optional; ship when A is stable |
|
||
|
||
Why phase A is enough on its own to ship value: the user gets Custom Views, the unified-inbox-shape becomes available, every system page keeps working untouched. Phase B is a clean-up — eliminating duplicate read paths — and can wait until A's substrate is exercised.
|
||
|
||
If we tried to do A+B in one shot, the PR would be:
|
||
|
||
- 1× new substrate (~1500 LoC across services + handlers + frontend)
|
||
- 4× system page refactors (~800 LoC each = ~3200 LoC)
|
||
- = ~4700 LoC, 4 surfaces moving simultaneously
|
||
|
||
That's a 2-week change and a much higher rollback-cost. Phasing means A is shippable in ~1500 LoC and B can be tackled per-page later.
|
||
|
||
### Q17 — Auth + RLS + lost project access
|
||
|
||
**Recommendation: fail open with attribution.**
|
||
|
||
Behaviour:
|
||
|
||
- A saved view's `filter_spec.scope.projects` may include UUIDs the user no longer has team access to.
|
||
- The substrate query JOINs through `paliad.projects p` with the visibility predicate (`paliad.can_see_project(p.id)` per t-139). RLS naturally hides rows from inaccessible projects.
|
||
- The view loads. The user sees the rows they *can* see; the inaccessible ones are absent.
|
||
- A one-time toast surfaces: "1 Projekt in dieser Sicht ist nicht mehr sichtbar" (count derived server-side: requested-IDs minus visible-IDs).
|
||
- The toast offers a "Sicht bearbeiten" link → opens the editor with the inaccessible IDs prefilled in a "Entfernen?" section.
|
||
|
||
Alternatives considered:
|
||
|
||
| Alternative | Why rejected |
|
||
|---|---|
|
||
| Fail closed (whole view 403) | Too aggressive — a 50-project view shouldn't black out because 1 was archived. |
|
||
| Silently drop with no surface | Confuses the user; "why is my view empty today?" |
|
||
| Auto-prune on first load | Mutates stored data without consent. |
|
||
|
||
Failing open + attributing matches the "transparent honesty" principle from t-139 (derived membership annotated, not silent).
|
||
|
||
### Q18 — Materialisation & performance
|
||
|
||
**Recommendation: no materialisation v1. Cursor pagination + per-source row caps.**
|
||
|
||
Performance shape:
|
||
|
||
- **Substrate runs on every load.** Each source contributes one SQL path; merge happens in Go (small per-page result set). No precomputation.
|
||
- **Pagination** is cursor-based: `(event_date DESC, id DESC)` for retrospective views, `(event_date ASC, id ASC)` for forward-looking. Cursor = base64-encoded `{date, id}`. Default page size 100; cap 200.
|
||
- **Time horizon is mandatory.** Default is `next_30d` for forward-looking views, `past_30d` for retrospective. The validator rejects `time.horizon = "all"` *unless* `scope.projects` is set to a non-empty explicit list (capping the row pool).
|
||
- **Per-source LIMIT** inside each SQL path (default 500; configurable per-source). Caps the worst case where one source dominates the union.
|
||
|
||
What this looks like for the worst case the issue body raised — "all events from all my projects in the next 90 days, sorted by due_date":
|
||
|
||
- 50 projects × thousands of rows each = ~150k rows, theoretical. In practice, paliad data today has dozens-to-low-hundreds per project; even at 50 projects, the *date-bounded* result is in the hundreds-low-thousands range.
|
||
- Each per-source query has the visibility predicate (RLS is via `EXISTS` against `project_teams` + path-walk) — t-124 confirmed this scales with depth, not row count.
|
||
- Even at 5k merged rows, in-memory sort + 100-row paginated slice is a few ms.
|
||
|
||
We add materialisation only if telemetry says we need to. Concretely: a request-duration histogram on `/api/views/{slug}/run` with p99 alarm at 1s. If p99 climbs past 500ms, we add per-source materialised rollups (e.g. `mv_user_view_counts_daily`) and short-circuit summary cards through them.
|
||
|
||
The substrate's `count` endpoint (used by the sidebar badge for `show_count=true` views) is a lighter shape — it returns one integer per source. That can hit a lighter path (no JOINs to projects beyond the RLS predicate). If a user has 10 saved views with `show_count=true` × 60s refresh = 10 COUNT(*) queries per minute per logged-in user. That's the first scale wall and is the candidate for caching in Phase B.
|
||
|
||
---
|
||
|
||
## 6. Section D — Cross-cutting concerns
|
||
|
||
### 6.1 Coexistence with t-139 (hierarchy aggregation, in flight)
|
||
|
||
t-139 adds `paliad.project_partner_units` + `derive_grants_authority` + an extended `can_see_project()` predicate. The substrate uses `can_see_project()` (or equivalent positional helpers like `visibilityPredicate("p")` already does) — so derived membership transparently widens what shows up in saved views, just like it widens what shows up on `/agenda` today.
|
||
|
||
**No coordination commit required.** If t-139 lands first, this design's substrate inherits derivation for free. If this design lands first (unlikely given the merge order), the substrate works against the pre-139 visibility predicate; t-139's later landing widens results without code change here.
|
||
|
||
The `scope.projects = "my_subtree"` semantic resolves through `DerivationService.EffectiveProjectRole` (added by t-139 Phase 2). Until t-139 lands, "my_subtree" falls back to "direct + descendant" (via `projectDescendantPredicate` from t-124). The frontend chip label stays the same; only the resolved set widens.
|
||
|
||
### 6.2 Coexistence with t-138 (approvals, shipped)
|
||
|
||
t-138 added `paliad.approval_requests` + `entity.approval_status` + the inbox SQL. The substrate uses `approval_requests` as `data_source = "approval_request"` directly — same RLS, same JOIN against `paliad.users` for requester/decider names. The substrate's approval-side filter `predicates.approval_request.viewer_role = "approver_eligible"` resolves via `ApprovalService.ListPendingForApprover` (its existing SQL).
|
||
|
||
The entity-side pill (`approval_status='pending'`) on deadline/appointment rows in the substrate is unchanged — `EventListItem.ApprovalStatus` is already populated in `event_service.go`.
|
||
|
||
### 6.3 Existing `EventService` — extend or replace?
|
||
|
||
**Recommendation: extend.** Rename `EventService` → `ViewService` (or keep `EventService` as the type and add a `ListVisibleAsViewRows` method that returns `[]ViewRow` instead of `[]EventListItem`). The existing `ListVisibleForUser([]EventListItem, …)` callers (`/api/events`, `/api/events/summary`) keep working unchanged.
|
||
|
||
Two-source → four-source generalisation:
|
||
|
||
- Add `loadProjectEventRows(ctx, userID, spec)` → similar to `loadAppointments` shape, queries `paliad.project_events` JOIN `paliad.projects` with visibility predicate.
|
||
- Add `loadApprovalRequestRows(ctx, userID, spec)` → wraps `ApprovalService.ListPendingForApprover` / `ListSubmittedByUser` and projects to `ViewRow`.
|
||
- The merge step in `ListVisibleForUser` becomes "merge N source results sorted by event_date".
|
||
|
||
`AgendaService` is the second substrate today (timeline-shaped). Phase B can retire it (Agenda becomes a SystemView with `shape: "cards"`); Phase A leaves it untouched.
|
||
|
||
### 6.4 i18n
|
||
|
||
User-facing strings:
|
||
|
||
- "Meine Sichten" / "My Views" (sidebar group label)
|
||
- "Neue Sicht" / "New View" (creation entry)
|
||
- "Speichern als Sicht" / "Save as View"
|
||
- "Sicht bearbeiten" / "Edit View"
|
||
- shape labels: "Liste / List", "Karten / Cards", "Kalender / Calendar"
|
||
- per-source labels: "Fristen / Deadlines", "Termine / Appointments", "Projekt-Verlauf / Project history", "Genehmigungen / Approvals"
|
||
- empty-state composition strings (filter summary)
|
||
- error toast for inaccessible-project case
|
||
|
||
Total estimate: ~80 new keys, DE + EN.
|
||
|
||
### 6.5 Bottom nav (mobile)
|
||
|
||
The bottom nav today shows 4 fixed entries (Übersicht-band). It does NOT extend with saved views — that would balloon to N+4 at every saved view. Saved views remain accessible via the sidebar drawer.
|
||
|
||
If telemetry shows mobile users routinely hitting saved views, consider a "Pin to bottom-nav" toggle on individual views (max 1 pinned view added between Übersicht and the burger).
|
||
|
||
---
|
||
|
||
## 7. Section E — Implementation phasing (PR shape)
|
||
|
||
### PR split decision (2026-05-07)
|
||
|
||
m delegated the split call to the inventor. Phase A is split into **two stacked PRs**:
|
||
|
||
- **A1 — Backend substrate + Custom Views API.** Migration 056, FilterSpec/RenderSpec types + validators, ViewService 4-source extension, UserViewService CRUD, SystemView registry, all `/api/*` endpoints, full backend test coverage. *No user-visible change.* Smoke-testable via curl. ~1800 LoC.
|
||
- **A2 — Frontend Custom Views UI.** Generic view shell (`/views/{slug}`), view editor (`/views/new`, `/views/{slug}/edit`), 3 render-shape components (list/cards/calendar), sidebar "Meine Sichten" group, i18n, CSS. Builds on A1's API. ~1600 LoC.
|
||
|
||
Why split: A1 is mergeable + deployable in isolation (additive, no UI risk), exercises the validator surface, lets A2 build on a stable contract. A2 is purely additive once A1 lands. Each PR fits in a normal review window.
|
||
|
||
A1 → main → A2 → main is the merge order.
|
||
|
||
### Phase A — substrate + Custom Views (this task's locked scope)
|
||
|
||
| Step | Files | Approx. LoC | Notes |
|
||
|---|---|---|---|
|
||
| 1. Migration `056_user_views` | `internal/db/migrations/056_user_views.up.sql` (+ down) | 60 | table + indexes + RLS + trigger |
|
||
| 2. Filter/Render spec types + validator | `internal/services/filter_spec.go`, `render_spec.go` | 350 | Go structs + JSON marshalling + `Validate*` |
|
||
| 3. ViewService — extend EventService | `internal/services/view_service.go` (rename + extend) | 500 | add 2 source loaders; merge N sources |
|
||
| 4. UserViewService — CRUD | `internal/services/user_view_service.go` | 300 | List/Get/Create/Update/Delete/Touch |
|
||
| 5. SystemView registry | `internal/services/system_views.go` | 150 | 4 SystemView definitions + reserved-slug list |
|
||
| 6. HTTP handlers | `internal/handlers/views.go` (new) + adjust `events.go`, `agenda.go`, `inbox.go` minimally | 400 | `/api/user-views/*`, `/api/views/{slug}/run`, `/views/*` page handlers |
|
||
| 7. Frontend — generic view shell | `frontend/src/views.tsx` + `client/views.ts` | 500 | renders any FilterSpec + RenderSpec; powers `/views/*` |
|
||
| 8. Frontend — render shape components | `frontend/src/views/{list,cards,calendar,activity}.ts` | 600 | shared by system + custom |
|
||
| 9. Frontend — view editor | `frontend/src/views-editor.tsx` + client | 400 | inline-save modal + full editor |
|
||
| 10. Sidebar — Meine Sichten group | `frontend/src/components/Sidebar.tsx` + sidebar.ts | 150 | render saved views from /api/user-views; badge refresh |
|
||
| 11. i18n | `frontend/src/i18n.ts` | ~80 keys | DE + EN |
|
||
| 12. Tests | `*_test.go` for spec validators + ViewService | 400 | spec round-trip, RLS, source merge ordering |
|
||
| **Total** | | ~3400 | one PR |
|
||
|
||
Phase A ships standalone — no defaults are touched, no existing pages move.
|
||
|
||
### Phase B — refactor system pages onto substrate (separate task)
|
||
|
||
Per-page refactor: `/agenda` (substrate-shape `cards`), `/events` (substrate-shape `list`/`calendar`), `/inbox` (substrate-shape `list` + tab tied to viewer_role), `/dashboard` (composes multiple SystemViews into its sections). Each is its own PR. Total estimate: ~2000 LoC across all four. Ships any time after A is stable.
|
||
|
||
### Phase C — sharing + advanced shapes (future)
|
||
|
||
Cross-user sharing (`user_view_shares`), connections-graph render shape, kanban shape, real-time push updates. None of these are in scope for the current task; called out so the v1 spec doesn't paint us into a corner.
|
||
|
||
---
|
||
|
||
## 8. Section F — Worked examples
|
||
|
||
### 8.1 The unified-inbox m described
|
||
|
||
m's brainstorm: "approval candidates + project activity + new cases + status changes + everything that happened on my projects."
|
||
|
||
`FilterSpec`:
|
||
|
||
```json
|
||
{
|
||
"version": 1,
|
||
"sources": ["approval_request", "project_event", "deadline", "appointment"],
|
||
"scope": { "projects": "my_subtree" },
|
||
"time": { "horizon": "past_30d", "field": "auto" },
|
||
"predicates": {
|
||
"approval_request": { "viewer_role": "approver_eligible", "status": ["pending"] },
|
||
"project_event": { "event_types": ["project_created", "status_changed", "deadline_created", "appointment_created", "approval_decided", "project_archived"] },
|
||
"deadline": { "approval_status": ["approved","pending","legacy"], "status": ["pending"] },
|
||
"appointment": { }
|
||
}
|
||
}
|
||
```
|
||
|
||
`RenderSpec`:
|
||
|
||
```json
|
||
{ "shape": "list", "list": { "density": "compact", "columns": ["time", "actor", "title", "project"], "sort": "date_desc" } }
|
||
```
|
||
|
||
(The "activity-feed feel" comes from `density: "compact"` + the actor/time column set, not from a separate shape — m's correction 2026-05-07.)
|
||
|
||
User saves as `meine-aktivitaet`. URL: `/views/meine-aktivitaet`. Sidebar entry under "Meine Sichten" with the bell icon. show_count=true → badge shows count of pending approvals + new audit events in past 30d.
|
||
|
||
### 8.2 The "myVerySpecialAgenda"
|
||
|
||
```json
|
||
{
|
||
"version": 1,
|
||
"sources": ["deadline", "appointment"],
|
||
"scope": { "projects": [<project-uuid-1>, <project-uuid-2>] },
|
||
"time": { "horizon": "next_14d" },
|
||
"predicates": {
|
||
"deadline": { "status": ["pending"], "event_types": [<litigation-event-type-uuid>] },
|
||
"appointment": { "appointment_types": ["hearing", "deadline_hearing"] }
|
||
}
|
||
}
|
||
```
|
||
|
||
`RenderSpec`: `{ "shape": "calendar", "calendar": { "default_view": "week" } }`
|
||
|
||
### 8.3 "Was hat sich auf Siemens AG geändert?"
|
||
|
||
```json
|
||
{
|
||
"version": 1,
|
||
"sources": ["project_event"],
|
||
"scope": { "projects": [<siemens-client-uuid>] },
|
||
"time": { "horizon": "past_90d" },
|
||
"predicates": { "project_event": { "event_types": ["status_changed", "project_reparented", "deadline_completed"] } }
|
||
}
|
||
```
|
||
|
||
`RenderSpec`: `{ "shape": "list", "list": { "density": "compact", "columns": ["time", "actor", "title"], "sort": "date_desc" } }`
|
||
|
||
(`scope.projects` referencing a top-level Client UUID + the path-walk visibility predicate naturally pulls all descendants — this is exactly the t-139 aggregation, surfaced through the substrate.)
|
||
|
||
---
|
||
|
||
## 9. Section G — Trade-offs flagged
|
||
|
||
1. **Substrate complexity vs default-page simplicity.** The substrate is meaningfully more complex than today's `EventService`. The win is that *every future "show me X across my work"* request maps to the same code path. Without it, every new viewpoint is a new bespoke handler — t-138's inbox is the most recent precedent (~900 LoC).
|
||
2. **JSON spec discoverability.** Power users will appreciate the JSON-spec affordance; casual users may never see it. The risk is that the affordance attracts feature-creep ("can we just add a `like_pattern` predicate?"). Mitigation: `version: 1` field + strict validator + a "spec changes go through inventor" rule documented in `docs/`.
|
||
3. **Storage cost of `paliad.user_views`.** Each saved view is ~2KB jsonb. 100 active users × 5 saved views = 1MB. Negligible.
|
||
4. **Sidebar growth.** Heavy users may end up with 10+ saved views in the sidebar group. The drag-reorder editor is the relief valve; if pain emerges, add a "Collapse group" affordance.
|
||
5. **`show_count` query load.** Each show_count=true view = 1 COUNT(*) per refresh. If users go count-happy, this becomes a real load. Mitigation: cap show_count=true to 5 per user; cache counts for 30s server-side.
|
||
6. **System pages staying independent (Phase A).** Two read paths during the A→B window. Drift risk if the substrate gains behaviour the system pages miss. Mitigation: feature flag the new `/views/*` for power users until B is in flight.
|
||
7. **Slug collisions with future system URLs.** Reserve a static list (`dashboard`, `agenda`, `events`, `inbox`, `new`, `edit`, `tools`, `admin`, `settings`, `login`, `logout`, `projects`, `team`, `courts`, `glossary`, `links`, `downloads`, `checklists`, `views`). Validator rejects on write. Future URLs added → migration script renames any user views that crash.
|
||
8. **Mobile UX of in-page render-shape switcher.** Calendar shape on a phone is cramped. Mitigation: when viewport width < 600px, calendar shape auto-falls back to cards (with a notice). Same pattern as `/events` today.
|
||
|
||
---
|
||
|
||
## 10. Section H — Open questions for m
|
||
|
||
**Status: LOCKED 2026-05-07.** m signed off on all Q19–Q27 recommendations.
|
||
|
||
Inventor has made recommendations on every Q1–Q18 from the issue body. The questions below are points where m's call would specifically refine the design before coder shift starts. Numbered fresh (Q19+) so they don't collide with the issue body's numbering.
|
||
|
||
**Q19. Curated `project_event` event-type list.**
|
||
The audit table today has free-text `event_type` strings (`project_created`, `status_changed`, `deadline_created`, `approval_decided`, …). The substrate's filter dropdown needs a curated list. Should I:
|
||
- (a) ship a hardcoded list of ~12 known kinds (verified via grep on `insertProjectEvent` callsites), or
|
||
- (b) ship a `paliad.project_event_kinds` registry table seeded with the same list, future-extensible by admins?
|
||
|
||
Recommendation: (a). Free-text `event_type` is a code-resident constant; new kinds appear when code emits them, so a registry table would just shadow the code.
|
||
|
||
**Q20. Sidebar group position.**
|
||
I placed "Meine Sichten" between Arbeit and Werkzeuge. Three other reasonable positions:
|
||
- top of the sidebar (above Übersicht — most-used-first)
|
||
- inside Übersicht (mixed with Dashboard/Agenda — but blurs the system/user distinction)
|
||
- between Übersicht and Arbeit (saved views are *overviews* by intent)
|
||
|
||
Pick one — the implementation is identical in all four placements.
|
||
|
||
**Q21. Bottom-nav inclusion.**
|
||
Mobile bottom-nav today has 4 fixed entries. The recommendation is to **not** extend it with saved views (sidebar drawer fills the gap). Confirm or reject. If reject: should pinned views be a per-view setting (max 1 pinned), or auto-pin the most-recently-used?
|
||
|
||
**Q22. Show-count default.**
|
||
Per-view `show_count` defaults to false (recommendation §5 Q7). Confirm — alternative is default true with an explicit opt-out. The cost of true-default is more COUNT(*) queries.
|
||
|
||
**Q23. Reserved slugs.**
|
||
List of forbidden user-view slugs (§9 trade-off 7). Anything to add or remove?
|
||
|
||
**Q24. Phase A surface area in coder shift.**
|
||
Phase A is ~3400 LoC. Confirm one PR is the right shape, or split into A1 (substrate + spec types + system view refactor of /events only) + A2 (Custom Views CRUD + sidebar + editor)?
|
||
|
||
**Q25. View deletion confirmation.**
|
||
A user deleting a saved view: should I require a "type the view name to confirm" pattern (matching admin deletes elsewhere in paliad), or a single Yes/No modal?
|
||
|
||
**Q26. Time-horizon mandatory clamp.**
|
||
The validator rejects `time.horizon = "all"` unless `scope.projects` is non-empty (perf safeguard, §5 Q18). Does this feel right, or should `"all"` always be allowed (and we trust the per-source LIMIT to bound things)?
|
||
|
||
**Q27. Render-spec live preview in editor.**
|
||
The editor today (proposed) saves on submit. Should the editor render a *live preview* of the current spec (running the substrate against the in-progress filter) — useful but adds a query per keystroke? Default-debounced (500ms) or explicit "Vorschau" button?
|
||
|
||
---
|
||
|
||
## 11. Out of scope (v1)
|
||
|
||
Per the issue body — quoted for traceability:
|
||
|
||
- Replacing the fixed pages (they stay; can be removed later if usage warrants).
|
||
- Cross-user view sharing.
|
||
- Public / read-only links to views.
|
||
- Real-time push updates ("inbox row appears when someone files an approval").
|
||
- Cross-project rollups (rolling rows across unrelated projects).
|
||
- Themes / per-view colour palettes.
|
||
|
||
Adding from inventor analysis:
|
||
|
||
- Connections-graph render shape (deferred per §4 Q4 — its own page later).
|
||
- Kanban shape (no obvious column axis across mixed sources).
|
||
- "Pin to bottom-nav" mobile affordance.
|
||
- Materialised view/cache layer (deferred per §5 Q18 — telemetry-driven).
|
||
|
||
---
|
||
|
||
## 12. Files the implementer will touch (Phase A)
|
||
|
||
Backend:
|
||
- `internal/db/migrations/056_user_views.up.sql` + `.down.sql` (new)
|
||
- `internal/services/filter_spec.go` (new) — types + validator
|
||
- `internal/services/render_spec.go` (new) — types + validator
|
||
- `internal/services/view_service.go` (new — extends/renames `event_service.go`)
|
||
- `internal/services/user_view_service.go` (new) — CRUD
|
||
- `internal/services/system_views.go` (new) — 4 SystemView definitions
|
||
- `internal/services/event_service.go` — update callers (or alias for back-compat)
|
||
- `internal/handlers/views.go` (new) — `/api/user-views/*`, `/api/views/{slug}/run`, page handlers for `/views/*`
|
||
- `internal/handlers/handlers.go` — wire the new routes
|
||
- `internal/handlers/inbox.go` (light touch) — refactor read path to `ViewService` (Phase B candidate; can stay independent in Phase A if we want to minimize blast radius)
|
||
|
||
Frontend:
|
||
- `frontend/src/views.tsx` (new) — generic view shell (`/views/{slug}` and `/views`)
|
||
- `frontend/src/views-editor.tsx` (new) — full editor at `/views/new`, `/views/{slug}/edit`
|
||
- `frontend/src/client/views/list.ts`, `cards.ts`, `calendar.ts`, `activity.ts` (new) — render shape components
|
||
- `frontend/src/client/views.ts` (new) — view shell glue + shape switcher
|
||
- `frontend/src/client/views-editor.ts` (new) — editor logic
|
||
- `frontend/src/components/Sidebar.tsx` — add Meine Sichten group + render from `window.__PALIAD_USER_VIEWS__`
|
||
- `frontend/src/client/sidebar.ts` — fetch/cache user views; badge refresh
|
||
- `frontend/src/i18n.ts` — ~80 new keys DE+EN
|
||
- `frontend/src/styles/global.css` — view-shell + render-shape switcher styles
|
||
|
||
Tests:
|
||
- `internal/services/filter_spec_test.go` — validator (happy + edge cases + reject paths)
|
||
- `internal/services/render_spec_test.go` — same
|
||
- `internal/services/view_service_test.go` — 4-source merge ordering, RLS bounded
|
||
- `internal/services/user_view_service_test.go` — CRUD + RLS
|
||
- `frontend/src/client/views/*.test.ts` (if frontend testing infra exists; otherwise smoke via Playwright)
|
||
|
||
Build infra: none — uses existing `golang-migrate` + Bun pipelines.
|
||
|
||
---
|
||
|
||
## 13. Inventor stays parked
|
||
|
||
This design needs m's go on §10 (Q19–Q27) before coder shift starts. After m's call, the head routes the implementer (recommendation: noether or fresh coder; Phase A is mechanical-substantial but pattern-fluent — t-139's hierarchy substrate is the closest precedent in the codebase).
|
||
|
||
NOT cronus per m's directive (2026-05-06: cronus retired from paliad).
|
||
|
||
`mai report completed "DESIGN READY FOR REVIEW: data display model — additive Custom Views + 4-source substrate + 4 render shapes + paliad.user_views. 27 questions answered (18 from issue body + 9 follow-ups in §10). Awaiting m's go/no-go before coder shift."`
|