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.
59 KiB
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_requestsas 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:
- A substrate — one read API that returns rows from N data sources, filterable by one shared grammar.
- A render layer — a small set of presentation components (List, Cards, Calendar, Activity) that all consume the substrate's row shape.
- 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.
// 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_dateandrule_code, appointment hasstart_at/end_at/location, project_event hasevent_type(free text) +metadata jsonb, approval_request haslifecycle_event+requested_at. AUNION ALLmaterialised 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 atappointment_type, project_event filters look atevent_type, approval_request filters look atlifecycle_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.goline 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 moreDataSourcevalues. 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(ordecided_atif 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-sourceinclude_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.fieldoverride.
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.
{
"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_requestlist.sort="date_asc"for forward-looking views,"date_desc"for retrospectivecards.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:
-
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. -
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:
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_systemflag — system views are code-resident (Q8), not seeded rows. Keeps the table strictly user-owned. filter_spec/render_specasjsonb— 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 SCOPEper the issue body. If sharing lands later, add a separateuser_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-standand resolves againstauth.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=trueon 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_atis 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
/inboxbecomes twoSystemViewdefinitions: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
/inboxhandler resolves to one of these depending on the active tab; the data path goes throughViewService.Run(ctx, userID, spec). - The frontend keeps the existing two-tab UI; the per-row card markup also stays (the substrate's
listshape withkind="approval_request"knows how to render approval rows including approve/reject buttons). - The
nav.inboxsidebar entry stays; the bell badge keeps reading fromApprovalService.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.projectsmay include UUIDs the user no longer has team access to. - The substrate query JOINs through
paliad.projects pwith 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_30dfor forward-looking views,past_30dfor retrospective. The validator rejectstime.horizon = "all"unlessscope.projectsis 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
EXISTSagainstproject_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 toloadAppointmentsshape, queriespaliad.project_eventsJOINpaliad.projectswith visibility predicate. - Add
loadApprovalRequestRows(ctx, userID, spec)→ wrapsApprovalService.ListPendingForApprover/ListSubmittedByUserand projects toViewRow. - The merge step in
ListVisibleForUserbecomes "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
- 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). - 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_patternpredicate?"). Mitigation:version: 1field + strict validator + a "spec changes go through inventor" rule documented indocs/. - Storage cost of
paliad.user_views. Each saved view is ~2KB jsonb. 100 active users × 5 saved views = 1MB. Negligible. - 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.
show_countquery 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.- 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. - 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. - 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
/eventstoday.
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
insertProjectEventcallsites), or - (b) ship a
paliad.project_event_kindsregistry 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 + validatorinternal/services/render_spec.go(new) — types + validatorinternal/services/view_service.go(new — extends/renamesevent_service.go)internal/services/user_view_service.go(new) — CRUDinternal/services/system_views.go(new) — 4 SystemView definitionsinternal/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 routesinternal/handlers/inbox.go(light touch) — refactor read path toViewService(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}/editfrontend/src/client/views/list.ts,cards.ts,calendar.ts,activity.ts(new) — render shape componentsfrontend/src/client/views.ts(new) — view shell glue + shape switcherfrontend/src/client/views-editor.ts(new) — editor logicfrontend/src/components/Sidebar.tsx— add Meine Sichten group + render fromwindow.__PALIAD_USER_VIEWS__frontend/src/client/sidebar.ts— fetch/cache user views; badge refreshfrontend/src/i18n.ts— ~80 new keys DE+ENfrontend/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— sameinternal/services/view_service_test.go— 4-source merge ordering, RLS boundedinternal/services/user_view_service_test.go— CRUD + RLSfrontend/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."