Files
paliad/docs/design-universal-filter-2026-05-08.md
m 1e23745792 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.
2026-05-08 21:44:09 +02:00

43 KiB
Raw Blame History

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:

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:

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 24 mutually exclusive options. Always-visible, click-fast.
  • <select> (e.g. /events status, project, appointment-type) — best for 530 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:

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.tsmountFilterBar entry point.
  • frontend/src/client/filter-bar/axes.ts — per-axis render functions (one per AxisKey).
  • frontend/src/client/filter-bar/url-codec.tsencode/decode/diffWithBase.
  • frontend/src/client/filter-bar/save-modal.ts — the "Speichern als Sicht" modal.
  • frontend/src/client/filter-bar/types.tsFilterBarOpts, 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).


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.