Compare commits

..

16 Commits

Author SHA1 Message Date
m
7a35cad09f feat(deadlines/new): collapse Regel + Typ to ONE field when rule sets type
m's 2026-05-08 22:08 dogfood: my first auto-fill landed but kept Regel
and Typ as TWO separate input fields. m wanted ONE — "these two are
connected, it's the same thing".

Now: when a Regel is selected and the rule's concept resolves to a
canonical event_type via the jurisdiction-aware junction, the Typ
chip cluster is HIDDEN and replaced by an inline summary —

    Klageerwiderung (vorgegeben durch Regel)   Anderen Typ wählen

Clicking "Anderen Typ wählen" sets a sticky expandedOverride flag
that forces the picker visible for the rest of the form session.
The chip stays in the picker so the user can edit / remove it.
The picker also stays visible when the rule has no canonical
event_type (fallback to free-text Typ) or when the user has picked
a different event_type from the canonical default (mismatch
warning surfaces yellow next to the picker, never blocking).

DE+EN i18n: deadlines.field.rule.{autofill_inline,override}.
New CSS: .event-type-collapsed{,-label,-source,-override} reusing
the existing lime-tint chip palette.
2026-05-08 22:20:48 +02:00
m
6058d21ce6 fix(deadline-rules): pick rule's jurisdiction-aware event_type default
m's 2026-05-08 22:08 dogfood: rule '§ 276 Abs. 1 S. 2 ZPO — Klageerwiderung'
(DE) auto-filled to 'Klageerwiderung' label but the chosen event_type was
upc_statement_of_defence (UPC). Both render as 'Klageerwiderung' in the
UI, but they are different legal events in different jurisdictions.

Migration 074 adds a jurisdiction column to
paliad.deadline_concept_event_types and swaps the unique-default index
from per-concept to per-(concept, jurisdiction). Backfills jurisdiction
from each event_type's own column, then re-elects DE / DPMA / EPO
defaults where a non-UPC event_type genuinely exists. Idempotent: uses
ADD COLUMN IF NOT EXISTS, ON CONFLICT DO UPDATE, partial unique index.

DeadlineRuleService.hydrateConceptDefaultEventTypes now JOINs
paliad.proceeding_types and matches on (rule.concept, rule.jurisdiction)
with EPA→EPO canonicalisation. Rules whose (concept, jurisdiction) has
no default stay NULL — silent no-op on the form, better than a wrong
jurisdictional default. UPC rules unchanged; DE rules now resolve to
de_klageerwiderung when concept = statement-of-defence, else no autofill.

Live audit confirms: every active rule now resolves to a same-
jurisdiction event_type or no event_type at all. No more cross-
jurisdiction matches in the seed.
2026-05-08 22:16:55 +02:00
m
52caba51ec fix(inbox): default approval_viewer_role chip to "any_visible" so the page lands populated
m's 2026-05-08 22:08 dogfood, after t-paliad-163 Phase 1: "I like the
new inbox filters but now the inbox somehow does not show nothing no
more..." The new bar opened with the chip defaulted to
"approver_eligible" (the legacy "Zur Genehmigung" tab semantics —
requests EXCLUDING ones the caller authored). For users who only
SUBMIT requests and have nothing to approve themselves (incl. m,
who has 4 own pending submissions and 0 incoming), that's an empty
view.

Flip the default to "any_visible" on both ends:

- internal/services/system_views.go InboxSystemView.Filter — base
  spec ViewerRole = "any_visible".
- frontend/src/client/filter-bar/axes.ts approval_viewer_role chip —
  default = "any_visible" when the URL doesn't pin one. The two
  defaults are intentionally redundant: the server narrows on its
  default if the request omits a_role, and the chip highlights the
  same option on the empty URL.

The chip still narrows. "Zur Genehmigung" + "Eigene Anfragen" stay
one click away; the bar just doesn't pre-narrow into "Zur Genehmigung"
on first visit anymore.

The "/views/inbox-mine" SystemView (slug + URL stays "self_requested")
keeps its narrower default — that route exists precisely to land on
the requester's view.
2026-05-08 22:11:19 +02:00
m
1faffb682e Merge: t-paliad-163 Phase 1 — universal <FilterBar> primitive + /inbox migration
Three slices on mai/riemann/inventor-universal:

  d5a01e6  Slice 1 — RenderSpec.list.row_action + validator + tests
  de4e133  Slice 2 — <FilterBar> scaffolding (axes / url-codec / save-modal)
  4670cd6  Slice 3 — /inbox migrates to <FilterBar>; tabs collapse to chips

What ships (Phase 1):

- A new frontend/src/client/filter-bar/ module:
    types.ts        — Spec + RenderSpec + AxisDeclaration types
    axes.ts         — registry of supported filter axes
    url-codec.ts    — URL ↔ FilterSpec serialization (round-tripping)
    save-modal.ts   — "Speichern als Sicht" dialog
    index.ts        — <FilterBar> mounts
  Plus a url-codec.test.ts golden table.
- /inbox surface migrates to the bar:
    Top-level "Zur Genehmigung / Meine Anfragen" tabs collapse into the
    bar's `approval_viewer_role` chip cluster (incoming / outgoing /
    both). One control, three mutually exclusive options. Stateful via
    `?role=` URL param.
    Bookmark-friendly: legacy `?tab=mine` + `?tab=pending-mine` redirect
    to `?role=outgoing` and `?role=incoming` respectively for one
    release.
    Sortable column headers on the result list (list-shape only;
    cards/calendar shape-modes defer their own ordering to the spec).
- RenderSpec.list gains `row_action` ("navigate" | "expand" | "none")
  so list-shape surfaces declare row click behaviour explicitly. The
  validator + tests cover the new field.
- system_views.go gains the inbox SystemView definitions so the bar
  reads its base spec from the same registry that custom views use.

m's locked positions (commit `1e23745` design doc; m's greenlight
2026-05-08 21:47): all 11 default picks honoured. Q4 = collapse
tabs to chips ✓.

Phase 2 surfaces (port /agenda → bar; port /events → bar; port
/deadlines → bar; port /appointments → bar) follow as separate PRs.

Refs m/paliad#23.
2026-05-08 22:03:51 +02:00
m
4b681792ab Merge: t-paliad-165 — Regel ↔ Typ collapse via auto-link on the deadline create form
Two slices on mai/noether/collapse-regel-typ-on:

  0c12644  feat(deadline-rules): expose concept's canonical event_type per rule
  1e97ecc  feat(deadlines/new): auto-link Typ to Regel's concept

What ships:

- New junction paliad.deadline_concept_event_types maps every
  paliad.deadline_concepts row to its canonical paliad.event_types
  row(s). Many-to-many for concepts with multiple legitimate variants
  (statement-of-defence ↔ base + with_ccr + no_ccr; opposition across
  EPO + DPMA). Exactly one row per concept marked is_default = true
  by a partial unique index — that is the row the deadline form
  auto-fills with.

- Backend: paliad.deadline_rules_with_concept_event_type view + the
  deadline-rules read path now expose the rule's default concept
  event_type so the form has the auto-fill target without an extra
  round-trip.

- Frontend deadline create / edit form: when the user picks a Regel,
  the Typ chip auto-fills with the rule's concept's default event_type.
  A small "vorgegeben durch Regel — überschreiben?" hint sits next to
  the chip so the auto-fill is visible. The user can override (free-
  text or pick a different type); the override is explicit, no
  blocking validation.

- Free-text Typ stays available — manual deadlines without a
  matching rule (e.g. "Call me" reminders) keep working as today.

Migration housekeeping
======================

noether authored her migration as 072 on her branch but main had
already taken 072 via minkowski's t-paliad-164 (paliad.projects.our_side).
Renumbered to 073 during merge resolution to resolve the same-number
collision. Added IF NOT EXISTS guards on CREATE TABLE / CREATE INDEX
for re-run safety (the seed INSERT already had ON CONFLICT DO NOTHING).

Live tracker bumped 72 → 73 in the same operation: both effects
(our_side column AND deadline_concept_event_types table) were
applied to live during dev (each worker against the same DB), so
the tracker advance reflects schema reality. Next deploy sees
tracker=73 with file 073 present and has nothing to apply.

Refs m/paliad#18.
2026-05-08 22:01:44 +02:00
m
236bb3270e Merge: t-paliad-164 — project our_side + Determinator perspective predefine
Three slices on mai/minkowski/project-level-our-side:

  188d8ec  Slice 1 — paliad.projects.our_side column + service plumbing
  5d9c62d  Slice 2 — "Wir vertreten" select on the project edit form
  3a41ace  Slice 3 — Determinator predefines perspective from our_side

What ships:

- Migration 072 adds paliad.projects.our_side text with check constraint
  IN ('claimant','defendant','court','both', NULL). Idempotent
  (IF NOT EXISTS / DO blocks). NULL stays the default.
- Project model + service plumbing: OurSide *string on models.Project,
  threaded into Create / Update / SELECT projections + handlers.
- Project edit form: new "Wir vertreten" select with the four options
  + "unbekannt / nicht gesetzt", DE+EN i18n.
- Fristenrechner Determinator (Slice 3c — perspective chip): when a
  project is selected and our_side is set, the chip is predefined to
  that value with a "vorgegeben durch Akte" hint above. The user can
  still override (chip click); the override is explicit. When
  our_side is NULL, the existing free-pick behaviour stays.

m's dogfood (2026-05-08 21:42): "We chose a case of ours where our
side should be predefined - yet I can make a selection for which
side we are." Now resolved end-to-end: edit the project once to set
"Wir vertreten = Klägerseite", and the Determinator perspective chip
auto-locks to that side on every subsequent visit.
2026-05-08 22:00:13 +02:00
m
4670cd660a feat(inbox): migrate to <FilterBar> — t-paliad-163 Slice 3
/inbox is the first surface to consume the universal FilterBar. The
two-tab UI collapses into the bar's approval_viewer_role chip cluster
(per Q4 lock-in 2026-05-08 21:47); status / entity_type / time chips
are new affordances; density toggle gives the activity-feed look the
brief asked for.

Changes:
- system_views.go: InboxSystemView + InboxRequesterSystemView render
  spec gains RowAction=approve so shape-list.ts knows which row
  layout to stamp (entity title + diff + approve/reject/revoke).
- shape-list.ts: row_action='approve' branch — stamps the inbox-row
  markup the surface owned today; surface attaches click handlers
  via data-attrs on .views-approval-action / .views-approval-row.
- inbox.tsx: tab row replaced with <div id='inbox-filter-bar'> +
  <div id='inbox-results'>. Heading + admin nudge unchanged.
- client/inbox.ts: shrunk to mountFilterBar with axes [time,
  approval_viewer_role, approval_status, approval_entity_type,
  density, sort]. Action handlers run via fetch + bar.refresh().
  Legacy ?tab=mine -> ?a_role=self_requested redirect on mount so
  bookmarks / sidebar bell still land on the right sub-view.

Build clean: bun run build + go build/vet/test all pass.
2026-05-08 21:59:44 +02:00
m
1e97eccaed feat(deadlines/new): auto-link Typ to Regel's concept
When the user picks a Regel on /projects/{id}/deadlines/new (or the
global /deadlines/new), auto-populate the Typ chip with the rule's
concept's canonical event_type — using the
concept_default_event_type_id field server-side hydrated by mig 072.

Soft hint "Typ vorgegeben durch Regel — entfernen, um zu überschreiben"
when the chip exactly matches the rule's suggestion. Soft warning
"Hinweis: Typ widerspricht Regel" when the user has picked an event_type
that contradicts the rule's concept.

The picker is replaced silently when it still reflects the previous
rule's auto-fill (or is empty); leaves a manually-edited picker alone.
DE+EN i18n via deadlines.field.rule.{autofill,mismatch}. Reuses the
existing .form-hint--warning yellow-tint style; no new CSS.

Closes m/paliad#18 Item A — rule-vs-event redundancy on the manual
deadline create form.
2026-05-08 21:59:22 +02:00
m
3a41acee07 feat(fristenrechner): predefine Determinator perspective from our_side (t-paliad-164 slice 3)
Closes m's 2026-05-08 21:42 dogfood loop: when the user picks an Akte
that knows its own side, the Determinator perspective chip should be
locked to that side instead of asking the user to re-pick something
the project already knows.

ProjectOption gains our_side; the JSON already carries it from
slice 1 (ProjectService.projectColumns). New helper
applyOurSidePredefine maps project.our_side onto the chip:

  claimant  → "claimant"   chip active
  defendant → "defendant"  chip active
  court     → null          chip cleared (court actions are neutral
                            to the user's side, so no narrowing)
  both      → null          explicit "Beide" intent
  null/undef → no-op

URL wins: if ?role= is present at call time the user (or a shared
link) chose it explicitly and we don't overwrite. When we do predefine,
we write the same value to the URL so refresh + back/forward round-trip
correctly. Two call sites:

- selectProject: in-page Akte pick. push history (replaceURL=false) so
  back-button restores the prior state.
- post-fetchProjects hydration: the deep-link / refresh path. Use
  history replace so the URL stays clean.

A small "vorgegeben durch Akte" / "predefined from project" hint
renders next to the chip strip (italic muted). Visible whenever the
active perspective came from the project; cleared on any chip click
(explicit override) and on Step-1 reselect (no Akte = no hint).
popstate restores hint visibility by recomputing from
project.our_side ↔ currentPerspective so back/forward feels right.

Free-pick is preserved: clicking another chip overrides the
predefine and the cascade re-narrows immediately.
2026-05-08 21:58:44 +02:00
m
de4e133f03 feat(filter-bar): scaffolding — t-paliad-163 Slice 2
The universal FilterBar primitive: one client component every list-
shaped paliad surface mounts. Owns URL state (within an optional
namespace), localStorage prefs (density / shape / sort), the per-axis
chrome, and the round-trip to /api/views/{slug}/run with a transient
filter override.

Files:
- client/filter-bar/types.ts       — AxisKey, BarState, MountOpts, BarHandle
- client/filter-bar/url-codec.ts   — parseBar/encodeBar with namespace prefix
- client/filter-bar/url-codec.test.ts — 12 round-trip cases (bun test pass)
- client/filter-bar/axes.ts        — per-axis renderers (10 axes shipped;
  deadline_event_type + project_event_kind stubs land with their surfaces)
- client/filter-bar/save-modal.ts  — Speichern-als-Sicht <dialog>
- client/filter-bar/index.ts       — mountFilterBar + computeEffective overlay

Plus i18n (DE+EN, ~50 keys under views.bar.*) and CSS (.filter-bar*
scoped, reuses .agenda-chip / .filter-group / .entity-select for
parity).

No surface uses the bar yet — Slice 3 wires /inbox.
2026-05-08 21:55:29 +02:00
m
0c12644563 feat(deadline-rules): expose concept's canonical event_type per rule
Add paliad.deadline_concept_event_types junction (mig 072) mapping each
deadline_concept to its canonical paliad.event_types row(s). Hydrate
DeadlineRule.ConceptDefaultEventTypeID via one IN query per List call so
/api/deadline-rules carries the autofill hint for the deadline create
form (t-paliad-165 / m/paliad#18).

Seed mapping covers the active concepts driving existing rules — 29
rows across 26 distinct concepts. Concepts without an obvious event_type
counterpart (decision, filing, grant, the DE-only Begründung family)
stay unmapped; auto-fill silently skips them.
2026-05-08 21:55:15 +02:00
m
5d9c62d858 feat(projects-form): "Wir vertreten" select for our_side (t-paliad-164 slice 2)
ProjectFormFields gains a fifth select between case-specific block and
the description textarea: "Wir vertreten" with options claimant /
defendant / court / both / "" (the unset sentinel labelled
"Unbekannt / nicht gesetzt"). Type-agnostic — every project type
carries it because the Determinator picks it up regardless. Form-hint
explains it predefines the Determinator perspective and stays
overridable.

client/project-form.ts: readPayload writes our_side as a normal
stringField (empty string in edit mode clears the column via the
nullableOurSide helper on the service); prefillForm hydrates the
select from p.our_side. Both gate on tryGet so /projects/new (which
shares the form) still loads if the field is later removed.

i18n already in slice 1; this commit only wires the markup +
client logic.
2026-05-08 21:55:00 +02:00
m
188d8ec9ba feat(projects): add projects.our_side column + service plumbing (t-paliad-164 slice 1)
m's 2026-05-08 21:42 dogfood feedback on the Determinator perspective
chip: when an Akte is selected, the chip should be locked to the firm's
known side instead of asking the user to re-pick. paliad didn't track
that anywhere — paliad.parties.role records each party's role but no
flag for "this is the side we represent".

Migration 072 adds paliad.projects.our_side text with a CHECK
constraint (claimant | defendant | court | both | NULL). NULL stays the
default so existing rows are neutral and the Determinator falls back to
free-pick. Idempotent (ADD COLUMN IF NOT EXISTS + DO-block guarded
constraint) so a re-run against a partially-applied state is safe —
paliad has been bitten by collision twice this week.

Project model + ProjectService:
- OurSide *string field on models.Project
- CreateProjectInput / UpdateProjectInput accept our_side
- INSERT and partial UPDATE thread the value through; validateOurSide
  rejects unknown enum values with ErrInvalidInput before the DB
  constraint would; nullableOurSide turns "" into NULL so the form's
  "unset" sentinel can clear the column
- Update logs an our_side_changed audit event with "<from> → <to>"
  description (matching status_changed / project_type_changed
  shape); both ends use the literal "none" sentinel for NULL so the
  frontend renderer can map it to projects.field.our_side.none

i18n: event.title.our_side_changed (DE/EN), dashboard.action.short
verb form, projects.field.our_side.{label,hint,unset,claimant,
defendant,court,both,none} for the upcoming Slice 2 select.

Frontend translateEventDescription gets an our_side_changed branch
that runs translateArrowSlugs over the projects.field.our_side.*
prefix so the Verlauf tab renders localized labels.

Slice 2 wires the form, Slice 3 wires the Determinator.
2026-05-08 21:52:50 +02:00
m
d5a01e6682 feat(render-spec): add list.row_action — t-paliad-163 Slice 1
Schema bump that lets the universal <FilterBar> tell shape-list which
row interaction to wire (navigate / complete_toggle / approve / none).
Defaults to navigate when empty so existing SystemView definitions and
saved user views continue to render rows that route to the per-kind
detail page.

Validator extended; pure-Go test cases over every enum value + reject.
TS mirror updated in client/views/types.ts. No DB migration — the
field is purely additive on the JSON shape.
2026-05-08 21:49:00 +02:00
m
02d4ac2f4e Merge: t-paliad-161 Slices F + G — Paliadin DB-driven history sync + tmux crash-recovery primer
Two follow-up slices on the inline-Paliadin scope (m's 2026-05-08 21:37):

  1782dfa  Slice F — cross-surface DB-driven history hydrate
  ae1cba4  Slice G — tmux crash-recovery primer

What ships:

- The inline drawer (client/paliadin-widget.ts) and the standalone
  /paliadin page (client/paliadin.ts) now share one session id and
  one history bucket. localStorage stays as a render-cache only;
  the DB (paliad.paliadin_turns) is source of truth. Both surfaces
  hydrate from GET /api/paliadin/history?session=<id>&limit=N on
  mount, then reconcile localStorage with the server response (always
  prefer server). Eliminates the trap klaus warned about (paliad#19,
  the localStorage short-circuit that hid late server-side responses).

- A turn typed into the drawer now shows up when the user opens
  /paliadin and vice versa, on both the same browser and across
  refreshes.

- tmux crash-recovery primer: when LocalPaliadinService /
  RemotePaliadinService detects a fresh pane (tmux session label
  rotated, or no prior turn output in the response dir), it injects
  a context-dump primer with the last N exchanges from
  paliad.paliadin_turns BEFORE the new prompt lands. The persona
  catches up on the conversation rather than starting from zero.
  Primer format documented in scripts/skills/paliadin/SKILL.md.

Auth gate unchanged: /api/paliadin/history honours PaliadinOwnerEmail
just like /api/paliadin/turn. Tests added for the hydrate + reconcile
+ primer paths in paliadin_test.go.
2026-05-08 21:48:52 +02:00
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
32 changed files with 3346 additions and 303 deletions

View 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 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:
```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.

View File

@@ -1,10 +1,23 @@
import { initI18n, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
import { attachEventTypePicker, type PickerHandle } from "./event-types";
import {
attachEventTypePicker,
eventTypeLabel,
fetchEventTypes,
type EventType,
type PickerHandle,
} from "./event-types";
import { projectIndent } from "./project-indent";
let eventTypePicker: PickerHandle | null = null;
let currentUserAdmin = false;
let eventTypesByID = new Map<string, EventType>();
// expandedOverride flips to true when the user clicks "Anderen Typ
// wählen" on the collapsed inline summary. Sticky for the rest of the
// form session — cleared only when the user reverts the rule to "Keine
// Regel". When true, the picker stays visible regardless of whether
// the chip matches the rule's canonical default.
let expandedOverride = false;
interface Project {
id: string;
@@ -19,8 +32,22 @@ interface DeadlineRule {
name: string;
name_en: string;
rule_code?: string;
// t-paliad-165 — canonical event_type for this rule's concept,
// hydrated server-side from paliad.deadline_concept_event_types.
// Drives auto-fill of the Typ chip when the user picks this rule.
concept_default_event_type_id?: string | null;
}
// Rules indexed by id so the Regel-change handler can look up the
// concept's canonical event_type without re-fetching.
let rulesByID = new Map<string, DeadlineRule>();
// Last event_type the rule auto-filled. Tracked so we can tell whether
// the picker still reflects the rule's suggestion (replace silently on
// new rule pick) or whether the user has manually edited (leave alone,
// surface the mismatch warning instead).
let lastAutoFilledEventTypeID: string | null = null;
let preselectedProjectID = "";
function esc(s: string): string {
@@ -71,6 +98,7 @@ async function loadRules() {
const resp = await fetch("/api/deadline-rules");
if (!resp.ok) return;
const rules: DeadlineRule[] = await resp.json();
rulesByID = new Map(rules.map((r) => [r.id, r]));
const opts: string[] = [
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
];
@@ -85,6 +113,93 @@ async function loadRules() {
}
}
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
// picker. The two modes are mutually exclusive:
//
// collapsed: rule selected + canonical event_type known + picker
// contains exactly [default] + user hasn't clicked "Anderen Typ
// wählen". Hides the chip cluster, surfaces a single inline
// summary "Klageerwiderung (vorgegeben durch Regel)" + an
// override link.
//
// expanded: every other case — no rule, no default for the rule,
// picker has been edited, or expandedOverride is sticky after the
// user clicked the override link. Picker visible; mismatch warning
// surfaces yellow when the rule expected a different event_type.
function refreshRuleView(): void {
const collapsed = document.getElementById("deadline-event-type-collapsed");
const collapsedLabel = document.getElementById("deadline-event-type-collapsed-label");
const pickerHost = document.getElementById("deadline-event-types");
const warn = document.getElementById("deadline-event-type-rule-mismatch");
if (!collapsed || !collapsedLabel || !pickerHost || !warn) return;
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
const expected = rule?.concept_default_event_type_id ?? null;
const picked = eventTypePicker?.getIDs() ?? [];
const pickerMatchesDefault =
expected !== null && picked.length === 1 && picked[0] === expected;
const wantsCollapsed =
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault;
if (wantsCollapsed) {
const et = eventTypesByID.get(expected!);
collapsedLabel.textContent = et ? eventTypeLabel(et) : "";
collapsed.style.display = "";
pickerHost.style.display = "none";
warn.style.display = "none";
return;
}
collapsed.style.display = "none";
pickerHost.style.display = "";
// Mismatch warning: rule expected an event_type AND the picker
// doesn't contain it. (When the picker is empty + no override, no
// warning — user is free to leave it blank.)
if (expected && picked.length > 0 && !picked.includes(expected)) {
warn.style.display = "";
} else {
warn.style.display = "none";
}
}
// applyRuleAutoFill replaces the picker silently when it still reflects
// the previous rule's suggestion (or is empty); leaves a manually-edited
// picker alone. Called whenever the Regel select changes.
function applyRuleAutoFill(): void {
if (!eventTypePicker) return;
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
const expected = rule?.concept_default_event_type_id ?? null;
const current = eventTypePicker.getIDs();
// Reset the override on transition to "Keine Regel" — fresh form
// session. Otherwise expandedOverride stays sticky.
if (ruleID === "") {
expandedOverride = false;
}
const pickerStillReflectsLastSuggestion =
lastAutoFilledEventTypeID !== null &&
current.length === 1 &&
current[0] === lastAutoFilledEventTypeID;
const pickerIsEmpty = current.length === 0;
if (expected) {
if (pickerIsEmpty || pickerStillReflectsLastSuggestion) {
eventTypePicker.setIDs([expected]);
lastAutoFilledEventTypeID = expected;
}
} else if (pickerStillReflectsLastSuggestion) {
// New rule has no canonical event_type — clear the stale auto-fill
// so the picker doesn't carry a chip from the old rule.
eventTypePicker.setIDs([]);
lastAutoFilledEventTypeID = null;
}
refreshRuleView();
}
function initBackLinks() {
if (preselectedProjectID) {
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
@@ -233,8 +348,36 @@ document.addEventListener("DOMContentLoaded", async () => {
if (pickerHost) {
eventTypePicker = attachEventTypePicker(pickerHost, {
currentUserAdmin,
onChange: () => refreshRuleView(),
});
}
// t-paliad-165 follow-up — preload event_types so the collapsed
// summary can render the type's label inline without an extra round
// trip when the user picks a Regel.
fetchEventTypes()
.then((types) => {
eventTypesByID = new Map(types.map((et) => [et.id, et]));
refreshRuleView();
})
.catch(() => {/* non-fatal — collapsed view falls back to empty label */});
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
// concept's canonical event_type, when the picker hasn't been
// manually edited away from the previous rule's suggestion.
document.getElementById("deadline-rule")?.addEventListener("change", () => {
applyRuleAutoFill();
});
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
// visible even when the chip still matches the rule's default.
document.getElementById("deadline-event-type-override-btn")?.addEventListener("click", () => {
expandedOverride = true;
refreshRuleView();
// Move focus into the picker's search box so the user can type
// immediately without an extra click.
const search = document.querySelector<HTMLInputElement>(
"#deadline-event-types .event-type-search",
);
search?.focus();
});
// Wire approval-hint refresh: on first render + on project change.
void refreshApprovalHint();
document.getElementById("deadline-project")?.addEventListener("change", () => {

View File

@@ -0,0 +1,354 @@
// Per-axis renderers for the FilterBar — t-paliad-163.
//
// Each axis is a small, self-contained render function that takes the
// current BarState slice and a callback. The bar's mountFilterBar
// composes them in the order declared on the surface.
//
// Reuses existing CSS classes wherever possible:
// - .agenda-chip / .agenda-chip-active (chip cluster pattern)
// - .filter-group (label + control wrapping)
// - .akten-multi-trigger / .multi-anchor / .multi-panel
//
// New classes are scoped under .filter-bar-* so they don't bleed.
import { t, tDyn, type I18nKey } from "../i18n";
import type { BarState, AxisKey } from "./types";
export interface AxisCtx {
// Read the current value for this axis.
get<K extends keyof BarState>(key: K): BarState[K];
// Patch one or more axis values + trigger re-run.
patch(delta: Partial<BarState>): void;
}
// renderAxis returns the HTML element for a single axis. The bar's
// mountFilterBar appends the result to its internal toolbar. Returns
// null when the axis is ignored (e.g. surface didn't declare it).
export function renderAxis(axis: AxisKey, ctx: AxisCtx): HTMLElement | null {
switch (axis) {
case "time": return renderTimeAxis(ctx);
case "project": return null; // populated lazily — see attachProjectAxis below
case "personal_only": return renderPersonalOnlyAxis(ctx);
case "approval_viewer_role": return renderApprovalRoleAxis(ctx);
case "approval_status": return renderApprovalStatusAxis(ctx);
case "approval_entity_type": return renderApprovalEntityTypeAxis(ctx);
case "deadline_status": return renderDeadlineStatusAxis(ctx);
case "appointment_type": return renderAppointmentTypeAxis(ctx);
case "shape": return renderShapeAxis(ctx);
case "density": return renderDensityAxis(ctx);
case "sort": return renderSortAxis(ctx);
// Per-source predicates that need their own widgets and a roundtrip
// through fetched option lists. Phase 2+ will fill these in by
// wiring the existing event-types / project-list components.
case "deadline_event_type":
case "project_event_kind":
return null;
}
}
// ----------------------------------------------------------------------
// time — chip cluster (presets + Anpassen)
// ----------------------------------------------------------------------
const TIME_PRESETS: Array<{ value: BarState["time"] extends infer T ? (T extends { horizon: infer H } ? H : never) : never; key: I18nKey }> = [
{ value: "next_7d", key: "views.bar.time.next_7d" },
{ value: "next_30d", key: "views.bar.time.next_30d" },
{ value: "next_90d", key: "views.bar.time.next_90d" },
{ value: "past_30d", key: "views.bar.time.past_30d" },
{ value: "any", key: "views.bar.time.any" },
];
function renderTimeAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.time");
const row = chipRow();
const current = ctx.get("time")?.horizon ?? "any";
for (const preset of TIME_PRESETS) {
const chip = chipBtn(t(preset.key), preset.value === current);
chip.addEventListener("click", () => {
if (preset.value === "any") {
ctx.patch({ time: undefined });
} else {
ctx.patch({ time: { horizon: preset.value } });
}
});
row.appendChild(chip);
}
// Custom range — placeholder chip; opens a small popover with two
// <input type="date"> in Phase 2. For Phase 1 we render the chip
// disabled with a tooltip so the affordance is discoverable.
const customChip = chipBtn(t("views.bar.time.custom"), current === "custom");
customChip.classList.add("filter-bar-chip-pending");
customChip.title = t("views.bar.time.custom.coming_soon");
customChip.disabled = true;
row.appendChild(customChip);
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// personal_only — single chip (binary)
// ----------------------------------------------------------------------
function renderPersonalOnlyAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.personal");
const chip = chipBtn(t("views.bar.personal.on"), !!ctx.get("personal_only"));
chip.addEventListener("click", () => {
ctx.patch({ personal_only: !ctx.get("personal_only") });
});
wrap.appendChild(chip);
return wrap;
}
// ----------------------------------------------------------------------
// approval_viewer_role — chip cluster (3 mutually exclusive)
// ----------------------------------------------------------------------
const APPROVAL_ROLES: Array<{ value: NonNullable<BarState["approval_viewer_role"]>; key: I18nKey }> = [
{ value: "approver_eligible", key: "views.bar.approval_role.approver_eligible" },
{ value: "self_requested", key: "views.bar.approval_role.self_requested" },
{ value: "any_visible", key: "views.bar.approval_role.any_visible" },
];
function renderApprovalRoleAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.approval_role");
const row = chipRow();
// Default to "any_visible" so the surface lands on a populated view
// for every user. The InboxSystemView's base spec also defaults here;
// these two defaults must stay in sync — otherwise the chip and the
// server narrow disagree on the empty URL.
const current = ctx.get("approval_viewer_role") ?? "any_visible";
for (const role of APPROVAL_ROLES) {
const chip = chipBtn(t(role.key), role.value === current);
chip.addEventListener("click", () => {
ctx.patch({ approval_viewer_role: role.value });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// approval_status — chip cluster (multi-select)
// ----------------------------------------------------------------------
const APPROVAL_STATUSES: Array<{ value: string; key: I18nKey }> = [
{ value: "pending", key: "views.bar.approval_status.pending" },
{ value: "approved", key: "views.bar.approval_status.approved" },
{ value: "rejected", key: "views.bar.approval_status.rejected" },
{ value: "revoked", key: "views.bar.approval_status.revoked" },
];
function renderApprovalStatusAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.approval_status");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("approval_status")?.length);
all.addEventListener("click", () => ctx.patch({ approval_status: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("approval_status") ?? []);
for (const status of APPROVAL_STATUSES) {
const chip = chipBtn(t(status.key), current.has(status.value));
chip.addEventListener("click", () => {
if (current.has(status.value)) current.delete(status.value);
else current.add(status.value);
ctx.patch({ approval_status: current.size ? [...current] : undefined });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// approval_entity_type — chip pair (multi-select; deadline / appointment)
// ----------------------------------------------------------------------
const APPROVAL_ENTITY_TYPES: Array<{ value: string; key: I18nKey }> = [
{ value: "deadline", key: "views.bar.approval_entity.deadline" },
{ value: "appointment", key: "views.bar.approval_entity.appointment" },
];
function renderApprovalEntityTypeAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.approval_entity");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("approval_entity_type")?.length);
all.addEventListener("click", () => ctx.patch({ approval_entity_type: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("approval_entity_type") ?? []);
for (const ent of APPROVAL_ENTITY_TYPES) {
const chip = chipBtn(t(ent.key), current.has(ent.value));
chip.addEventListener("click", () => {
if (current.has(ent.value)) current.delete(ent.value);
else current.add(ent.value);
ctx.patch({ approval_entity_type: current.size ? [...current] : undefined });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// deadline_status — chip cluster (multi-select)
// ----------------------------------------------------------------------
const DEADLINE_STATUSES: Array<{ value: string; key: I18nKey }> = [
{ value: "pending", key: "views.bar.deadline_status.pending" },
{ value: "completed", key: "views.bar.deadline_status.completed" },
];
function renderDeadlineStatusAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.deadline_status");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("deadline_status")?.length);
all.addEventListener("click", () => ctx.patch({ deadline_status: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("deadline_status") ?? []);
for (const s of DEADLINE_STATUSES) {
const chip = chipBtn(t(s.key), current.has(s.value));
chip.addEventListener("click", () => {
if (current.has(s.value)) current.delete(s.value);
else current.add(s.value);
ctx.patch({ deadline_status: current.size ? [...current] : undefined });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// appointment_type — chip cluster (multi-select)
// ----------------------------------------------------------------------
const APPOINTMENT_TYPES: Array<{ value: string; key: I18nKey }> = [
{ value: "hearing", key: "views.bar.appointment_type.hearing" },
{ value: "meeting", key: "views.bar.appointment_type.meeting" },
{ value: "consultation", key: "views.bar.appointment_type.consultation" },
{ value: "deadline_hearing", key: "views.bar.appointment_type.deadline_hearing" },
];
function renderAppointmentTypeAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.appointment_type");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("appointment_type")?.length);
all.addEventListener("click", () => ctx.patch({ appointment_type: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("appointment_type") ?? []);
for (const ty of APPOINTMENT_TYPES) {
const chip = chipBtn(t(ty.key), current.has(ty.value));
chip.addEventListener("click", () => {
if (current.has(ty.value)) current.delete(ty.value);
else current.add(ty.value);
ctx.patch({ appointment_type: current.size ? [...current] : undefined });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// shape — segmented control (list / cards / calendar)
// ----------------------------------------------------------------------
const SHAPES: Array<{ value: NonNullable<BarState["shape"]>; key: I18nKey }> = [
{ value: "list", key: "views.bar.shape.list" },
{ value: "cards", key: "views.bar.shape.cards" },
{ value: "calendar", key: "views.bar.shape.calendar" },
];
function renderShapeAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.shape");
const row = chipRow();
row.classList.add("filter-bar-segment");
const current = ctx.get("shape");
for (const sh of SHAPES) {
const chip = chipBtn(t(sh.key), sh.value === current);
chip.addEventListener("click", () => ctx.patch({ shape: sh.value }));
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// density — segmented pair (comfortable / compact)
// ----------------------------------------------------------------------
const DENSITIES: Array<{ value: NonNullable<BarState["density"]>; key: I18nKey }> = [
{ value: "comfortable", key: "views.bar.density.comfortable" },
{ value: "compact", key: "views.bar.density.compact" },
];
function renderDensityAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.density");
const row = chipRow();
row.classList.add("filter-bar-segment");
const current = ctx.get("density") ?? "comfortable";
for (const d of DENSITIES) {
const chip = chipBtn(t(d.key), d.value === current);
chip.addEventListener("click", () => ctx.patch({ density: d.value }));
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// sort — small <select>
// ----------------------------------------------------------------------
const SORTS: Array<{ value: NonNullable<BarState["sort"]>; key: I18nKey }> = [
{ value: "date_asc", key: "views.bar.sort.date_asc" },
{ value: "date_desc", key: "views.bar.sort.date_desc" },
];
function renderSortAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.sort");
const sel = document.createElement("select");
sel.className = "entity-select filter-bar-select";
for (const s of SORTS) {
const opt = document.createElement("option");
opt.value = s.value;
opt.textContent = t(s.key);
sel.appendChild(opt);
}
sel.value = ctx.get("sort") ?? "date_asc";
sel.addEventListener("change", () => ctx.patch({ sort: sel.value as NonNullable<BarState["sort"]> }));
wrap.appendChild(sel);
return wrap;
}
// Suppress unused warning for tDyn — it's available for future axes
// (deadline_event_type) that need dynamic enum labels.
void tDyn;
// ----------------------------------------------------------------------
// shared helpers — group + chip + row
// ----------------------------------------------------------------------
function group(labelKey: I18nKey): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "filter-group filter-bar-group";
const label = document.createElement("span");
label.className = "filter-bar-label";
label.textContent = t(labelKey);
wrap.appendChild(label);
return wrap;
}
function chipRow(): HTMLElement {
const row = document.createElement("div");
row.className = "filter-bar-chip-row";
return row;
}
function chipBtn(text: string, active: boolean): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "agenda-chip filter-bar-chip" + (active ? " agenda-chip-active" : "");
btn.textContent = text;
return btn;
}

View File

@@ -0,0 +1,325 @@
// FilterBar — the universal filter + view-mode primitive
// (t-paliad-163). One client component every list-shaped paliad surface
// mounts.
//
// Lifecycle:
// 1. Caller hands in baseFilter + baseRender + axes + onResult.
// 2. We parse URL params (within urlNamespace) and localStorage prefs,
// overlay them on the base spec to compute the effective spec.
// 3. We render the toolbar (one chip cluster / popover / select per
// axis, plus trailing actions).
// 4. We POST /api/views/{slug}/run with the effective spec as override
// and hand the result + effective spec to onResult. The surface's
// shape host renders.
// 5. Every axis interaction patches BarState, re-encodes the URL,
// re-runs the spec.
//
// The bar is a closed loop — surfaces don't see FilterSpec/RenderSpec
// directly, just BarState diffs and the final ViewRunResult. That keeps
// the substrate's validation invariants in one place (the bar).
import { onLangChange, t } from "../i18n";
import type { FilterSpec, RenderSpec, ViewRunResult } from "../views/types";
import {
parseBar,
encodeBar,
} from "./url-codec";
import { renderAxis, type AxisCtx } from "./axes";
import { openSaveModal } from "./save-modal";
import type { BarState, MountOpts, BarHandle, EffectiveSpec, AxisKey } from "./types";
export type { MountOpts, BarHandle, AxisKey } from "./types";
const PREFS_PREFIX = "paliad.bar.";
interface PrefsBlob {
shape?: string;
density?: string;
sort?: string;
}
export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
let state: BarState = {};
const ns = opts.urlNamespace;
// Hydrate state: URL > localStorage prefs > base.
const urlParams = new URLSearchParams(window.location.search);
state = parseBar(urlParams, ns);
hydratePrefs(state, opts.surfaceKey);
// Toolbar shell.
const toolbar = document.createElement("div");
toolbar.className = "filter-bar";
host.appendChild(toolbar);
// Trailing actions: Save as view + Reset (when not suppressed).
const showSave = opts.showSaveAsView !== false;
// Run + render orchestration.
let runVersion = 0;
let lastEffective: EffectiveSpec | null = null;
const runAndRender = async () => {
const effective = computeEffective(opts.baseFilter, opts.baseRender, state);
lastEffective = effective;
const myVersion = ++runVersion;
try {
const r = await fetch(`/api/views/${encodeURIComponent(opts.systemViewSlug)}/run`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filter: effective.filter }),
});
if (myVersion !== runVersion) return; // a newer click superseded us
if (!r.ok) {
opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
return;
}
const result = (await r.json()) as ViewRunResult;
opts.onResult(result, effective);
} catch (_e) {
if (myVersion !== runVersion) return;
opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
}
};
// Axis context — all axis renderers patch state through here.
const ctx: AxisCtx = {
get<K extends keyof BarState>(key: K) { return state[key]; },
patch(delta) {
state = { ...state, ...delta };
// Coerce empties so URL stays clean.
for (const k of Object.keys(delta) as (keyof BarState)[]) {
const v = state[k];
if (Array.isArray(v) && v.length === 0) delete state[k];
if (v === undefined || v === null || v === false) delete state[k];
}
// personal_only false should also be deleted (handled above as
// falsy, but explicit for clarity).
if (state.personal_only === false) delete state.personal_only;
syncURL();
syncPrefs();
renderToolbar();
void runAndRender();
},
};
// First paint.
const renderToolbar = () => {
toolbar.innerHTML = "";
for (const axis of opts.axes) {
const el = renderAxis(axis as AxisKey, ctx);
if (el) toolbar.appendChild(el);
}
if (showSave) {
const trailing = document.createElement("div");
trailing.className = "filter-bar-trailing";
const resetBtn = document.createElement("button");
resetBtn.type = "button";
resetBtn.className = "btn-secondary btn-small filter-bar-reset";
resetBtn.textContent = t("views.bar.action.reset");
resetBtn.disabled = !isDirty(state);
resetBtn.addEventListener("click", () => handle.reset());
trailing.appendChild(resetBtn);
const saveBtn = document.createElement("button");
saveBtn.type = "button";
saveBtn.className = "btn-primary btn-small filter-bar-save";
saveBtn.textContent = t("views.bar.action.save_as_view");
saveBtn.addEventListener("click", async () => {
if (!lastEffective) return;
const result = await openSaveModal(lastEffective.filter, lastEffective.render);
if (result) {
window.location.href = `/views/${encodeURIComponent(result.view.slug)}`;
}
});
trailing.appendChild(saveBtn);
toolbar.appendChild(trailing);
}
};
const syncURL = () => {
const params = new URLSearchParams(window.location.search);
encodeBar(state, params, ns);
const qs = params.toString();
const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
history.replaceState(null, "", url);
};
const syncPrefs = () => {
const blob: PrefsBlob = {};
if (state.shape) blob.shape = state.shape;
if (state.density) blob.density = state.density;
if (state.sort) blob.sort = state.sort;
try {
if (Object.keys(blob).length === 0) {
localStorage.removeItem(PREFS_PREFIX + opts.surfaceKey);
} else {
localStorage.setItem(PREFS_PREFIX + opts.surfaceKey, JSON.stringify(blob));
}
} catch { /* private mode / quota — ignore */ }
};
// Re-render labels on language change without losing state. The
// existing onLangChange API is register-only (no off-handler). We
// gate via a `destroyed` flag so a torn-down bar's callback no-ops.
let destroyed = false;
onLangChange(() => {
if (destroyed) return;
renderToolbar();
});
const handle: BarHandle = {
reset() {
state = {};
syncURL();
syncPrefs();
renderToolbar();
void runAndRender();
},
async refresh() {
await runAndRender();
},
getEffective() {
if (lastEffective) return lastEffective;
return computeEffective(opts.baseFilter, opts.baseRender, state);
},
destroy() {
destroyed = true;
toolbar.remove();
},
};
renderToolbar();
void runAndRender();
return handle;
}
// hydratePrefs reads the saved `paliad.bar.<surfaceKey>` blob and fills
// in render axes the URL didn't already pin. URL wins over prefs.
function hydratePrefs(state: BarState, surfaceKey: string): void {
let blob: PrefsBlob;
try {
const raw = localStorage.getItem(PREFS_PREFIX + surfaceKey);
if (!raw) return;
blob = JSON.parse(raw) as PrefsBlob;
} catch { return; }
if (!state.shape && (blob.shape === "list" || blob.shape === "cards" || blob.shape === "calendar")) {
state.shape = blob.shape;
}
if (!state.density && (blob.density === "comfortable" || blob.density === "compact")) {
state.density = blob.density;
}
if (!state.sort && (blob.sort === "date_asc" || blob.sort === "date_desc")) {
state.sort = blob.sort;
}
}
// computeEffective overlays the BarState onto the base FilterSpec +
// RenderSpec to produce the spec that gets POSTed to the substrate.
//
// Server-side validator (FilterSpec.Validate) is the final gate; we
// produce shapes the validator will accept, but defer to it for the
// hard rejection case (e.g. PersonalOnly + ScopeExplicit).
export function computeEffective(
base: FilterSpec,
baseRender: RenderSpec,
state: BarState,
): EffectiveSpec {
// Deep-clone to avoid mutating the caller's base. JSON round-trip is
// fine here — every field on FilterSpec is a primitive / array /
// object literal (no class instances, no Date, no functions).
const filter = JSON.parse(JSON.stringify(base)) as FilterSpec;
const render = JSON.parse(JSON.stringify(baseRender)) as RenderSpec;
if (state.time) {
filter.time = {
...filter.time,
horizon: state.time.horizon,
from: state.time.horizon === "custom" ? state.time.from : undefined,
to: state.time.horizon === "custom" ? state.time.to : undefined,
};
}
if (state.project) {
if (state.project.mode === "personal") {
filter.scope = {
...filter.scope,
personal_only: true,
// When personal_only takes over, leave projects on the base
// mode (typically all_visible). Validator rejects ScopeExplicit
// + personal_only so we don't overwrite the mode here.
};
} else if (state.project.id) {
filter.scope = {
...filter.scope,
projects: { mode: "explicit", ids: [state.project.id] },
};
}
}
if (state.personal_only) {
filter.scope = { ...filter.scope, personal_only: true };
}
// Per-source predicates. Build the predicates map idempotently;
// never inject a predicate for a source the spec doesn't list.
const sources = new Set(filter.sources);
filter.predicates = filter.predicates ?? {};
if (sources.has("deadline") && (state.deadline_status || state.deadline_event_type)) {
const cur = filter.predicates.deadline ?? {};
const next = { ...cur };
if (state.deadline_status) next.status = state.deadline_status;
if (state.deadline_event_type) {
next.event_types = state.deadline_event_type.ids;
next.include_untyped = state.deadline_event_type.include_untyped;
}
filter.predicates.deadline = next;
}
if (sources.has("appointment") && state.appointment_type) {
const cur = filter.predicates.appointment ?? {};
filter.predicates.appointment = { ...cur, appointment_types: state.appointment_type };
}
if (sources.has("approval_request") && (state.approval_viewer_role || state.approval_status || state.approval_entity_type)) {
const cur = filter.predicates.approval_request ?? {};
const next = { ...cur };
if (state.approval_viewer_role) next.viewer_role = state.approval_viewer_role;
if (state.approval_status) next.status = state.approval_status;
if (state.approval_entity_type) next.entity_types = state.approval_entity_type;
filter.predicates.approval_request = next;
}
if (sources.has("project_event") && state.project_event_kind) {
const cur = filter.predicates.project_event ?? {};
filter.predicates.project_event = { ...cur, event_types: state.project_event_kind };
}
// Render overlays.
if (state.shape) render.shape = state.shape;
if (state.sort) {
if (render.shape === "list" || (state.shape === "list" && !render.list)) {
render.list = { ...(render.list ?? {}), sort: state.sort };
}
if (render.shape === "cards" || state.shape === "cards") {
render.cards = { ...(render.cards ?? {}), sort: state.sort };
}
}
if (state.density && (render.shape === "list" || state.shape === "list")) {
render.list = { ...(render.list ?? {}), density: state.density };
}
return { filter, render };
}
// isDirty — used to enable the Reset button only when there's something
// to reset to.
function isDirty(state: BarState): boolean {
for (const k of Object.keys(state) as (keyof BarState)[]) {
const v = state[k];
if (v === undefined || v === null || v === false) continue;
if (Array.isArray(v) && v.length === 0) continue;
return true;
}
return false;
}

View File

@@ -0,0 +1,146 @@
// Save-as-view modal for the FilterBar. Mirrors the create form on
// /views/new (frontend/src/client/views-editor.ts:168) but as a modal
// so the user can save the bar's current effective spec without
// leaving the page they're filtering on.
//
// On success, the new view appears in the "Meine Sichten" sidebar
// group on next render (the sidebar polls /api/user-views on init).
import { t } from "../i18n";
import type { FilterSpec, RenderSpec, UserView } from "../views/types";
export interface SaveModalResult {
view: UserView;
}
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,62}$/;
export function openSaveModal(filter: FilterSpec, render: RenderSpec): Promise<SaveModalResult | null> {
return new Promise((resolve) => {
const dialog = document.createElement("dialog");
dialog.className = "filter-bar-save-modal";
dialog.innerHTML = `
<form method="dialog" class="filter-bar-save-form">
<h2>${t("views.bar.save.heading")}</h2>
<label class="filter-bar-save-field">
<span>${t("views.bar.save.field.name")}</span>
<input type="text" name="name" required maxlength="100" autocomplete="off" />
</label>
<label class="filter-bar-save-field">
<span>${t("views.bar.save.field.slug")}</span>
<input type="text" name="slug" required maxlength="63" autocomplete="off" pattern="[a-z0-9][a-z0-9-]*" />
<small>${t("views.bar.save.field.slug_hint")}</small>
</label>
<label class="filter-bar-save-checkbox">
<input type="checkbox" name="show_count" />
<span>${t("views.bar.save.field.show_count")}</span>
</label>
<p class="filter-bar-save-error" hidden></p>
<div class="filter-bar-save-actions">
<button type="button" class="btn-secondary" data-action="cancel">${t("views.bar.save.cancel")}</button>
<button type="submit" class="btn-primary">${t("views.bar.save.confirm")}</button>
</div>
</form>
`;
document.body.appendChild(dialog);
const form = dialog.querySelector<HTMLFormElement>(".filter-bar-save-form")!;
const errorEl = dialog.querySelector<HTMLParagraphElement>(".filter-bar-save-error")!;
const nameInput = form.elements.namedItem("name") as HTMLInputElement;
const slugInput = form.elements.namedItem("slug") as HTMLInputElement;
const showCount = form.elements.namedItem("show_count") as HTMLInputElement;
const cancelBtn = dialog.querySelector<HTMLButtonElement>('[data-action="cancel"]')!;
// Auto-derive slug from name as the user types — but only until
// they touch the slug field manually.
let slugDirty = false;
nameInput.addEventListener("input", () => {
if (!slugDirty) slugInput.value = derivedSlug(nameInput.value);
});
slugInput.addEventListener("input", () => { slugDirty = true; });
const cleanup = () => {
dialog.close();
dialog.remove();
};
cancelBtn.addEventListener("click", () => {
cleanup();
resolve(null);
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorEl.hidden = true;
errorEl.textContent = "";
const name = nameInput.value.trim();
const slug = slugInput.value.trim();
if (!name) {
showError(errorEl, t("views.bar.save.error.name_required"));
return;
}
if (!SLUG_REGEX.test(slug)) {
showError(errorEl, t("views.bar.save.error.slug_format"));
return;
}
const payload = {
name,
slug,
filter_spec: filter,
render_spec: render,
show_count: showCount.checked,
};
try {
const r = await fetch("/api/user-views", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (r.status === 409) {
showError(errorEl, t("views.bar.save.error.slug_taken"));
return;
}
if (!r.ok) {
const body = await r.json().catch(() => ({} as { error?: string }));
showError(errorEl, body.error || `${r.status}: ${r.statusText}`);
return;
}
const view = (await r.json()) as UserView;
cleanup();
resolve({ view });
} catch (_e) {
showError(errorEl, t("views.bar.save.error.network"));
}
});
dialog.addEventListener("cancel", () => {
cleanup();
resolve(null);
});
dialog.showModal();
nameInput.focus();
});
}
function showError(el: HTMLElement, msg: string): void {
el.textContent = msg;
el.hidden = false;
}
function derivedSlug(name: string): string {
return name
.toLowerCase()
.replace(/[äÄ]/g, "ae")
.replace(/[öÖ]/g, "oe")
.replace(/[üÜ]/g, "ue")
.replace(/[ß]/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 63);
}

View File

@@ -0,0 +1,132 @@
// FilterBar types — t-paliad-163. Mirrors the Go FilterSpec/RenderSpec
// shapes from internal/services/{filter_spec,render_spec}.go via
// client/views/types.ts. The FilterBar is the universal frontend
// primitive that consumes a base FilterSpec + RenderSpec, declares
// which axes the surface supports, and emits diffs back through
// onResult after running the spec via /api/views/run.
import type { FilterSpec, RenderSpec, RenderShape, ViewRunResult, ListRowAction } from "../views/types";
// AxisKey — every filter dimension the bar can render. Declared per
// surface in mountFilterBar's `axes` array. See design §3.1 for the
// universal-vs-per-surface split.
export type AxisKey =
| "time"
| "project"
| "personal_only"
| "deadline_status"
| "deadline_event_type"
| "appointment_type"
| "approval_viewer_role"
| "approval_status"
| "approval_entity_type"
| "project_event_kind"
| "shape"
| "sort"
| "density";
// Effective spec — the result of overlaying URL + localStorage prefs
// on top of the base spec. Handed back to onResult so the surface can
// dispatch into the matching shape renderer with the right config.
export interface EffectiveSpec {
filter: FilterSpec;
render: RenderSpec;
}
// Per-axis state — what the URL codec round-trips. Each axis's value
// type is bounded to the FilterSpec/RenderSpec subset it touches.
export interface BarState {
// Universal
time?: TimeOverlay;
project?: ProjectOverlay;
personal_only?: boolean;
// Per-source
deadline_status?: string[];
deadline_event_type?: { ids: string[]; include_untyped: boolean };
appointment_type?: string[];
approval_viewer_role?: "approver_eligible" | "self_requested" | "any_visible";
approval_status?: string[];
approval_entity_type?: string[];
project_event_kind?: string[];
// Render
shape?: RenderShape;
sort?: "date_asc" | "date_desc";
density?: "comfortable" | "compact";
}
export interface TimeOverlay {
horizon: "next_7d" | "next_30d" | "next_90d" | "past_30d" | "past_90d" | "any" | "all" | "custom";
from?: string; // ISO 8601 — only when horizon === "custom"
to?: string;
}
export interface ProjectOverlay {
// The bar's project chip is single-select today; Phase C upgrades
// to multi-select. "personal" is a sentinel — the legacy /events
// contract reserved this name, we keep it so old bookmarks still
// resolve to the right state.
mode: "single" | "personal";
id?: string;
}
// MountOpts — the public API.
export interface MountOpts {
// Base spec. Usually a SystemView's FilterSpec+RenderSpec, fetched
// from /api/views/system on the surface and passed in here. For
// /views/{slug}, the saved user-view's spec.
baseFilter: FilterSpec;
baseRender: RenderSpec;
// Which axes the surface exposes. Order is preserved in the rendered
// chrome — surfaces use this to control left-to-right grouping.
axes: AxisKey[];
// URL parameter namespace. When set, every URL key is prefixed
// (`?<ns>_time=`, `?<ns>_project=`, …). Used when two bars share a
// page (dashboard inline lists). Defaults to no prefix.
urlNamespace?: string;
// Surface key for localStorage prefs (density, default shape).
// Required so two surfaces don't share preferences.
surfaceKey: string;
// Whether to render "Speichern als Sicht" + "Zur&uuml;cksetzen"
// trailing actions. Defaults to true. Set false on the dashboard
// inline bars (per design Q6).
showSaveAsView?: boolean;
// Slug of the surface's underlying system view (or saved user view).
// POSTed to /api/views/{slug}/run with the override body. Required —
// the bar runs through that endpoint, never the ad-hoc /api/views/run,
// so the substrate's reserved-slug path stays the canonical entry.
systemViewSlug: string;
// When true, the bar exposes an "Aktualisieren" affordance that
// PATCHes /api/user-views/{userViewId} with the effective spec.
// Set on /views/{slug} where the user is viewing a saved view.
userViewId?: string;
// Called every time the spec changes (mount, URL change, axis
// interaction). The surface dispatches to the matching shape
// renderer with the rows from /api/views/{slug}/run.
onResult(result: ViewRunResult, effective: EffectiveSpec): void;
// Optional — surface-specific row-action override. Phase 1: /inbox
// pins this to "approve"; /events Phase 3 pins to "complete_toggle".
// Future: sourced from the spec's render.list.row_action when set.
rowAction?: ListRowAction;
}
// Bar handle — what mountFilterBar returns. Pages can call .reset()
// from page-level controls (e.g. an empty-state "Filter zurücksetzen"
// button), or .destroy() if the page tears down.
export interface BarHandle {
reset(): void;
refresh(): Promise<void>;
destroy(): void;
// Read-only effective spec at this moment (post URL + localStorage
// overlay). Pages use this to construct deep-link URLs etc.
getEffective(): EffectiveSpec;
}

View File

@@ -0,0 +1,102 @@
// Unit tests for the FilterBar URL codec. Round-trip discipline:
// every BarState shape parseBar produces must encode back to the same
// URL params, and vice versa. Run with `bun test`.
import { test, expect, describe } from "bun:test";
import { parseBar, encodeBar } from "./url-codec";
import type { BarState } from "./types";
function roundTrip(state: BarState, ns?: string): BarState {
const params = new URLSearchParams();
encodeBar(state, params, ns);
return parseBar(params, ns);
}
describe("filter-bar/url-codec", () => {
test("empty state round-trips to empty", () => {
expect(roundTrip({})).toEqual({});
});
test("time horizon round-trips", () => {
for (const h of ["next_7d", "next_30d", "next_90d", "past_30d", "past_90d", "any", "all"] as const) {
expect(roundTrip({ time: { horizon: h } })).toEqual({ time: { horizon: h } });
}
});
test("custom time horizon round-trips with from + to", () => {
const state: BarState = { time: { horizon: "custom", from: "2026-01-01", to: "2026-12-31" } };
expect(roundTrip(state)).toEqual(state);
});
test("project sentinel + uuid round-trip", () => {
expect(roundTrip({ project: { mode: "personal" } })).toEqual({ project: { mode: "personal" } });
expect(roundTrip({ project: { mode: "single", id: "11111111-1111-1111-1111-111111111111" } }))
.toEqual({ project: { mode: "single", id: "11111111-1111-1111-1111-111111111111" } });
});
test("personal_only flag round-trips", () => {
expect(roundTrip({ personal_only: true })).toEqual({ personal_only: true });
expect(roundTrip({})).toEqual({});
});
test("deadline_event_type honours legacy 'none' sentinel", () => {
const state: BarState = { deadline_event_type: { ids: ["a", "b"], include_untyped: true } };
expect(roundTrip(state)).toEqual(state);
const state2: BarState = { deadline_event_type: { ids: [], include_untyped: true } };
expect(roundTrip(state2)).toEqual(state2);
const state3: BarState = { deadline_event_type: { ids: ["a"], include_untyped: false } };
expect(roundTrip(state3)).toEqual(state3);
});
test("approval_request triple round-trips together", () => {
const state: BarState = {
approval_viewer_role: "approver_eligible",
approval_status: ["pending", "approved"],
approval_entity_type: ["deadline"],
};
expect(roundTrip(state)).toEqual(state);
});
test("namespace prefix isolates two bars on the same page", () => {
const a: BarState = { time: { horizon: "next_7d" } };
const b: BarState = { time: { horizon: "next_30d" } };
const params = new URLSearchParams();
encodeBar(a, params, "agenda");
encodeBar(b, params, "activity");
expect(parseBar(params, "agenda")).toEqual(a);
expect(parseBar(params, "activity")).toEqual(b);
// Without namespace neither bar's keys are visible.
expect(parseBar(params)).toEqual({});
});
test("render axes round-trip", () => {
const state: BarState = { shape: "cards", sort: "date_desc", density: "compact" };
expect(roundTrip(state)).toEqual(state);
});
test("encode is idempotent — re-encoding same state replaces, doesn't accumulate", () => {
const state: BarState = { time: { horizon: "next_7d" }, deadline_status: ["pending"] };
const params = new URLSearchParams();
encodeBar(state, params);
encodeBar(state, params);
expect(params.get("d_status")).toBe("pending");
// Only one entry per key.
expect(params.getAll("d_status")).toHaveLength(1);
});
test("encode replaces stale keys when state shrinks", () => {
const params = new URLSearchParams();
encodeBar({ deadline_status: ["pending"], approval_viewer_role: "self_requested" }, params);
encodeBar({ deadline_status: ["completed"] }, params);
expect(params.get("d_status")).toBe("completed");
expect(params.has("a_role")).toBe(false);
});
test("parse drops unknown enum values silently (forward-compat)", () => {
const params = new URLSearchParams();
params.set("a_role", "future_role_we_dont_know_yet");
params.set("shape", "kanban");
params.set("density", "huge");
expect(parseBar(params)).toEqual({});
});
});

View File

@@ -0,0 +1,188 @@
// FilterBar URL codec — t-paliad-163. Encodes BarState ↔ URL
// parameters with optional namespace prefix (?<ns>_<key>=).
//
// The bar treats the URL as canonical for everything that affects
// which rows you see. Round-trip discipline: anything written by
// encodeBar must parse back identically via parseBar so deep-links
// and refresh both yield the same effective spec.
//
// Empty / default values are NOT written — the URL stays clean for
// users who don't tweak. The page's base spec is the implicit baseline.
import type { BarState, TimeOverlay, ProjectOverlay } from "./types";
const PERSONAL_PROJECT_SENTINEL = "personal";
// parseBar reads URL params into a BarState. Unknown values are
// dropped silently (forward-compat with future axes).
export function parseBar(params: URLSearchParams, ns?: string): BarState {
const k = (key: string) => (ns ? `${ns}_${key}` : key);
const out: BarState = {};
// time
const time = params.get(k("time"));
if (time) {
const horizon = parseHorizon(time);
if (horizon) {
const overlay: TimeOverlay = { horizon };
if (horizon === "custom") {
const from = params.get(k("from"));
const to = params.get(k("to"));
if (from) overlay.from = from;
if (to) overlay.to = to;
}
out.time = overlay;
}
}
// project
const project = params.get(k("project"));
if (project) {
if (project === PERSONAL_PROJECT_SENTINEL) {
out.project = { mode: "personal" };
} else {
out.project = { mode: "single", id: project };
}
}
// personal_only
if (params.get(k("personal")) === "1") {
out.personal_only = true;
}
// deadline.status
const dStatus = params.get(k("d_status"));
if (dStatus) out.deadline_status = parseCSV(dStatus);
// deadline.event_types — preserves the legacy /events contract
// where "none" inside the CSV means include_untyped=true.
const dEvent = params.get(k("d_event_type"));
if (dEvent) {
const tokens = parseCSV(dEvent);
const ids: string[] = [];
let untyped = false;
for (const tok of tokens) {
if (tok === "none") untyped = true;
else ids.push(tok);
}
out.deadline_event_type = { ids, include_untyped: untyped };
}
// appointment.types
const appType = params.get(k("app_type"));
if (appType) out.appointment_type = parseCSV(appType);
// approval_request.viewer_role
const aRole = params.get(k("a_role"));
if (aRole === "approver_eligible" || aRole === "self_requested" || aRole === "any_visible") {
out.approval_viewer_role = aRole;
}
// approval_request.status
const aStatus = params.get(k("a_status"));
if (aStatus) out.approval_status = parseCSV(aStatus);
// approval_request.entity_types
const aEntity = params.get(k("a_entity_type"));
if (aEntity) out.approval_entity_type = parseCSV(aEntity);
// project_event.event_types
const peKind = params.get(k("pe_kind"));
if (peKind) out.project_event_kind = parseCSV(peKind);
// render.shape
const shape = params.get(k("shape"));
if (shape === "list" || shape === "cards" || shape === "calendar") out.shape = shape;
// render.list.sort / render.cards.sort — the bar treats sort as one axis
const sort = params.get(k("sort"));
if (sort === "date_asc" || sort === "date_desc") out.sort = sort;
// render.list.density
const density = params.get(k("density"));
if (density === "comfortable" || density === "compact") out.density = density;
return out;
}
// encodeBar writes BarState back into URL params, mutating the
// passed-in URLSearchParams. Empty / undefined values are omitted.
// The caller controls how the result is applied (history.replaceState
// with the page pathname unchanged).
export function encodeBar(state: BarState, params: URLSearchParams, ns?: string): void {
const k = (key: string) => (ns ? `${ns}_${key}` : key);
// Clear every key the bar owns first, then re-write the non-empty ones.
for (const key of [
"time", "from", "to", "project", "personal",
"d_status", "d_event_type",
"app_type",
"a_role", "a_status", "a_entity_type",
"pe_kind",
"shape", "sort", "density",
]) {
params.delete(k(key));
}
if (state.time) {
params.set(k("time"), state.time.horizon);
if (state.time.horizon === "custom") {
if (state.time.from) params.set(k("from"), state.time.from);
if (state.time.to) params.set(k("to"), state.time.to);
}
}
if (state.project) {
if (state.project.mode === "personal") {
params.set(k("project"), PERSONAL_PROJECT_SENTINEL);
} else if (state.project.id) {
params.set(k("project"), state.project.id);
}
}
if (state.personal_only) params.set(k("personal"), "1");
if (state.deadline_status?.length) params.set(k("d_status"), state.deadline_status.join(","));
if (state.deadline_event_type) {
const parts = [...state.deadline_event_type.ids];
if (state.deadline_event_type.include_untyped) parts.push("none");
if (parts.length) params.set(k("d_event_type"), parts.join(","));
}
if (state.appointment_type?.length) params.set(k("app_type"), state.appointment_type.join(","));
if (state.approval_viewer_role) params.set(k("a_role"), state.approval_viewer_role);
if (state.approval_status?.length) params.set(k("a_status"), state.approval_status.join(","));
if (state.approval_entity_type?.length) params.set(k("a_entity_type"), state.approval_entity_type.join(","));
if (state.project_event_kind?.length) params.set(k("pe_kind"), state.project_event_kind.join(","));
if (state.shape) params.set(k("shape"), state.shape);
if (state.sort) params.set(k("sort"), state.sort);
if (state.density) params.set(k("density"), state.density);
}
function parseHorizon(s: string): TimeOverlay["horizon"] | null {
switch (s) {
case "next_7d":
case "next_30d":
case "next_90d":
case "past_30d":
case "past_90d":
case "any":
case "all":
case "custom":
return s;
default:
return null;
}
}
function parseCSV(s: string): string[] {
return s.split(",").map((x) => x.trim()).filter(Boolean);
}
export { PERSONAL_PROJECT_SENTINEL };
// Re-exported so consumers don't need to import ProjectOverlay just
// to construct one in tests.
export type { ProjectOverlay };

View File

@@ -288,6 +288,12 @@ interface ProjectOption {
// (Slice 3b) can scope the cascade by the project's jurisdiction
// without an extra fetch.
proceeding_type_id?: number | null;
// our_side carries which side the firm represents on this project
// (t-paliad-164). When a user selects an Akte, the perspective chip
// pre-locks to this value; a small hint above the strip flags the
// pre-selection and the user can still click another chip to
// override. NULL/undefined leaves the chip unset (free-pick).
our_side?: "claimant" | "defendant" | "court" | "both" | null;
}
function escAttr(s: string): string {
@@ -2530,6 +2536,11 @@ function selectProject(project: ProjectOption) {
writeStep1ContextToURL(currentStep1Context);
renderStep1Summary();
showStep2Card();
// t-paliad-164: project.our_side predefines the perspective chip.
// Only fires when the user hasn't already locked a perspective via
// ?role= in the URL — the URL pick wins because it represents an
// explicit choice (chip click or shared link).
applyOurSidePredefine(project, /* replaceURL */ false);
// Slice 3b: project's proceeding type narrows the B1 cascade if the
// user reaches it via Step 2 → Etwas ist passiert. Refresh here so
// a cascade already on screen (rare but possible via popstate) picks
@@ -2551,6 +2562,12 @@ function clearStep1Context() {
renderStep1Summary();
hideStep2Card();
triggerCascadeRefresh();
// t-paliad-164: hint dies with the project context. We deliberately
// leave the perspective chip itself alone — the user may want to
// keep their pick when returning to Step 1; we only clear the
// "vorgegeben durch Akte" annotation since there's no Akte anymore.
const hint = document.getElementById("fristen-perspective-hint");
if (hint) hint.hidden = true;
}
function renderStep1Summary() {
@@ -2626,6 +2643,10 @@ function initPathwayFork() {
if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
currentStep1Context.project = cachedAkten.find((p) => p.id === currentStep1Context.projectId);
renderStep1Summary();
// t-paliad-164: deep-link / refresh path. project loaded async, so
// the predefine has to wait for cachedAkten. replace=true keeps
// the URL clean — the user didn't navigate, they just refreshed.
applyOurSidePredefine(currentStep1Context.project, /* replaceURL */ true);
}
renderAkteList("");
// Cascade may already be on screen if the user landed with
@@ -2657,6 +2678,11 @@ function initPathwayFork() {
const next: Perspective = isClear ? null : ((chip.dataset.perspective as Perspective) ?? null);
writePerspectiveToURL(next);
applyPerspective(next);
// t-paliad-164: any chip click is an explicit override; hide the
// "vorgegeben durch Akte" hint so the bar reads as "user choice"
// from here on.
const hint = document.getElementById("fristen-perspective-hint");
if (hint) hint.hidden = true;
});
});
@@ -2744,6 +2770,17 @@ function initPathwayFork() {
renderStep1Summary();
if (currentStep1Context.kind !== "none") showStep2Card(); else hideStep2Card();
applyPerspective(readPerspectiveFromURL());
// t-paliad-164: restore the hint visibility from URL+project state.
// The hint shows when the active URL perspective matches what the
// current project's our_side would have predefined — i.e. the
// "predefined-and-not-yet-overridden" state. Approximation: hint
// visible iff project.our_side maps to currentPerspective.
const hint = document.getElementById("fristen-perspective-hint");
if (hint) {
const proj = currentStep1Context.kind === "project" ? currentStep1Context.project : undefined;
const expected = ourSideToPerspective(proj?.our_side);
hint.hidden = !(proj && proj.our_side && expected === currentPerspective);
}
const path = readPathwayFromURL();
const mode = readBModeFromURL();
showPathway(path, mode);
@@ -3382,6 +3419,45 @@ function applyPerspective(p: Perspective) {
triggerCascadeRefresh();
}
// ourSideToPerspective maps the project-level "Wir vertreten" enum
// onto the chip-strip Perspective. 'court' / 'both' map to null
// (chip cleared) — court actions are neutral to the user's side and
// "both" is explicit no-filter intent.
function ourSideToPerspective(os: string | null | undefined): Perspective {
if (os === "claimant") return "claimant";
if (os === "defendant") return "defendant";
return null;
}
// applyOurSidePredefine locks the perspective chip from
// project.our_side when the user hasn't already explicitly picked
// one. The URL is the "explicit pick" signal: if ?role= is present
// at call time, the user (or a shared link) chose it and we don't
// overwrite. When we do predefine, we write the same value to the
// URL so back/forward + refresh round-trip cleanly, and we show the
// "vorgegeben durch Akte" hint so the user knows where the
// pre-selection came from. Clicking a chip clears the hint.
//
// `replaceURL=true` is for the deep-link / refresh path; `false` for
// in-page project selection so back-button restores the empty state.
function applyOurSidePredefine(project: ProjectOption | undefined, replaceURL: boolean) {
const hint = document.getElementById("fristen-perspective-hint");
if (!project || !project.our_side) {
if (hint) hint.hidden = true;
return;
}
// URL wins — user has an explicit pick. Don't clobber it; also no
// hint, since the active perspective didn't come from the project.
if (readPerspectiveFromURL() !== null) {
if (hint) hint.hidden = true;
return;
}
const next = ourSideToPerspective(project.our_side);
writePerspectiveToURL(next, replaceURL);
applyPerspective(next);
if (hint) hint.hidden = false;
}
// perspectiveAllowsParty returns true when a node tagged with `party`
// should be visible under the current perspective. Neutral nodes
// (party undefined / empty) always pass. "both" matches every

View File

@@ -376,6 +376,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.perspective.claimant.title": "Klägerseite — versteckt typische Beklagten-Schriftsätze",
"deadlines.perspective.defendant.title": "Beklagtenseite — versteckt typische Kläger-Schriftsätze",
"deadlines.perspective.appeal_filed_by.label": "Berufung eingelegt durch:",
"deadlines.perspective.predefined_hint": "vorgegeben durch Akte",
"deadlines.event.composite.label": "Zusammengesetzt:",
"deadlines.event.unit.days.one": "Tag",
"deadlines.event.unit.days.many": "Tage",
@@ -740,6 +741,10 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.field.due": "F\u00e4lligkeitsdatum",
"deadlines.field.rule": "Regel (optional)",
"deadlines.field.rule.none": "Keine Regel",
"deadlines.field.rule.autofill": "Typ vorgegeben durch Regel — entfernen, um zu überschreiben.",
"deadlines.field.rule.autofill_inline": " (vorgegeben durch Regel)",
"deadlines.field.rule.mismatch": "Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.",
"deadlines.field.rule.override": "Anderen Typ wählen",
"deadlines.field.notes": "Notizen (optional)",
"deadlines.field.notes.placeholder": "Hinweise, Verweise, n\u00e4chste Schritte\u2026",
"deadlines.error.required": "Akte, Titel und F\u00e4lligkeitsdatum sind Pflichtfelder.",
@@ -875,6 +880,7 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.action.short.project_reparented": "ordnete Projekt neu zu",
"dashboard.action.short.project_type_changed": "\u00e4nderte Projekt-Typ",
"dashboard.action.short.status_changed": "\u00e4nderte Status",
"dashboard.action.short.our_side_changed": "\u00e4nderte vertretene Seite",
"dashboard.action.short.visibility_changed": "\u00e4nderte Sichtbarkeit",
"dashboard.action.short.collaborators_updated": "aktualisierte Bearbeiter",
"dashboard.action.short.note_created": "f\u00fcgte Notiz hinzu",
@@ -896,6 +902,7 @@ const translations: Record<Lang, Record<string, string>> = {
"event.title.project_reparented": "Projekt umstrukturiert",
"event.title.project_type_changed": "Projekt-Typ ge\u00e4ndert",
"event.title.status_changed": "Status ge\u00e4ndert",
"event.title.our_side_changed": "Vertretene Seite ge\u00e4ndert",
"event.title.note_created": "Notiz hinzugef\u00fcgt",
"event.title.deadline_created": "Frist angelegt",
"event.title.deadline_updated": "Frist ge\u00e4ndert",
@@ -1125,6 +1132,14 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.field.court": "Gericht",
"projects.field.case_number": "Aktenzeichen (Gericht)",
"projects.field.proceeding_type_id": "Verfahrensart",
"projects.field.our_side": "Wir vertreten",
"projects.field.our_side.hint": "Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. Lässt sich dort jederzeit überschreiben.",
"projects.field.our_side.unset": "Unbekannt / nicht gesetzt",
"projects.field.our_side.claimant": "Klägerseite",
"projects.field.our_side.defendant": "Beklagtenseite",
"projects.field.our_side.court": "Gericht / Tribunal",
"projects.field.our_side.both": "Beide Seiten",
"projects.field.our_side.none": "—",
"projects.field.status": "Status",
"projects.error.title_required": "Titel erforderlich",
"projects.detail.edit.type_change_warning.title": "Diese Felder werden geleert:",
@@ -2154,6 +2169,63 @@ const translations: Record<Lang, Record<string, string>> = {
"views.editor.error.sources_required": "Mindestens eine Quelle wählen.",
"views.editor.error.load_failed": "Ansicht konnte nicht geladen werden.",
"views.editor.error.delete_failed": "Ansicht konnte nicht gelöscht werden.",
// Universal FilterBar — t-paliad-163. Mounted on every list-shaped
// surface (starts with /inbox in Phase 1; /agenda + /events follow).
"views.bar.label.time": "Zeitraum",
"views.bar.label.personal": "Eigene",
"views.bar.label.approval_role": "Sicht",
"views.bar.label.approval_status": "Status",
"views.bar.label.approval_entity": "Art",
"views.bar.label.deadline_status": "Frist-Status",
"views.bar.label.appointment_type": "Termin-Typ",
"views.bar.label.shape": "Darstellung",
"views.bar.label.density": "Dichte",
"views.bar.label.sort": "Sortierung",
"views.bar.common.all": "Alle",
"views.bar.time.next_7d": "7 Tage",
"views.bar.time.next_30d": "30 Tage",
"views.bar.time.next_90d": "90 Tage",
"views.bar.time.past_30d": "Letzte 30 T.",
"views.bar.time.any": "Beliebig",
"views.bar.time.custom": "Anpassen",
"views.bar.time.custom.coming_soon": "Benutzerdefinierter Zeitraum folgt in einer der nächsten Iterationen.",
"views.bar.personal.on": "Nur eigene",
"views.bar.approval_role.approver_eligible": "Zur Genehmigung",
"views.bar.approval_role.self_requested": "Eigene Anfragen",
"views.bar.approval_role.any_visible": "Alle sichtbaren",
"views.bar.approval_status.pending": "Wartend",
"views.bar.approval_status.approved": "Genehmigt",
"views.bar.approval_status.rejected": "Abgelehnt",
"views.bar.approval_status.revoked": "Zurückgezogen",
"views.bar.approval_entity.deadline": "Frist",
"views.bar.approval_entity.appointment": "Termin",
"views.bar.deadline_status.pending": "Offen",
"views.bar.deadline_status.completed": "Erledigt",
"views.bar.appointment_type.hearing": "Verhandlung",
"views.bar.appointment_type.meeting": "Besprechung",
"views.bar.appointment_type.consultation": "Beratung",
"views.bar.appointment_type.deadline_hearing": "Mündliche Verhandlung",
"views.bar.shape.list": "Liste",
"views.bar.shape.cards": "Karten",
"views.bar.shape.calendar": "Kalender",
"views.bar.density.comfortable": "Bequem",
"views.bar.density.compact": "Kompakt",
"views.bar.sort.date_asc": "Datum aufsteigend",
"views.bar.sort.date_desc": "Datum absteigend",
"views.bar.action.reset": "Zurücksetzen",
"views.bar.action.save_as_view": "Als Sicht speichern",
"views.bar.save.heading": "Sicht speichern",
"views.bar.save.field.name": "Name",
"views.bar.save.field.slug": "Slug",
"views.bar.save.field.slug_hint": "Wird Teil der URL: /views/<slug>",
"views.bar.save.field.show_count": "Anzahl in der Sidebar zeigen",
"views.bar.save.cancel": "Abbrechen",
"views.bar.save.confirm": "Speichern",
"views.bar.save.error.name_required": "Bitte Namen vergeben.",
"views.bar.save.error.slug_format": "Slug muss mit einem Buchstaben oder einer Ziffer beginnen und darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.",
"views.bar.save.error.slug_taken": "Dieser Slug ist bereits vergeben.",
"views.bar.save.error.network": "Netzwerkfehler — bitte erneut versuchen.",
},
en: {
@@ -2520,6 +2592,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.perspective.claimant.title": "Claimant side — hides typical defendant submissions",
"deadlines.perspective.defendant.title": "Defendant side — hides typical claimant submissions",
"deadlines.perspective.appeal_filed_by.label": "Appeal filed by:",
"deadlines.perspective.predefined_hint": "predefined from project",
"deadlines.event.composite.label": "Composite:",
"deadlines.event.unit.days.one": "day",
"deadlines.event.unit.days.many": "days",
@@ -2877,6 +2950,10 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.field.due": "Due date",
"deadlines.field.rule": "Rule (optional)",
"deadlines.field.rule.none": "No rule",
"deadlines.field.rule.autofill": "Type set by rule — remove to override.",
"deadlines.field.rule.autofill_inline": " (set by rule)",
"deadlines.field.rule.mismatch": "Note: type contradicts rule — you have overridden the type.",
"deadlines.field.rule.override": "Choose another type",
"deadlines.field.notes": "Notes (optional)",
"deadlines.field.notes.placeholder": "References, hints, next steps\u2026",
"deadlines.error.required": "Matter, title and due date are required.",
@@ -3002,6 +3079,7 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.action.short.project_reparented": "re-parented project",
"dashboard.action.short.project_type_changed": "changed project type",
"dashboard.action.short.status_changed": "changed status",
"dashboard.action.short.our_side_changed": "changed represented side",
"dashboard.action.short.visibility_changed": "changed visibility",
"dashboard.action.short.collaborators_updated": "updated collaborators",
"dashboard.action.short.note_created": "added note",
@@ -3023,6 +3101,7 @@ const translations: Record<Lang, Record<string, string>> = {
"event.title.project_reparented": "Project re-parented",
"event.title.project_type_changed": "Project type changed",
"event.title.status_changed": "Status changed",
"event.title.our_side_changed": "Represented side changed",
"event.title.note_created": "Note added",
"event.title.deadline_created": "Deadline created",
"event.title.deadline_updated": "Deadline updated",
@@ -3250,6 +3329,14 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.field.court": "Court",
"projects.field.case_number": "Case number (court)",
"projects.field.proceeding_type_id": "Proceeding type",
"projects.field.our_side": "We represent",
"projects.field.our_side.hint": "Pre-selects the perspective chip in the Fristenrechner Determinator. Always overridable from there.",
"projects.field.our_side.unset": "Unknown / not set",
"projects.field.our_side.claimant": "Claimant side",
"projects.field.our_side.defendant": "Defendant side",
"projects.field.our_side.court": "Court / tribunal",
"projects.field.our_side.both": "Both sides",
"projects.field.our_side.none": "—",
"projects.field.status": "Status",
"projects.error.title_required": "Title required",
"projects.detail.edit.type_change_warning.title": "These fields will be cleared:",
@@ -4276,6 +4363,62 @@ const translations: Record<Lang, Record<string, string>> = {
"views.editor.error.sources_required": "Pick at least one source.",
"views.editor.error.load_failed": "Could not load this view.",
"views.editor.error.delete_failed": "Could not delete this view.",
// Universal FilterBar — t-paliad-163.
"views.bar.label.time": "Time",
"views.bar.label.personal": "Mine",
"views.bar.label.approval_role": "View",
"views.bar.label.approval_status": "Status",
"views.bar.label.approval_entity": "Kind",
"views.bar.label.deadline_status": "Deadline status",
"views.bar.label.appointment_type": "Appointment type",
"views.bar.label.shape": "Display",
"views.bar.label.density": "Density",
"views.bar.label.sort": "Sort",
"views.bar.common.all": "All",
"views.bar.time.next_7d": "7 days",
"views.bar.time.next_30d": "30 days",
"views.bar.time.next_90d": "90 days",
"views.bar.time.past_30d": "Past 30 d.",
"views.bar.time.any": "Any",
"views.bar.time.custom": "Custom",
"views.bar.time.custom.coming_soon": "Custom date range arrives in a follow-up iteration.",
"views.bar.personal.on": "Mine only",
"views.bar.approval_role.approver_eligible": "To approve",
"views.bar.approval_role.self_requested": "My requests",
"views.bar.approval_role.any_visible": "All visible",
"views.bar.approval_status.pending": "Pending",
"views.bar.approval_status.approved": "Approved",
"views.bar.approval_status.rejected": "Rejected",
"views.bar.approval_status.revoked": "Revoked",
"views.bar.approval_entity.deadline": "Deadline",
"views.bar.approval_entity.appointment": "Appointment",
"views.bar.deadline_status.pending": "Open",
"views.bar.deadline_status.completed": "Completed",
"views.bar.appointment_type.hearing": "Hearing",
"views.bar.appointment_type.meeting": "Meeting",
"views.bar.appointment_type.consultation": "Consultation",
"views.bar.appointment_type.deadline_hearing": "Oral hearing",
"views.bar.shape.list": "List",
"views.bar.shape.cards": "Cards",
"views.bar.shape.calendar": "Calendar",
"views.bar.density.comfortable": "Comfortable",
"views.bar.density.compact": "Compact",
"views.bar.sort.date_asc": "Date ascending",
"views.bar.sort.date_desc": "Date descending",
"views.bar.action.reset": "Reset",
"views.bar.action.save_as_view": "Save as view",
"views.bar.save.heading": "Save view",
"views.bar.save.field.name": "Name",
"views.bar.save.field.slug": "Slug",
"views.bar.save.field.slug_hint": "Becomes part of the URL: /views/<slug>",
"views.bar.save.field.show_count": "Show count in sidebar",
"views.bar.save.cancel": "Cancel",
"views.bar.save.confirm": "Save",
"views.bar.save.error.name_required": "Please supply a name.",
"views.bar.save.error.slug_format": "Slug must start with a letter or digit and contain only lowercase letters, digits, and hyphens.",
"views.bar.save.error.slug_taken": "This slug is already in use.",
"views.bar.save.error.network": "Network error — please retry.",
},
};
@@ -4351,6 +4494,12 @@ function translateEventDescription(eventType: string, description: string): stri
// New format: "active → archived". Legacy: "Status active → archived".
return translateArrowSlugs(body.replace(/^Status\s+/, ""), "projects.filter.status.");
}
if (eventType === "our_side_changed") {
// Format: "<from> → <to>", where each side is one of
// claimant / defendant / court / both / "none" (the sentinel for
// NULL the service writes when the column is unset on either end).
return translateArrowSlugs(body, "projects.field.our_side.");
}
if (eventType === "note_created") {
// New format: just the parent slug. Legacy: "Note zu <slug> hinzugefügt".
const m = body.match(/^Note zu (project|deadline|appointment) hinzugef[üu]gt$/i);

View File

@@ -1,122 +1,176 @@
import { initI18n, t, getLang, type I18nKey } from "./i18n";
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
import { mountFilterBar, type BarHandle } from "./filter-bar";
import type { AxisKey } from "./filter-bar";
import type { FilterSpec, RenderSpec, SystemView, ViewRunResult } from "./views/types";
import { renderListShape } from "./views/shape-list";
// /inbox client. Two tabs (pending-mine / mine), action buttons (approve /
// reject / revoke), and a small inline diff for update / complete / delete
// lifecycle events.
// /inbox client — t-paliad-163 universal-filter migration.
//
// State is URL-driven via ?tab= so back/forward buttons work and the bell
// badge can deep-link to either tab. The badge in the sidebar (id
// sidebar-inbox-badge) is updated by the shared global polling loop in
// sidebar.ts; this module just keeps the page content in sync.
// The bar owns every axis the old tab UI exposed plus more:
// - approval_viewer_role: "Zur Genehmigung" / "Eigene Anfragen" /
// "Alle sichtbaren" (collapses the legacy two-tab UI per Q4 lock-in)
// - approval_status: chip cluster (default: pending)
// - approval_entity_type: chip pair (Frist / Termin)
// - time: chip cluster (Any default)
// - density: comfortable / compact
// - sort: date asc / desc
//
// Row rendering: shape-list.ts with row_action="approve" stamps the
// inbox markup (entity title, diff, approve/reject/revoke buttons).
// We wire action click handlers in onResult and refresh through the
// bar handle.
type Lifecycle = "create" | "update" | "complete" | "delete";
type RequestStatus = "pending" | "approved" | "rejected" | "revoked" | "superseded";
type DecisionKind = "peer" | "admin_override";
const INBOX_AXES: AxisKey[] = [
"time",
"approval_viewer_role",
"approval_status",
"approval_entity_type",
"density",
"sort",
];
interface ApprovalRequestView {
id: string;
project_id: string;
project_title: string;
entity_type: "deadline" | "appointment";
entity_id: string;
entity_title?: string;
lifecycle_event: Lifecycle;
pre_image?: Record<string, unknown> | null;
payload?: Record<string, unknown> | null;
required_role: string;
status: RequestStatus;
requested_at: string;
requested_by: string;
requester_name: string;
decided_at?: string;
decided_by?: string;
decider_name?: string;
decision_kind?: DecisionKind;
decision_note?: string;
// t-paliad-161: 'user' (direct create) or 'agent' (Paliadin-drafted).
// 'agent' rows render with a sparkle ✨ next to the requester's name.
requester_kind?: "user" | "agent";
agent_turn_id?: string;
}
type Tab = "pending-mine" | "mine";
let currentTab: Tab = "pending-mine";
initI18n();
initSidebar();
let bar: BarHandle | null = null;
document.addEventListener("DOMContentLoaded", () => {
const url = new URL(window.location.href);
const t = url.searchParams.get("tab");
if (t === "mine") currentTab = "mine";
bindTabs();
refresh();
initI18n();
initSidebar();
applyLegacyTabRedirect();
void hydrate();
});
function bindTabs() {
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
const tab = (btn.dataset.tab as Tab) || "pending-mine";
if (tab === currentTab) return;
currentTab = tab;
const url = new URL(window.location.href);
url.searchParams.set("tab", tab);
history.replaceState({}, "", url.toString());
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((b) => {
b.classList.toggle("active", b.dataset.tab === tab);
});
refresh();
});
// ?tab=pending-mine | mine -> ?a_role=approver_eligible | self_requested.
// Done client-side because /inbox serves a static dist file (no Go
// router involvement). Bookmarks from the sidebar bell + outbound
// emails keep landing on the right sub-view through the bar.
function applyLegacyTabRedirect(): void {
const url = new URL(window.location.href);
const tab = url.searchParams.get("tab");
if (!tab) return;
url.searchParams.delete("tab");
if (tab === "mine") {
url.searchParams.set("a_role", "self_requested");
} else if (tab === "pending-mine") {
url.searchParams.set("a_role", "approver_eligible");
}
history.replaceState(null, "", url.toString());
}
async function hydrate(): Promise<void> {
const host = document.getElementById("inbox-filter-bar");
const loading = document.getElementById("inbox-loading");
const results = document.getElementById("inbox-results");
const empty = document.getElementById("inbox-empty");
if (!host || !loading || !results || !empty) return;
const sys = await fetchInboxSystemView();
if (!sys) {
loading.style.display = "none";
empty.style.display = "";
empty.textContent = t("approvals.error.internal");
return;
}
bar = mountFilterBar(host, {
baseFilter: sys.Filter,
baseRender: sys.Render,
axes: INBOX_AXES,
surfaceKey: "inbox",
systemViewSlug: sys.Slug,
onResult: (result, effective) => paint(result, effective.render, results, empty, loading),
});
}
async function refresh() {
const loading = document.getElementById("inbox-loading") as HTMLElement | null;
const empty = document.getElementById("inbox-empty") as HTMLElement | null;
const list = document.getElementById("inbox-list") as HTMLUListElement | null;
if (!loading || !empty || !list) return;
loading.style.display = "";
empty.style.display = "none";
list.innerHTML = "";
const path = currentTab === "pending-mine" ? "/api/inbox/pending-mine" : "/api/inbox/mine";
let rows: ApprovalRequestView[] = [];
async function fetchInboxSystemView(): Promise<SystemView | null> {
try {
const r = await fetch(path, { credentials: "include" });
if (r.ok) {
// Defensive: a Go `nil` slice serialises as JSON `null`, not `[]`.
// Coerce so `rows.length` never throws (t-paliad-160 §D regression
// hardening). Server-side handler also forces `[]`, but keep the
// client guard for older / cached deploys.
const body = (await r.json()) as ApprovalRequestView[] | null;
rows = body ?? [];
}
const r = await fetch("/api/views/system", { credentials: "include" });
if (!r.ok) return null;
const list = (await r.json()) as SystemView[];
return list.find((v) => v.Slug === "inbox") ?? null;
} catch (_e) {
// Network errors fall through to empty render.
return null;
}
}
function paint(
result: ViewRunResult,
render: RenderSpec,
results: HTMLElement,
empty: HTMLElement,
loading: HTMLElement,
): void {
loading.style.display = "none";
if (rows.length === 0) {
empty.textContent = t(
currentTab === "pending-mine"
? "approvals.empty.pending_mine"
: "approvals.empty.mine"
);
if (!result.rows || result.rows.length === 0) {
results.innerHTML = "";
empty.style.display = "";
empty.textContent = t("approvals.empty.pending_mine");
void maybeShowAdminNudge();
return;
}
hideAdminNudge();
for (const row of rows) list.appendChild(renderRow(row));
empty.style.display = "none";
// shape-list.ts honours render.list.row_action — InboxSystemView's
// RenderSpec sets row_action="approve" so we get the inbox markup.
renderListShape(results, result.rows, render);
// Wire action handlers on the freshly stamped DOM. The action
// POSTs land on the same endpoints the legacy /inbox used; on
// success we trigger a bar refresh so the new state propagates.
wireApprovalActions(results);
}
function wireApprovalActions(host: HTMLElement): void {
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
const action = btn.dataset.action as "approve" | "reject" | "revoke" | undefined;
const li = btn.closest<HTMLLIElement>(".views-approval-row");
const id = li?.dataset.requestId;
if (!action || !id) return;
btn.addEventListener("click", async () => {
let note = "";
if (action === "reject") {
note = window.prompt(t("approvals.note.placeholder")) || "";
}
btn.disabled = true;
try {
const r = await fetch(`/api/approval-requests/${id}/${action}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note }),
});
if (!r.ok) {
const body = await r.json().catch(() => ({} as { error?: string }));
alert(mapApprovalError(body.error || "internal"));
btn.disabled = false;
return;
}
await bar?.refresh();
await refreshInboxBadge();
} catch (_e) {
alert("Network error");
btn.disabled = false;
}
});
});
}
function mapApprovalError(key: string): string {
switch (key) {
case "self_approval_blocked": return t("approvals.error.self_approval");
case "no_qualified_approver": return t("approvals.error.no_qualified_approver");
case "concurrent_pending": return t("approvals.error.concurrent_pending");
case "not_authorized": return t("approvals.error.not_authorized");
case "request_not_pending": return t("approvals.error.request_not_pending");
default: return key;
}
}
// t-paliad-154 — show the admin-only "configure policies" nudge when:
// - the current user is global_admin
// - the inbox is empty
// - no approval_policies row exists firm-wide (matrix is dormant)
//
// All three checks are AND-ed. Anonymous users + non-admins + active-policy
// admins all skip the nudge.
// - current user is global_admin
// - inbox empty
// - no approval_policies row exists firm-wide
async function maybeShowAdminNudge(): Promise<void> {
const nudge = document.getElementById("inbox-admin-nudge");
if (!nudge) return;
@@ -132,9 +186,7 @@ async function maybeShowAdminNudge(): Promise<void> {
if (data.any) return;
nudge.style.display = "";
} catch (_e) {
// Network failure → keep nudge hidden.
}
} catch (_e) { /* keep hidden */ }
}
function hideAdminNudge(): void {
@@ -142,175 +194,7 @@ function hideAdminNudge(): void {
if (nudge) nudge.style.display = "none";
}
function renderRow(row: ApprovalRequestView): HTMLLIElement {
const li = document.createElement("li");
li.className = "inbox-row";
// Header: project / entity / lifecycle / required-role
const head = document.createElement("div");
head.className = "inbox-row-head";
const title = document.createElement("div");
title.className = "inbox-row-title";
const entityLabel = t(("approvals.entity." + row.entity_type) as I18nKey);
const lifecycleLabel = t(("approvals.lifecycle." + row.lifecycle_event) as I18nKey);
const entityTitle = row.entity_title || "—";
title.textContent = `${entityLabel}: ${entityTitle}${lifecycleLabel}`;
head.appendChild(title);
const meta = document.createElement("div");
meta.className = "inbox-row-meta";
const reqByLabel = t("approvals.requested_by");
const roleLabel = t(("approvals.required_role." + row.required_role) as I18nKey);
// t-paliad-161 ✨: when the request was drafted by Paliadin, surface
// that next to the requester's name. Reads as "von Anna ✨ Paliadin".
const requesterTag = row.requester_kind === "agent"
? `${row.requester_name}${t("approvals.agent.byline")}`
: row.requester_name;
meta.textContent = `${row.project_title} · ${reqByLabel} ${requesterTag} · ${roleLabel}+ · ${formatRelativeTime(row.requested_at)}`;
head.appendChild(meta);
li.appendChild(head);
// Diff for update / complete (date-bearing fields)
const diff = renderDiff(row);
if (diff) li.appendChild(diff);
// Decision note if any
if (row.decision_note) {
const note = document.createElement("div");
note.className = "inbox-row-note";
note.textContent = row.decision_note;
li.appendChild(note);
}
// Action row
const actions = document.createElement("div");
actions.className = "inbox-row-actions";
if (row.status === "pending" && currentTab === "pending-mine") {
actions.appendChild(actionButton("approve", row.id, () => doDecision(row.id, "approve")));
actions.appendChild(actionButton("reject", row.id, () => doDecision(row.id, "reject")));
} else if (row.status === "pending" && currentTab === "mine") {
actions.appendChild(actionButton("revoke", row.id, () => doDecision(row.id, "revoke")));
} else {
// historic — show status pill
const pill = document.createElement("span");
pill.className = "approval-pill approval-pill--historic";
pill.textContent = t(("approvals.status." + row.status) as I18nKey);
if (row.decider_name && row.status !== "revoked") {
const decided = document.createElement("span");
decided.className = "inbox-row-decided";
decided.textContent = ` · ${t("approvals.decided_by")} ${row.decider_name}`;
pill.appendChild(decided);
}
actions.appendChild(pill);
}
li.appendChild(actions);
return li;
}
function renderDiff(row: ApprovalRequestView): HTMLElement | null {
const before = (row.pre_image || {}) as Record<string, unknown>;
const after = (row.payload || {}) as Record<string, unknown>;
const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)]));
if (keys.length === 0) return null;
const wrap = document.createElement("div");
wrap.className = "inbox-row-diff";
for (const k of keys) {
const line = document.createElement("div");
line.className = "inbox-row-diff-line";
const label = document.createElement("span");
label.className = "inbox-row-diff-key";
label.textContent = k;
line.appendChild(label);
const span = document.createElement("span");
span.className = "inbox-row-diff-values";
const fmt = (v: unknown) =>
v === null || v === undefined ? "—" : String(v);
if (k in before && k in after) {
span.textContent = `${fmt(before[k])}${fmt(after[k])}`;
} else if (k in before) {
span.textContent = `${t("approvals.diff.before")}: ${fmt(before[k])}`;
} else {
span.textContent = `${t("approvals.diff.after")}: ${fmt(after[k])}`;
}
line.appendChild(span);
wrap.appendChild(line);
}
return wrap;
}
function actionButton(action: "approve" | "reject" | "revoke", _requestID: string, onClick: () => void): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `btn btn-${action === "approve" ? "primary" : action === "reject" ? "danger" : "secondary"} inbox-row-action`;
btn.textContent = t(("approvals.action." + action) as I18nKey);
btn.addEventListener("click", onClick);
return btn;
}
async function doDecision(requestID: string, action: "approve" | "reject" | "revoke") {
let note = "";
if (action === "reject") {
note = window.prompt(t("approvals.note.placeholder")) || "";
}
let r: Response;
try {
r = await fetch(`/api/approval-requests/${requestID}/${action}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note }),
});
} catch (_e) {
alert("Network error");
return;
}
if (!r.ok) {
const body = await r.json().catch(() => ({}));
const errKey = (body && body.error) || "internal";
const msg = mapApprovalError(errKey);
alert(msg);
return;
}
refresh();
// Update sidebar bell count.
refreshInboxBadge();
}
function mapApprovalError(key: string): string {
switch (key) {
case "self_approval_blocked":
return t("approvals.error.self_approval");
case "no_qualified_approver":
return t("approvals.error.no_qualified_approver");
case "concurrent_pending":
return t("approvals.error.concurrent_pending");
case "not_authorized":
return t("approvals.error.not_authorized");
case "request_not_pending":
return t("approvals.error.request_not_pending");
default:
return key;
}
}
function formatRelativeTime(iso: string): string {
const t0 = Date.parse(iso);
if (isNaN(t0)) return iso;
const diffMs = Date.now() - t0;
const sec = Math.floor(diffMs / 1000);
if (sec < 60) return getLang() === "de" ? `vor ${sec}s` : `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return getLang() === "de" ? `vor ${min}m` : `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return getLang() === "de" ? `vor ${hr}h` : `${hr}h ago`;
const day = Math.floor(hr / 24);
return getLang() === "de" ? `vor ${day}d` : `${day}d ago`;
}
// Update the sidebar inbox badge (shared with sidebar.ts polling).
async function refreshInboxBadge() {
async function refreshInboxBadge(): Promise<void> {
const badge = document.getElementById("sidebar-inbox-badge");
if (!badge) return;
try {
@@ -323,7 +207,5 @@ async function refreshInboxBadge() {
} else {
badge.style.display = "none";
}
} catch (_e) {
/* noop */
}
} catch (_e) { /* noop */ }
}

View File

@@ -29,6 +29,7 @@ export interface ProjectFormState {
grantDate: string;
court: string;
caseNumber: string;
ourSide: string;
}
let parentCandidates: ProjectMini[] = [];
@@ -178,6 +179,17 @@ export function readPayload(
stringField("project-case-number", "case_number");
}
// our_side is type-agnostic — every project type can carry "Wir
// vertreten" because the Determinator picks it up regardless of
// type. The select uses "" for the unset option; the service maps
// empty string to NULL via nullableOurSide.
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
if (osSel) {
const v = osSel.value.trim();
if (v) payload.our_side = v;
else if (!opts.omitEmpty) payload.our_side = "";
}
const desc = ($("project-description") as HTMLTextAreaElement).value.trim();
if (desc) payload.description = desc;
else if (!opts.omitEmpty) payload.description = "";
@@ -214,6 +226,8 @@ export function prefillForm(p: Record<string, unknown>) {
get("project-grant-date").value = isoToDate(p.grant_date as string | null | undefined);
get("project-court").value = String(p.court ?? "");
get("project-case-number").value = String(p.case_number ?? "");
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
if (osSel) osSel.value = String(p.our_side ?? "");
getTA("project-description").value = String(p.description ?? "");
getSel("project-status").value = String(p.status ?? "active");
}

View File

@@ -1,17 +1,25 @@
import { t, type I18nKey } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
import { t, tDyn, getLang, type I18nKey } from "../i18n";
import type { ListRowAction, RenderSpec, ViewRow } from "./types";
import { formatDate, formatRelative, parseDateOnly } from "./format";
// shape-list: renders ViewRows as a table (density=comfortable) or a
// compact one-line stream (density=compact). The "activity feed" look
// is just density=compact + actor/time columns — see Q4 lock-in
// 2026-05-07 (3 shapes; no separate "activity").
//
// Row interaction is controlled by render.list.row_action
// (t-paliad-163 schema bump). Default "navigate" keeps every existing
// caller's contract — clicking a row goes to the per-kind detail
// page. "approve" produces the approval-list layout for /inbox.
// "complete_toggle" is wired in Phase 3 (/events). "none" suppresses
// any row interaction (audit views).
export function renderListShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
host.innerHTML = "";
const list = render.list ?? {};
const density = list.density ?? "comfortable";
const sort = list.sort ?? "date_asc";
const rowAction: ListRowAction = list.row_action ?? "navigate";
const sorted = [...rows].sort((a, b) => {
const aT = sortKey(a.event_date);
@@ -19,6 +27,11 @@ export function renderListShape(host: HTMLElement, rows: ViewRow[], render: Rend
return sort === "date_asc" ? aT - bT : bT - aT;
});
if (rowAction === "approve") {
host.appendChild(renderApprovalList(sorted));
return;
}
if (density === "compact") {
host.appendChild(renderCompact(sorted));
} else {
@@ -162,3 +175,166 @@ function sortKey(iso: string): number {
if (dateOnly) return dateOnly.getTime();
return Date.parse(iso);
}
// ----------------------------------------------------------------------
// row_action = "approve" — approval inbox layout
//
// Stamps the markup the /inbox surface needs (data attrs + classes);
// the surface (client/inbox.ts) wires the action handlers in onResult.
// This keeps shape-list independent of any specific surface's wiring.
// ----------------------------------------------------------------------
interface ApprovalDetail {
status?: string;
lifecycle_event?: string;
entity_type?: string;
entity_title?: string;
pre_image?: Record<string, unknown> | null;
payload?: Record<string, unknown> | null;
required_role?: string;
requester_name?: string;
requester_kind?: "user" | "agent";
decider_name?: string;
decision_note?: string;
}
function renderApprovalList(rows: ViewRow[]): HTMLElement {
const ul = document.createElement("ul");
ul.className = "inbox-list views-approval-list";
for (const row of rows) {
const detail = (row.detail || {}) as ApprovalDetail;
const li = document.createElement("li");
li.className = "inbox-row views-approval-row";
li.dataset.requestId = row.id;
li.dataset.status = detail.status ?? "";
// Header: entity / lifecycle
const head = document.createElement("div");
head.className = "inbox-row-head";
const title = document.createElement("div");
title.className = "inbox-row-title";
const entityLabel = detail.entity_type ? t(("approvals.entity." + detail.entity_type) as I18nKey) : "";
const lifecycleLabel = detail.lifecycle_event ? t(("approvals.lifecycle." + detail.lifecycle_event) as I18nKey) : "";
const entityTitle = detail.entity_title || row.title || "—";
title.textContent = `${entityLabel}: ${entityTitle}${lifecycleLabel}`;
head.appendChild(title);
const meta = document.createElement("div");
meta.className = "inbox-row-meta";
const reqByLabel = t("approvals.requested_by");
const roleLabel = detail.required_role
? t(("approvals.required_role." + detail.required_role) as I18nKey)
: "";
const requester = detail.requester_name || row.actor_name || "";
const requesterTag = detail.requester_kind === "agent"
? `${requester}${t("approvals.agent.byline")}`
: requester;
const projectTitle = row.project_title ?? "";
const parts = [
projectTitle,
`${reqByLabel} ${requesterTag}`,
];
if (roleLabel) parts.push(`${roleLabel}+`);
parts.push(formatRelativeTime(row.event_date));
meta.textContent = parts.filter(Boolean).join(" · ");
head.appendChild(meta);
li.appendChild(head);
// Diff for update / complete
const diff = renderDiff(detail);
if (diff) li.appendChild(diff);
if (detail.decision_note) {
const note = document.createElement("div");
note.className = "inbox-row-note";
note.textContent = detail.decision_note;
li.appendChild(note);
}
// Action row — surface attaches handlers via data-attrs.
const actions = document.createElement("div");
actions.className = "inbox-row-actions";
if (detail.status === "pending") {
// The bar's approval_viewer_role distinguishes which actions are
// appropriate. The surface inspects the active role and decides
// which buttons to keep — but for default rendering we stamp all
// three with role-class hints and let the surface filter.
actions.appendChild(actionBtn("approve"));
actions.appendChild(actionBtn("reject"));
actions.appendChild(actionBtn("revoke"));
} else if (detail.status) {
const pill = document.createElement("span");
pill.className = "approval-pill approval-pill--historic";
pill.textContent = t(("approvals.status." + detail.status) as I18nKey);
if (detail.decider_name && detail.status !== "revoked") {
const decided = document.createElement("span");
decided.className = "inbox-row-decided";
decided.textContent = ` · ${t("approvals.decided_by")} ${detail.decider_name}`;
pill.appendChild(decided);
}
actions.appendChild(pill);
}
li.appendChild(actions);
ul.appendChild(li);
}
return ul;
}
function renderDiff(detail: ApprovalDetail): HTMLElement | null {
const before = (detail.pre_image || {}) as Record<string, unknown>;
const after = (detail.payload || {}) as Record<string, unknown>;
const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)]));
if (keys.length === 0) return null;
const wrap = document.createElement("div");
wrap.className = "inbox-row-diff";
for (const k of keys) {
const line = document.createElement("div");
line.className = "inbox-row-diff-line";
const label = document.createElement("span");
label.className = "inbox-row-diff-key";
label.textContent = k;
line.appendChild(label);
const span = document.createElement("span");
span.className = "inbox-row-diff-values";
const fmt = (v: unknown) => v === null || v === undefined ? "—" : String(v);
if (k in before && k in after) {
span.textContent = `${fmt(before[k])}${fmt(after[k])}`;
} else if (k in before) {
span.textContent = `${t("approvals.diff.before")}: ${fmt(before[k])}`;
} else {
span.textContent = `${t("approvals.diff.after")}: ${fmt(after[k])}`;
}
line.appendChild(span);
wrap.appendChild(line);
}
return wrap;
}
function actionBtn(action: "approve" | "reject" | "revoke"): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.action = action;
const cls = action === "approve" ? "btn-primary" : action === "reject" ? "btn-danger" : "btn-secondary";
btn.className = `btn ${cls} inbox-row-action views-approval-action`;
btn.textContent = t(("approvals.action." + action) as I18nKey);
return btn;
}
function formatRelativeTime(iso: string): string {
const t0 = Date.parse(iso);
if (isNaN(t0)) return iso;
const diffMs = Date.now() - t0;
const sec = Math.floor(diffMs / 1000);
if (sec < 60) return getLang() === "de" ? `vor ${sec}s` : `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return getLang() === "de" ? `vor ${min}m` : `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return getLang() === "de" ? `vor ${hr}h` : `${hr}h ago`;
const day = Math.floor(hr / 24);
return getLang() === "de" ? `vor ${day}d` : `${day}d ago`;
}
// Suppress unused warning for tDyn — kept available for future axes.
void tDyn;

View File

@@ -71,10 +71,13 @@ export interface FilterSpec {
export type RenderShape = "list" | "cards" | "calendar";
export type ListRowAction = "navigate" | "complete_toggle" | "approve" | "none";
export interface ListConfig {
columns?: string[];
sort?: "date_asc" | "date_desc";
density?: "comfortable" | "compact";
row_action?: ListRowAction;
}
export interface CardsConfig {

View File

@@ -153,6 +153,20 @@ export function ProjectFormFields(): string {
</div>
</div>
<div className="form-field">
<label htmlFor="project-our-side" data-i18n="projects.field.our_side">Wir vertreten</label>
<select id="project-our-side">
<option value="" data-i18n="projects.field.our_side.unset">Unbekannt / nicht gesetzt</option>
<option value="claimant" data-i18n="projects.field.our_side.claimant">Kl&auml;gerseite</option>
<option value="defendant" data-i18n="projects.field.our_side.defendant">Beklagtenseite</option>
<option value="court" data-i18n="projects.field.our_side.court">Gericht / Tribunal</option>
<option value="both" data-i18n="projects.field.our_side.both">Beide Seiten</option>
</select>
<p className="form-hint" data-i18n="projects.field.our_side.hint">
Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. L&auml;sst sich dort jederzeit &uuml;berschreiben.
</p>
</div>
<div className="form-field">
<label htmlFor="project-description" data-i18n="projects.field.description">Notizen</label>
<textarea id="project-description" rows={4} placeholder="Kurznotizen zum Projekt (optional)..." data-i18n-placeholder="projects.field.description.placeholder" />

View File

@@ -55,9 +55,50 @@ export function renderDeadlinesNew(): string {
/>
</div>
<div className="form-field">
<div className="form-field" id="deadline-event-type-field">
<label data-i18n="deadlines.field.event_type">Typ (optional)</label>
{/* t-paliad-165 follow-up — collapsed view: when a Regel
is selected and a default event_type is known, the
Typ chip is hidden and the type is rendered inline
as a single read-only summary with an "Anderen Typ
wählen" link that re-expands the picker. */}
<div
className="event-type-collapsed"
id="deadline-event-type-collapsed"
style="display:none"
>
<span
className="event-type-collapsed-label"
id="deadline-event-type-collapsed-label"
/>
<span
className="event-type-collapsed-source"
data-i18n="deadlines.field.rule.autofill_inline"
>
&nbsp;(vorgegeben durch Regel)
</span>
<button
type="button"
className="event-type-collapsed-override"
id="deadline-event-type-override-btn"
data-i18n="deadlines.field.rule.override"
>
Anderen Typ w&auml;hlen
</button>
</div>
<div id="deadline-event-types" className="event-type-picker-host" />
{/* Soft warning when the user is in expanded mode AND
has picked an event_type that doesn't include the
rule's canonical default. Reuses the existing
yellow form-hint--warning style; never blocking. */}
<p
className="form-hint form-hint--warning"
id="deadline-event-type-rule-mismatch"
style="display:none"
data-i18n="deadlines.field.rule.mismatch"
>
Hinweis: Typ widerspricht Regel &mdash; Sie haben den Typ &uuml;berschrieben.
</p>
</div>
<div className="form-field-row">

View File

@@ -273,6 +273,14 @@ export function renderFristenrechner(): string {
<span data-i18n="deadlines.perspective.both.short">Beide</span>
</button>
</div>
{/* t-paliad-164 — predefined-from-Akte hint. Hidden by
default; client/fristenrechner.ts shows it when the
active perspective came from project.our_side. The
user can still click another chip to override. */}
<span className="fristen-perspective-hint" id="fristen-perspective-hint"
data-i18n="deadlines.perspective.predefined_hint" hidden>
vorgegeben durch Akte
</span>
</div>
<div className="fristen-inbox-bar" id="fristen-inbox-bar" role="group" aria-label="Inbox channel">
<span className="fristen-inbox-bar-label" data-i18n="deadlines.inbox.label">Wo kam es an?</span>

View File

@@ -652,6 +652,7 @@ export type I18nKey =
| "dashboard.action.short.fristen_imported"
| "dashboard.action.short.note_created"
| "dashboard.action.short.notiz_created"
| "dashboard.action.short.our_side_changed"
| "dashboard.action.short.partei_added"
| "dashboard.action.short.partei_removed"
| "dashboard.action.short.project_archived"
@@ -819,7 +820,11 @@ export type I18nKey =
| "deadlines.field.notes"
| "deadlines.field.notes.placeholder"
| "deadlines.field.rule"
| "deadlines.field.rule.autofill"
| "deadlines.field.rule.autofill_inline"
| "deadlines.field.rule.mismatch"
| "deadlines.field.rule.none"
| "deadlines.field.rule.override"
| "deadlines.field.title"
| "deadlines.field.title.placeholder"
| "deadlines.filter.akte"
@@ -905,6 +910,7 @@ export type I18nKey =
| "deadlines.perspective.defendant.short"
| "deadlines.perspective.defendant.title"
| "deadlines.perspective.label"
| "deadlines.perspective.predefined_hint"
| "deadlines.print"
| "deadlines.priority.date"
| "deadlines.proceeding.reselect"
@@ -1108,6 +1114,7 @@ export type I18nKey =
| "event.title.deadline_updated"
| "event.title.deadlines_imported"
| "event.title.note_created"
| "event.title.our_side_changed"
| "event.title.project_archived"
| "event.title.project_created"
| "event.title.project_reparented"
@@ -1746,6 +1753,14 @@ export type I18nKey =
| "projects.field.matter_number"
| "projects.field.netdocuments_url"
| "projects.field.office"
| "projects.field.our_side"
| "projects.field.our_side.both"
| "projects.field.our_side.claimant"
| "projects.field.our_side.court"
| "projects.field.our_side.defendant"
| "projects.field.our_side.hint"
| "projects.field.our_side.none"
| "projects.field.our_side.unset"
| "projects.field.parent"
| "projects.field.parent.hint"
| "projects.field.parent.placeholder"
@@ -1929,6 +1944,60 @@ export type I18nKey =
| "unit_role.paralegal"
| "unit_role.senior_pa"
| "views.action.edit"
| "views.bar.action.reset"
| "views.bar.action.save_as_view"
| "views.bar.appointment_type.consultation"
| "views.bar.appointment_type.deadline_hearing"
| "views.bar.appointment_type.hearing"
| "views.bar.appointment_type.meeting"
| "views.bar.approval_entity.appointment"
| "views.bar.approval_entity.deadline"
| "views.bar.approval_role.any_visible"
| "views.bar.approval_role.approver_eligible"
| "views.bar.approval_role.self_requested"
| "views.bar.approval_status.approved"
| "views.bar.approval_status.pending"
| "views.bar.approval_status.rejected"
| "views.bar.approval_status.revoked"
| "views.bar.common.all"
| "views.bar.deadline_status.completed"
| "views.bar.deadline_status.pending"
| "views.bar.density.comfortable"
| "views.bar.density.compact"
| "views.bar.label.appointment_type"
| "views.bar.label.approval_entity"
| "views.bar.label.approval_role"
| "views.bar.label.approval_status"
| "views.bar.label.deadline_status"
| "views.bar.label.density"
| "views.bar.label.personal"
| "views.bar.label.shape"
| "views.bar.label.sort"
| "views.bar.label.time"
| "views.bar.personal.on"
| "views.bar.save.cancel"
| "views.bar.save.confirm"
| "views.bar.save.error.name_required"
| "views.bar.save.error.network"
| "views.bar.save.error.slug_format"
| "views.bar.save.error.slug_taken"
| "views.bar.save.field.name"
| "views.bar.save.field.show_count"
| "views.bar.save.field.slug"
| "views.bar.save.field.slug_hint"
| "views.bar.save.heading"
| "views.bar.shape.calendar"
| "views.bar.shape.cards"
| "views.bar.shape.list"
| "views.bar.sort.date_asc"
| "views.bar.sort.date_desc"
| "views.bar.time.any"
| "views.bar.time.custom"
| "views.bar.time.custom.coming_soon"
| "views.bar.time.next_30d"
| "views.bar.time.next_7d"
| "views.bar.time.next_90d"
| "views.bar.time.past_30d"
| "views.calendar.mobile_fallback"
| "views.col.actor"
| "views.col.appointment_type"

View File

@@ -5,13 +5,20 @@ import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Approval inbox page (t-paliad-138). Two-tab UI:
// - "Zur Genehmigung": requests where the caller is qualified to approve
// - "Meine Anfragen": requests submitted by the caller
// /inbox — t-paliad-163 universal-filter migration.
//
// Hydrates lazily on load (no inline payload) — unlike the dashboard, the
// inbox doesn't carry SSR state. The client bundle calls /api/inbox/* on
// hydration and re-renders.
// The page is a thin shell around two host divs: one for the
// <FilterBar> primitive and one for the result list. The bar takes
// care of every axis (approval_viewer_role chip cluster replaces the
// two-tab UI; status / entity_type / time chips are new affordances).
// Rows render via shape-list.ts with row_action="approve" — the
// inbox-specific markup that produces the diff + approve/reject/revoke
// buttons. Action handlers are wired in client/inbox.ts.
//
// The legacy `?tab=` URL is preserved by the client: ?tab=mine maps
// to ?a_role=self_requested before the bar mounts so old bookmarks
// (sidebar bell, Genehmigungen email links) keep landing on the
// expected sub-view.
export function renderInbox(): string {
return "<!DOCTYPE html>" + (
@@ -38,18 +45,11 @@ export function renderInbox(): string {
</p>
</div>
<div className="agenda-controls">
<div className="agenda-filter-group" role="group">
<div className="agenda-chip-row" id="inbox-tab-row">
<button type="button" className="agenda-chip active" data-tab="pending-mine" data-i18n="approvals.tab.pending_mine">Zur Genehmigung</button>
<button type="button" className="agenda-chip" data-tab="mine" data-i18n="approvals.tab.mine">Meine Anfragen</button>
</div>
</div>
</div>
<div id="inbox-filter-bar" />
<div className="agenda-loading" id="inbox-loading" data-i18n="agenda.loading">L&auml;dt &hellip;</div>
<div className="entity-empty" id="inbox-empty" style="display:none" />
<ul className="inbox-list" id="inbox-list" />
<div id="inbox-results" />
{/* t-paliad-154 — admin-only nudge surfaced when:
- the user is global_admin

View File

@@ -1981,6 +1981,22 @@ input[type="range"]::-moz-range-thumb {
margin-bottom: 0.4rem;
}
/* t-paliad-164 — "vorgegeben durch Akte" hint shown next to the
* perspective chips when project.our_side has predefined the chip.
* Italic, muted, with a subtle leading bullet so it reads as
* meta-info rather than a chip. The user can still click another
* chip to override; the hint quietly disappears when they do. */
.fristen-perspective-hint {
font-size: 0.8rem;
font-style: italic;
color: var(--color-muted, #666);
margin-left: 0.4rem;
}
.fristen-perspective-hint::before {
content: "·\00a0";
}
.fristen-inbox-bar-label {
font-size: 0.875rem;
color: var(--color-muted, #666);
@@ -10404,6 +10420,43 @@ dialog.quick-add-sheet::backdrop {
t-paliad-088 — Event Types: picker, multi-select filter, add modal
============================================================================ */
/* t-paliad-165 follow-up — collapsed read-only view used on
/deadlines/new when a Regel is selected and a default event_type is
known. Replaces the picker with a single inline label + an
"Anderen Typ wählen" override link. */
.event-type-collapsed {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.35rem 0.55rem;
background: var(--color-bg-lime-tint);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
font-size: 0.95rem;
line-height: 1.3;
flex-wrap: wrap;
}
.event-type-collapsed-label {
font-weight: 600;
color: var(--color-text);
}
.event-type-collapsed-source {
color: var(--color-text-muted);
font-size: 0.85rem;
}
.event-type-collapsed-override {
margin-left: auto;
background: transparent;
border: 0;
padding: 0;
color: var(--color-link, #1d4ed8);
text-decoration: underline;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
}
.event-type-collapsed-override:hover { color: var(--color-link-hover, #1e40af); }
/* Picker host — chip cluster + search + suggest dropdown */
.event-type-picker {
display: flex;
@@ -13235,3 +13288,166 @@ dialog.quick-add-sheet::backdrop {
width: 16px;
height: 16px;
}
/* ----------------------------------------------------------------------
Universal FilterBar — t-paliad-163.
Mounts on every list-shaped surface (starting with /inbox in Phase 1).
Reuses .agenda-chip + .filter-group + .entity-select for legacy
parity; wraps them with .filter-bar* scoping so the bar can be
styled independently if a surface needs to override.
---------------------------------------------------------------------- */
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.85rem 1.1rem;
padding: 0.75rem 1rem;
margin: 0 0 1rem 0;
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.75rem;
}
.filter-bar-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
align-items: stretch;
}
.filter-bar-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted, #6b7280);
}
.filter-bar-chip-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.filter-bar-chip-row.filter-bar-segment {
flex-wrap: nowrap;
gap: 0;
padding: 0.15rem;
background: var(--color-surface-muted, #f5f5f5);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 999px;
}
.filter-bar-segment .filter-bar-chip {
background: transparent;
border: 1px solid transparent;
}
.filter-bar-segment .filter-bar-chip.agenda-chip-active {
background: var(--color-surface, #ffffff);
border-color: var(--color-border, #e5e7eb);
}
.filter-bar-chip-pending {
opacity: 0.55;
cursor: not-allowed;
}
.filter-bar-select {
min-width: 8rem;
}
.filter-bar-trailing {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.4rem;
}
@media (max-width: 768px) {
.filter-bar {
gap: 0.6rem 0.7rem;
padding: 0.6rem;
margin: 0 0 0.75rem 0;
}
.filter-bar-trailing {
margin-left: 0;
width: 100%;
justify-content: flex-end;
}
.filter-bar-chip-row {
flex-wrap: wrap;
}
}
/* Save-as-view modal — anchored as a native <dialog>. */
.filter-bar-save-modal::backdrop {
background: rgba(15, 23, 42, 0.4);
}
.filter-bar-save-modal {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.85rem;
padding: 0;
max-width: 28rem;
width: calc(100% - 2rem);
background: var(--color-surface, #ffffff);
color: var(--color-text, #111827);
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
}
.filter-bar-save-form {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 1.1rem 1.25rem 1.25rem;
margin: 0;
}
.filter-bar-save-form h2 {
margin: 0 0 0.35rem 0;
font-size: 1.15rem;
}
.filter-bar-save-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.filter-bar-save-field span {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-muted, #6b7280);
}
.filter-bar-save-field input {
padding: 0.45rem 0.6rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.4rem;
background: var(--color-surface, #ffffff);
color: var(--color-text, #111827);
font: inherit;
}
.filter-bar-save-field small {
font-size: 0.7rem;
color: var(--color-text-muted, #6b7280);
}
.filter-bar-save-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.filter-bar-save-error {
margin: 0;
color: var(--status-red-fg, #c54);
font-size: 0.85rem;
}
.filter-bar-save-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}

View File

@@ -0,0 +1,4 @@
-- Reverse t-paliad-164: drop the our_side column + check constraint.
ALTER TABLE paliad.projects DROP CONSTRAINT IF EXISTS projects_our_side_check;
ALTER TABLE paliad.projects DROP COLUMN IF EXISTS our_side;

View File

@@ -0,0 +1,42 @@
-- t-paliad-164 / m's 2026-05-08 21:42 dogfood feedback: when the user
-- selects an Akte in the Determinator (Slice 3c perspective chip),
-- the chip should already be locked to the firm's known side instead
-- of asking the user to re-pick something the project already knows.
--
-- Add a project-level our_side text column. NULL = unknown / not set
-- (default), so existing projects stay neutral and the Determinator
-- falls back to free-pick. The chip values mirror event_categories.
-- party so the Determinator can predefine the chip without mapping.
--
-- 'court' is allowed for completeness (paliad runs internal projects
-- where the firm represents the court / a tribunal-side stakeholder
-- — rare but real); the Determinator currently only acts on
-- claimant / defendant.
--
-- Idempotent so re-runs against a partially-applied state stay safe
-- (live tracker is at v71; paliad has been bitten by collisions
-- twice this week, see m/paliad#15 commits and dirac's mig 070).
ALTER TABLE paliad.projects
ADD COLUMN IF NOT EXISTS our_side text;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'projects_our_side_check'
AND conrelid = 'paliad.projects'::regclass
) THEN
ALTER TABLE paliad.projects
ADD CONSTRAINT projects_our_side_check
CHECK (our_side IS NULL
OR our_side IN ('claimant', 'defendant', 'court', 'both'));
END IF;
END $$;
COMMENT ON COLUMN paliad.projects.our_side IS
'Which side the firm represents on this project. Used by the '
'Fristenrechner Determinator (Slice 3c) to predefine the '
'perspective chip from the project context. Allowed: claimant, '
'defendant, court, both. NULL = unknown / not set; Determinator '
'falls back to free-pick.';

View File

@@ -0,0 +1,2 @@
-- t-paliad-165 down: drop the concept→event_type junction.
DROP TABLE IF EXISTS paliad.deadline_concept_event_types;

View File

@@ -0,0 +1,113 @@
-- t-paliad-165: junction paliad.deadline_concept_event_types — maps each
-- deadline_concept to the canonical paliad.event_types row(s) that
-- represent it on the Typ chip cluster of the deadline create form.
--
-- Why this exists
-- ---------------
-- The deadline create form (/projects/{id}/deadlines/new and the global
-- /deadlines/new) lets the user pick a Regel (paliad.deadline_rules) AND
-- independently pick a Typ (paliad.event_types). They are decoupled, so a
-- user can save a deadline whose Regel is `damages.rejoin — Duplik` but
-- Typ is `Klageerwiderung` — two different legal events. m hit this
-- contradiction during 2026-05-08 dogfooding (Gitea m/paliad#18).
--
-- Each rule already carries paliad.deadline_rules.concept_id (mig 040),
-- so the rule knows what legal idea it represents. What was missing was
-- the canonical event_type for that concept. Slug-pattern heuristics are
-- unreliable (concept `notice-of-appeal` ↔ event_type
-- `upc_statement_of_appeal_2201`) and many concepts have multiple
-- candidate event_types (`statement-of-defence` ↔ base + with_ccr +
-- no_ccr); this junction makes the mapping explicit and curated.
--
-- Shape
-- -----
-- Many-to-many, so concepts that genuinely have several candidate types
-- (with_ccr / no_ccr / base; UPC + EPO + DPMA opposition) get one row
-- per type. is_default picks the single row the create-form auto-fills
-- when the user picks a Regel attached to this concept. The remaining
-- rows are reserved for future surfaces (e.g. Determinator save flow
-- might want to see all candidates) but the create-form only consumes
-- is_default for now.
--
-- Idempotent against re-seeds: the seed below uses ON CONFLICT DO
-- NOTHING so a second run after manual mapping additions doesn't blow
-- them away. Down migration drops the table entirely.
CREATE TABLE IF NOT EXISTS paliad.deadline_concept_event_types (
concept_id uuid NOT NULL
REFERENCES paliad.deadline_concepts(id) ON DELETE CASCADE,
event_type_id uuid NOT NULL
REFERENCES paliad.event_types(id) ON DELETE CASCADE,
is_default bool NOT NULL DEFAULT false,
sort_order int NOT NULL DEFAULT 100,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (concept_id, event_type_id)
);
COMMENT ON TABLE paliad.deadline_concept_event_types IS
'Junction mapping paliad.deadline_concepts → paliad.event_types. '
'Lets the deadline create form auto-populate the Typ chip when the '
'user picks a Regel — the rule''s concept points here for its '
'canonical event_type(s). Many-to-many for concepts with several '
'natural variants (with_ccr / no_ccr / base, EPO + DPMA opposition).';
COMMENT ON COLUMN paliad.deadline_concept_event_types.is_default IS
'Exactly one row per concept_id should be marked default — that is '
'the row the create-form chip cluster auto-fills with. Other rows '
'remain selectable from the picker as alternatives.';
CREATE UNIQUE INDEX IF NOT EXISTS deadline_concept_event_types_one_default
ON paliad.deadline_concept_event_types (concept_id)
WHERE is_default = true;
CREATE INDEX IF NOT EXISTS deadline_concept_event_types_event_type
ON paliad.deadline_concept_event_types (event_type_id);
-- ============================================================================
-- Seed: curated mapping for active concepts that drive existing rules.
--
-- Concepts without an obvious event_type counterpart (filing, grant,
-- decision, publication, communication-r71-3, search-report, the various
-- DE-only Begründung concepts) stay unmapped — auto-fill silently
-- skips them, leaving the user to pick a Typ manually as today.
-- Future migrations can fill those gaps as event_types are added.
-- ============================================================================
INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order)
SELECT dc.id, et.id, mapping.is_default, mapping.sort_order
FROM (VALUES
-- (concept_slug, event_type_slug, is_default, sort_order)
('application-for-cost-decision', 'upc_application_for_cost_decision', true, 10),
('application-for-determination-of-damages', 'upc_application_for_damages', true, 10),
('application-for-revocation', 'upc_statement_for_revocation', true, 10),
('application-for-provisional-measures', 'upc_protective_letter', true, 10),
('cost-decision', 'upc_decision_on_costs', true, 10),
('counterclaim-for-infringement', 'upc_counterclaim_for_infringement', true, 10),
('counterclaim-for-revocation', 'upc_counterclaim_for_revocation', true, 10),
('cross-appeal', 'upc_cross_appeal_2242a', true, 10),
('defence-to-application-to-amend', 'upc_defence_to_amend_patent', true, 10),
('defence-to-counterclaim-for-revocation', 'upc_defence_to_revocation', true, 10),
('notice-of-appeal', 'upc_statement_of_appeal_2201', true, 10),
('opposition', 'epo_opposition_filing', true, 10),
('opposition', 'dpma_opposition', false, 20),
('oral-hearing', 'upc_oral_hearing', true, 10),
('order', 'upc_case_management_order', true, 10),
('rejoinder', 'upc_rejoinder_to_reply', true, 10),
('reply-to-cross-appeal', 'upc_cross_appeal_2242a', true, 10),
('reply-to-defence', 'upc_reply_to_defence', true, 10),
('reply-to-defence-to-application-to-amend', 'upc_reply_to_defence_to_amend_patent', true, 10),
('reply-to-defence-to-counterclaim-for-revocation','upc_reply_to_defence_to_revocation', true, 10),
('request-for-examination', 'dpma_examination_request', true, 10),
('request-to-lay-open-books', 'upc_request_to_lay_open_books', true, 10),
('response-to-appeal', 'upc_grounds_of_appeal_2242a', true, 10),
('statement-of-claim', 'upc_statement_of_claim', true, 10),
('statement-of-defence', 'upc_statement_of_defence', true, 10),
('statement-of-defence', 'upc_statement_of_defence_with_ccr', false, 20),
('statement-of-defence', 'upc_statement_of_defence_no_ccr', false, 30),
('statement-of-grounds-of-appeal', 'upc_grounds_of_appeal_2242a', true, 10),
('statement-of-grounds-of-appeal', 'epo_appeal_grounds', false, 20)
) AS mapping(concept_slug, event_type_slug, is_default, sort_order)
JOIN paliad.deadline_concepts dc ON dc.slug = mapping.concept_slug
JOIN paliad.event_types et ON et.slug = mapping.event_type_slug
AND et.archived_at IS NULL
ON CONFLICT (concept_id, event_type_id) DO NOTHING;

View File

@@ -0,0 +1,13 @@
-- t-paliad-165 follow-up down: remove jurisdiction column + restore the
-- old one-default-per-concept index. The added jurisdictional default
-- rows are kept (harmless without the index), but this isn't an
-- expected operation in production.
DROP INDEX IF EXISTS paliad.deadline_concept_event_types_one_default_per_jur;
ALTER TABLE paliad.deadline_concept_event_types
DROP COLUMN IF EXISTS jurisdiction;
CREATE UNIQUE INDEX IF NOT EXISTS deadline_concept_event_types_one_default
ON paliad.deadline_concept_event_types (concept_id)
WHERE is_default = true;

View File

@@ -0,0 +1,143 @@
-- t-paliad-165 follow-up (m's 2026-05-08 22:08 dogfood): add jurisdiction
-- to paliad.deadline_concept_event_types so DE rules don't auto-fill a
-- UPC event_type and vice versa.
--
-- Bug being fixed
-- ---------------
-- m: rule '§ 276 Abs. 1 S. 2 ZPO — Klageerwiderung' (DE) auto-filled to
-- 'Klageerwiderung' but the chosen event_type was upc_statement_of_defence
-- (UPC). Both render as 'Klageerwiderung' in the UI, but they are
-- different legal events in different jurisdictions — the auto-link is
-- technically wrong even though the label looks right.
--
-- Root cause
-- ----------
-- Migration 073 made the junction one-default-per-concept. The same
-- legal concept ('statement-of-defence' = Klageerwiderung) has several
-- jurisdictional flavours (upc_statement_of_defence, de_klageerwiderung,
-- DPMA Erwiderung, EPA Patentinhaber-Erwiderung). The default was
-- jurisdiction-blind — it always picked the UPC variant.
--
-- Fix
-- ---
-- 1. Add a jurisdiction text column to the junction.
-- 2. Backfill from each event_type's own jurisdiction.
-- 3. Replace the unique-default index with a (concept_id, jurisdiction)
-- pair so each concept can carry one default per jurisdiction.
-- 4. Add jurisdictional defaults where a non-UPC event_type genuinely
-- exists (DE Klageerwiderung, DPMA / EPO opposition + appeal).
--
-- Lookup contract (consumed by the rule-service hydrator)
-- -------------------------------------------------------
-- For a rule with proceeding_types.jurisdiction = J, the auto-fill
-- looks up the row WHERE is_default AND jurisdiction = J. EPA→EPO
-- canonicalisation lives in the Go service (proceeding_types use 'EPA'
-- but event_types use 'EPO' — the two columns disagreed before this
-- mapping table existed). When NO row matches the rule's jurisdiction,
-- the auto-fill silently no-ops; better than a wrong default.
ALTER TABLE paliad.deadline_concept_event_types
ADD COLUMN IF NOT EXISTS jurisdiction text;
COMMENT ON COLUMN paliad.deadline_concept_event_types.jurisdiction IS
'Which jurisdiction this default applies to. Matches the rule''s '
'proceeding_types.jurisdiction (UPC / DE / DPMA / EPO). EPA→EPO '
'canonicalisation is done service-side. NULL = applies to any '
'jurisdiction (the catch-all fallback — currently unused).';
-- Backfill jurisdiction from the event_type's own column.
UPDATE paliad.deadline_concept_event_types j
SET jurisdiction = et.jurisdiction
FROM paliad.event_types et
WHERE j.event_type_id = et.id
AND j.jurisdiction IS NULL;
-- Replace the old unique-default index (one default per concept) with
-- one default per (concept, jurisdiction). We DROP IF EXISTS so the
-- migration is rerunnable against a freshly-rebuilt schema.
DROP INDEX IF EXISTS paliad.deadline_concept_event_types_one_default;
CREATE UNIQUE INDEX IF NOT EXISTS deadline_concept_event_types_one_default_per_jur
ON paliad.deadline_concept_event_types (concept_id, jurisdiction)
WHERE is_default = true;
-- ============================================================================
-- Demote then re-elect defaults so each (concept, jurisdiction) pair is
-- correctly anchored. Rows that never had jurisdiction picked stay as
-- non-defaults until a curated row beats them.
-- ============================================================================
-- statement-of-defence DE: de_klageerwiderung becomes the DE default.
INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order, jurisdiction)
SELECT dc.id, et.id, true, 10, 'DE'
FROM paliad.deadline_concepts dc
JOIN paliad.event_types et ON et.slug = 'de_klageerwiderung' AND et.archived_at IS NULL
WHERE dc.slug = 'statement-of-defence'
ON CONFLICT (concept_id, event_type_id) DO UPDATE
SET is_default = true, jurisdiction = 'DE', sort_order = 10;
-- opposition: split per jurisdiction. EPO is the canonical EU-wide
-- pre-grant Einspruch, DPMA is the German national variant.
UPDATE paliad.deadline_concept_event_types j
SET is_default = true, jurisdiction = 'EPO', sort_order = 10
FROM paliad.deadline_concepts dc, paliad.event_types et
WHERE j.concept_id = dc.id
AND j.event_type_id = et.id
AND dc.slug = 'opposition'
AND et.slug = 'epo_opposition_filing';
UPDATE paliad.deadline_concept_event_types j
SET is_default = true, jurisdiction = 'DPMA', sort_order = 20
FROM paliad.deadline_concepts dc, paliad.event_types et
WHERE j.concept_id = dc.id
AND j.event_type_id = et.id
AND dc.slug = 'opposition'
AND et.slug = 'dpma_opposition';
-- request-for-examination: DPMA is the only jurisdiction with an
-- event_type counterpart (EP-grant exam request has no event_type yet).
UPDATE paliad.deadline_concept_event_types j
SET is_default = true, jurisdiction = 'DPMA'
FROM paliad.deadline_concepts dc, paliad.event_types et
WHERE j.concept_id = dc.id
AND j.event_type_id = et.id
AND dc.slug = 'request-for-examination'
AND et.slug = 'dpma_examination_request';
-- notice-of-appeal: keep the UPC default (already set by mig 073) AND
-- add EPO + DPMA jurisdictional variants for non-UPC rules.
INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order, jurisdiction)
SELECT dc.id, et.id, true, 10, 'EPO'
FROM paliad.deadline_concepts dc
JOIN paliad.event_types et ON et.slug = 'epo_appeal_notice' AND et.archived_at IS NULL
WHERE dc.slug = 'notice-of-appeal'
ON CONFLICT (concept_id, event_type_id) DO UPDATE
SET is_default = true, jurisdiction = 'EPO', sort_order = 10;
INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order, jurisdiction)
SELECT dc.id, et.id, true, 10, 'DPMA'
FROM paliad.deadline_concepts dc
JOIN paliad.event_types et ON et.slug = 'dpma_appeal' AND et.archived_at IS NULL
WHERE dc.slug = 'notice-of-appeal'
ON CONFLICT (concept_id, event_type_id) DO UPDATE
SET is_default = true, jurisdiction = 'DPMA', sort_order = 10;
-- statement-of-grounds-of-appeal: keep UPC default; add EPO variant.
UPDATE paliad.deadline_concept_event_types j
SET is_default = true, jurisdiction = 'EPO', sort_order = 10
FROM paliad.deadline_concepts dc, paliad.event_types et
WHERE j.concept_id = dc.id
AND j.event_type_id = et.id
AND dc.slug = 'statement-of-grounds-of-appeal'
AND et.slug = 'epo_appeal_grounds';
-- ============================================================================
-- Final pass: any junction row that still has NULL jurisdiction (none
-- expected after the backfill, but defensive) gets its event_type's
-- jurisdiction copied so the partial-unique index is well-defined.
-- ============================================================================
UPDATE paliad.deadline_concept_event_types j
SET jurisdiction = et.jurisdiction
FROM paliad.event_types et
WHERE j.event_type_id = et.id
AND j.jurisdiction IS NULL;

View File

@@ -156,6 +156,13 @@ type Project struct {
CaseNumber *string `db:"case_number" json:"case_number,omitempty"`
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
// OurSide is which side the firm represents on this project. Used
// by the Fristenrechner Determinator to predefine the perspective
// chip from the project context (t-paliad-164). NULL = unknown /
// not set; Determinator falls back to free-pick. Allowed values:
// claimant, defendant, court, both.
OurSide *string `db:"our_side" json:"our_side,omitempty"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
@@ -468,6 +475,12 @@ type DeadlineRule struct {
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"`
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
// ConceptDefaultEventTypeID is the canonical paliad.event_types row for
// this rule's concept (joined via paliad.deadline_concept_event_types
// where is_default = true). Lets the deadline create form auto-populate
// the Typ chip when the user picks this rule. Hydrated by the service
// layer; not a column. NULL when the concept has no mapped event_type.
ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"`
LegalSource *string `db:"legal_source" json:"legal_source,omitempty"`
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`

View File

@@ -32,6 +32,9 @@ const proceedingTypeColumns = `id, code, name, name_en, description, jurisdictio
category, default_color, sort_order, is_active`
// List returns active rules, optionally filtered by proceeding type.
// Each row has ConceptDefaultEventTypeID hydrated from
// paliad.deadline_concept_event_types so the deadline-create form can
// auto-populate the Typ chip when the user picks a Regel.
func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) ([]models.DeadlineRule, error) {
var rules []models.DeadlineRule
var err error
@@ -52,9 +55,71 @@ func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) (
if err != nil {
return nil, fmt.Errorf("list deadline rules: %w", err)
}
if err := s.hydrateConceptDefaultEventTypes(ctx, rules); err != nil {
return nil, err
}
return rules, nil
}
// hydrateConceptDefaultEventTypes resolves each rule's (concept_id,
// proceeding_type.jurisdiction) pair to the canonical paliad.event_types
// row from paliad.deadline_concept_event_types (where is_default and
// jurisdiction matches), and assigns it to ConceptDefaultEventTypeID.
//
// One round-trip via JOIN to paliad.proceeding_types so we can match on
// the rule's jurisdiction without a per-rule second query. EPA→EPO
// canonicalisation is done in SQL because event_types use 'EPO' but
// proceeding_types use 'EPA' — the two columns disagreed before this
// mapping table existed (mig 074).
//
// Rules whose (concept, jurisdiction) has no default stay NULL —
// silent no-op on the form, better than a wrong-jurisdiction default.
func (s *DeadlineRuleService) hydrateConceptDefaultEventTypes(ctx context.Context, rules []models.DeadlineRule) error {
ruleIDs := make([]uuid.UUID, 0, len(rules))
for _, r := range rules {
if r.ConceptID == nil {
continue
}
ruleIDs = append(ruleIDs, r.ID)
}
if len(ruleIDs) == 0 {
return nil
}
query, args, err := sqlx.In(
`SELECT dr.id AS rule_id, j.event_type_id
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concept_event_types j
ON j.concept_id = dr.concept_id
AND j.is_default = true
AND j.jurisdiction = CASE WHEN pt.jurisdiction = 'EPA' THEN 'EPO' ELSE pt.jurisdiction END
WHERE dr.id IN (?)`, ruleIDs)
if err != nil {
return fmt.Errorf("build rule→event_type IN query: %w", err)
}
query = s.db.Rebind(query)
type row struct {
RuleID uuid.UUID `db:"rule_id"`
EventTypeID uuid.UUID `db:"event_type_id"`
}
var rows []row
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
return fmt.Errorf("load rule→event_type defaults: %w", err)
}
defaultByRule := make(map[uuid.UUID]uuid.UUID, len(rows))
for _, r := range rows {
defaultByRule[r.RuleID] = r.EventTypeID
}
for i := range rules {
if et, ok := defaultByRule[rules[i].ID]; ok {
etCopy := et
rules[i].ConceptDefaultEventTypeID = &etCopy
}
}
return nil
}
// RuleTreeNode pairs a rule with its child rules in a parent_id hierarchy.
type RuleTreeNode struct {
models.DeadlineRule

View File

@@ -97,7 +97,7 @@ func (s *ProjectService) DB() *sqlx.DB { return s.db }
const projectColumns = `id, type, parent_id, path, title, reference, description, status,
created_by, industry, country, billing_reference, client_number, matter_number,
netdocuments_url, patent_number, filing_date, grant_date, court, case_number,
proceeding_type_id, metadata, ai_summary, created_at, updated_at`
proceeding_type_id, our_side, metadata, ai_summary, created_at, updated_at`
// CreateProjectInput is the payload for Create.
type CreateProjectInput struct {
@@ -121,6 +121,7 @@ type CreateProjectInput struct {
Court *string `json:"court,omitempty"`
CaseNumber *string `json:"case_number,omitempty"`
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
OurSide *string `json:"our_side,omitempty"`
}
// UpdateProjectInput is the partial-update payload.
@@ -144,6 +145,7 @@ type UpdateProjectInput struct {
Court *string `json:"court,omitempty"`
CaseNumber *string `json:"case_number,omitempty"`
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
OurSide *string `json:"our_side,omitempty"`
}
// ListFilter narrows List results. Zero-value → no filter.
@@ -819,14 +821,19 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
// path is NOT NULL but the trigger populates it; supply a placeholder
// the trigger will overwrite. (BEFORE INSERT trigger rewrites path.)
if input.OurSide != nil {
if err := validateOurSide(*input.OurSide); err != nil {
return nil, err
}
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, reference, description, status,
created_by, industry, country, billing_reference, client_number,
matter_number, netdocuments_url, patent_number, filing_date, grant_date,
court, case_number, proceeding_type_id, metadata, created_at, updated_at)
court, case_number, proceeding_type_id, our_side, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $1::text, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, '{}'::jsonb, $21, $21)`,
$14, $15, $16, $17, $18, $19, $20, $21, '{}'::jsonb, $22, $22)`,
id, input.Type, input.ParentID,
input.Title, input.Reference, input.Description, status,
userID,
@@ -834,6 +841,7 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
input.ClientNumber, input.MatterNumber, input.NetDocumentsURL,
input.PatentNumber, input.FilingDate, input.GrantDate,
input.Court, input.CaseNumber, input.ProceedingTypeID,
nullableOurSide(input.OurSide),
now,
); err != nil {
return nil, fmt.Errorf("insert project: %w", err)
@@ -967,6 +975,12 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
if input.ProceedingTypeID != nil {
appendSetSkippable("proceeding_type_id", *input.ProceedingTypeID)
}
if input.OurSide != nil {
if err := validateOurSide(*input.OurSide); err != nil {
return nil, err
}
appendSet("our_side", nullableOurSide(input.OurSide))
}
if typeChanged {
for _, col := range typeSpecificColumns(current.Type) {
appendSet(col, nil)
@@ -1012,6 +1026,32 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
return nil, err
}
}
// our_side change: log when the value (or its set/unset state) actually
// flips. Description follows the same value-only "old → new" pattern as
// status_changed; frontend renderer maps the slugs to localized labels
// (claimant / defendant / court / both / "—" for NULL).
if input.OurSide != nil {
nextOS := strings.TrimSpace(*input.OurSide)
prevOS := ""
if current.OurSide != nil {
prevOS = *current.OurSide
}
if nextOS != prevOS {
from := prevOS
if from == "" {
from = "none"
}
to := nextOS
if to == "" {
to = "none"
}
desc := fmt.Sprintf("%s → %s", from, to)
descPtr := &desc
if err := insertProjectEvent(ctx, tx, id, userID, "our_side_changed", "Represented side changed", descPtr); err != nil {
return nil, err
}
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update project: %w", err)
}
@@ -1518,6 +1558,35 @@ func validateProjectStatus(s string) error {
return fmt.Errorf("%w: invalid status %q", ErrInvalidInput, s)
}
// validateOurSide checks the project-level "represented side" enum
// (t-paliad-164). Empty string is the explicit "clear" sentinel —
// callers pass the value as-is from the form payload, and the helper
// accepts it so an Update can null the column. The DB-level CHECK
// constraint enforces the same set; this validation gives a clearer
// error than relying on the constraint to fire.
func validateOurSide(s string) error {
switch strings.TrimSpace(s) {
case "", "claimant", "defendant", "court", "both":
return nil
}
return fmt.Errorf("%w: invalid our_side %q", ErrInvalidInput, s)
}
// nullableOurSide returns nil for an empty / whitespace value so the
// SQL driver writes NULL, otherwise the trimmed string. Mirrors the
// Update payload contract: empty string from the form clears the
// column, a value sets it.
func nullableOurSide(p *string) any {
if p == nil {
return nil
}
v := strings.TrimSpace(*p)
if v == "" {
return nil
}
return v
}
func sortByOrder(xs []models.Project, order map[uuid.UUID]int) {
// Insertion sort — ancestor lists are short (<20).
for i := 1; i < len(xs); i++ {

View File

@@ -45,10 +45,23 @@ type RenderSpec struct {
// ListConfig is the per-shape config for shape=list. Powers both the
// /events table look (density=comfortable) and the activity-feed look
// (density=compact + actor/time columns).
//
// RowAction tells shape-list which row interaction to wire when the
// universal <FilterBar> renders the table. "navigate" (the default and
// the contract for the existing /agenda/dashboard surfaces) routes a
// row click to a per-kind detail page. "complete_toggle" is the
// /events deadline-row pattern (checkbox + reopen button). "approve"
// is the /inbox approver row (approve/reject buttons + revoke). "none"
// is read-only (audit views, retrospective lists).
//
// shape-list.ts honours this when emitting the table's `entity-table`
// classes — `entity-table--readonly` plus `none` skips the navigate
// handler entirely.
type ListConfig struct {
Columns []string `json:"columns,omitempty"`
Sort SortOrder `json:"sort,omitempty"`
Density ListDensity `json:"density,omitempty"`
Columns []string `json:"columns,omitempty"`
Sort SortOrder `json:"sort,omitempty"`
Density ListDensity `json:"density,omitempty"`
RowAction ListRowAction `json:"row_action,omitempty"`
}
// CardsConfig is the per-shape config for shape=cards.
@@ -78,6 +91,29 @@ const (
DensityCompact ListDensity = "compact"
)
// ListRowAction identifies which row interaction the list-shape renderer
// should wire. Defaults to RowActionNavigate when empty so existing
// SystemView definitions and saved user views continue to render rows
// that route to the per-kind detail page.
type ListRowAction string
const (
RowActionNavigate ListRowAction = "navigate"
RowActionCompleteToggle ListRowAction = "complete_toggle"
RowActionApprove ListRowAction = "approve"
RowActionNone ListRowAction = "none"
)
// KnownRowActions is the registry the validator checks against. Adding a
// new action = add a const above AND append here AND extend
// shape-list.ts's switch.
var KnownRowActions = []ListRowAction{
RowActionNavigate,
RowActionCompleteToggle,
RowActionApprove,
RowActionNone,
}
type CardsGroupBy string
const (
@@ -148,6 +184,9 @@ func (c *ListConfig) validate() error {
default:
return fmt.Errorf("%w: unknown list.density %q", ErrInvalidInput, c.Density)
}
if c.RowAction != "" && !slices.Contains(KnownRowActions, c.RowAction) {
return fmt.Errorf("%w: unknown list.row_action %q", ErrInvalidInput, c.RowAction)
}
return nil
}

View File

@@ -75,6 +75,26 @@ func TestRenderSpec_CalendarViewEnum(t *testing.T) {
}
}
func TestRenderSpec_RowActionEnum(t *testing.T) {
for _, action := range KnownRowActions {
t.Run(string(action), func(t *testing.T) {
s := RenderSpec{Shape: ShapeList, List: &ListConfig{RowAction: action}}
if err := s.Validate(); err != nil {
t.Fatalf("known row_action %q must validate: %v", action, err)
}
})
}
s := RenderSpec{Shape: ShapeList, List: &ListConfig{RowAction: "delete"}}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown row_action must reject, got %v", err)
}
// Empty defaults to navigate at the renderer level — schema accepts.
empty := RenderSpec{Shape: ShapeList, List: &ListConfig{}}
if err := empty.Validate(); err != nil {
t.Fatalf("empty row_action must validate (defaults to navigate): %v", err)
}
}
func TestRenderSpec_RoundTrip(t *testing.T) {
original := RenderSpec{
Shape: ShapeList,

View File

@@ -101,8 +101,17 @@ func EventsSystemView() SystemView {
}
// InboxSystemView returns the SystemView definition for /inbox — the
// 4-eye approval surface (the "Zur Genehmigung" tab). The "Meine
// Anfragen" tab is a sibling spec resolved by tab-state on the page.
// 4-eye approval surface. The bar's approval_viewer_role chip
// cluster narrows to "Zur Genehmigung" / "Eigene Anfragen" /
// "Alle sichtbaren". Default is "any_visible" so the page lands on
// a populated view for every user (m's 2026-05-08 22:08 dogfood:
// "the inbox somehow does not show nothing no more" — the prior
// default was approver_eligible, which is empty for users who only
// SUBMIT requests and have nothing to approve themselves).
//
// RowAction = RowActionApprove → shape-list.ts renders the approval
// row layout (entity title + diff + approve/reject/revoke buttons)
// and the surface wires action handlers via the rendered data-attrs.
func InboxSystemView() SystemView {
return SystemView{
Slug: "inbox",
@@ -114,7 +123,7 @@ func InboxSystemView() SystemView {
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "approver_eligible",
ViewerRole: "any_visible",
Status: []string{"pending"},
}},
},
@@ -122,14 +131,17 @@ func InboxSystemView() SystemView {
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
Density: DensityComfortable,
Sort: SortDateAsc,
RowAction: RowActionApprove,
},
},
}
}
// InboxRequesterSystemView is the "Meine Anfragen" tab of /inbox.
// InboxRequesterSystemView is the "Eigene Anfragen" sibling view of
// /inbox. Reachable via the bar's approval_viewer_role chip ("Eigene
// Anfragen") on the /inbox surface, or as its own URL on /views/inbox-mine.
func InboxRequesterSystemView() SystemView {
return SystemView{
Slug: "inbox-mine",
@@ -148,8 +160,9 @@ func InboxRequesterSystemView() SystemView {
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
Density: DensityComfortable,
Sort: SortDateAsc,
RowAction: RowActionApprove,
},
},
}