Compare commits

..

3 Commits

Author SHA1 Message Date
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
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
25 changed files with 596 additions and 2478 deletions

View File

@@ -1,469 +0,0 @@
# 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,350 +0,0 @@
// 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();
const current = ctx.get("approval_viewer_role") ?? "approver_eligible";
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

@@ -1,325 +0,0 @@
// 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

@@ -1,146 +0,0 @@
// 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

@@ -1,132 +0,0 @@
// 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

@@ -1,102 +0,0 @@
// 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

@@ -1,188 +0,0 @@
// 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",
@@ -875,6 +876,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 +898,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 +1128,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,63 +2165,6 @@ 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: {
@@ -2577,6 +2531,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",
@@ -3059,6 +3014,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",
@@ -3080,6 +3036,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",
@@ -3307,6 +3264,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:",
@@ -4333,62 +4298,6 @@ 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.",
},
};
@@ -4464,6 +4373,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,176 +1,122 @@
import { initI18n, t } from "./i18n";
import { initI18n, t, getLang, type I18nKey } 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 — t-paliad-163 universal-filter migration.
// /inbox client. Two tabs (pending-mine / mine), action buttons (approve /
// reject / revoke), and a small inline diff for update / complete / delete
// lifecycle events.
//
// 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.
// 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.
const INBOX_AXES: AxisKey[] = [
"time",
"approval_viewer_role",
"approval_status",
"approval_entity_type",
"density",
"sort",
];
type Lifecycle = "create" | "update" | "complete" | "delete";
type RequestStatus = "pending" | "approved" | "rejected" | "revoked" | "superseded";
type DecisionKind = "peer" | "admin_override";
let bar: BarHandle | null = null;
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();
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
applyLegacyTabRedirect();
void hydrate();
const url = new URL(window.location.href);
const t = url.searchParams.get("tab");
if (t === "mine") currentTab = "mine";
bindTabs();
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 fetchInboxSystemView(): Promise<SystemView | null> {
try {
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) {
return null;
}
}
function paint(
result: ViewRunResult,
render: RenderSpec,
results: HTMLElement,
empty: HTMLElement,
loading: HTMLElement,
): void {
loading.style.display = "none";
if (!result.rows || result.rows.length === 0) {
results.innerHTML = "";
empty.style.display = "";
empty.textContent = t("approvals.empty.pending_mine");
void maybeShowAdminNudge();
return;
}
hideAdminNudge();
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 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();
});
});
}
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;
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[] = [];
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 ?? [];
}
} catch (_e) {
// Network errors fall through to empty render.
}
loading.style.display = "none";
if (rows.length === 0) {
empty.textContent = t(
currentTab === "pending-mine"
? "approvals.empty.pending_mine"
: "approvals.empty.mine"
);
empty.style.display = "";
void maybeShowAdminNudge();
return;
}
hideAdminNudge();
for (const row of rows) list.appendChild(renderRow(row));
}
// t-paliad-154 — show the admin-only "configure policies" nudge when:
// - current user is global_admin
// - inbox empty
// - no approval_policies row exists firm-wide
// - 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.
async function maybeShowAdminNudge(): Promise<void> {
const nudge = document.getElementById("inbox-admin-nudge");
if (!nudge) return;
@@ -186,7 +132,9 @@ async function maybeShowAdminNudge(): Promise<void> {
if (data.any) return;
nudge.style.display = "";
} catch (_e) { /* keep hidden */ }
} catch (_e) {
// Network failure → keep nudge hidden.
}
}
function hideAdminNudge(): void {
@@ -194,7 +142,175 @@ function hideAdminNudge(): void {
if (nudge) nudge.style.display = "none";
}
async function refreshInboxBadge(): Promise<void> {
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() {
const badge = document.getElementById("sidebar-inbox-badge");
if (!badge) return;
try {
@@ -207,5 +323,7 @@ async function refreshInboxBadge(): Promise<void> {
} 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,25 +1,17 @@
import { t, tDyn, getLang, type I18nKey } from "../i18n";
import type { ListRowAction, RenderSpec, ViewRow } from "./types";
import { t, type I18nKey } from "../i18n";
import type { 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);
@@ -27,11 +19,6 @@ 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 {
@@ -175,166 +162,3 @@ 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,13 +71,10 @@ 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

@@ -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"
@@ -905,6 +906,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 +1110,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 +1749,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,60 +1940,6 @@ 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,20 +5,13 @@ import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /inbox — t-paliad-163 universal-filter migration.
// 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
//
// 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.
// 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.
export function renderInbox(): string {
return "<!DOCTYPE html>" + (
@@ -45,11 +38,18 @@ export function renderInbox(): string {
</p>
</div>
<div id="inbox-filter-bar" />
<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 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" />
<div id="inbox-results" />
<ul className="inbox-list" id="inbox-list" />
{/* 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);
@@ -13235,166 +13251,3 @@ 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

@@ -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"`

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,23 +45,10 @@ 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"`
RowAction ListRowAction `json:"row_action,omitempty"`
Columns []string `json:"columns,omitempty"`
Sort SortOrder `json:"sort,omitempty"`
Density ListDensity `json:"density,omitempty"`
}
// CardsConfig is the per-shape config for shape=cards.
@@ -91,29 +78,6 @@ 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 (
@@ -184,9 +148,6 @@ 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,26 +75,6 @@ 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,13 +101,8 @@ func EventsSystemView() SystemView {
}
// InboxSystemView returns the SystemView definition for /inbox — the
// 4-eye approval surface (the "Zur Genehmigung" view). The "Eigene
// Anfragen" sibling view is selected via the bar's
// approval_viewer_role axis (chip cluster on the same surface).
//
// 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.
// 4-eye approval surface (the "Zur Genehmigung" tab). The "Meine
// Anfragen" tab is a sibling spec resolved by tab-state on the page.
func InboxSystemView() SystemView {
return SystemView{
Slug: "inbox",
@@ -127,17 +122,14 @@ func InboxSystemView() SystemView {
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
RowAction: RowActionApprove,
Density: DensityComfortable,
Sort: SortDateAsc,
},
},
}
}
// 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.
// InboxRequesterSystemView is the "Meine Anfragen" tab of /inbox.
func InboxRequesterSystemView() SystemView {
return SystemView{
Slug: "inbox-mine",
@@ -156,9 +148,8 @@ func InboxRequesterSystemView() SystemView {
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
RowAction: RowActionApprove,
Density: DensityComfortable,
Sort: SortDateAsc,
},
},
}