docs(t-paliad-163): inventor design — universal filter + view-mode primitive
m/paliad#23. Recommends a single <FilterBar> client component on top of the existing Custom Views substrate (t-paliad-144) — FilterSpec + RenderSpec + ViewService + 5 code-resident SystemViews + ad-hoc /api/views/run already cover every axis the issue lists. Position: m's "halfway there without custom views" is exactly right. Lift the substrate from /views/{slug} up to "the bar that every list- shaped page reads from", with one schema bump (RenderSpec.list.row_action) to keep entity-table row-click contracts intact. Migrate one surface per PR: /inbox first (lowest blast radius, no filter today), /events last (proof point, richest filter). /projects stays bespoke per t-paliad-149 lock-in. 12 open questions (Q1-Q12) for m before lock-in. No hour estimates. Verified premises: the issue body's `paliad.user_view_layouts` is a typo — actual table is `paliad.user_views` (056). `/api/views/run` and `/api/views/{slug}/run` confirmed live in internal/handlers/views.go.
This commit is contained in:
469
docs/design-universal-filter-2026-05-08.md
Normal file
469
docs/design-universal-filter-2026-05-08.md
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
# Universal filter + view-mode primitive across all entity-views
|
||||||
|
|
||||||
|
**Issue:** m/paliad#23 (t-paliad-163)
|
||||||
|
**Inventor:** riemann (mai/riemann/inventor-universal)
|
||||||
|
**Date:** 2026-05-08
|
||||||
|
**Status:** READY FOR REVIEW — no code yet, design only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR — the central position
|
||||||
|
|
||||||
|
m's framing is exactly right: "halfway there without custom views". The Custom Views substrate (t-paliad-144) is the missing primitive — it just hasn't been lifted from "a saved-view feature on /views/{slug}" up to "the bar that every list-shaped page reads from".
|
||||||
|
|
||||||
|
Concrete take:
|
||||||
|
|
||||||
|
- **Don't invent a new schema or a new query layer.** `internal/services/filter_spec.go` + `render_spec.go` + `view_service.go` already cover every axis the issue lists, and `POST /api/views/run` and `POST /api/views/{slug}/run` already accept ad-hoc spec overrides. The substrate's own comment says it: *"Phase B will route them here; Phase A1 leaves the wiring as a no-op for those pages."* (`internal/handlers/views.go:247`). t-paliad-163 is Phase B with a UX-shaped artifact at the front.
|
||||||
|
- **Build one frontend `<FilterBar>` component** that consumes a `FilterSpec` + `RenderSpec` + a per-surface `axes[]` declaration, owns URL/local-state, and emits diffs. Drop it on every list-shaped surface. Each system page declares a base spec (= one of the existing `SystemView` definitions) and the supported axes.
|
||||||
|
- **"Save current filter as named view" is one button** on the bar. It POSTs the effective spec to `/api/user-views`. The custom-view editor (`/views/new`, `/views/{slug}/edit`) becomes a power-user form for the same data the bar produces; the bar is the everyday entry point.
|
||||||
|
- **/projects stays bespoke** (locked in t-paliad-149). Source⊥Shape orthogonality breaks for projects — they don't render as cards/calendar in the events sense, and `paliad.user_card_layouts` is a different primitive (per-card facts, not filters). The bar coexists with the `<details>`-chip cluster on /projects without subsuming it.
|
||||||
|
|
||||||
|
The migration is one surface at a time. /inbox first (no filter today, lowest blast radius), /events last (richest filter today, the proof point that the primitive can absorb it).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Premises verified live
|
||||||
|
|
||||||
|
Before designing on top of CLAUDE.md / memory / the issue body, I checked the live tree:
|
||||||
|
|
||||||
|
- **`paliad.user_views` (056) exists.** `paliad.user_card_layouts` (061) exists. **`paliad.user_view_layouts` does NOT exist** — the issue body's reference is a typo. Real names: `paliad.user_views` is the FilterSpec/RenderSpec store; `paliad.user_card_layouts` is the per-card-facts store for /projects only. `grep -rn user_view_layouts` returns nothing.
|
||||||
|
- **`POST /api/views/run`** takes an inline `FilterSpec` and returns `ViewRunResult{rows, inaccessible_project_ids}` without touching the DB. (`internal/handlers/views.go:248`)
|
||||||
|
- **`POST /api/views/{slug}/run`** accepts an optional `{filter: <override>}` body that overrides the saved/system spec for one run — does not mutate storage. (`internal/handlers/views.go:282`, `runRequest` at `:238`)
|
||||||
|
- **5 SystemViews are already code-resident** (`dashboard`, `agenda`, `events`, `inbox`, `inbox-mine`) at `internal/services/system_views.go:35`-`156`. Their slugs are reserved against user-view collisions. Each carries a canonical `FilterSpec` + `RenderSpec`.
|
||||||
|
- **3 render-shape components exist** in `frontend/src/client/views/`: `shape-list.ts`, `shape-cards.ts`, `shape-calendar.ts`. They take `(host, rows, render)` — pure config-driven dispatch.
|
||||||
|
- **List shape supports density (compact|comfortable), 13 known columns, and sort.** Column registry at `internal/services/render_spec.go:99`: `["date","time","title","project","actor","status","rule","event_type","location","appointment_type","approval_status","decided_by","kind"]`. Sort: `date_asc | date_desc`.
|
||||||
|
- **`attachEventTypeMultiSelectFilter`** in `frontend/src/client/event-types.ts` is a mature listbox-panel component (search + grouped checkboxes + URL round-trip + internal `onLangChange` subscription per t-paliad-117). The pattern to copy for project + appointment-type + status panels.
|
||||||
|
- **`renderAgendaTimeline`** in `frontend/src/client/agenda-render.ts` is the day-grouped timeline used both by `/agenda` and dashboard inline; reusable.
|
||||||
|
- **`.entity-table` row-click contract** is the project-wide rule (CLAUDE.md "Frontend conventions"). Any list-shape table must wire row-handlers that skip clicks on inner `<a>`/`<button>` and add `entity-table--readonly` when rows don't navigate. The bar must not regress this — it doesn't, because `shape-list.ts` already emits `entity-table--readonly` on its tables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The 7 list-shaped surfaces today — what they each have
|
||||||
|
|
||||||
|
A factual map of who has what. The underlinings are the axes the issue calls out.
|
||||||
|
|
||||||
|
| Surface | Filter axes today | View modes | State store |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **/agenda** (`client/agenda.ts`, 226 LoC) | type chip (deadlines/appointments/both), range chip (7/14/30/90d), event-type multi-select | timeline only | URL `?range=&types=&event_type=` |
|
||||||
|
| **/events** (`client/events.ts`, 1083 LoC) — also `/deadlines`, `/appointments` via 302 redirect | type chip (deadline/appointment/all), status select (8 buckets), project select (single, with `__personal__` sentinel), event-type multi (deadline-only), appointment-type select (appointment-only) | cards / list / calendar | URL `?type=&view=&status=&project_id=&personal_only=&event_type=&type_filter=` |
|
||||||
|
| **/inbox** (`client/inbox.ts`, 329 LoC) — both tabs | tab (pending-mine / mine), nothing else | list only | URL `?tab=` |
|
||||||
|
| **/projects** (`client/projects.ts` + `client/projects-cards.ts`) | search input, 6 chips (scope/status/type/has-open-deadlines), `<details>` multi-select for status + type | tree / cards / flat | sessionStorage `paliad.projects.lastView` + URL overlay |
|
||||||
|
| **/views/{slug}** (`client/views.ts`) | none in the viewer (only saved-spec); shape switcher (list/cards/calendar) | list / cards / calendar | URL path |
|
||||||
|
| **dashboard** (`client/dashboard.ts`, inline Agenda + Letzte Aktivität) | none | inline timeline / inline list | none |
|
||||||
|
| **/views/new \| /views/{slug}/edit** (`client/views-editor.ts`) | full FilterSpec form (sources / scope / time / shape / list density) | n/a — author surface | n/a |
|
||||||
|
|
||||||
|
The pattern m sees on `/inbox?tab=mine` is the natural endpoint of seven surfaces all building filters their own way: the surface that didn't have a filter author yet is also the surface with no filter chrome at all.
|
||||||
|
|
||||||
|
The good news: every axis on every surface is **already nameable in the FilterSpec / RenderSpec grammar** that `internal/services/filter_spec.go` ships. There's a one-to-one mapping; nothing has to be invented at the data layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. What the universal primitive is — `<FilterBar>`
|
||||||
|
|
||||||
|
A single TypeScript component, mounted on a host `<div>`, parameterised by:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface FilterBarOpts {
|
||||||
|
// Base spec — usually a SystemView's FilterSpec, fetched from /api/views/system.
|
||||||
|
// For /views/{slug}, this is the user-view's saved filter_spec.
|
||||||
|
baseFilter: FilterSpec;
|
||||||
|
baseRender: RenderSpec;
|
||||||
|
|
||||||
|
// Which axes the surface supports. Universal axes always render;
|
||||||
|
// per-surface axes render iff present in this list.
|
||||||
|
axes: AxisKey[];
|
||||||
|
|
||||||
|
// Optional fixed predicates the surface refuses to let users tweak.
|
||||||
|
// E.g. /inbox forces sources=[approval_request], not relaxable.
|
||||||
|
pinned?: PartialFilterSpec;
|
||||||
|
|
||||||
|
// Where to write rows when filter changes. The bar runs the spec via
|
||||||
|
// /api/views/run and hands the result back here for shape rendering.
|
||||||
|
onResult: (res: ViewRunResult, effective: { filter: FilterSpec; render: RenderSpec }) => void;
|
||||||
|
|
||||||
|
// Optional URL-param namespace (defaults to the empty namespace).
|
||||||
|
// Useful for embedding the bar twice on one page (dashboard inline)
|
||||||
|
// without colliding ?time= / ?time2=. Phase 4 ramps this up if needed.
|
||||||
|
urlNamespace?: string;
|
||||||
|
|
||||||
|
// Optional surface key — used as the localStorage key for view-mode
|
||||||
|
// and density preferences ("paliad.bar.<surfaceKey>.prefs").
|
||||||
|
surfaceKey: string;
|
||||||
|
|
||||||
|
// Optional sidebar slot — when present, "Save as view" + "Reset" are
|
||||||
|
// rendered. Defaults to true on every surface except dashboard inline.
|
||||||
|
showSaveAsView?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AxisKey =
|
||||||
|
| "project" // ← universal (always rendered if axes contains it; otherwise the chip is hidden)
|
||||||
|
| "time" // ← universal
|
||||||
|
| "personal_only" // ← universal
|
||||||
|
| "deadline_status" // ← per-surface (deadline source only)
|
||||||
|
| "deadline_event_type"
|
||||||
|
| "appointment_type"
|
||||||
|
| "approval_viewer_role"
|
||||||
|
| "approval_status"
|
||||||
|
| "approval_entity_type"
|
||||||
|
| "project_event_kind"
|
||||||
|
| "shape" // ← view-mode (list|cards|calendar)
|
||||||
|
| "sort" // ← per-shape
|
||||||
|
| "density" // ← list-shape only
|
||||||
|
| "columns"; // ← list-shape only (advanced; popover with checkboxes)
|
||||||
|
```
|
||||||
|
|
||||||
|
The bar's job:
|
||||||
|
1. On mount, parse URL params (within `urlNamespace`) and `localStorage["paliad.bar.<surfaceKey>.prefs"]`, overlay them on `baseFilter` + `baseRender`, validate, and POST `/api/views/run` with the effective spec.
|
||||||
|
2. Render chrome — chips for booleans / single-selects, popovers for multi-selects, segmented control for view-mode. Each control is a thin wrapper over an existing pattern (chip-row, multi-anchor + multi-panel, segment-control).
|
||||||
|
3. On any change, re-validate, sync URL, sync localStorage (for prefs only — see §3), POST the spec again, hand the result + effective spec to `onResult`. The shape host renders.
|
||||||
|
4. Expose two trailing actions (when `showSaveAsView`): **Speichern als Sicht** and **Zurücksetzen**.
|
||||||
|
|
||||||
|
What the bar is NOT:
|
||||||
|
- Not a router. Pages still own their URL.
|
||||||
|
- Not a layout system. Cards on /projects keep the `paliad.user_card_layouts` primitive (per-card facts) — that's orthogonal to filtering.
|
||||||
|
- Not the renderer. The bar just hands `(rows, effectiveRender)` to one of `shape-list / shape-cards / shape-calendar`.
|
||||||
|
- Not a substitute for the dedicated views editor. That stays for power-users who want full control (predicates, custom horizons, columns).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The 7 brief items — taking positions
|
||||||
|
|
||||||
|
### 3.1 Filter axes: which are universal, which are per-surface, how does the bar declare its supported axes?
|
||||||
|
|
||||||
|
**Universal** — render always when `axes` contains them (and the surface's pinned spec doesn't rule them out):
|
||||||
|
- `project` — single-select with the existing `<select>` (Alle / Nur persönliche / each project, ltree-indented). On surfaces where multi-project would help later (system-wide views), the same control upgrades to a multi-select listbox-panel by adding a `multi: true` flag — postpone to phase C, single-select covers every surface today.
|
||||||
|
- `time` — segmented chip group (`Heute · 7T · 30T · 90T · Alles · Anpassen`). Maps to `time.horizon`. "Anpassen" pops a date-range pair (`time.horizon = "custom"` + from/to). On /inbox the chip group reads "Heute · 7T · 30T · Alles" since approval queues are usually now-shaped — but the same control.
|
||||||
|
- `personal_only` — boolean chip ("Nur eigene"). Active when `scope.personal_only=true`. Hidden when source set excludes deadline AND appointment (others don't honour personal_only).
|
||||||
|
|
||||||
|
**Per-surface** — declared in `axes`, controlled by which sources the spec uses:
|
||||||
|
- `deadline_status` (chip cluster: "Offen · Überfällig · Erledigt · Alle") — only when `sources` includes deadline.
|
||||||
|
- `deadline_event_type` (multi-select listbox-panel, reuses `attachEventTypeMultiSelectFilter`) — only when sources includes deadline.
|
||||||
|
- `appointment_type` (single-select for now: hearing/meeting/consultation/deadline_hearing/Alle) — only when sources includes appointment.
|
||||||
|
- `approval_viewer_role` (segmented chips: "Zur Genehmigung · Eigene Anfragen · Alle sichtbaren") — only when sources includes approval_request. This subsumes the /inbox tab.
|
||||||
|
- `approval_status` (chip cluster: "Wartend · Entschieden · Alle") — only when sources includes approval_request.
|
||||||
|
- `approval_entity_type` (chip pair: "Fristen · Termine") — only when sources includes approval_request.
|
||||||
|
- `project_event_kind` (multi-select listbox-panel; the 13 `KnownProjectEventKinds`) — only when sources includes project_event. Powers the dashboard "Letzte Aktivität" filter.
|
||||||
|
|
||||||
|
**View-mode + per-shape** — declared in `axes`, but special:
|
||||||
|
- `shape` — segmented chips (list/cards/calendar). Always rendered when `axes` contains `shape`; available shapes derived from `baseRender` + the surface's whitelist. The bar emits a transient render override (mirrors how `client/views.ts:171` does shape-switching today: it doesn't rerun, just re-renders).
|
||||||
|
- `sort` — single-select (`date_asc | date_desc`).
|
||||||
|
- `density` — segmented chip pair (Komfortabel / Kompakt) — list shape only, hidden otherwise.
|
||||||
|
- `columns` — popover with checkbox list of `KnownListColumns` — list shape only, advanced opt-in.
|
||||||
|
|
||||||
|
**How the surface declares its axes:** an array. No higher-order component, no slot composition. Plain config. The bar's render is a switch over each axis key:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
mountFilterBar(host, {
|
||||||
|
baseFilter: agendaSystemView.filter,
|
||||||
|
baseRender: agendaSystemView.render,
|
||||||
|
axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort"],
|
||||||
|
surfaceKey: "agenda",
|
||||||
|
onResult: ({rows, inaccessible_project_ids}, effective) => { ... },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Slot composition was considered. It's overkill — every existing chrome pattern paliad uses (chip cluster, multi-anchor popover, segmented control, `<select>`) is already in `frontend/src/styles/global.css`; there's nothing to plug or override. A flat axis-config keeps the bar a 600-LoC component, not a framework.
|
||||||
|
|
||||||
|
### 3.2 State model: URL vs in-memory vs hybrid
|
||||||
|
|
||||||
|
**Hybrid**, with a sharp split:
|
||||||
|
|
||||||
|
- **URL is canonical** for everything that affects which rows you see. That means: project (`?project=`), sources (`?sources=`), time (`?time=` for horizon, `?from=&to=` for custom), personal-only, every per-source predicate (`?deadline_status=`, `?event_type=`, `?appointment_type=`, `?approval_role=`, `?approval_status=`, `?approval_entity_type=`, `?project_event_kind=`), shape (`?shape=`), sort (`?sort=`). Bookmarkable, shareable, refresh-survives, deep-linkable from the dashboard or /inbox bell.
|
||||||
|
- **localStorage holds preferences** that don't change rows: density (`?density=` is also a URL param when explicitly chosen, but absence falls through to localStorage default), default columns per surface (advanced opt-in), default shape per surface (only when the user has overridden the SystemView's default — first visit uses base). Keyed `paliad.bar.<surfaceKey>.prefs`. Mirrors the spirit of /projects' sessionStorage `paliad.projects.lastView` (t-paliad-149 Q1 lock-in) but at the right scope: the "what I prefer" sticks per surface, the "what this URL is showing" stays in the URL.
|
||||||
|
- **No sessionStorage.** /projects' use was justified by tab restoration; for the bar, every interesting bit is in the URL (so back/forward + refresh + share both work). Adding a third tier would create the worst-of-three: state in URL ∪ session ∪ local, three places to look when something's off.
|
||||||
|
|
||||||
|
URL parameter names are stable and short. The bar exports a tiny URL codec (`encodeBarParams(filter, render) → URLSearchParams` and inverse) so the same params work whether the bar is on /agenda, /inbox, /events, or /views/{slug}.
|
||||||
|
|
||||||
|
The migration from /events' bespoke `?type=&view=&status=&project_id=&personal_only=&event_type=&type_filter=` to the bar's params is straightforward: each old param maps to a new one (or stays, when names already match — `?project_id`, `?personal_only`, `?event_type` are unchanged; `?type` becomes `?sources`; `?view` becomes `?shape`; `?status` and `?type_filter` become per-surface predicates). Server middleware on the legacy /events handler can rewrite old → new params for one release so existing bookmarks don't 404.
|
||||||
|
|
||||||
|
### 3.3 View-mode switcher — universal or per-surface? Sort-state ownership? Density?
|
||||||
|
|
||||||
|
**Universal.** The bar always owns the segmented `shape` control. The surface declares which shapes it whitelists (e.g. /inbox might whitelist `["list"]` and hide the switcher; /agenda might whitelist `["cards", "list", "calendar"]`). When the whitelist has only one entry the bar suppresses the chip; when ≥2 it renders.
|
||||||
|
|
||||||
|
**Sort lives in the bar's `RenderSpec.list.sort` / `cards.sort`.** Already exists in the schema. The list-shape table renderer is currently sort-by-config-only; promoting `<th>` clicks to update `RenderSpec.list.sort` is a one-line callback in the bar (`onListHeaderSort`) → server-side re-sort isn't needed because `shape-list.ts:16` already sorts in JS. **Sortable column headers become a list-shape feature owned by the bar**, not a per-surface concern.
|
||||||
|
|
||||||
|
**Density** is a list-shape config (`comfortable | compact`). The bar exposes the pair as a chip; `shape-list.ts` already supports both. Density on /inbox today is implicitly comfortable; toggling it to compact gives the user the activity-feed look on the inbox surface for free, which is the kind of small win the brief calls out.
|
||||||
|
|
||||||
|
**Multi-column sort** is out-of-scope for v1 — `shape-list.ts:16` does single-column sort, which matches every surface today. Add when a user asks.
|
||||||
|
|
||||||
|
### 3.4 Composability — drop-in API without forcing existing pages to refactor
|
||||||
|
|
||||||
|
The bar mounts onto an empty `<div>`. The surface's TSX changes are:
|
||||||
|
- Replace the per-page filter chrome (chip cluster, selects, popovers, view-mode segment) with `<div id="filter-bar"></div>`.
|
||||||
|
- Replace the per-page result rendering with `<div id="filter-bar-results"></div>`.
|
||||||
|
- The page's `client/<surface>.ts` shrinks to: read `__PALIAD_<SURFACE>__` initial payload (or skip), call `mountFilterBar(host, opts)`, write `onResult` to dispatch into the matching shape component (already exist).
|
||||||
|
|
||||||
|
That's it. The page surface is reduced to ~50 LoC of orchestration around the bar; the bulk of `events.ts` (1083 LoC) drops to a baseline of ≈80 LoC after Phase 3 because the per-axis filter state, the project select populator, the language-hot-swap, the URL-sync, the type-visibility logic, the appointment-type filter logic, the calendar month-paging, and the cards-vs-list-vs-calendar dispatch all migrate into shared components: the bar (filter axes, view-mode, URL, language hot-swap), `shape-list.ts` (table), `shape-cards.ts` (cards), `shape-calendar.ts` (month grid).
|
||||||
|
|
||||||
|
The bar **does not own row interaction**. Row click → detail page is already a per-shape concern (`shape-list.ts` emits `entity-table--readonly`; the bar doesn't override that). Lifecycle actions (complete/reopen/approve/reject) are also per-shape — `shape-list.ts` will need a small extension to emit clickable-row tables on /events (so the existing complete-checkbox + reopen flow keeps working). That extension is one new render flag in `RenderSpec.list.row_action: "navigate" | "approve" | "complete-toggle" | "none"`, defaulting to navigate. Honest scope: this is a small `RenderSpec` schema bump (new optional field), not an axis change.
|
||||||
|
|
||||||
|
### 3.5 Reuse with the existing /views layout-spec — does the universal bar inherit, or does the spec become a special case of saved bar state?
|
||||||
|
|
||||||
|
**The latter.** m's hint ("halfway there without custom views") points at exactly this.
|
||||||
|
|
||||||
|
A **Custom View is the persisted form of a bar state.** When the user clicks "Speichern als Sicht" on /agenda, the bar gathers the effective `FilterSpec` + `RenderSpec`, prompts for name + slug + icon + show-count (a small modal — one form, four fields, mirroring `views-editor.ts`'s collectForm), and POSTs `/api/user-views`. The user is then redirected to `/views/{slug}` (or stays in place with a confirmation toast — see §3.7).
|
||||||
|
|
||||||
|
Conversely, **a SystemView is a code-resident bar state.** The bar already knows how to load one (`/api/views/system` → match slug). The "system pages" become surfaces whose default state happens to live in code instead of in `paliad.user_views`.
|
||||||
|
|
||||||
|
Implementation consequence:
|
||||||
|
- `views-editor.ts` keeps existing for power users who want to edit predicates that the bar doesn't expose (e.g. pinning a `time.field = "created_at"` for an "audit-trail" view). The editor and the bar produce identical `FilterSpec` + `RenderSpec` JSON; they're alternate authoring UX.
|
||||||
|
- `views.ts` (the `/views/{slug}` viewer) gains the bar above its rows. The bar renders with the saved spec as its base; the user can tweak axes (e.g. narrow the time horizon for a quick glance) — those tweaks are URL-overlays and don't mutate the saved spec until the user clicks "Aktualisieren" (a new affordance). This satisfies the brief's "halfway there" hint: today /views/{slug} renders a saved spec **statically**; with the bar, it becomes interactive without losing the saved-state semantics.
|
||||||
|
|
||||||
|
### 3.6 Migration path — phase one surface at a time, identify the hardest
|
||||||
|
|
||||||
|
The bar is shippable on one surface in one PR. Then each subsequent surface is its own small PR.
|
||||||
|
|
||||||
|
**Phase 1 — /inbox (the cold start).** Lowest blast radius: today /inbox has no filter chrome, only tabs. Replace tabs with the `approval_viewer_role` axis (the bar collapses two tabs into one chip cluster). Drop the bar with `axes: ["time", "approval_status", "approval_entity_type", "approval_viewer_role", "shape", "density", "sort"]`. Pin `sources: [approval_request]`. Density toggle gives the user a stream view m's "looks really bad" was diagnosing. URL contract: keep `?tab=` redirecting to `?approval_role=` for one release.
|
||||||
|
|
||||||
|
**Phase 2 — /agenda.** Already filter-shaped and the most readable orchestrator (226 LoC). Bar replaces the chip cluster + range chip + event-type popover. `axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort"]`. Default: shape="cards" (matching today's timeline default). The dashboard inline Agenda gets a stripped-down bar with `axes: ["time", "deadline_event_type"]` and `urlNamespace: "agenda"` (so the page-level bar on the dashboard doesn't collide with anything else if the dashboard adds another bar later for "Letzte Aktivität").
|
||||||
|
|
||||||
|
**Phase 3 — /events (the proof point).** Most complex filter today: type chip + status select + project select + personal-only + event-type multi + appointment-type select + cards/list/calendar. Every one of these axes is already nameable in FilterSpec/RenderSpec (verified §1). `axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort", "density"]`. The 5-card summary above the table (Heute / Diese Woche / Nächste Woche / Später / Überfällig) becomes a bar-driven facet: clicking a card sets `time.horizon` (or for "Überfällig", a special `deadline_status: ["overdue"]` predicate). Identifying /events as the hardest surface up front means the primitive's axis registry has to be wide enough on day 1; the design above already names every needed axis, so Phase 1's primitive is forward-compatible.
|
||||||
|
|
||||||
|
**Phase 4 — dashboard inline lists (Agenda + Letzte Aktivität).** The dashboard composes two tiny bars: one for Agenda (cards/list, narrow time horizon, no save-as-view), one for Letzte Aktivität (project_event source, density=compact, no save-as-view). Both use `urlNamespace` to keep params tidy.
|
||||||
|
|
||||||
|
**Phase 5 — /views/{slug}.** Add the bar above the rows. Saved spec → bar's base; URL overlays are transient until "Aktualisieren" persists them. The custom-view editor (`/views/new`, `/views/{slug}/edit`) stays for power users; "Speichern als Sicht" from the bar is the everyday path.
|
||||||
|
|
||||||
|
**Out of phasing:** /projects stays bespoke. The bar coexists on the page only if a future task adds it — today the chip cluster + tree/cards/flat segment are doing fine, and Source⊥Shape orthogonality breaks for projects (no ProjectSource in the substrate; no TreeShape in the substrate). t-paliad-149's locked-in choice stands.
|
||||||
|
|
||||||
|
**Hardest surface, identified:** /events. Phase 3 is the proof point. By designing the bar's axis registry against /events on day 1 (not retrofitting), Phase 1 (/inbox) and Phase 2 (/agenda) ship without redesign churn.
|
||||||
|
|
||||||
|
### 3.7 "Save current filter as named view" — making it trivial
|
||||||
|
|
||||||
|
The bar's trailing action is a single button: **Speichern als Sicht**. Click → small modal:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Sicht speichern ─────────────────────┐
|
||||||
|
│ Name [_________________] │
|
||||||
|
│ Slug [_________________] (opt) │
|
||||||
|
│ Icon [▼ Auswählen ] │
|
||||||
|
│ □ Anzahl in der Sidebar zeigen │
|
||||||
|
│ │
|
||||||
|
│ [ Abbrechen ] [ Speichern ] │
|
||||||
|
└───────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
If slug is empty, derive from name (kebab-case) and validate against the regex + reserved-slug list client-side (mirrors `views-editor.ts:179`). On 409 (slug taken), show inline error and let the user adjust. On success, two affordances:
|
||||||
|
- A toast "Als Sicht 'Heute überfällig' gespeichert. Zur Sicht wechseln?" with a link to `/views/{slug}`.
|
||||||
|
- The new view automatically appears in the **Meine Sichten** sidebar group (t-paliad-144) on next page load (or sooner, if the bar emits a window event the sidebar listens to).
|
||||||
|
|
||||||
|
This means: every list-shaped surface gets "save current filter as named view" for free. No per-surface plumbing.
|
||||||
|
|
||||||
|
**"Aktualisieren" on /views/{slug}** is the symmetric write-back: when the user is viewing a saved view and tweaks the bar, a "Aktualisieren" button appears next to "Speichern als Sicht". Click → PATCH `/api/user-views/{id}` with the effective spec. Confirmation toast.
|
||||||
|
|
||||||
|
**"Zurücksetzen"** clears the URL overlay and re-renders with the base spec only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Two harder questions worth surfacing now
|
||||||
|
|
||||||
|
### 4.1 The chip-vs-popover-vs-select tension
|
||||||
|
|
||||||
|
paliad has three patterns for "pick from a set" today:
|
||||||
|
|
||||||
|
- **Chip cluster** (e.g. /agenda type chip, /projects scope chip) — best for 2–4 mutually exclusive options. Always-visible, click-fast.
|
||||||
|
- **`<select>`** (e.g. /events status, project, appointment-type) — best for 5–30 single-select options, especially when the option list is dynamic (project list grows).
|
||||||
|
- **Listbox-panel popover** (e.g. event-type multi, /projects status/type `<details>`) — best for multi-select or for >30 options with search.
|
||||||
|
|
||||||
|
The bar must use the right pattern per axis to feel native, not regress one surface in service of another. My picks:
|
||||||
|
|
||||||
|
| Axis | Pattern | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| project (single) | `<select>` | dynamic list; option count grows with the firm |
|
||||||
|
| time | chip cluster + "Anpassen" overflow | 5 mutually exclusive presets cover 95% of usage |
|
||||||
|
| personal_only | single chip | binary |
|
||||||
|
| sources (when `axes` exposes it) | listbox-panel multi | 4 options but multi-select |
|
||||||
|
| deadline_status | chip cluster | 4 options, mutually exclusive |
|
||||||
|
| deadline_event_type | listbox-panel multi | 40+ options, search + grouped checkboxes (reuses event-types.ts pattern) |
|
||||||
|
| appointment_type | chip cluster (4 + Alle) | small mutually-exclusive set |
|
||||||
|
| approval_viewer_role | chip cluster | 3 mutually exclusive options |
|
||||||
|
| approval_status | chip cluster | 4 options |
|
||||||
|
| approval_entity_type | chip cluster | 2 options |
|
||||||
|
| project_event_kind | listbox-panel multi | 13 options, multi-select |
|
||||||
|
| shape | segmented control | 1-of-N, special UX (icon-only buttons) |
|
||||||
|
| sort | `<select>` (small) | 2 options today, room for `title_asc/desc` later |
|
||||||
|
| density | segmented control | binary, icon-shaped |
|
||||||
|
|
||||||
|
The point: the bar isn't one widget, it's a thin shell that delegates each axis to the right existing control. CSS reuse: `.agenda-chip` / `.events-view-btn` / `.akten-multi-trigger` / `.multi-anchor` / `.multi-panel` all stay; the bar just composes them.
|
||||||
|
|
||||||
|
### 4.2 Empty-state UX when an axis is invalid for the current sources
|
||||||
|
|
||||||
|
If the user clears all sources, every per-source axis becomes meaningless. Two options:
|
||||||
|
- **Hide invalid axes.** Cleanest. Bar reacts to source changes by collapsing dependent chips. Risk: feels jumpy.
|
||||||
|
- **Disable + tooltip.** Less jumpy but visually noisier.
|
||||||
|
|
||||||
|
Recommend **hide**, with one twist: the bar persists hidden-axis state in the URL anyway, so toggling sources back on restores the user's prior filter. This matches /events' existing behaviour (when type=appointment, event-type panel is hidden but its state persists in `?event_type=`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. RenderSpec extensions — one schema bump
|
||||||
|
|
||||||
|
The bar exposes capabilities that are already in `RenderSpec` (shape, sort, density, columns) plus one new field:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ListConfig struct {
|
||||||
|
Columns []string `json:"columns,omitempty"`
|
||||||
|
Sort SortOrder `json:"sort,omitempty"`
|
||||||
|
Density ListDensity `json:"density,omitempty"`
|
||||||
|
RowAction ListRowAction `json:"row_action,omitempty"` // NEW — "navigate" (default) | "complete_toggle" | "approve" | "none"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`RowAction` lets `shape-list.ts` know whether to wire an `entity-table--readonly` or to attach the existing checkbox / reopen / approve / reject buttons. Default `navigate` keeps the contract stable; system pages explicitly set `complete_toggle` (events list) and `approve` (inbox list).
|
||||||
|
|
||||||
|
This is the only schema change. Every other axis is already in the spec.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Hard requirements from the brief — addressed
|
||||||
|
|
||||||
|
- **`.entity-table` row-click contract.** The bar's list-shape table is rendered by `shape-list.ts:80` which already emits `entity-table--readonly`. When `RowAction="navigate"` the bar adds a row-handler that does `window.location.href = detailRoute(row)` and skips clicks on inner `<a>`/`<button>` (mirrors the existing `events.ts:wireRowHandlers` pattern). Whole-card / whole-row click → JS row-handler, never `::before` overlays (CLAUDE.md frontend conventions, t-paliad-102).
|
||||||
|
- **No hour estimates.** Throughout this design.
|
||||||
|
- **DE+EN bilingual.** Every new label gets a key under `views.bar.*` (single new namespace; ~25 keys for axes + ~10 for save modal + ~10 for empty/loading/error states). Keys are added to `frontend/src/client/i18n.ts`'s registry at the appropriate phase.
|
||||||
|
- **Mobile.** The bar collapses to a single horizontal scroll row on `≤768px` (mirrors `.frist-summary-cards` mobile pattern). The "Speichern als Sicht" + "Zurücksetzen" actions move into a `<details>` "Mehr" affordance on mobile to keep the scrollable strip clean. Re-imagining mobile-list-mode is out of scope per the brief.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Trade-offs — the honest list
|
||||||
|
|
||||||
|
### What this design gains
|
||||||
|
1. **One filter chrome across all list-shaped surfaces.** Users learn one bar, every surface respects it. Discoverability for "save as view" jumps from one surface (/views/new editor) to seven.
|
||||||
|
2. **System pages become substrate clients.** `/api/views/run` (already shipped) becomes the canonical event-fetching path. Phase B from t-paliad-144 design lands.
|
||||||
|
3. **`events.ts` shrinks ~10×.** Most of its 1083 lines are filter chrome + URL sync + view-mode dispatch — all now shared.
|
||||||
|
4. **Save-as-view is universal.** Today only /views/new + /views/{slug}/edit can author saved views; after the migration, every page can.
|
||||||
|
5. **/inbox gains filters and sort and density** as a free side effect of the migration — directly addressing m's "looks really bad" diagnosis.
|
||||||
|
6. **Sortable column headers** become a substrate feature (small bar callback that updates `RenderSpec.list.sort`).
|
||||||
|
7. **The schema barely moves** — one new optional field on `ListConfig`. Migrations not needed.
|
||||||
|
|
||||||
|
### What this design risks
|
||||||
|
1. **One component holding many axes is at risk of bloat.** Mitigation: the bar is a flat axis-config (no slot composition, no HOC). 600 LoC ceiling enforced by the per-axis switch pattern. CSS reuse keeps the visual surface small.
|
||||||
|
2. **The /events migration is the largest single PR.** 1083 LoC client → ≈100 LoC + ≈250 LoC of bar config + per-shape extensions. A regression on the 5-card summary or the deadline complete/reopen flow would be visible. Mitigation: Phase 3 is gated behind Phase 1 (/inbox) and Phase 2 (/agenda) shipping cleanly, and the design lands the `RowAction` schema bump in Phase 1 so `complete_toggle` is wired before /events arrives.
|
||||||
|
3. **URL overlay on /views/{slug} creates two states.** Saved spec ≠ effective spec when the user has tweaked the bar. The "Aktualisieren" / "Speichern als Sicht" actions resolve which becomes canonical, but a user who navigates away with unsaved tweaks loses them. Mitigation: a `?dirty=1` URL marker + a small toast on first tweak ("Änderungen sind nicht gespeichert").
|
||||||
|
4. **Two filter chromes coexist on /projects.** The bar doesn't subsume the chip cluster (Source⊥Shape break). Future visual unification would standardise the chip pattern between the two — out of scope here.
|
||||||
|
5. **Hidden-axis URL state.** Persisting `?event_type=` even when sources excludes deadline can confuse a user reading their URL. Acceptable: matches /events' current behaviour and is reversible by toggling the source back. The alternative (pruning URL params on source change) loses the user's prior state on a quick re-toggle.
|
||||||
|
6. **i18n hot-swap correctness.** Every dynamic populator must subscribe to `onLangChange` (the t-paliad-117 lesson). The bar handles this once internally for every axis; surfaces don't need to wire it per-page.
|
||||||
|
7. **Default per-surface defaults can drift from SystemView.** The bar reads `localStorage` for prefs (e.g. preferred shape on /agenda). If a user toggles a pref then a SystemView default changes, the user's pref wins. Mitigation: `localStorage` only stores explicit overrides, not the base value, so changes to the SystemView's base flow through for users who haven't overridden.
|
||||||
|
8. **Two storage primitives ("user_views" + "user_card_layouts") could be confusing.** Names are similar; they store different things. Mitigation: documentation. The bar only ever reads/writes `paliad.user_views`. /projects' card-layout is a separate, narrow concern that stays bespoke.
|
||||||
|
|
||||||
|
### Reversibility
|
||||||
|
- The bar is purely additive. Phase 1 doesn't touch /agenda or /events. If after Phase 1 the bar feels wrong, /inbox can revert to its prior chrome by reverting one PR. Phase 2 only ships after Phase 1 holds.
|
||||||
|
- The new `RenderSpec.list.row_action` field is optional with a `navigate` default; existing rows continue to render correctly.
|
||||||
|
- The URL contract is preserved for /events for one release via a thin redirect middleware that maps old → new params; bookmarks don't 404.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Open questions for m before lock-in
|
||||||
|
|
||||||
|
These are decisions where my recommendation might be challenged:
|
||||||
|
|
||||||
|
**Q1. State model: full URL-canonical, or do we accept localStorage for shape/density preferences?** I recommend hybrid: URL for filter axes, localStorage for shape + density prefs (per-surface). Keeps shareable URLs honest while letting "I always want compact density on /inbox" persist across sessions.
|
||||||
|
|
||||||
|
**Q2. Save-as-view modal vs slide-out vs inline.** I recommend modal — minimal surface, four fields, blocks the page. Alternatives: a slide-out (less interruption, more work) or an inline expansion of the "Speichern" button (cramped on mobile). Modal lines up with existing `<dialog>` usage on /admin.
|
||||||
|
|
||||||
|
**Q3. /events 5-card summary — keep, or fold into the bar?** I recommend keep (above the bar, unchanged). The cards encode urgency at a glance; collapsing them into the bar's `time` chip would lose the "9 / 3 / 2 / 5 / Überfällig 1" density. Clicking a card still updates the bar's time horizon (existing behaviour preserved).
|
||||||
|
|
||||||
|
**Q4. Tabs on /inbox — collapse into the `approval_viewer_role` chip cluster, or keep tabs as visual chrome above the bar?** I recommend collapse — one fewer place for state, the chip cluster is exactly the right control for 3 mutually exclusive options. Counter-argument: tabs are a strong visual hint of "two pages with the same shape". My counter-counter: the bar's chips are the same hint, less mid-air.
|
||||||
|
|
||||||
|
**Q5. URL parameter naming.** I recommend short, namespaced names: `?time=`, `?sources=`, `?project=`, `?personal=`, per-source predicate names (`?d_status=` for deadline.status, `?a_role=` for approval_request.viewer_role, `?pe_kind=` for project_event.event_types). Cargo-friendly to long names like `?deadline_status=` if m prefers — same axis, same wire format.
|
||||||
|
|
||||||
|
**Q6. "Speichern als Sicht" on the dashboard inline bars — show or hide?** I recommend hide. The dashboard composes two tiny bars; saving a sub-bar's spec as a custom view would feel disjoint from the dashboard concept. Power users can craft custom views via /views/new instead.
|
||||||
|
|
||||||
|
**Q7. Migration: do we keep `?type=` redirecting on /events for one release, or hard-cut?** I recommend keep for one release (small middleware in `internal/handlers/events_pages.go`) so existing bookmarks (Sidebar, internal docs, the /events sidebar links at `events.ts:838`) keep working through Phase 3.
|
||||||
|
|
||||||
|
**Q8. /views/{slug} — should the URL overlay tweak persist in localStorage as a "draft" until the user resets or saves?** I recommend no — URL is the only state, and a tweak that disappears on reload matches user expectation. The `?dirty=1` toast is enough. Alternative: a per-view-id `paliad.bar.view-{id}.draft` localStorage key that re-applies on re-visit — more powerful, more surprising.
|
||||||
|
|
||||||
|
**Q9. Sortable column headers — list shape only, or also rule for cards/calendar in a future phase?** I recommend list-shape only for v1. Cards and calendar have their own ordering semantics (group_by + within-group sort); promoting headers would over-complicate.
|
||||||
|
|
||||||
|
**Q10. Bar embedding twice on dashboard — `urlNamespace` worth the complexity, or single namespace and accept that dashboard's two bars share `?time=`?** I recommend `urlNamespace` for dashboard only (e.g. `?agenda_time=` and `?activity_time=`). Costs ~10 LoC, keeps two bars from colliding.
|
||||||
|
|
||||||
|
**Q11. Multi-project select — phase C, or fold into Phase 2?** I recommend phase C. Single-project covers every surface today; multi-project unlocks "all my Düsseldorf cases this week" type queries but no current page asks for it. Save complexity until a user does.
|
||||||
|
|
||||||
|
**Q12. EventTypeMultiSelect today supports `none` ("Ohne Typ") — keep or drop?** I recommend keep. The bar's deadline_event_type axis just wraps `attachEventTypeMultiSelectFilter`, so `none` works as-is. Honestly nothing to design here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Scope boundaries (in + out)
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
- New `<FilterBar>` component + axis registry + URL codec.
|
||||||
|
- One `RenderSpec.list.row_action` field with validator update.
|
||||||
|
- Phase 1: /inbox surface + tests.
|
||||||
|
- Documentation + i18n keys for the bar.
|
||||||
|
- Phase 2..5 named in the migration path with clear gates between them — but each is its own PR and not part of "the inventor design has shipped" definition-of-done.
|
||||||
|
|
||||||
|
### Out of scope (per the brief + my reading)
|
||||||
|
- New entity surfaces. Only the 7 named surfaces.
|
||||||
|
- Backend SQL migrations beyond the one optional `RenderSpec.list.row_action` field. The bar runs through `/api/views/run` which already exists.
|
||||||
|
- /projects redesign — t-paliad-149 stands.
|
||||||
|
- Mobile-list-mode reimagining — separate workstream.
|
||||||
|
- Multi-project selection — phase C, not v1.
|
||||||
|
- Multi-column sort — when a user asks.
|
||||||
|
- Internationalisation beyond DE + EN.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Files implementer will touch (Phase 1: /inbox)
|
||||||
|
|
||||||
|
To make the scope concrete:
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
- `frontend/src/components/FilterBar.tsx` — TSX wrapper with the host divs.
|
||||||
|
- `frontend/src/client/filter-bar/index.ts` — `mountFilterBar` entry point.
|
||||||
|
- `frontend/src/client/filter-bar/axes.ts` — per-axis render functions (one per `AxisKey`).
|
||||||
|
- `frontend/src/client/filter-bar/url-codec.ts` — `encode/decode/diffWithBase`.
|
||||||
|
- `frontend/src/client/filter-bar/save-modal.ts` — the "Speichern als Sicht" modal.
|
||||||
|
- `frontend/src/client/filter-bar/types.ts` — `FilterBarOpts`, `AxisKey`.
|
||||||
|
- `frontend/src/client/filter-bar/i18n.ts` — namespace registry helper.
|
||||||
|
|
||||||
|
**Modified (Phase 1):**
|
||||||
|
- `frontend/src/inbox.tsx` — replace tab row with `<div id="filter-bar">` + `<div id="filter-bar-results">`.
|
||||||
|
- `frontend/src/client/inbox.ts` — shrink to `mountFilterBar(host, {baseFilter: inboxSystemView, axes: [...], onResult: renderListShape})`.
|
||||||
|
- `internal/handlers/inbox.go` — add `?approval_role=` redirect from old `?tab=` for one release. (The actual rows continue to come from `/api/views/run` via the bar.)
|
||||||
|
- `internal/services/render_spec.go` — add `RowAction` field + validator + `KnownRowActions = ["navigate", "complete_toggle", "approve", "none"]`.
|
||||||
|
- `frontend/src/client/views/types.ts` — TS mirror of the new `RowAction` field.
|
||||||
|
- `frontend/src/client/views/shape-list.ts` — honour `RowAction` (navigate is the existing default; `approve` mounts approve/reject buttons; `complete_toggle` mounts the checkbox).
|
||||||
|
- `frontend/src/client/i18n.ts` + `i18n-keys.ts` — ~30 new keys under `views.bar.*`.
|
||||||
|
- `frontend/src/styles/global.css` — bar layout + mobile rules. Reuses existing `.agenda-chip`, `.akten-multi-*`, `.frist-summary-card`, `.multi-anchor`/`.multi-panel`, `.events-view-btn` styles.
|
||||||
|
|
||||||
|
**Tests (Phase 1):**
|
||||||
|
- `internal/services/render_spec_test.go` — add cases for `RowAction` validator (8 cases: each enum value + invalid + omitted + …).
|
||||||
|
- `frontend/src/client/filter-bar/url-codec.test.ts` — round-trip encode/decode for every `AxisKey`.
|
||||||
|
- `internal/handlers/inbox_redirect_test.go` — old-tab → new-axis redirect.
|
||||||
|
|
||||||
|
**Phase 2..5 file lists** are not enumerated here — each is a separate PR with its own surface refactor and follows the same shape (replace per-page chrome + URL sync, mount the bar, hand `onResult` to the existing shape components).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Recommended implementer
|
||||||
|
|
||||||
|
**Pattern-fluent Sonnet coder** is the right fit. Substrate is well-trodden:
|
||||||
|
- Custom Views client + render shapes already exist (t-paliad-144).
|
||||||
|
- Multi-select listbox-panel already exists (`event-types.ts`).
|
||||||
|
- Chip-row pattern exists on `/agenda`, `/projects`, `/events`.
|
||||||
|
- Save modal pattern exists on `/views/new` (`views-editor.ts`).
|
||||||
|
- URL-sync pattern exists on every system page.
|
||||||
|
|
||||||
|
The first PR (Phase 1: /inbox + bar scaffolding + `RowAction` schema bump) is contained and reviewable in one window. Subsequent phases are smaller — they're "swap in the bar and delete page-local code".
|
||||||
|
|
||||||
|
I am happy to be the coder if m wants minimum context-switch — riemann has the live model of every piece of this design. Equally happy to hand off to a fresh Sonnet coder with this doc as the brief; the doc is intended to be self-contained for that path.
|
||||||
|
|
||||||
|
The head decides.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Phasing summary (no estimates, just order)
|
||||||
|
|
||||||
|
1. /inbox migration + `<FilterBar>` scaffolding + `RowAction` schema bump.
|
||||||
|
2. /agenda migration.
|
||||||
|
3. /events migration (proof point — most complex filter today, biggest LoC delta).
|
||||||
|
4. Dashboard inline bars (Agenda + Letzte Aktivität).
|
||||||
|
5. /views/{slug} bar overlay + "Aktualisieren" affordance.
|
||||||
|
|
||||||
|
Each phase is its own PR. Phases must merge in order; m's merge gate at every step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Why this is worth an inventor
|
||||||
|
|
||||||
|
m's last line in the brainstorm: *"worth an inventor?"*. Yes — and the reason is exactly what the design doc surfaces: the substrate already exists, the schema's right, the run endpoints are shipped, and 5 SystemViews are already declared. A coder coming in cold would either (a) not realise the substrate is there and reinvent it, or (b) realise and underestimate how much per-surface chrome can collapse into one bar. The inventor's job here was to read what's there, name the bar primitive, identify /events as the proof point, propose the one schema bump (`RowAction`) that makes /inbox shippable in Phase 1, and resist designing a layout-spec system that's already covered by `RenderSpec`.
|
||||||
|
|
||||||
|
Stop. DESIGN READY FOR REVIEW.
|
||||||
Reference in New Issue
Block a user