Files
paliad/docs/design-data-display-model-2026-05-06.md
m 956ff10e4d design(t-paliad-144): m signed off + Q4 correction (3 shapes, not 4)
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.
2026-05-07 12:36:05 +02:00

59 KiB
Raw Permalink Blame History

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 40193 — 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 78128 — 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 730810 — 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 491805 — 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 storypaliad.user_views + a "Meine Sichten" group + URL contract /views/{slug}.

§§35 cover those three. §6 covers cross-cutting concerns (RLS, performance, migration). §10 lists open questions for m to answer before coder shift.


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 (Q1Q3, Q13)

Q1 — What's the fundamental row?

Recommendation: discriminated ViewRow projection over an explicit data-source registry.

// 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.

{
  "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:

  • Hiddenversion, 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 (Q4Q6, Q11Q12, 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.

{
  "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 (Q7Q10, Q14, Q15, Q17, Q18)

Q7 — Schema for paliad.user_views

Recommendation:

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.

// 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 EventServiceViewService (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:

{
  "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:

{ "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"

{
  "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?"

{
  "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 Q19Q27 recommendations.

Inventor has made recommendations on every Q1Q18 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 (Q19Q27) 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."