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.
43 KiB
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.goalready cover every axis the issue lists, andPOST /api/views/runandPOST /api/views/{slug}/runalready 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 aFilterSpec+RenderSpec+ a per-surfaceaxes[]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 existingSystemViewdefinitions) 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_layoutsis 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_layoutsdoes NOT exist — the issue body's reference is a typo. Real names:paliad.user_viewsis the FilterSpec/RenderSpec store;paliad.user_card_layoutsis the per-card-facts store for /projects only.grep -rn user_view_layoutsreturns nothing.POST /api/views/runtakes an inlineFilterSpecand returnsViewRunResult{rows, inaccessible_project_ids}without touching the DB. (internal/handlers/views.go:248)POST /api/views/{slug}/runaccepts an optional{filter: <override>}body that overrides the saved/system spec for one run — does not mutate storage. (internal/handlers/views.go:282,runRequestat:238)- 5 SystemViews are already code-resident (
dashboard,agenda,events,inbox,inbox-mine) atinternal/services/system_views.go:35-156. Their slugs are reserved against user-view collisions. Each carries a canonicalFilterSpec+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. attachEventTypeMultiSelectFilterinfrontend/src/client/event-types.tsis a mature listbox-panel component (search + grouped checkboxes + URL round-trip + internalonLangChangesubscription per t-paliad-117). The pattern to copy for project + appointment-type + status panels.renderAgendaTimelineinfrontend/src/client/agenda-render.tsis the day-grouped timeline used both by/agendaand dashboard inline; reusable..entity-tablerow-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 addentity-table--readonlywhen rows don't navigate. The bar must not regress this — it doesn't, becauseshape-list.tsalready emitsentity-table--readonlyon 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:
- On mount, parse URL params (within
urlNamespace) andlocalStorage["paliad.bar.<surfaceKey>.prefs"], overlay them onbaseFilter+baseRender, validate, and POST/api/views/runwith the effective spec. - 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).
- 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. - 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_layoutsprimitive (per-card facts) — that's orthogonal to filtering. - Not the renderer. The bar just hands
(rows, effectiveRender)to one ofshape-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 amulti: trueflag — postpone to phase C, single-select covers every surface today.time— segmented chip group (Heute · 7T · 30T · 90T · Alles · Anpassen). Maps totime.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 whenscope.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 whensourcesincludes deadline.deadline_event_type(multi-select listbox-panel, reusesattachEventTypeMultiSelectFilter) — 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 13KnownProjectEventKinds) — 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 whenaxescontainsshape; available shapes derived frombaseRender+ the surface's whitelist. The bar emits a transient render override (mirrors howclient/views.ts:171does 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 ofKnownListColumns— 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). Keyedpaliad.bar.<surfaceKey>.prefs. Mirrors the spirit of /projects' sessionStoragepaliad.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>.tsshrinks to: read__PALIAD_<SURFACE>__initial payload (or skip), callmountFilterBar(host, opts), writeonResultto 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.tskeeps existing for power users who want to edit predicates that the bar doesn't expose (e.g. pinning atime.field = "created_at"for an "audit-trail" view). The editor and the bar produce identicalFilterSpec+RenderSpecJSON; 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:
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-tablerow-click contract. The bar's list-shape table is rendered byshape-list.ts:80which already emitsentity-table--readonly. WhenRowAction="navigate"the bar adds a row-handler that doeswindow.location.href = detailRoute(row)and skips clicks on inner<a>/<button>(mirrors the existingevents.ts:wireRowHandlerspattern). Whole-card / whole-row click → JS row-handler, never::beforeoverlays (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 tofrontend/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-cardsmobile 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
- 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.
- System pages become substrate clients.
/api/views/run(already shipped) becomes the canonical event-fetching path. Phase B from t-paliad-144 design lands. events.tsshrinks ~10×. Most of its 1083 lines are filter chrome + URL sync + view-mode dispatch — all now shared.- Save-as-view is universal. Today only /views/new + /views/{slug}/edit can author saved views; after the migration, every page can.
- /inbox gains filters and sort and density as a free side effect of the migration — directly addressing m's "looks really bad" diagnosis.
- Sortable column headers become a substrate feature (small bar callback that updates
RenderSpec.list.sort). - The schema barely moves — one new optional field on
ListConfig. Migrations not needed.
What this design risks
- 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.
- 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
RowActionschema bump in Phase 1 socomplete_toggleis wired before /events arrives. - 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=1URL marker + a small toast on first tweak ("Änderungen sind nicht gespeichert"). - 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.
- 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. - 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. - Default per-surface defaults can drift from SystemView. The bar reads
localStoragefor prefs (e.g. preferred shape on /agenda). If a user toggles a pref then a SystemView default changes, the user's pref wins. Mitigation:localStorageonly stores explicit overrides, not the base value, so changes to the SystemView's base flow through for users who haven't overridden. - 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_actionfield is optional with anavigatedefault; 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_actionfield 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_actionfield. The bar runs through/api/views/runwhich 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—mountFilterBarentry point.frontend/src/client/filter-bar/axes.ts— per-axis render functions (one perAxisKey).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 tomountFilterBar(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/runvia the bar.)internal/services/render_spec.go— addRowActionfield + validator +KnownRowActions = ["navigate", "complete_toggle", "approve", "none"].frontend/src/client/views/types.ts— TS mirror of the newRowActionfield.frontend/src/client/views/shape-list.ts— honourRowAction(navigate is the existing default;approvemounts approve/reject buttons;complete_togglemounts the checkbox).frontend/src/client/i18n.ts+i18n-keys.ts— ~30 new keys underviews.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-btnstyles.
Tests (Phase 1):
internal/services/render_spec_test.go— add cases forRowActionvalidator (8 cases: each enum value + invalid + omitted + …).frontend/src/client/filter-bar/url-codec.test.ts— round-trip encode/decode for everyAxisKey.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)
- /inbox migration +
<FilterBar>scaffolding +RowActionschema bump. - /agenda migration.
- /events migration (proof point — most complex filter today, biggest LoC delta).
- Dashboard inline bars (Agenda + Letzte Aktivität).
- /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.