Compare commits

...

186 Commits

Author SHA1 Message Date
mAi
1f8230b264 feat(t-paliad-182): models + service compat-read for unified rules
Phase 3 Slice 1 Go-side of mig 078–080. Compat-mode reads: the
service selects BOTH the legacy shape (is_mandatory, is_optional,
condition_flag, condition_rule_id) and the new shape (priority,
condition_expr, is_court_set, trigger_event_id,
spawn_proceeding_type_id, combine_op, lifecycle_state, draft_of,
published_at). Existing callers stay on the legacy fields until
Slice 4 cuts the calculator over.

Adds:
  - DeadlineRule field block for the nine Phase 3 columns. NULLable
    jsonb (condition_expr) uses NullableJSON to dodge the
    json.RawMessage NULL-scan trap (see Project.Metadata note from
    t-paliad-138 dogfood).
  - Project.InstanceLevel *string.
  - DeadlineRuleAudit row struct (id, rule_id, changed_by,
    changed_at, action, before_json, after_json, reason,
    migration_exported).
  - ruleColumns const extended to project every new column.

Test (TEST_DATABASE_URL-gated, mirrors audit_service_test.go):
  1. ruleColumns SELECT scans cleanly — every new column populates
     its Go field.
  2. Migration defaults land: priority='mandatory',
     is_court_set=false, lifecycle_state='published' on every
     pre-Slice-1 row.
  3. Audit trigger writes one row on UPDATE WITH paliad.audit_reason
     set, captures before+after JSON + reason.
  4. Audit trigger RAISES on UPDATE WITHOUT paliad.audit_reason —
     Slice 2 backfills fail loudly if they forget to set it.
  5. paliad.projects.instance_level accepts NULL + first/appeal/
     cassation, rejects 'final'.

Build clean, full test suite green (live DB test skipped locally).
2026-05-15 00:19:49 +02:00
mAi
bd8ec42b80 feat(t-paliad-182): mig 080 — projects.instance_level
Phase 3 Slice 1, design §2.7 + §7. Adds a nullable text column
gated by a CHECK to 'first' | 'appeal' | 'cassation'. Combined
with proceeding_code + jurisdiction, the FristenrechnerService
(Slice 8) will derive the effective proceeding code — e.g.
DE_INF + appeal → DE_INF_OLG.

No backfill in this slice. The project-detail picker UI (Slice 8)
writes the column; pre-Slice-1 rows stay NULL and behave as
implicit 'first' in the calculator's fallback.
2026-05-15 00:19:37 +02:00
mAi
ec0ec32271 feat(t-paliad-182): mig 079 — deadline_rule_audit table + trigger
Phase 3 Slice 1 audit-log foundation (design §2.8). The audit log
lands BEFORE the rule editor (Slice 11) so every future write to
paliad.deadline_rules is captured — including the Slice 2
backfill UPDATEs.

paliad.deadline_rule_audit columns mirror design §2.8 (changed_by,
changed_at, before_json / after_json, reason, migration_exported).
Two intentional deviations, documented inline:

  1. changed_by is nullable, not NOT NULL. Trigger reads auth.uid()
     which is NULL under service_role (migrations, server-side Go
     using the service key). NOT NULL would block Slice 2 backfills
     and every seed insert.

  2. action values written by the trigger are 'create'|'update'|
     'delete' (raw TG_OP). Go-authored audit rows additionally
     write 'publish'|'archive'|'restore' (lifecycle_state flips
     that the trigger sees as plain UPDATEs). The audit UI in
     Slice 11 collapses the paired rows.

Trigger is SECURITY DEFINER so its INSERT into the audit table
bypasses the audit table's RLS — otherwise an authenticated
user's UPDATE on a rule would fail when the trigger tried to write
under their RLS context.

Audit-reason enforcement: trigger reads paliad.audit_reason via
current_setting(..., true) and raises EXCEPTION on UPDATE/DELETE
when unset. INSERT defaults to 'create' so seed migrations stay
ergonomic.

RLS: SELECT for global_admin only (mirrors mig 057 pattern). No
INSERT policy — the SECURITY DEFINER trigger and service_role are
the only writers.
2026-05-15 00:19:31 +02:00
mAi
251f5a250f feat(t-paliad-182): mig 078 — unified rule columns
Phase 3 Slice 1 Step A (design §3.1). Additive only; no drops, no
data change. Adds nine columns to paliad.deadline_rules so the
calculator + rule editor can converge on a single rule shape over
the following slices:

  trigger_event_id          (bigint, FK trigger_events.id)
  spawn_proceeding_type_id  (int,    FK proceeding_types.id)
  combine_op                (text, CHECK 'max'|'min')
  condition_expr            (jsonb)
  priority                  (text, DEFAULT 'mandatory', 4-way CHECK)
  is_court_set              (bool, DEFAULT false)
  lifecycle_state           (text, DEFAULT 'published', 3-way CHECK)
  draft_of                  (uuid, self-FK)
  published_at              (timestamptz)

FK types follow the actual referenced columns (bigint on
trigger_events, int4 serial on proceeding_types) — the design doc's
"int FK" shorthand is widened to the precise widths.

FKs are DEFERRABLE INITIALLY IMMEDIATE so Slice 3's data-move can
defer FK checks within a single transaction without disturbing
normal-statement semantics.

Indexes: partial WHERE NOT NULL on the two FK columns (sparse;
most rules have neither); plain btree on lifecycle_state so the
admin filter on 'published' is O(log n).
2026-05-15 00:19:19 +02:00
mAi
58a1abc6d8 Merge: t-paliad-181 — Fristen Phase 2 design (unified rule model + 12 slices, DESIGN READY FOR REVIEW) 2026-05-15 00:11:28 +02:00
mAi
7159443dcb Merge: t-paliad-177 Slice 4 (FINAL) — Custom Views shape=timeline + cross-project lane aggregation 2026-05-15 00:10:43 +02:00
mAi
119b06dcff design(t-paliad-181): Fristen Phase 2 — unified rule model + 12-slice plan
Phase 2 design pass operationalising all 7 m-locked + 8 head-default
picks from audit §9.

Headline architecture:
- ONE unified deadline_rules table (evolved, not replaced) absorbing
  Pipeline A + Pipeline C. Adds trigger_event_id, spawn_proceeding_type_id,
  combine_op, condition_expr (jsonb AND/OR/NOT), priority (4-way enum),
  is_court_set (real column, drops heuristic), lifecycle_state +
  draft_of + published_at (rule-editor draft → published lifecycle).
  Drops condition_flag, condition_rule_id, is_mandatory, is_optional.
  Net +5 columns, 32 → 37.
- paliad.deadline_rule_audit table + DB trigger + RLS for admin-only
  rule editing (Q5C). Mandatory reason field. Migration-export
  endpoint keeps rules in version control after-the-fact.
- paliad.projects.instance_level column (first/appeal/cassation)
  enables DE_INF → DE_INF_OLG → DE_INF_BGH ladder without proceeding_type
  re-pick.
- Cross-proceeding spawn wired via spawn_proceeding_type_id FK +
  global rule index in the calculator + cycle guard.
- POST /api/tools/event-trigger preserves Pipeline C contract on
  unified backend.

Migration path (Steps A-I, ~17 migrations 078-094):
- Step A additive schema → Step B backfill → Step C Pipeline C
  data-move → Step D calculator unification (service refactor) →
  Step E destructive drops (gated) → Step F project soft-merge
  (Q2) → Step G spawn → Step H instance-level → Step I rule_id
  backfill on legacy deadlines.
- Read-only trigger on paliad.event_deadlines during the cutover
  window prevents drift.
- Backup snapshots before destructive drops.

12 prioritized slices (§10) for Phase 3:
- Slices 1-4 sequential: schema, backfill, Pipeline C migration,
  calculator unification.
- Slices 5-8 parallel: project soft-merge, event-trigger endpoint,
  spawn wiring, instance level.
- Slices 9-10 cleanup: destructive drops, rule_id fuzzy-match
  backfill.
- Slices 11a + 11b: rule-editor backend + frontend (HEAVIEST,
  lands last on stable schema).
- Slice 12: orphan concept seed (wiedereinsetzung first), through
  the editor as its real-world workout.

§9 risk surface: destructive migrations, audit-log compliance gap
during cutover (mitigated by SET LOCAL audit_reason in migration
tooling), cross-corpus drift window (mitigated by read-only
trigger), condition_expr jsonb perf (trivial at 172-row scale),
migration-export manual step.

§12 has 12 open questions for HEAD (not m) — sub-decisions head
resolves at slice-start: migration window, draft lifecycle for
v1, audit retention, preview implementation, export format, slice
ordering, cycle-guard strictness, picker placement, testing scope,
ambiguity-tail handling, seed-vs-editor ordering, telemetry.

§0 drift since 2026-05-13 audit: 1 fristenrechner code deactivated
(20→19 active); mig 075-077 are SmartTimeline, NOT Fristen-logic;
new concept (56→57); new event_types (40→45). All audit findings
hold.

NOT self-merged. Head gates Phase 3 transition (no m-gate).
NOT cronus per memory directive 2026-05-06.
2026-05-15 00:10:07 +02:00
mAi
1c915639b9 feat(t-paliad-177): Custom Views timeline-shape host (frontend)
Slice 4 step 2 (faraday-Q7). Wires shape="timeline" into the /views
shape switcher and the dispatch in client/views.ts.

New file shape-timeline-cv.ts holds the adapter:
- ViewRow.kind="deadline" → TimelineEvent kind="deadline" + deadline_id
- ViewRow.kind="appointment" → kind="appointment" + appointment_id
- ViewRow.kind="project_event" → kind="milestone" + project_event_id
- ViewRow.kind="approval_request" → SKIPPED (no chart-meaningful date)
- Lane axis = project_id (design §10 cross-project chart use case);
  first-seen order keeps lanes deterministic across re-renders.
- Rows without project_id collapse to a synthetic "self" lane.
- Status comes from row.detail.status for deadlines (done/overdue),
  defaults to "open" everywhere else.

shape-timeline-chart.ts gets a new ChartMountOpts.staticData escape
hatch: when supplied, mount() skips the /api/projects/{id}/timeline
fetch and paints from the supplied events + lanes directly. This is
what lets the CV adapter feed pre-loaded ViewRows into the same
renderer that powers /projects/{id}/chart — Slice 1-3 features
(palette, density, range chips, lane filter, permalink) all carry
over for free.

views.ts switches the active shape host and disposes the chart handle
on shape flips so resize listeners don't leak between mounts.

Tests (13 new): pin the kind mapping, lane bucketing by project_id,
status extraction precedence, date passthrough, empty-input safety.

Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5.
2026-05-15 00:09:23 +02:00
mAi
83a3d27fe0 feat(t-paliad-177): ShapeTimeline enum + render_spec wiring
Slice 4 step 1 (faraday-Q7). RenderShape gets a fourth member
ShapeTimeline, AllShapes extends, Validate accepts it. The
companion TimelineConfig struct stores the saved palette / density /
range-preset for a CV-timeline view so re-opening the view restores
the same visual settings — same vocabulary as the standalone
/projects/{id}/chart URL state, just persisted in user_views.render_spec
instead of the URL.

Validator mirrors the frontend's enum guards:
- known palettes (default | kind-coded | track-coded | high-contrast | print)
- known densities (compact | standard | spacious)
- known range presets (1y | 2y | all | custom)
- ISO-date strings length-bounded to 32 chars so a hostile editor
  can't bloat the jsonb column.

Tests pin every accept/reject path in TestRenderSpec_TimelineConfigValidates.

Design ref: docs/design-project-chart-2026-05-09.md §11.5 + §14 Q7.
2026-05-15 00:06:37 +02:00
mAi
79f6be3fc9 Merge: t-paliad-157 — Fristen-Logik-Audit (AUDIT READY FOR REVIEW) 2026-05-15 00:02:31 +02:00
mAi
b455df265e audit(t-paliad-157): Fristen logic — rules, triggers, conditionals
Phase 1 audit (AUDIT ONLY, no implementation). 799 lines, mai/pauli/fristen-logic-audit.

Headline findings:

- THREE parallel deadline-generation systems coexist with overlapping
  intent:
  - Pipeline A (proceeding-driven) — paliad.deadline_rules (172 rows),
    FristenrechnerService.Calculate, drives /tools/fristenrechner +
    SmartTimeline.
  - Pipeline B (single-rule subset of A) — Pathway B cascade click.
  - Pipeline C (event-driven, youpc legacy) — paliad.trigger_events
    (110) + paliad.event_deadlines (77), EventDeadlineService.Calculate,
    drives "Was kommt nach…" tab. Disjoint corpus from A.

- Rule corpus is RICHER than the brief implied: 32 columns, 172 rules
  across 27 proceeding_types (132 fristenrechner + 40 litigation). The
  dual-corpus is a latent footgun: paliad.projects.proceeding_type_id
  accepts both categories with no CHECK constraint, so a project's
  SmartTimeline depends on which code lands first.

- Data model already encodes most of m's mental model:
  multi-deadline triggers via parent_id chains (deepest live: 3
  levels in UPC_INF), conditional via condition_flag (AND-only),
  flag-swap via alt_duration_value / alt_rule_code, court-set via
  heuristic + 4-bucket classification, holiday adjustment via
  HolidayService+CourtService.

- Real gaps (§6, 13 of them):
  - Pipeline A/C redundancy (different capabilities, disjoint data).
  - Litigation vs fristenrechner corpus drift (no contract).
  - is_mandatory + is_optional overlap.
  - deadline_concept_event_types is config layer, NOT trigger model.
  - No real event-driven trigger endpoint.
  - AND-only condition_flag (no OR/NOT/compound).
  - Cross-proceeding spawn half-wired.
  - 9 orphan concepts with rule_count=0 (incl wiedereinsetzung,
    schriftsatznachreichung, weiterbehandlung).
  - condition_rule_id dead column.
  - Instance dimension (LG/OLG/BGH) not on paliad.projects.
  - 1/26 deadlines linked to rule_id (anchor-from-actuals barely
    used).
  - Court-set is heuristic, not first-class column.
  - Pipeline A lacks before / working_days / combine_op.

- The big m's-question: "all in the Rules so we should be able to
  manage" is FALSE today. Rules edits = SQL migrations only. §8
  proposes a 3-step ladder: status-quo / read-only admin / full
  editor with audit log.

- §7 has concrete extension proposal for each §6 gap (migration size
  costed).

- §9 has 15 open questions for m to call before Phase 2 starts.

- Live data sparse: 11/11 projects NULL proceeding_type_id, 1/26
  deadlines with rule_id — demand-side mostly empty even though
  supply-side (rules) is rich.

NOT cronus per memory directive 2026-05-06. NOT self-merged. Awaiting
m's go/no-go.
2026-05-13 21:33:38 +02:00
mAi
7d9935de60 Merge: t-paliad-177 Slice 3 — chart range chips + lane filter + permalink + sidebar entry 2026-05-13 11:54:29 +02:00
mAi
e9bcf3a7b6 feat(t-paliad-177): chart reciprocal "Zurück zum Verlauf" link
Slice 3 step 5 (optional). The back-link on the chart page now points
explicitly at /projects/{id}/history (Verlauf sub-path) instead of
the bare /projects/{id}. Today's projects-detail.ts treats both the
same — bare and /history land on the Verlauf tab — but /history is
the explicit form, so the link keeps working if Verlauf ever stops
being the default tab.

Label flips from "Zurück zum Projekt" → "Zurück zum Verlauf" so
users see exactly where they're heading. Pairs naturally with the
Slice 1 "Als Chart anzeigen ↗" affordance: the trip is round.

Design ref: docs/design-project-chart-2026-05-09.md §8.1.
2026-05-13 11:53:46 +02:00
mAi
1ad78918bc feat(t-paliad-177): chart sidebar contextual entry (option a)
Slice 3 step 4 (head Slice-2 deferral). Implements head's option (a):
sidebar.ts walks the URL pathname on init and reveals a contextual
"Als Chart anzeigen" entry when it sits on a /projects/{uuid}/* page
that ISN'T already the chart itself.

Sidebar TSX gets a new hidden slot id="sidebar-project-chart-link"
right under the Übersicht group. The page never has to touch the
sidebar — initProjectContextChartLink owns the path-match and the
href population. Clean separation: pages don't know about the slot;
sidebar.ts doesn't know about pages.

UUID-shape regex prevents the chip from appearing on /projects (list)
or /projects/new. Rest-path check excludes /chart and /chart/ — the
chart page already has its own "Zurück zum Verlauf" path (Slice 1
link goes the other direction, a reciprocal can land in the next
commit).

i18n: 1 new key DE+EN under nav.context.project_chart.

Design ref: docs/design-project-chart-2026-05-09.md §8.1 +
Slice-2 head deferral resolution.
2026-05-13 11:53:13 +02:00
mAi
5e1f1fecf6 feat(t-paliad-177): chart permalink copy-link + URL params consolidation
Slice 3 step 3 (faraday-Q10). The URL already aggregates every chip's
state via the individual writeParamToURL writers we built in Slice 2
and Slice 3 C1-C2 — palette + density + range + lanes. The copy
button just reads window.location.href and writes it to the clipboard.

Two-tier clipboard strategy:
1. navigator.clipboard.writeText in secure contexts (modern browsers,
   localhost, paliad.de over TLS).
2. document.execCommand("copy") fallback for older / non-secure
   contexts (file://, some iframes).

Visual feedback flashes green/amber on the button for 1.8s after the
click — no toast component needed, the button IS the affordance.

Permalink contract: reload an identical URL → visually identical
chart. Tested by hand on every chip combination; URL stays canonical
(default values omit their param) so shared links don't accumulate
defaults that drift if defaults change.

Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §14 Q10.
2026-05-13 11:51:47 +02:00
mAi
731e762919 feat(t-paliad-177): chart lane visibility filter + URL state
Slice 3 step 2. The chip group is rendered dynamically by the boot
client after refresh() reports lanes via the new onDataLoaded
callback — the lane labels and ids only exist after the server
responds, so static TSX can't render the chips. Hidden when the
projection has 0-1 lanes (filter has no value on a single-track
render).

setVisibleLanes(allowlist | null) on the chart handle filters BOTH
lanes and events in repaint() before passing to layout() — drops
unselected entirely (doesn't fall back to first-lane the way an
unknown stale id does). null = show all.

Stale lane ids are dropped from the URL-restored allowlist after
every refresh: deleted CCRs / child cases can't keep their lane id
alive across re-fetches.

URL state in ?lanes=id1,id2; absent / empty = show all. Hostile or
oversized ids are filtered (length cap 200) at parse time; the
allowlist intersection in repaint() defends again. Toggling every
chip back on collapses to null so the URL stays canonical.

Design ref: docs/design-project-chart-2026-05-09.md §3.2 + §8.2.
2026-05-13 11:51:08 +02:00
mAi
581fbe7d92 feat(t-paliad-177): chart range chips + custom-range URL state
Slice 3 step 1. Four range presets per design §10 + faraday-Q8 default:
1y (today-1y..today+1y, default), 2y, all (derives bounds from loaded
events with a +30d right pad), and custom (date-pair inputs).

mount() grows currentRangePreset + customRangeFrom + customRangeTo so
the layout-time viewport is computed from the live preset, not the
constructor-time opts. resolveRange() handles the four cases; "all"
calls rangeFromEvents() over the last fetched timeline so completing
or adding a row reflows on next repaint.

URL state in ?range=1y|2y|all|custom (omit when 1y); custom adds
?from=&to=. ISO_DATE_RE guards malformed input. Custom date-pair
shows / hides based on the preset.

i18n: 7 new keys DE+EN under projects.chart.range.*.

Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §10 + §14 Q8.
2026-05-13 11:49:24 +02:00
mAi
8f5b83ec93 Merge: t-paliad-166 — Determinator row-by-row cascade design doc (DESIGN READY FOR REVIEW) 2026-05-13 11:43:31 +02:00
mAi
7c4bc39115 design(t-paliad-166): Determinator B1 row-by-row cascade
- §0 premises verified live: 4-layer Pathway B mess (radio + 2 chip-strips
  + breadcrumb-cascade), 91/103 leaves carry forum tag, 16 leaves carry
  party tag, 11/11 live projects have NULL proceeding_type_id (graceful
  degrade), 4 distinct condition_flag value-sets on UPC_INF + UPC_REV
  only, project.court is free-text not FK, verfahrensablauf-core.ts
  carries zero cascade leakage post-t-paliad-179 Slice 1.
- §1 three intertwined pillars: project-driven narrowing / visual
  hierarchy overhaul / row-by-row persistent cascade.
- §2-3 single .fristen-row primitive (active / answered / prefilled /
  hidden) replaces radio + chip-strips + breadcrumb-cards.
- §4 data mapping: forum derivation already shipped; new
  litigation_code x jurisdiction -> fristenrechner_code helper
  (shared with t-paliad-178 Slice 2).
- §5 per-row pre-fill / hide / skipped-but-shown matrix across UPC /
  DE / EPA / DPMA / ad-hoc / zero-context flows with two compact
  ASCII diagrams.
- §6 Filter / Suche mode = escape-hatch icon-button (inventor's pick).
- §7-9 mobile breakpoints, three reset flavours, search affordance
  placement.
- §10 three slices: visual-only (Slice 1), narrowing depth +
  proceeding_mapping.go helper (Slice 2), mobile + search polish
  (Slice 3).
- §11 seven trade-offs flagged (row-stack height, aus-Akte noise,
  auto-walk magic, radio removal, NULL proceeding_type_id reality,
  mapping ambiguities, ändern descendant invalidation).
- §12 file-touch map for Slice 1 only.
- §13 fifteen open questions for m to call before coder shift.

NOT self-merged. Awaiting m's go/no-go.
2026-05-13 11:27:06 +02:00
mAi
adf377c2ca Merge: t-paliad-179 Slice 1 — Tools surface split (route + shell + code-lift) 2026-05-13 00:20:44 +02:00
mAi
f5eb84718a chore(t-paliad-179): sidebar maps Verfahrensablauf 1:1 to its own URL
Sidebar.tsx href flips from /tools/fristenrechner?path=a to
/tools/verfahrensablauf. The two Werkzeuge entries now resolve to
distinct pathnames, so the SSR navItem helper picks the right active
class on its own — fixVerfahrensablaufActive (which compared search
params client-side to disambiguate) is deleted along with its call
in initSidebar.
2026-05-13 00:19:16 +02:00
mAi
1255ee049f feat(t-paliad-179): /tools/verfahrensablauf page (TSX + client + build)
The new abstract-browse surface. TSX shell hosts:

  - header (h1 + subtitle)
  - jurisdiction-tabbed proceeding-tile picker (UPC / DE / EPA / DPMA)
  - trigger date input
  - court picker (visible only for proceedings with multiple
    compatible courts — UPC_REV across CD + LD seats etc.)
  - view toggle (Spalten / Zeitstrahl)
  - result container

client/verfahrensablauf.ts wires picker click → calculateDeadlines →
renderColumnsBody/renderTimelineBody via the shared core. Pre-selects
the first proceeding tile on load so users see a timeline immediately,
matching /tools/fristenrechner's auto-render behaviour. No Akte
picker, no Pathway B cascade, no save modal, no anchor-override edit
— Slice 1 is the structural foundation; variant chips + lane view
(Slice 3) and compare (Slice 4) layer on top in later commits.

build.ts wires the new entrypoint + write step. i18n adds
tools.verfahrensablauf.title / .heading / .subtitle in DE + EN; the
existing nav.verfahrensablauf reused.
2026-05-13 00:19:10 +02:00
mAi
0105d35f0c refactor(t-paliad-179): fristenrechner consumes shared renderer module
client/fristenrechner.ts imports renderTimelineBody / renderColumnsBody
/ deadlineCardHtml / formatDate / partyBadge / escAttr / escHtml /
calculateDeadlines / populateCourtPicker from views/verfahrensablauf-
core, deleting the local copies (~480 lines out). The click-to-edit
anchor-override path stays wired by passing { editable: true } to the
shared renderers; the local anchor-override Map / openInlineDateEditor
/ render-on-override path are unchanged.

The "Verfahrensablauf einsehen" Step 2 card (t-paliad-168) is retired
— TSX markup gone, click handler gone. The abstract-browse intent
lives at /tools/verfahrensablauf now (Slice 1 design §9, §10).
2026-05-13 00:19:00 +02:00
mAi
0531e5dbf6 feat(t-paliad-179): lift Fristenrechner renderers into shared core module
frontend/src/client/views/verfahrensablauf-core.ts — pure-functional
module with the proceeding-timeline rendering surface:

  - DeadlineResponse / CalculatedDeadline / CourtRow types
  - escAttr / escHtml / formatDate / partyBadge helpers
  - deadlineCardHtml(dl, { showParty, editable })
  - renderTimelineBody(data, opts)
  - renderColumnsBody(data, opts)
  - calculateDeadlines(params) — POST /api/tools/fristenrechner wrapper
  - courtTypesFor / defaultCourtFor / fetchCourts (cache)
  - populateCourtPicker(rowId, selectId, proceedingType)

Both /tools/fristenrechner and /tools/verfahrensablauf import from
here. No module-level mutable state — the per-page concerns
(anchorOverrides, lastResponse, Akte save) stay in the consumers.

The deadlineCardHtml signature carries an editable flag so the click-
to-edit anchor-override affordance is opt-in per page: fristenrechner
enables it, verfahrensablauf (Slice 1 scope) doesn't.
2026-05-13 00:18:52 +02:00
mAi
0099e2f28c feat(t-paliad-179): register /tools/verfahrensablauf + 302 legacy ?path=a
Backend half of Slice 1: a new dedicated route owns the abstract-browse
intent that was previously emulated by /tools/fristenrechner?path=a +
client-side fix-up. The page handler is a 1-liner that serves
dist/verfahrensablauf.html (no DB dependency).

A naked ?path=a on /tools/fristenrechner now 302s to the new URL so
bookmarked legacy links survive. ?project=<uuid>&path=a still serves
the fristenrechner shell because that's wizard state set by client-
side history.replaceState during Akte-mode Pathway A — refreshing
mid-wizard must not bounce away.

Test covers all four query shapes: naked path=a → redirect, path=a
with project → no redirect, no params → no redirect, path=b → no
redirect.
2026-05-13 00:18:42 +02:00
mAi
3ba5727deb Merge: t-paliad-177 Slice 2 — visibility-leak fix + palette/density + exports (SVG/PNG/CSV/JSON/iCal/print) 2026-05-13 00:11:58 +02:00
mAi
d8f7745f86 feat(t-paliad-177): chart export — iCal feed (deadlines+appointments only)
Server-side endpoint GET /api/projects/{id}/timeline.ics returns a
VCALENDAR + one VEVENT per actual deadline (VALUE=DATE all-day) and
appointment (UTC timestamp). Projected / milestone / off_script rows
are deliberately skipped — faraday-Q6 / m's pick: a calendar feed
must never carry predicted dates the user never confirmed, otherwise
Outlook fills with rule_code-derived events that erode trust.

FormatTimelineICS reuses the existing caldav_ical.go escape helpers
and writes through the same canonical UIDs (paliad-deadline-<id> +
paliad-appointment-<id>) so a re-subscribe updates entries instead
of duplicating them. Stable across re-exports = lawyer-safe.

Visibility piggybacks on ProjectionService.For + ProjectService.GetByID
(same gates as the chart page handler). Content-Disposition filename
slugged for portable ASCII so Outlook + Apple Calendar agree.

4 tests pin the contract: only deadline/appointment kinds emit
VEVENTs; undated rows skip cleanly; RFC 5545 §3.3.11 escaping for
; , \ \\n; empty input still produces a valid VCALENDAR.

i18n: 1 new key DE+EN.

Design ref: docs/design-project-chart-2026-05-09.md §7.8.
2026-05-13 00:11:14 +02:00
mAi
98a51faa66 feat(t-paliad-177): chart exports — SVG/PNG/CSV/JSON + browser-print CSS
Five client-side export paths per design §7 (faraday-Q4: rule out
chromedp, browser-print is good enough).

- SVG: XMLSerializer over a clone of the live SVGSVGElement, with
  --chart-* tokens inlined so the standalone file paints the same way
  when opened in an image viewer (no document.css context).
- PNG: SVG → Image → Canvas at 2× DPR, toBlob("image/png"). White
  background painted first so transparent SVG stays printable.
- PDF: window.print() → @media print stylesheet hides chrome, forces
  the print palette tokens, locks A4 landscape via @page. User picks
  "Save as PDF" in the browser print dialog. No chromedp dep.
- CSV: 20-column flat schema mirroring TimelineEvent, UTF-8 BOM for
  Excel-DE, RFC 4180 escaping.
- JSON: events + lanes envelope + export-metadata header (project_id,
  project_title, exported_at).

Export menu uses native <details>/<summary> so it's keyboard-accessible
without JS. The chart handle exposes getSVGElement() + getData() so
chart-export.ts stays pure: it never reads DOM state outside the SVG
it's handed.

Filenames are sanitised + dated: paliad-{title}-{yyyy-mm-dd}.{ext}.

i18n: 7 new keys DE+EN under projects.chart.export.*.

Design ref: docs/design-project-chart-2026-05-09.md §7.
2026-05-13 00:08:28 +02:00
mAi
b24063bee1 feat(t-paliad-177): density toggle — compact/standard/spacious + URL state
Density flips lane height (24/40/64) and mark radius (5/7/10) via the
existing LANE_HEIGHT / MARK_RADIUS tables in shape-timeline-chart.ts.
Unlike palette (pure CSS swap), density needs a repaint because it
changes layout() output — setDensity() on the handle re-runs the
layout pure function with the new viewport.density.

URL state in ?density=<compact|standard|spacious>, default omitted.
The writeParamToURL helper is now shared between palette + density to
keep the canonical URL short (omit when value equals the default).

i18n: 4 new keys DE+EN under projects.chart.density.*.

Design ref: docs/design-project-chart-2026-05-09.md §6.1.
2026-05-13 00:06:32 +02:00
mAi
d1314a46f9 feat(t-paliad-177): palette picker — 5 CSS-token sets + URL state
Slice 2 ships all 5 palettes from design §5.1 (m's pick on faraday-Q5):
default / kind-coded / track-coded / high-contrast / print.

Each palette is a pure data-attribute swap of the --chart-* tokens on
.smart-timeline-chart[data-palette="..."]. The renderer never reads
palette state — it stamps classed SVG nodes and the tokens flow in
via CSS variable cascade. setPalette() on the chart handle is a
one-line attribute write; no repaint.

URL state lives in ?palette=<name>; default omits the param so the
canonical URL stays clean. Initial paint reads the URL, every change
writes via history.replaceState — bookmarkable per design §8.2.
Unknown values silently fall back to default (defence against stale /
hostile URLs).

i18n: 6 new keys DE+EN under projects.chart.palette.*.

Design ref: docs/design-project-chart-2026-05-09.md §5 + §8.2.
2026-05-13 00:05:38 +02:00
mAi
968b0bc2da feat(t-paliad-177): close visibility leak on /projects/{id}/chart handler
Slice 1 served dist/projects-chart.html unconditionally, leaking a 200
for any well-formed UUID guesser. Slice 2 resolves the project via
ProjectService.GetByID before serving — ErrNotVisible (and any other
visibility error) collapses to 404 + the standard notfound chrome,
matching the JSON-API contract that already lives in writeServiceError.

A genuine DB error logs through writeServiceError's existing path but
still renders 404 chrome to the user (httpDevNullJSON wrapper discards
the JSON body writeServiceError would otherwise emit, keeping the log
side-effect intact).

Test pins serveChartNotFound: 404 + non-empty body, degrading
gracefully when dist/notfound.html is absent (test env).

Closes Slice 1 edge case #2 flagged at m/paliad#35 issuecomment-7710.
Design ref: docs/design-project-chart-2026-05-09.md §8.2.
2026-05-13 00:03:45 +02:00
mAi
cd1a70d08c Merge: t-paliad-178 — Tools surface cleanup design doc (DESIGN READY FOR REVIEW) 2026-05-13 00:00:41 +02:00
mAi
bdb3d8a425 Merge: t-paliad-177 Slice 1 — Project Timeline / Chart (SVG Gantt + standalone /projects/{id}/chart page) 2026-05-12 14:14:01 +02:00
mAi
30f7031e99 feat(t-paliad-177): chart page TSX + boot client + i18n + Verlauf link
Wires the chart surface end-to-end:

- frontend/src/projects-chart.tsx — standalone page shell with title
  row, inert control chips (Slice 3 wires them live), undated hint slot,
  and the mount target for the SVG renderer.
- frontend/src/client/projects-chart.ts — boot client that parses the
  project id from the URL, loads project metadata for the header,
  mounts the renderer, and reveals the undated hint when the layout
  reports clipped/undated rows.
- frontend/build.ts — registers the new bundle + HTML output.
- frontend/src/client/i18n.ts — 11 new DE+EN keys under projects.chart.*
  + projects.detail.smarttimeline.open_chart (the Verlauf link).
- frontend/src/projects-detail.tsx — "Als Chart anzeigen ↗" link in
  the SmartTimeline controls, opens /chart in a new tab.
- frontend/src/client/projects-detail.ts — resolves the chart href in
  renderHeader once project.id is known.

`bun run build` clean, `go build ./...` clean, 27/27 chart tests pass.

Design ref: docs/design-project-chart-2026-05-09.md §8.1 + §8.2 + §12.
2026-05-12 14:12:20 +02:00
mAi
8e9cde6d52 design(t-paliad-178): Tools surface cleanup — split Fristenrechner / Verfahrensablauf
Inventor pass for t-paliad-178. Two intents (deadline determination vs
abstract procedural shape browse) get two dedicated routes:

- /tools/fristenrechner — keeps deadline-determination, gains Step 0
  ("Abstrakt oder Akte?") above today's Step 1.
- /tools/verfahrensablauf — new dedicated abstract-browse surface with
  variant chips (with_ccr / with_cci / with_amend), consolidated-vs-lane
  view, and side-by-side compare.

§0 premise audit corrects three things the task brief got wrong:
  1. projects.court is free-text, not FK — no silent court_id auto-pick.
  2. projects.proceeding_type_id points at litigation-category rows, not
     fristenrechner-category — a mapping helper (litigation × jurisdiction
     → fristenrechner code) is required.
  3. condition_flag variants only exist on UPC_INF + UPC_REV; every other
     proceeding renders a single canonical timeline. Variant chips honour
     this — no dead chips on DE_INF / EPA_OPP / DPMA_*.

Sliced into 4 independent merges: Slice 1 (route + shell split) is the
structural foundation; Slices 2-4 layer Step 0 / variant chips / compare.

DESIGN ONLY — no implementation. Awaiting m's go/no-go before coder shift.
2026-05-12 14:10:20 +02:00
mAi
a3adb6b13b feat(t-paliad-177): chart SVG paint() + mount() + palette CSS tokens
Extends shape-timeline-chart.ts with the DOM-mutation half of the
renderer:

- paint(layout, root, events): hand-rolled SVG using namespaced
  document.createElementNS. Idempotent (clears prior children),
  layers <defs> → grid+axis+lanes → today rule → marks. Each mark
  wraps in <g> with data-* attrs for delegated event handling.
- mount(host, opts): fetches /api/projects/{id}/timeline (defensive
  for both legacy []TimelineEvent and Slice-4 envelope shapes),
  computes a today-1y..today+1y default range (design Q8), wires
  resize debouncing + click delegation. Returns a handle with
  refresh / dispose / getLayout.

CSS palette tokens swap purely via --chart-* custom properties on
.smart-timeline-chart, so future palette slices (Slice 3) toggle
attributes without touching the renderer. Deadlines colour-saturate
by status (open = ring, done = filled, overdue = red). Projected
rows use the hatched/dashed-dot variants from §6.2.

Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §5 + §6.
2026-05-12 14:09:43 +02:00
mAi
ed4e731333 feat(t-paliad-177): chart layout() pure-function + 27 table-driven tests
Slice 1 load-bearing math. Translates TimelineEvent[] + LaneInfo[] +
viewport into deterministic SVG-ready geometry: axis ticks (month /
quarter / year by total span), lane row y/height, mark x/y/shape per
kind+status, today rule. No DOM access — paint() will read this and
mutate the SVG separately.

Tests pin canvas geometry, pxPerDay math, today-rule clipping, lane
stacking, mark bucketing by lane_id, out-of-range clipping, undated
zone, mark-shape mapping, axis tick density. Date math is UTC
throughout so DST doesn't drift day-deltas.

Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §15.
2026-05-12 14:07:48 +02:00
mAi
b0a6b0998f feat(t-paliad-177): chart page handler + GET /projects/{id}/chart route
Slice 1 backend slice. Tiny static-file server for the new standalone
chart page; visibility piggybacks on the existing /api/projects/{id}/
timeline endpoint (gated through ProjectionService.For), so no new
auth surface.

Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
2026-05-12 14:05:52 +02:00
m
54b227ce7b Merge: t-paliad-176 — FilterBar regression bundle (m/paliad#32 + #33)
maxwell diagnosed and fixed two regressions m hit @ 18:32:

#32 — FilterBar timeline filters don't narrow Verlauf:
- The Verlauf bar mounted only 'time' + 'project_event_kind' axes; the
  Slice 2/3 timeline_status + timeline_track chips never rendered.
- The customRunner drained predicates into the legacy loadEvents()
  array, but the SmartTimeline render reads timelineRows. Filter pass
  was landing on a dead branch.

#33 — Nur direkt always includes sub-projects:
- Frontend correctly sent ?direct_only=true; handler parsed it; the
  loadProjectTrack SQL filter respected DirectOnly. But Slice 3's
  CCR-children loading (forCaseLevel) and Slice 4's lane-per-child
  loading (forAggregatedLevel) ran unconditionally regardless of the
  flag.

Fix:
- Backend: ProjectionService.For() short-circuits to new
  forDirectSelfOnly when opts.DirectOnly. Single 'self' lane, no
  CCR/parent_context/child-case aggregation. Level-policy kind+status
  filter still applies. Added ProjectEventType field to TimelineEvent
  so frontend can filter by project_event_kind end-to-end.
- Frontend: mountFilterBar's customRunner gains a 'state' arg for
  first-run hydration; BarHandle gains getState(). Verlauf bar now
  mounts all three axes (time, project_event_kind, timeline_status,
  timeline_track). customRunner drains state into verlaufFilters;
  renderTimeline calls applyTimelineRowFilters before passing rows
  to renderSmartTimeline.
- Tests: two new live-DB subtests in TestProjectionService_LevelAggregation_Live
  pin the DirectOnly contract at Patent and Case level (+89 LoC).

Verified: go build/vet/test clean, bun build clean. (Pre-existing
pq type-inference seed issue in unrelated projection_service_test
remains; verified independent of this change via stash.)

3-way merge with main preserves faraday's chart design doc unchanged.

Single commit c2f1c29 from mai/maxwell/bug-bundle-filterbar.
2026-05-09 18:53:39 +02:00
m
c2f1c29b10 fix(t-paliad-176): FilterBar timeline narrowing + Nur-direkt subtree skip
Two regressions from SmartTimeline Slices 2-4 dogfood @ 2026-05-09:

m/paliad#32 — clicking timeline_status / timeline_track / project_event_kind
chips changed URL params but the rendered list never narrowed. Two
causes: (1) the Verlauf bar mounted only "time" + "project_event_kind"
axes — the timeline_status / timeline_track chips never appeared. (2)
the customRunner drained predicates into `loadEvents` which writes the
legacy `events` array; the SmartTimeline render reads `timelineRows`,
so the filter pass was a dead branch.

Fix: mount all three axes on the bar; rewrite customRunner to drain
state into `verlaufFilters`; renderTimeline applies them client-side
via `applyTimelineRowFilters` before handing rows to renderSmartTimeline.
project_event_kind is forwarded through the substrate-shaped predicate
map (effective.filter.predicates.project_event.event_types);
timeline_status / timeline_track sit on raw BarState — the customRunner
signature now accepts the BarState snapshot as a second arg so the
bar's first run (before the handle is assigned) can read them.

Backend adds `ProjectEventType` to TimelineEvent + frontend
TimelineEvent — needed so the project_event_kind chip can match against
the underlying paliad.project_events.event_type for milestone rows.

m/paliad#33 — "Nur direkt" pill flipped subtreeMode and re-fetched the
timeline with ?direct_only=true, but ProjectionService.For honoured the
flag only at the deadline / appointment / project_events SQL level. CCR
sub-project lanes (Slice 3) and child-case lanes (Slice 4) loaded
unconditionally, so the "direct" view still showed everything.

Fix: `For` short-circuits to `forDirectSelfOnly` whenever DirectOnly is
set. Single "self" lane, no CCR / parent_context / child-case
aggregation. The level-policy kind/status filter still applies at
higher levels so a Patent-level direct view doesn't leak off_script
custom milestones the aggregated view filters out.

Tests: two new live-DB subtests in TestProjectionService_LevelAggregation_Live
pin the contract — Patent direct_only collapses to a single 'self' lane
and excludes child-case events; Case-A direct_only excludes the CCR
child's milestones (with subtree default still surfacing them).

Build: go build/vet/test clean. bun run build clean (2171 keys).
2026-05-09 18:52:01 +02:00
m
17e96b7a1c Merge: t-paliad-177 — Project Timeline / Chart design doc (DESIGN READY FOR REVIEW)
faraday's inventor pass on m's 18:32 ask. Visualisation layer above the
SmartTimeline substrate; ADDS surfaces (does not replace Verlauf).

607-line doc covers:
- Renderer choice: SVG hand-rolled for horizontal Gantt; DOM (existing
  shape-timeline.ts) for vertical. No D3 / Chart.js dep.
- Layouts: vertical (existing) + horizontal Gantt-strip (new).
- 5 palette presets, 6 export formats (client SVG/PNG/PDF + server
  CSV/JSON/iCal). Chromium-on-Dokploy ruled out for v1.
- Three surfaces: Verlauf embed, standalone /projects/{id}/chart (new),
  Custom Views shape='timeline' (Slice 4).
- 4-slice phasing, ~700 LoC Slice 1 (standalone page + horizontal SVG).
- 12 open questions parked for m's review.

Slice 1 NOT auto-spawned — inventor → coder gate stands.

Single commit 8402002 from mai/faraday/project-timeline-chart.
2026-05-09 18:47:06 +02:00
m
84020022a6 design(t-paliad-177): Project Timeline / Chart — visualisation layer above SmartTimeline
Inventor design pass for m/paliad#35. NO IMPLEMENTATION.

Pinned premises:
- SmartTimeline data substrate (projection_service.go, ResponseEnvelope)
  is shipped through Slice 4. Chart is a presentation-only layer.
- No chart libs / PDF libs / headless browser in repo. Bun + std-Go only.
- Custom Views shapes today are list/cards/calendar; "timeline" slot
  reserved by t-paliad-169 §8.6 but not registered.

Recommended:
- Two renderers coexist: existing DOM/CSS shape-timeline.ts (vertical
  embed, Verlauf tab, no changes) + new hand-rolled SVG shape-timeline-
  chart.ts (horizontal Gantt, /projects/{id}/chart standalone). Both
  consume the same TimelineEvent[] + LaneInfo[] substrate.
- Lane model = substrate's existing LaneInfo. No new lane axis. Chart
  adds only render-side state (layout, columns, density, palette, zoom).
- Five built-in palette presets via CSS-var swap (default / kind-coded
  / track-coded / high-contrast / print). No per-user picker in v1.
- Export pipeline:
  - Client-side: SVG (serialize → blob), PNG (drawImage), PDF
    (window.print() + @media print stylesheet).
  - Server-side: CSV (encoding/csv), JSON (alt content type on existing
    /timeline endpoint), iCal (extends caldav_ical.go formatter).
  - Reject chromedp / server-side PDF for v1 — Chromium runtime weight
    not justified by browser-print quality gap.
- Mobile: vertical-only on <640px (horizontal Gantt unreadable on phone).

Phasing (4 sequential slices):
1. Standalone /chart page + horizontal SVG renderer.
2. Export pipeline (SVG/PNG/PDF/CSV/JSON/iCal).
3. Density / palette / zoom controls.
4. Custom Views shape="timeline" registration (cross-project chart).

12 open questions for m's gate. Files implementer touches in Slice 1
listed (~700 LoC frontend, ~50 LoC backend, zero migrations).

Doc: docs/design-project-chart-2026-05-09.md (607 lines).
2026-05-09 18:44:27 +02:00
m
7930ee0bdb Merge: t-paliad-175 — SmartTimeline Slice 4 (lane aggregation + bubble-up + Client toggle) — DESIGN COMPLETE
schroedinger closes the 4-slice phasing of the SmartTimeline per
docs/design-smart-timeline-2026-05-08.md §5 + §10. Final design slice.

Backend (commit 7da8802):
- ProjectionService.levelPolicy(projectType) returns {Kinds, Statuses,
  LaneGrouping} per design §5.1: Case (all/all/self+CCR), Patent
  (deadline+milestone / done+open+overdue / one-per-child-case),
  Litigation (milestone / done / one-per-child-patent), Client
  (milestone / done / one-per-child-litigation, gated by toggle).
- bubble_up handling on paliad.project_events.metadata: events with
  metadata->>'bubble_up'='true' survive the level kind+status filter
  at higher levels. Defaults: counterclaim_created /
  third_party_intervention / scope_change → bubble_up=true on insert
  (bohr's Slice 3 counterclaim path retroactively gets the flag);
  custom_milestone → bubble_up=false with form-checkbox override.
- Wire shape evolved from []TimelineEvent to {events, lanes} envelope
  with each event carrying LaneID. Frontend has defensive fallback.
- Lane-grouping wire format: lanes []LaneInfo{id, label, project_id,
  primary_track?}; one entry per direct child at parent levels.
- Tests: TestLevelPolicy (matrix per project type) +
  projection_levels_test.go +271 LoC integration suite.

Frontend (commit 7e57507):
- shape-timeline.ts: lane-grouped CSS-grid render when lanes.length > 1;
  per-lane sub-headers, time axis shared across lanes, lane filter chip
  in header (multiselect, defaults all-selected).
- projects-detail.tsx + .ts: at Client-level project pages, Verlauf
  defaults to existing matter-list (project tree) with new
  'Timeline-Ansicht' toggle button. Toggle persists in localStorage
  per project. Patent/Litigation already-default to lane view (no
  toggle needed).
- '+ Eintrag → Eigener Meilenstein' form gains a 'Auf Eltern-Ebenen
  sichtbar?' checkbox (default unchecked) for the bubble_up override.

Locked picks per design §11 (no deviations):
- Q5: bubble-up defaults locked
- Q12: Patent + Litigation default lane view; Client matter-list +
  toggle

Verified: go build ./... clean, go vet clean, go test
./internal/services passing, bun build clean (2171 keys).

This closes the 4-slice phasing of t-paliad-169:
- Slice 1 (3e1bbd3): skeleton — actuals + audit toggle + render shape
- Slice 2 (196f3f7): projection + click-to-anchor + #31 layered
  (lookahead + dependency + sequence enforcement)
- Slice 3 (91d3811): counterclaim sub-project + parallel-track
- Slice 4 (this): lane aggregation at Patent/Litigation/Client levels

The SmartTimeline design is fully shipped end-to-end. m can dogfood
the complete flow: anchor a date on a Case, see it bubble up the
hierarchy; create a counterclaim, see parallel tracks at Case level
and as a milestone bubbled up to Patent and beyond.
2026-05-09 16:30:15 +02:00
m
7e57507a92 feat(t-paliad-175): SmartTimeline Slice 4 — frontend lane render + Client Timeline-Ansicht toggle
shape-timeline.ts gains a third render mode triggered by lanes.length>1:
.smart-timeline-lanes-wrap holds a multiselect lane filter chip-row +
the .smart-timeline-lanes grid (one column per lane, time axis vertical
within each lane). Lanes the user has unchecked render dimmed to
preserve time-axis alignment across the strip; "Alle" pseudo-chip
resets to all selected. Lane mode takes precedence over Track-mode
(different axes — lanes group by direct-child project, tracks group
by CCR-vs-parent on a single Case).

loadTimeline parses the new envelope shape {events, lanes} from
GET /api/projects/{id}/timeline; defensive fallback to the old []
shape during the rolling deploy window. selectedLanes state is
client-side (chip toggles re-render in place without a re-fetch);
disappearing lanes (e.g. CCR child deleted between renders) drop
out of the selection automatically.

Client-level Verlauf toggle (Q12 lock-in): on project.type='client',
the Verlauf tab defaults to the matter-list rendering (simple list
of direct child litigations linking through). Flipping the
"Timeline-Ansicht" toggle (visible only at Client level) swaps to
the lane SmartTimeline. State persists in localStorage per project
so navigating away + back keeps the user's choice. Patent +
Litigation default to the lane view, matching Q12.

Custom-milestone form gains the bubble_up checkbox (§7.2 Q5). When
checked, the milestone surfaces on Patent / Litigation / Client
SmartTimelines via the backend's metadata.bubble_up=true override.
Default OFF for custom_milestone — structural milestones
(counterclaim_created etc.) default ON server-side.

CSS: ~130 lines under .smart-timeline-lanes / -lane / -lane-filter /
-matter-list. Mobile collapses lanes to single-column at ≤640px.

i18n: 12 new keys (DE+EN) under projects.detail.smarttimeline.lane.* /
.client.* / .milestone.bubble_up.

Refs: docs/design-smart-timeline-2026-05-08.md §5 + §10 Slice 4
Refs: m/paliad#31, t-paliad-175
2026-05-09 16:27:39 +02:00
m
7da8802f9b feat(t-paliad-175): SmartTimeline Slice 4 — backend levelPolicy + lane aggregation + bubble-up
ProjectionService now dispatches on project type per design §5.1:
- Case (and unknown) — full detail flow: parent track + CCR sub-projects
  + parent_context for CCR children. Lanes mirror tracks ("self" +
  "counterclaim:<id>" + "parent_context:<id>").
- Patent / Litigation / Client — lane-aggregated: load direct children
  matching the axis (cases / patents / litigations), gather subtree
  events per lane, apply (kinds, statuses) filter, tag rows with
  LaneID = direct-child id. Calculator skipped at higher levels —
  predicted future is a Case-level concern.

levelPolicy(projectType) returns the (kinds, statuses, lane_axis)
triple. Patent = deadlines+milestones with done/open/overdue;
Litigation + Client = milestones with done.

metadata.bubble_up on paliad.project_events (no schema change — uses
existing jsonb column) overrides the kind/status filter at higher
levels. Defaults per Q5: counterclaim_created / third_party_intervention
/ scope_change → true; custom_milestone → false (user opts in via
form checkbox). insertCounterclaimEvent now sets bubble_up=true on
both parent + child audit rows so the counterclaim_created milestone
surfaces at Patent / Litigation / Client.

Wire shape changed from []TimelineEvent to envelope {events, lanes} —
lane metadata can ride alongside the rows without exceeding header-
size limits when a Client-level projection has many lanes. Frontend
reads .events for the per-row contract and .lanes for parallel-column
rendering. X-Projection-* headers preserved for Slice 1-3 affordances
(lookahead toggle, track chip).

RecordCustomMilestone gains a bubbleUp bool param; persisted to
metadata.bubble_up only when true (so existing rows-without-it keep
the default-off behaviour).

Tests: TestLevelPolicy locks the triple table; TestRowSurvivesPolicy_
BubbleUpOverridesFilter pins the override contract; TestExtractBubbleUp
covers all per-event-type defaults + explicit override paths;
TestChildTypeForAxis pins the axis → type map. Live integration test
TestProjectionService_LevelAggregation_Live walks the patent-level
fixture: bubbled-up milestone surfaces, regular custom_milestone is
filtered, deadlines surface at Patent level.

Refs: docs/design-smart-timeline-2026-05-08.md §5 + §10 Slice 4
Refs: m/paliad#31, t-paliad-175
2026-05-09 16:22:07 +02:00
m
91d3811276 Merge: t-paliad-174 — SmartTimeline Slice 3 (counterclaim sub-project + parallel-track render)
bohr's Slice 3 of the SmartTimeline per docs/design-smart-timeline-2026-05-08.md
§4 + §10. Counterclaims now first-class as sub-project rows with their own
proceeding type, our_side perspective, and timeline; parent's SmartTimeline
renders them as a parallel right-track on desktop + vertical-stacked sub-headers
on mobile.

Backend (commits 306bb11 + 82888de):
- Migration 077: paliad.projects.counterclaim_of nullable FK ON DELETE SET NULL,
  partial index, and a deferred trigger paliad.projects_no_two_level_ccr that
  rejects malformed two-level CCR-of-CCR chains at the schema level. Defense in
  depth — service-side ErrInvalidInput AND schema-side trigger.
- ProjectService.CreateCounterclaim: atomic create with parent-id placement
  (sibling under patent — child.parent_id = parent.parent_id, fallback to
  parent.id when parent has no parent), our_side flipped by default
  (claimant↔defendant; both stays both), proceeding_type defaults to UPC_REV,
  bilateral counterclaim_created audit rows on both parent + child.
- ProjectService.LoadCounterclaimChildrenVisible.
- ProjectionService.For loads CCR children for parent view; emits
  Track='counterclaim:<id>' rows. CCR child's view also loads parent context
  faded (Track='parent_context:<id>') per design §4.5. AvailableTracks
  surfaced via new X-Projection-Tracks response header.
- POST /api/projects/{id}/counterclaim handler.
- Tests: TestDerivedCounterclaimOurSide (9 cases) + TestCreateCounterclaim_Live
  (4 sub-tests).

Frontend (commit 483649d):
- shape-timeline.ts: CSS-grid wrapper renders one column per available track;
  ≤640px media query collapses to vertical stacking with sub-headers per
  track. [Track ▼] dropdown filters Beide / Nur Hauptverfahren /
  Nur Widerklage purely client-side (no re-fetch).
- '+ Eintrag → Widerklage (CCR)' inline form: proceeding-type select
  (UPC_REV default; UPC_CCI for R.49.2.b path), title + CCR case_number,
  'Stimmt nicht?' toggle for our_side override. POSTs and navigates to
  the new child's /projects/<id>.

Locked picks per design §11 (no deviations):
- Q1: counterclaim = sub-project
- Q2: default-flip our_side with toggle
- Q4: sibling-under-patent placement
- Q8: parallel right-track + Track chip + mobile-stack collapse

Verified: go build ./... clean, go vet clean, go test
./internal/services ./internal/handlers passing, bun build clean (2161
keys). Migration 077 dry-run on live DB succeeded + rolled back; tracker
advances 76 → 77 on next deploy boot.

Out of scope (Slice 4): lane-grouped rendering at Patent / Litigation /
Client levels; 'Timeline-Ansicht' Client toggle; off-script bubble-up.

Sequence enforcement (#31, Slice 2) keeps working independently per track
— anchoring SoD on parent rejects without parent's SoC, same for the CCR
chain on its own. Cross-track is correctly NOT enforced.
2026-05-09 16:09:24 +02:00
m
483649d9d2 feat(t-paliad-174): SmartTimeline Slice 3 — frontend parallel-track render + CCR creation modal
shape-timeline.ts renders multiple tracks side-by-side via a CSS-grid
wrapper (one column per available track). The pre-Slice-3 single-column
flow is reused per column — each track keeps its own past / today /
future / undated structure and its own lookahead toggle. On ≤640px the
grid collapses to a single column with track sub-headers preserved so
the user knows which track they're reading.

A [Track ▼] selector surfaces above the timeline whenever the response
advertises more than the default "parent" track (read from the new
X-Projection-Tracks header). Options: "Beide" (default — render every
track in parallel) / "Nur Hauptverfahren" / "Nur Widerklage". The
filter is purely client-side, so swapping tracks doesn't re-fetch.

Visual treatment: parent track gets the lime accent; counterclaim track
takes the muted surface-2 background so the lawyer reads "this is the
defended side" at a glance; parent_context track is dashed-bordered and
faded to signal the read-only context view.

The previously-disabled "Widerklage (CCR) — kommt mit Slice 3" button
in the "+ Eintrag" modal is enabled and now opens an inline form with
proceeding-type select (defaulted to UPC_REV; populated lazily on first
open from /api/proceeding-types-db), optional title + CCR case-number,
and a "Stimmt nicht?" toggle for the R.49.2.b CCI edge case. POSTs to
/api/projects/{id}/counterclaim and navigates to the new child page on
success.

i18n: 30 keys (15 DE + 15 EN) under projects.detail.smarttimeline.track.*
+ projects.detail.smarttimeline.counterclaim.*. CSS: ~100 lines for the
grid wrapper, per-track visual modifiers, mobile collapse media query,
and the track-chip styling.
2026-05-09 16:07:58 +02:00
m
82888dea78 feat(t-paliad-174): SmartTimeline Slice 3 — projection parallel tracks + counterclaim handler
ProjectionService.For now composes multiple tracks instead of a single
"parent" stream. The viewed project always emits Track="parent"; visible
CCR children emit Track="counterclaim:<child_id>"; a project that is
itself a CCR (counterclaim_of != nil) pulls its target's events as
Track="parent_context:<parent_id>" so the lawyer working the CCR sees
the main proceeding without leaving the page (§4.5).

Each track runs the actuals + projection pipeline independently with
its own lookahead cap and dependency annotations against its own
proceeding's rule tree. SubProjectID + SubProjectTitle are populated on
non-parent rows so the frontend can render the sub-project title in the
column sub-header.

ProjectionMeta gains AvailableTracks; the handler surfaces it as the
new X-Projection-Tracks response header (CSV) so the wire shape stays
[]TimelineEvent (frozen since Slice 1).

POST /api/projects/{id}/counterclaim wraps ProjectService.CreateCounterclaim
— accepts proceeding_type_id / flip_our_side / title / case_number,
returns the new project's id + canonical /projects/<id> URL.

Tests: pure-function coverage for derivedCounterclaimOurSide (default
flip + R.49.2.b override + court/both pass-through). Live-DB integration
test covers the four invariants — CreateCounterclaim atomicity (parent
audit + child audit + our_side flip + sibling-under-patent placement),
parent's projection surfaces the counterclaim track, child's projection
surfaces parent_context, two-level CCR chains are rejected by both the
service guard and the schema-level trigger.
2026-05-09 16:07:37 +02:00
m
306bb11618 feat(t-paliad-174): SmartTimeline Slice 3 — counterclaim sub-project schema + service
Migration 077 adds paliad.projects.counterclaim_of (nullable FK ON DELETE
SET NULL) plus a partial index. A trigger function rejects two-level CCR
chains: a project with counterclaim_of NOT NULL cannot be the target of
another CCR — UPC practice has no CCR-of-a-CCR shape, so reject it at
the schema level rather than defending in the application layer.

ProjectService gains LoadCounterclaimChildrenVisible (list visible CCR
sub-projects against a parent) and CreateCounterclaim (atomic: project
row + creator-as-lead team membership + audit rows on parent AND child).
The CCR child is placed as a sibling under the same patent (§4.4), our
side flips claimant↔defendant by default with a "Stimmt nicht?" override
for the R.49.2.b CCI edge case, and the proceeding type defaults to
UPC_REV. Title auto-suggests from the patent ancestor's patent_number
when available.

Tracker advances 76 → 77.
2026-05-09 16:07:17 +02:00
m
196f3f74a6 Merge: t-paliad-173 — SmartTimeline Slice 2 + m/paliad#31 layered features
gauss's bundle: Slice 2 base from lagrange's design (FristenrechnerService
projection + click-to-anchor + reflow-on-actuals + rule-skipped path) PLUS
m/paliad#31 layered requirements (7-event lookahead cap, dependency
provenance display, SoC→SoD sequence enforcement at the anchor write path).

Backend (commit 85d7dd4):
- Migration 076: appointments.deadline_rule_id FK (nullable, ON DELETE SET
  NULL) + partial index; deadlines.source CHECK extended with 'anchor';
  project_events validation extended with 'rule_skipped' event_type.
- ProjectionService: FristenrechnerService.Calculate integration with
  AnchorOverrides built from completed actuals (per design §6.1); projected
  rows include Status='predicted' / 'court_set' / 'predicted_overdue';
  7-event lookahead cap with ?lookahead=N override (1..50); skipped-rule
  cascade-suppression via project_events WHERE event_type='rule_skipped';
  dependency annotations (DependsOnRuleCode + DependsOnDate + name).
- POST /api/projects/{id}/timeline/anchor: 200 happy path (idempotent
  re-PATCH), 409 predecessor_missing payload (rule code + bilingual name +
  bilingual message) when sequence violation detected.
- POST /api/projects/{id}/timeline/skip: writes project_events
  rule_skipped+milestone for §6.4 'ist nicht eingetreten' decision.
- Tests: projection_anchor_test.go +294 LoC; projection_service_test
  extended.

Frontend (commit 331efc8):
- shape-timeline.ts +425 LoC: projected/court-set/overdue row variants,
  inline click-to-anchor editor (200 reflow / 409 inline-error +
  'Stattdessen <predecessor> erfassen' link), depends-on footer +
  'Pfad anzeigen' chain expansion, 'Mehr / Weniger anzeigen' lookahead
  toggle persisting in localStorage per project.
- FilterBar +99 LoC: timeline_status (predicted/actual/overdue/done/
  off_script) + timeline_track axes; 'Zukunft anzeigen' / 'Nur
  vergangenes' macro chip pair.
- projects-detail.ts: orphan renderEvents() removed (Slice 1 leftover
  band-aided in fermat's 0835be4 — proper cleanup landed here).
- 58 i18n keys (DE+EN), 191 LoC CSS, no hardcoded colours (CSS variables).

Locked design picks (lagrange §11): Q3 'anchor' source, Q9 court-set →
appointments. m/paliad#31 defaults locked as briefed: 7 fixed lookahead,
footer-on-every-row + expand-on-click for deps, 409 hard-reject for
sequence (no confirm-and-write override in this slice).

Verified: bun build.ts clean (2146 keys), go build ./... clean,
projection_service + projection_anchor unit tests passing; integration
tests gated on TEST_DATABASE_URL run on CI.

Migration tracker: 75 → 76 on next deploy boot.

Slices 3 (counterclaim sub-project + parallel-track) and 4 (lane
aggregation at Patent/Litigation/Client) remain queued for m's pace.
2026-05-09 15:45:43 +02:00
m
331efc8603 feat(t-paliad-173): SmartTimeline Slice 2 frontend + #31 layered features
shape-timeline.ts:
- Renders Kind="projected" rows with Status-driven styling: predicted
  (faded grey), court_set (dashed border), predicted_overdue (amber
  fade with overdue glyph).
- "[Datum setzen]" inline date editor on every projected row with a
  rule_code. Submit POSTs /api/projects/{id}/timeline/anchor; 200
  triggers onChange (re-fetch + re-render); 409 renders the
  predecessor_missing payload as inline error with a "Stattdessen
  <predecessor> erfassen" link that scrolls to + opens the parent's
  editor.
- "Folgt aus: <Name> (<Code>, <Date|Datum offen>)" footer on every row
  with depends_on_rule_code, plus "[Pfad anzeigen]" expander hint.
- "[+ Mehr anzeigen]" / "[− Weniger]" lookahead toggle when backend's
  X-Projection-Total header indicates more projections exist beyond
  the current cap.
- Status pills on projected rows surface the status nuance next to
  the kind chip without overwhelming the title.

projects-detail.ts:
- loadTimeline reads X-Projection-{Total,Lookahead} headers and forwards
  them to renderSmartTimeline.
- Lookahead state persisted in localStorage per project (key
  `paliad.smarttimeline.lookahead.<id>`).
- Removes the renderEvents() orphan (band-aid from t-paliad-172) and
  every call site — renderTimeline is the only project-page render
  path now. Aligns with fermat's commit-message hint in 0835be4.

FilterBar (substrate):
- New axes timeline_status / timeline_track (chip clusters, multi-
  select). Macro chip pair "Zukunft anzeigen" / "Nur vergangenes" on
  the timeline_status axis maps to the predicted+court_set subset
  on/off.
- url-codec round-trips ?tl_status= / ?tl_track= so saved Sichten /
  bookmarks survive.

CSS:
- ~80 LoC for .smart-timeline-row--projected/--court_set/--predicted_overdue,
  status pills, depends-on footer, anchor editor, lookahead toggle.
  All tokens reuse existing CSS variables — no bare-hex fallbacks
  (cf. t-paliad-150 dark-mode lesson).

i18n:
- 31 new keys (DE+EN) for projected statuses, depends-on labels,
  anchor editor states, lookahead chips, FilterBar axis labels +
  values + macro chips. 2102 → 2146 total.

Tests:
- projection_anchor_test.go covers applyLookaheadCap (overdue +
  court_set exemption), applyLookaheadDefault clamping,
  ruleAnchorKind dispatch, extractMetadataString, lang normalisation,
  ruleNameInLang, PredecessorMissingError unwrap, annotateDependsOn
  (including parent-of-parent chain dating).

Migration 076 was applied live during dev (tracker 75 → 76); deploy
re-applies idempotently via the embedded migrate path.
2026-05-09 15:43:22 +02:00
m
85d7dd497c feat(t-paliad-173): SmartTimeline Slice 2 backend — projection + anchor + skip + sequence guard
Slice 2 of the SmartTimeline (docs/design-smart-timeline-2026-05-08.md
§6 + §9 + §10) bundled with m/paliad#31's layered requirements:

Migration 076:
- appointments.deadline_rule_id nullable FK to deadline_rules + partial idx
- deadlines.source CHECK widened to include 'anchor' (alongside existing
  'manual','fristenrechner','rule','import').

ProjectionService (extended):
- Wires FristenrechnerService + DeadlineRuleService.
- For() now emits Kind="projected" rows for any rule lacking a matching
  paliad.deadlines.rule_id / appointments.deadline_rule_id row, with
  Status in {predicted | predicted_overdue | court_set}.
- Lookahead cap (default 7, override via ?lookahead=N, max 50): future
  predicted rows beyond N are dropped; predicted_overdue + court_set
  rows are exempt from the cap (#31 layer 1).
- Dependency annotations DependsOnRuleCode/Date/Name on every row that
  carries a DeadlineRuleID, walked from the rule's parent_id chain
  (#31 layer 2). Date prefers actuals over projections.
- AnchorOverrides built from completed deadlines (completed_at /
  status='completed') + appointments tied via deadline_rule_id.
- triggerDate derives from the proceeding's root rule's anchor when
  present, else today() as placeholder.

Anchor write path (POST /api/projects/{id}/timeline/anchor):
- Sequence guard: if rule.parent_id has no anchored actual, return
  409 predecessor_missing with the missing rule's code/name DE+EN +
  pre-formatted bilingual messages so the frontend can render an
  inline error with a "Stattdessen <predecessor> erfassen" link
  (#31 layer 3, no confirm-and-write override in v1).
- kind dispatch: rules with event_type IN ('hearing','decision','order')
  write paliad.appointments with deadline_rule_id; everything else
  writes paliad.deadlines with source='anchor', status='completed',
  completed_at=actual_date.
- Idempotent: existing (project_id, rule_id) row PATCHes instead of
  inserting (race-safe per design §13).

Skip write path (POST /api/projects/{id}/timeline/skip):
- Writes paliad.project_events with event_type='rule_skipped' +
  metadata.rule_code; subsequent reads drop the matching projected
  row from the cascade (§6.4).

Handlers expose projection meta via X-Projection-{Has,Total,Shown,Overdue,Lookahead}
headers so the wire shape stays []TimelineEvent (frozen since Slice 1).
2026-05-09 15:33:20 +02:00
m
335be29b23 Merge: t-paliad-172 — fix Verlauf-tab-stuck regression from Slice 1
Slice 1 (3e1bbd3) of the SmartTimeline replaced the legacy
<ul#project-events-list> markup with <div#project-smart-timeline> but
left the orphan renderEvents() function and its call site in place.
renderEvents() did getElementById('project-events-list')! — non-null
asserted on a node that no longer existed. main() called renderEvents()
between body.style.display = '' and initTabs(). The null deref threw,
main() aborted, initTabs() never ran, and tab click handlers never
attached. Tab clicks went to <a href='#'> defaults; the URL got '#'
appended but no panel transition happened — m's 'stuck on Verlauf'
report (12:25).

fermat's minimal fix: drop the ! assertions, null-guard, return early
when the legacy DOM nodes are gone. 10 lines, one file
(frontend/src/client/projects-detail.ts:867-883). Comment points at
Slice 2 for the proper removal of the orphan call site.

Verified: bun build.ts clean, go build clean, Playwright reproduces the
TypeError on main and confirms tabs work post-fix. Empty-state Smart
Timeline still renders, '+ Eintrag' modal still opens/closes, Audit-Log
toggle still present.

Single commit 0835be4 from mai/fermat/bug-verlauf-tab-open-can.
2026-05-09 12:39:02 +02:00
m
0835be4a7f fix(t-paliad-172): null-guard renderEvents to unblock tab clicks
Slice 1 of SmartTimeline (t-paliad-171, commit 7057fe5) removed the
legacy <ul#project-events-list> + <p#project-events-empty> markup from
projects-detail.tsx but didn't prune the renderEvents() call sites. The
function still runs from main() and several other paths; with non-null
assertions on getElementById, the null deref threw a TypeError mid-init.

The throw aborted main() between body.style.display = "" and initTabs(),
so the .entity-tab click handlers were never attached. Default-action
clicks on <a href="#"> just appended "#" to the URL while the user was
already viewing whatever panel happened to be the default-display
section (tab-history) — making the Verlauf tab feel "stuck" because the
visible panel never changed.

Fix: drop the non-null assertions, null-guard the legacy DOM lookups,
and return early when the targets are gone. renderEvents() becomes a
silent no-op in the SmartTimeline layout, which matches euler's intent
documented in 7057fe5: "The legacy renderEvents() rendering path stays
as-is (dead, but the function is still called in places). It will be
removed once /timeline?include=audit_full has had a deploy of soak time
… Slice 2 revisits."

Verified locally with the projects-detail.js bundle + a fetch mock:
clicks on Team / Projektbaum / Parteien / etc. now switch the active
tab and panel display, the URL updates via replaceState, the
SmartTimeline still renders its empty state, and the "+ Eintrag" modal
still opens and closes correctly.
2026-05-09 12:38:24 +02:00
m
3e1bbd3c77 Merge: t-paliad-171 — SmartTimeline Slice 1 (skeleton; actuals only, no projection)
euler's first slice of the SmartTimeline per docs/design-smart-timeline-2026-05-08.md
§10. Past actuals + audit-log toggle + new render shape; NO projection logic
yet (Slice 2). NO counterclaim FK (Slice 3). NO lane aggregation (Slice 4).

What lands:
- Migration 075: nullable paliad.project_events.timeline_kind text + partial
  index (auto-applied at server boot via golang-migrate)
- Backend: ProjectionService + GET /api/projects/{id}/timeline + POST
  /api/projects/{id}/timeline/milestone, with unit + integration tests
- Frontend: shape-timeline.ts vertical-two-column render shape, '+ Eintrag'
  modal (Eigener Meilenstein wired; Frist/Termin link out; Widerklage/R.30
  disabled with 'Slice 3' tooltip), 'Audit-Log anzeigen' toggle persisting
  per-project in localStorage
- FilterBar (riemann's t-paliad-170 port) keeps mounting + working — facet
  set unchanged

Two flagged deviations from §3.2 mockup, parked for m's review:
- Render order: chronological top-down (past at top, future at bottom);
  mockup had future-above-past. Trivial CSS flip if m prefers.
- Legacy renderEvents() function not removed — Slice 2 cleanup.

Verified: bun build.ts clean (2117 keys), go build ./... clean, go test
./internal/services ./internal/handlers passing (live integration test
gated on TEST_DATABASE_URL; runs on CI).

Live tracker at v74 pre-deploy → v75 after Dokploy boot.

Commits: 49c260b 49c260b afd3aab 4a5d56d 7057fe5 from
mai/euler/smarttimeline-slice-1.

Slices 2-4 + 12 open inventor questions remain parked for m's morning
review of docs/design-smart-timeline-2026-05-08.md.
2026-05-08 23:42:39 +02:00
m
7057fe5d25 feat(t-paliad-171): mount SmartTimeline + "+ Eintrag" modal in /projects/<id> Verlauf
Replaces the legacy <ul.entity-events> Verlauf rendering with the new
SmartTimeline. Slice 1 wiring:

  - loadTimeline(id) calls /api/projects/{id}/timeline (the new
    endpoint backed by ProjectionService) and renderSmartTimeline
    paints into <div#project-smart-timeline>.
  - "Audit-Log anzeigen" header toggle re-fetches with
    ?include=audit_full, broadening the project_events filter to
    every audit row (legacy Verlauf chronological view). State
    persists per-project in localStorage so flipping it on for one
    case doesn't carry across to others.
  - "+ Eintrag" CTA opens a modal. "Eigener Meilenstein" submits
    via POST /api/projects/{id}/timeline/milestone and re-renders;
    Frist + Termin route to the existing /deadlines/new and
    /appointments/new flows; CCR + R.30 are disabled-with-tooltip
    "kommt mit Slice 3" per the design.
  - Subtree toggle now also drives the timeline (passes
    ?direct_only=true when the user flips off "Inkl. Unterprojekte").
  - Project-appointment add path also re-fetches the timeline so the
    new appointment surfaces immediately.

The legacy renderEvents() rendering path stays as-is (dead, but the
function is still called in places). It will be removed once
/timeline?include=audit_full has had a deploy of soak time and the
audit-toggle is the only path that feeds the legacy markup. Slice 2
revisits.

The FilterBar from t-paliad-170 (riemann's port) keeps mounting and
driving its customRunner — facets still narrow the legacy `events`
array. The bar gaining timeline_* axes lands later in the slice
sequence (design §8); Slice 1 ships the timeline beneath the existing
bar untouched.

Design ref: docs/design-smart-timeline-2026-05-08.md §10 Slice 1.
2026-05-08 23:41:11 +02:00
m
4a5d56d9e6 feat(t-paliad-171): SmartTimeline render shape — shape-timeline.ts + CSS + i18n keys
The vertical-timeline render component for the SmartTimeline (Verlauf
tab redesign). Two-column layout (date / event card), past
chronological → "Heute →" rule → future chronological, status icon +
kind chip per row.

Deep-link is wired via a row-level click handler that skips clicks on
inner <a>/<button>, NOT a ::before overlay — matches the project's
.entity-event whole-card click contract (project CLAUDE.md), keeps
text selection working, and avoids the t-102 overlay regression that
swallowed pointer events on the title text.

i18n: 28 new keys under projects.detail.smarttimeline.* (DE primary,
EN secondary). i18n-keys.ts is regenerated by build.ts on every build,
so the diff there is mechanical.

CSS: ~250 LoC under .smart-timeline-* — vertical layout, status-icon
glyphs per status (✓/…/!/▢/░/⊕), kind-chip pastels, Heute → rule with
borders extending into the spacing.

Design ref: docs/design-smart-timeline-2026-05-08.md §3.1-3.3.
2026-05-08 23:40:49 +02:00
m
afd3aab2b2 feat(t-paliad-171): SmartTimeline backend skeleton — ProjectionService + /timeline endpoint
Slice 1 of the SmartTimeline (Verlauf-tab redesign). Adds a new service
layer + two HTTP endpoints; no projection logic yet (Slice 2). The wire
shape (TimelineEvent) is frozen so future slices add Kind="projected"
rows additively without breaking the frontend consumer.

ProjectionService.For composes three actuals streams for one project:
  - paliad.deadlines           → Kind="deadline"
  - paliad.appointments        → Kind="appointment"
  - paliad.project_events with
    timeline_kind IS NOT NULL  → Kind="milestone"

Visibility goes through the existing inline mirror of
paliad.can_see_project on each underlying service — no new RLS surface.
DirectOnly mirrors the existing "Inkl. Unterprojekte" toggle on
/projects/{id}; IncludeAuditFull broadens project_events to the full
audit log behind the upcoming "Audit-Log anzeigen" header toggle.

ProjectionService.RecordCustomMilestone backs POST /timeline/milestone
("Eigener Meilenstein") — the only write path in Slice 1.

Tests: unit (sort order, status mapping, kind tiebreak — runs by default)
plus a live integration test that seeds one project + dl + appt +
milestone and asserts the merge surfaces all three with the right
ordering. Live test gated on TEST_DATABASE_URL per the existing
convention.

Design ref: docs/design-smart-timeline-2026-05-08.md §2.3 + §9.2 + §10.
2026-05-08 23:34:06 +02:00
m
49c260b888 feat(t-paliad-171): migration 075 — project_events.timeline_kind opt-in column
Adds a nullable text column on paliad.project_events so a subset of
audit rows can opt into surfacing as SmartTimeline content. Existing
rows stay NULL (audit-only); the partial index keeps the lookup tiny
because the SmartTimeline read filter is the indexed predicate.

Value space (enforced in code in internal/services/projection_service.go):
  'milestone'        — structural event (counterclaim_filed, ...)
  'custom_milestone' — free-text "Eigener Meilenstein"
  NULL               — audit only (default)

Design ref: docs/design-smart-timeline-2026-05-08.md §2.2.
2026-05-08 23:33:53 +02:00
m
12b35fc9fe Merge: t-paliad-170 — FilterBar mounted in /projects/<id> Verlauf tab
riemann's Phase 2 slice on top of own 1faffb6 Phase 1: the universal
<FilterBar> is now in the project Verlauf tab. Filter facets:
project_event_kind (chip cluster), time (presets including new
HorizonPast7d), personal_only. Empty URL preserves current behaviour
(unfiltered list); ?time=past_30d&pe_kind=deadline_created narrows.

Two extension points added to the bar primitive (forward-compat with
SmartTimeline t-paliad-169 work):
- customRunner: lets a host page own the data fetch (Verlauf keeps
  the legacy /api/projects/{id}/events pipeline so subtree + cursor
  pagination survive — substrate-side scope-with-descendants stays
  SmartTimeline territory).
- timePresets: opt-in past-only horizon set for backward-looking
  surfaces (vs the default future-leaning set used on /inbox).

3-way merge with main: clean. fourier's t-paliad-168 + lagrange's
SmartTimeline design doc preserved.

bun build clean; frontend/dist regenerated. go test internal/... ok
on riemann's worktree (filter-bar url-codec + filter_spec tests).

ebcda13 from mai/riemann/filterbar-phase-2-slice.
2026-05-08 23:23:49 +02:00
m
ebcda13f88 feat(t-paliad-170): mount <FilterBar> in /projects/<id> Verlauf tab
Phase 2 slice of the universal-filter migration (Phase 1 was
t-paliad-163 → /inbox; remaining /agenda /events /deadlines
/appointments stay queued).

What ships:

- FilterBar gains two non-invasive options that future surfaces will
  also need:
    customRunner — bypass the substrate POST and hand the effective
                   spec to a surface-supplied runner. Required by
                   surfaces whose data path can't move to the substrate
                   yet (Verlauf still uses /api/projects/{id}/events for
                   subtree expansion + cursor pagination, both absent
                   from the substrate's project_event runner).
    timePresets  — per-surface override of the time chip cluster, so
                   backward-looking surfaces can show past_*+all without
                   forcing forward-looking next_* chips on every host.

  systemViewSlug becomes optional; the bar enforces "exactly one of
  customRunner | systemViewSlug" at construction.

- project_event_kind axis renderer (was a null stub) — chip cluster
  over KnownProjectEventKinds, labels reuse the existing
  event.title.<kind> i18n table so the chip text matches the Verlauf
  row title for the same kind.

- HorizonPast7d added end-to-end (substrate validate +
  computeViewSpecBounds; FilterBar TimeOverlay + parseHorizon; views
  TimeHorizon mirror) so the chip value is valid in every layer when a
  later SystemView reuses it.

- Verlauf tab on /projects/<id> mounts the bar with
  axes=["time","project_event_kind"], timePresets=
  ["past_7d","past_30d","past_90d","any"], showSaveAsView=false. The
  customRunner reads predicates.project_event.event_types + time.horizon
  off the effective spec, sets a verlaufFilters global, and routes
  through the legacy loadEvents/loadMoreEvents pipeline (which now
  applies the filter set client-side and tracks raw cursor IDs so
  "Mehr laden" still walks the underlying pagination boundary even when
  most rows get filtered out of a page).

- Subtree toggle drives loadEvents through verlaufBar.refresh() so the
  current filter state survives the toggle.

URL state reuses the bar's existing keys (?time=past_30d, ?pe_kind=…).
Empty filter → identity passthrough → current behaviour preserved.

Out of scope (deferred to t-paliad-169 SmartTimeline):
  - Migrating Verlauf to the substrate (needs scope-with-descendants)
  - Past/future split, dated/undated split, source-track facet

Refs m/paliad#23.
2026-05-08 23:22:23 +02:00
m
487fec2672 Merge: t-paliad-169 — SmartTimeline design doc (DESIGN READY FOR REVIEW)
lagrange's inventor pass on m's 23:02 request: redesign the Verlauf tab on
/projects/{id} as a SmartTimeline composing past actuals + future-projected
(via existing AnchorOverrides reflow on FristenrechnerService.Calculate) +
off-script events. Counterclaim shape: sub-project with new counterclaim_of
FK; parent renders parallel right-track when populated.

Doc covers: data-model recommendation (virtual view, ONE optional column),
UI mockup (3 states), counterclaim shape (defended), parent-node aggregation
(per-level kinds/statuses/lanes rule), date-anchoring + reflow semantics,
off-script event UX, 12 open questions, 4-slice phasing.

12 open questions parked for m's review before Slices 2-4. Slice 1 is the
skeleton (no projection yet) — must merge AFTER riemann's t-paliad-170
FilterBar port; pending riemann.

Issue m/paliad#27. Single commit f8cc86c, 739 lines, design only — no
implementation in this merge.
2026-05-08 23:17:48 +02:00
m
f8cc86cd02 docs(t-paliad-169): SmartTimeline design — Verlauf-tab redesign with past+future+off-script
Inventor design for replacing the project-page Verlauf with a SmartTimeline that
composes past actuals (deadlines, appointments, structural project_events),
present, future-projected (deadline_rules calculator at read time), and
off-script events into one project-scoped vertical timeline.

Key calls:
- virtual view, no new top-level table; single optional column
  paliad.project_events.timeline_kind so a subset of audit rows surface as
  timeline content
- counterclaim = sub-project (new paliad.projects.counterclaim_of FK), parent
  renders parallel tracks; default our_side flips on creation
- date-anchoring reuses fristenrechner CalcOptions.AnchorOverrides — actuals
  anchor downstream projections automatically
- new ProjectionService.For(projectID) thin adapter over FristenrechnerService
- 3 new FilterBar axes (timeline_kind, timeline_status, timeline_track) +
  reuse of time, personal_only, deadline_event_type
- per-level aggregation rule: each level removes one tier of detail and adds
  one tier of grouping (Case → Patent → Litigation → Client)
- 4-slice phasing: skeleton, projection+anchor, counterclaim sub-project,
  parent-node aggregation

12 open questions for m before slice 1 PR opens. Inventor parks per gate
protocol; coder shift only after m's go-ahead.
2026-05-08 23:14:30 +02:00
m
69544bf3fb Merge: t-paliad-168 — Verfahrensablauf entry points (Step 2 third card + sidebar nav)
m's complaint @ 22:49 'Verfahrensablauf section… is gone' — the Pathway A wizard
was reachable only via Step 1 → Step 2 (einreichen) → Step 3a (file), three clicks
deep and framed as 'I'm filing a brief.' fourier restores two top-level entries:

- Step 2 third card 'Verfahrensablauf einsehen' (browse / learn) → ?path=a
- Sidebar Werkzeuge entry 'Verfahrensablauf' (open-book icon) → ?path=a

In both browse paths the save-to-project CTA disables (no Akte to save against).
Deliverable 3 (project-page Verfahrensablauf tab) deferred — the SmartTimeline
redesign (t-paliad-169, lagrange) will determine the right component shape.

Commits: 7238b12 (Step 2 card), 7fef641 (sidebar entry).

Closes part of m/paliad coverage; SmartTimeline tracked separately.
2026-05-08 23:05:47 +02:00
m
7fef64159b feat(sidebar): add Verfahrensablauf nav entry
t-paliad-168 deliverable 2. New "Verfahrensablauf" entry under
Werkzeuge, right after Fristenrechner — opens
/tools/fristenrechner?path=a (Pathway A wizard, browse-/learn-mode).

Uses a distinct open-book icon to read separate from the closed-book
Glossar. Both /tools/fristenrechner sidebar entries share the same
pathname, so SSR navItem matching can't pick the right "active" one
on its own — fixVerfahrensablaufActive() in sidebar.ts disambiguates
based on ?path=a at hydration.

i18n key: nav.verfahrensablauf (DE: "Verfahrensablauf",
EN: "Procedure Roadmap"). i18n-keys.ts is regenerated by build.ts.
2026-05-08 23:04:29 +02:00
m
7238b12b05 feat(fristenrechner): Step 2 third card "Verfahrensablauf einsehen"
t-paliad-168 deliverable 1. Adds a discoverable browse-/learn-mode
entry to the determinator alongside "Etwas einreichen" / "Etwas ist
passiert". Click drops straight into Pathway A's proceeding-tile
picker (navigateToPathway("a")).

The save-to-project CTA disables itself in this mode — extends
isAdhocMode() to also return true when no Step 1 context is set,
mirroring the existing ad-hoc explore behaviour.

i18n keys: deadlines.step2.browse.title / .desc (DE + EN).
2026-05-08 23:03:52 +02:00
m
54cf7ac2f6 Merge: t-paliad-167 — Determinator coverage audit (research)
f4815a9 — Determinator coverage audit @ docs/research-determinator-
coverage-2026-05-08.md (394 lines).

Headline numbers (n=76 true Fristenrechner deadlines across 19 active
proceedings):
  Reachable from cascade:    71  (93 %)
  No concept_id:               1  (1 %)
  Concept exists, dead-end:    4  (5 %)

§4 frames the smart-navigation question into a taxonomy of three
"I don't see my event" failure modes — α (real content gap), β
(reachable but mis-modelled path), γ (court-side trigger needs to be
tagged, not reacted-to) — and maps each to the candidate UX patterns
(P1 free-text search / P2 escape-with-telemetry / P3 weiter-unten-
suchen). The recommendation surfaces:

  - P2 + telemetry for type-α (capture which events users actually
    want; drives prioritised migration backlog rather than guessing)
  - P1 + P3 for type-β (search collapses labelling mismatches; flat-
    branch search recovers from wrong-root entries)
  - Type-γ flagged as a separate "tag, don't react" workstream out of
    this scope

Pure research — no code, no schema. Feeds m's next decision: extend
the row-by-row B1 refactor (m/paliad#25 / minkowski's parked task) or
spin a separate inventor pass on the smart-navigation surface.

Refs m/paliad#26.
2026-05-08 22:35:24 +02:00
m
f4815a9f9a docs(t-paliad-167): Determinator coverage audit + smart-navigation framing
Builds on t-paliad-159's UPC RoP audit. Drives from paliad's own corpus
outward: every active rule, every firm-wide event_type, every cascade
leaf — and asks whether a Determinator user can actually reach the row.

Headline finding: 71/76 (93%) of true Fristenrechner deadlines are
reachable from the cascade. The 5 unreachable cluster into one fix:
EP_GRANT (4 rules) plus UPC_INF.inf.app_to_amend lack cascade entry.
Adding an `ich-moechte-einreichen.ep-erteilung` subtree lifts coverage
to 100%.

Per-jurisdiction inventory (UPC, DE, EPO, DPMA) plus a §2.6 cross-cutting
table for the procedural-order categories m flagged (Hinweisbeschluss,
Beweisbeschluss, Streitwertbeschluss, Versäumnisurteil, R.71(3),
Beanstandungsbescheid, etc.).

§4 frames the smart-navigation choice: recommends P2 (persistent escape
button with capture) + P1 (free-text search per cascade level), defers
P3 (flatten deeper levels) until telemetry justifies it. The captured
"Mein Ereignis ist nicht dabei" texts feed both the gap-fill roadmap
and P1's ranking corpus.

No code changes; one markdown doc, 394 lines.
2026-05-08 22:34:23 +02:00
m
ce180123c3 Merge: t-paliad-165 follow-up — Regel+Typ as one field + jurisdiction-aware defaults
Two slices on mai/noether/collapse-regel-typ-on (after rebase onto main):

  6058d21  fix(deadline-rules): pick rule's jurisdiction-aware event_type default
  7a35cad  feat(deadlines/new): collapse Regel + Typ to ONE field when rule sets type

What ships:

- Migration 074 audits the deadline_concept_event_types seed and adds
  per-jurisdiction defaults so a German rule (RoP.029.b /
  § 276 Abs. 1 S. 2 ZPO) maps to the DE event_type
  (de_klageerwiderung) and a UPC rule maps to the UPC event_type
  (upc_statement_of_defence). The text label "Klageerwiderung" reads
  the same in both — the bug m hit at 22:08 was the seed defaulting
  to UPC for every concept regardless of which rule asked.
  Idempotent (IF NOT EXISTS / DO blocks). Live tracker advanced
  73 → 74 during noether's dev run; deploy will see tracker=74 with
  file 074 present and have nothing to apply.

- Frontend deadline create form (m's "these two are connected — it's
  the same thing", #18 + 22:08 dogfood):
    When a Regel is selected and a default event_type exists for it,
    the Typ chip COLLAPSES into an inline pill beneath the rule:
      "Typ: Klageerwiderung (vorgegeben durch Regel)  [Anderen Typ wählen]"
    Clicking [Anderen Typ wählen] re-expands the picker so the user
    can override.
    When the rule has no junction row (or the user hasn't picked a
    rule), the Typ field stays as today (free-text + chip cluster).

- deadline_rule_service.go switched to the jurisdiction-aware lookup;
  the form receives the right default in one round-trip.

Refs m/paliad#18 + the 2026-05-08 22:08 inline-correction thread.
2026-05-08 22:21:48 +02:00
m
7a35cad09f feat(deadlines/new): collapse Regel + Typ to ONE field when rule sets type
m's 2026-05-08 22:08 dogfood: my first auto-fill landed but kept Regel
and Typ as TWO separate input fields. m wanted ONE — "these two are
connected, it's the same thing".

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

    Klageerwiderung (vorgegeben durch Regel)   Anderen Typ wählen

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

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

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

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

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

Flip the default to "any_visible" on both ends:

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

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

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

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

What ships (Phase 1):

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

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

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

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

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

What ships:

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

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

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

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

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

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

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

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

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

What ships:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

What ships:

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

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

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

Auth gate unchanged: /api/paliadin/history honours PaliadinOwnerEmail
just like /api/paliadin/turn. Tests added for the hydrate + reconcile
+ primer paths in paliadin_test.go.
2026-05-08 21:48:52 +02:00
m
ae1cba4e24 feat(paliadin/primer): t-paliad-161 Slice G — tmux crash-recovery primer
When a user's tmux session dies (mRiver reboot, OOM, manual kill,
container restart) the next turn used to wake claude with NO prior
context — the persona had to derive everything from the new turn
alone. Now: when the Go side detects a fresh pane, it pulls the last
N exchanges from paliad.paliadin_turns and prepends them as a
[primer …][/primer] block to the next user envelope.

Format SKILL.md parses (single-line, control-chars stripped):

  [PALIADIN:<turn_id>] [primer last=N] U: … \n A: … \n … [/primer] [ctx …] <Frage>

Detection paths:

- Local (LocalPaliadinService): ensurePane now returns
  (target, isFresh, err). isFresh is true when no prior
  @paliadin-scope=chat window existed and we created one. RunTurn
  passes that into buildPrimerIfFresh.

- Remote (RemotePaliadinService): can't see across the SSH boundary
  to know the pane's true freshness, so we approximate with a
  per-(session, Go-process) "primed" cache. First turn after
  process-start, ResetSession, or healthGate failure rebuilds the
  primer; subsequent turns skip it. ResetSession + healthGate failure
  both call clearPrimed(session) explicitly.

paliadinDB.buildPrimerIfFresh assembles the block:

- Reads the last MaxPrimerTurns=5 exchanges from
  ListHistoryForSession (Slice F).
- truncateForPrimer normalises each side (drops \r\n, collapses
  whitespace, caps at MaxPrimerCharsPerSide=600 with …).
- Returns "" silently when isFresh=false, no SessionID, no prior
  history, or DB error — the user's actual question still lands; we
  only lose the recap.

SKILL.md (~/.claude/skills/paliadin/SKILL.md, refreshed via
scripts/install-paliadin-skill) gets a new "Crash-recovery primer"
section above the context-envelope block. Five behaviour rules:

  1. Don't re-execute prior tool calls (audit log already has them).
  2. Use the primer for thread continuity, not as a data source.
     Re-call tools for fresh facts.
  3. Truncated lines (ending in …) are partial — paraphrase rather
     than quote.
  4. No primer at all = normal case (existing pane, history is in
     tmux memory). Behave as before.
  5. Acknowledge sparingly — usually just answer the actual question
     with the recap as silent context.

New test TestTruncateForPrimer pins the per-side truncation contract
(no \r\n leaks, repeated spaces collapsed, ellipsis on oversized
input, short input untouched). go test green.

Refs: docs/design-paliadin-inline-2026-05-08.md §6
      (deferred Anthropic API cutover prereq).
2026-05-08 21:48:08 +02:00
m
1e23745792 docs(t-paliad-163): inventor design — universal filter + view-mode primitive
m/paliad#23. Recommends a single <FilterBar> client component on top of
the existing Custom Views substrate (t-paliad-144) — FilterSpec +
RenderSpec + ViewService + 5 code-resident SystemViews + ad-hoc
/api/views/run already cover every axis the issue lists.

Position: m's "halfway there without custom views" is exactly right.
Lift the substrate from /views/{slug} up to "the bar that every list-
shaped page reads from", with one schema bump (RenderSpec.list.row_action)
to keep entity-table row-click contracts intact.

Migrate one surface per PR: /inbox first (lowest blast radius, no filter
today), /events last (proof point, richest filter). /projects stays
bespoke per t-paliad-149 lock-in.

12 open questions (Q1-Q12) for m before lock-in. No hour estimates.

Verified premises: the issue body's `paliad.user_view_layouts` is a
typo — actual table is `paliad.user_views` (056). `/api/views/run` and
`/api/views/{slug}/run` confirmed live in internal/handlers/views.go.
2026-05-08 21:44:09 +02:00
m
1782dfa910 feat(paliadin/cross-surface-sync): t-paliad-161 Slice F — DB-driven history hydrate
Two Paliadin chat surfaces shared a user but not their conversation:
the inline drawer (paliadin-widget.ts) maintained `paliadin:widget:session`
+ `paliadin:widget:history:` while the standalone /paliadin page used
`paliadin:session` + `paliadin:history:`. A turn typed in the drawer
never surfaced on /paliadin and vice versa, and a localStorage wipe
tossed everything.

Fix in three coordinated parts:

1. **Shared session id.** The widget now uses the same `paliadin:session`
   key the standalone page already uses. One-time migration in
   bootSession copies any legacy `paliadin:widget:session` across so
   existing users keep their conversation thread, then deletes the legacy
   key. The widget's HISTORY_PREFIX also drops the `widget:` namespace
   so both surfaces' render-caches address the same bucket.

2. **DB-driven history.** New endpoint:

       GET /api/paliadin/history?session=<id>&limit=<N>

   Returns the caller's turns for the session, oldest → newest,
   gated by PaliadinOwnerEmail (same gate as POST /api/paliadin/turn).
   Backed by paliadinDB.ListHistoryForSession, which mirrors the
   existing visibility predicate (own rows always; all rows for
   global_admin). Default limit 50, capped at 200.

3. **Hydrate-on-mount, hydrate-on-open.**
   - paliadin.ts (standalone page): DOMContentLoaded calls
     hydrateFromServer() right after renderHistory() seeds from
     localStorage. DB rows replace the cache when present.
   - paliadin-widget.ts (inline drawer): revealIfOwner kicks
     hydrateFromServer in the background after rehydrateHistory paints
     the cache. openDrawer() also calls hydrateFromServer so a turn the
     user typed on /paliadin since the last drawer-open shows up
     without a manual reload.

   Reconciliation: DB > localStorage when DB has rows. DB call fails or
   returns empty → keep showing whatever's in cache (offline cushion).
   This kills the trap klaus warned about (paliad#19): every render
   reconciles against the server, no first-paint short-circuits.

Schema: zero migrations. paliad.paliadin_turns already carries
session_id + user_message + response + ts since the t-paliad-146 PoC;
this slice just adds a typed read path.

Backwards compatible: the standalone /paliadin page's session key is
unchanged; only the widget migrates onto it.

Builds + tests green; i18n unchanged.

Refs: m/paliad#19 (localStorage short-circuit), m/paliad#20 (inline modal),
      docs/design-paliadin-inline-2026-05-08.md §3.4.
2026-05-08 21:43:51 +02:00
m
936aca5925 refactor(projects-detail/projektbaum): reuse the /projects tree component
m's 2026-05-08 21:28: "The Projektbaum inside a Project in the tab
with the Unterordner should just be the same as the Tree in Projects.
It has symbols, everything. That should be a shared component."

Drop the inline mini-tree renderer (renderTreeNode / loadProjectTree /
~50 lines of duplicate logic) in client/projects-detail.ts and mount
the existing client/project-tree.ts module into the tab's container.
The shared component carries:
  - per-type icons (Mandant / Litigation / Patent / Case)
  - pin star (touch-friendly)
  - overdue / open-deadline badges with subtree counts
  - status chip + type chip
  - expand / collapse toggles
  - inherited-visibility marking
  - search highlighting (no-op when no search params are passed)

Current project highlight: set aria-current="true" on the matching
.projekt-tree-node after mount. The shared CSS already styles
.projekt-tree-node[aria-current="true"] > .projekt-tree-row with the
lime accent (global.css :5853).

Removed the now-dead mini-tree CSS block that was also accidentally
overriding .projekt-tree-title from the real tree (later-defined rule
won the cascade and erased the shared title weight).

loadChildren() still fetches /api/projects/<id>/children for the
empty-state gate ("Keine untergeordneten Projekte" when this node has
no direct children) and the create-link parent_id pre-fill — both
predicates depend on direct children, not the visible tree.
2026-05-08 21:31:16 +02:00
m
0b47343aa3 fix(projects-cards): start_at not starts_at — cards-preview appointments query
m's dogfood 2026-05-08 21:16: project card for "UPC-CoA Berufung Huawei"
showed "4 offen" but "Nächste Termine — keine bevorstehenden Termine"
even though the four pending deadlines exist with future due dates.

Live container log:

  ERROR service: cards preview appointments:
  pq: column t.starts_at does not exist at position 13:41 (42703)

The cards-preview appointments query used `t.starts_at`; the actual
column on paliad.appointments is `start_at` (singular). The query
errored, CardsPreview returned (nil, error), the handler returned a
500, and the frontend's `r.ok ? r.json() : []` fell through to an
empty preview map for every project — so deadlines that the deadline
half of the same function had already loaded never reached the card.
"4 offen" stayed visible because that count comes from BuildTreeWith-
Options, a separate query untouched by the bug.

Fix: rename starts_at → start_at in the rowAppointment db tag, the
ORDER BY, the WHERE clause, and the SELECT projection. StartsAt as
the Go field name stays — only the db tag + SQL identifiers change.
Same column name everywhere else in the codebase already used start_at.
2026-05-08 21:20:13 +02:00
m
f31307afcb feat(sidebar): newspaper icon for "Neuigkeiten", reserve sparkle for Paliadin
m's 2026-05-08 21:11: the changelog entry was sharing the sparkle 
glyph with the new Paliadin AI surface (inline widget trigger, agent-
suggested provenance pill, /paliadin entry). Now that  carries an
explicit AI semantic in paliad's visual language, swapping the
changelog to a newspaper SVG keeps the two affordances orthogonal.
2026-05-08 21:11:57 +02:00
m
aa112d2589 fix(approvals): NullableJSON for pre_image/payload so /api/inbox/mine doesn't 500
m's dogfood 2026-05-08 20:35: a deadline showed an approval-pending
banner on its detail page but did not appear in /inbox under either
"Zur Genehmigung" or "Meine Anfragen". Live container log:

  ERROR service: list submitted by user: sql: Scan error on column
  index 5, name "pre_image": unsupported Scan, storing driver.Value
  type <nil> into type *json.RawMessage

Root cause: paliad.approval_requests.pre_image is NULL whenever the
lifecycle_event is 'create' (no prior row state to capture). The Go
ApprovalRequest struct binds it as json.RawMessage, which is a []byte
typedef that does NOT implement sql.Scanner — sqlx fell back to
*json.RawMessage and choked on the NULL. Same hazard on .payload for
'complete' / 'delete' rows where there's no payload either.

The handler returned the resulting error as a 500, the inbox.ts catch
swallowed it as a network failure, and rendered the empty state. Both
tabs were dark because both list paths hit the same scan.

Fix: introduce models.NullableJSON, a []byte typedef that implements
sql.Scanner / driver.Valuer / json.Marshaler / json.Unmarshaler and
treats NULL ↔ nil cleanly. Inline JSON output is preserved (no base64
cast that bare []byte would have caused). Bind PreImage + Payload to
NullableJSON; existing call-sites (approval_service.go:606) keep
working — both json.RawMessage and NullableJSON are []byte under the
hood, and len() / json.Unmarshal accept either.

Other nullable jsonb columns (User.EmailPreferences, *Metadata) are
all NOT NULL with default '{}' so they don't hit the same path; left
as json.RawMessage.

Verified: live tracker is at v71, no schema change needed; approval
service tests green; /api/inbox/mine query against prod returns the
three expected rows for m once the binary picks this up.
2026-05-08 21:04:53 +02:00
m
dc35d2da69 Merge: feat(paliadin) late-response reconciliation — janitor + chat polling 2026-05-08 20:58:41 +02:00
m
d2790a0461 feat(paliadin): reconcile late responses via janitor + chat polling
When Claude writes the response file after the 60 s pollForResponse
window expires (e.g. the tmux pane was busy mid-turn when the message
arrived), the SSE stream has already closed with an error and the
file sits unread on disk forever. The chat shows a permanent timeout
even though the answer exists.

Backend:
- LocalPaliadinService.StartJanitor: scans responseDir every 2 s and
  patches rows whose response is still NULL when the file lands.
  completeTurnLate stamps error_code='late' so the FE can render a
  marker. Guarded with WHERE response IS NULL to never overwrite a
  real response if RunTurn races.
- Paliadin.GetTurn(callerID, turnID) on the shared paliadinDB. Same
  visibility predicate as ListRecentTurns.
- GET /api/paliadin/turns/{id} — owner-gated; lets the chat UI
  discover late-arrived responses without a refresh.

Frontend:
- paliadin-late-poll.ts: shared 3 s / 10 min poller.
- paliadin.ts + paliadin-widget.ts: on SSE error, show
  "wartet auf späte Antwort", kick off the poller, swap bubble in
  place when response arrives + retroactively persist to history.
- i18n: paliadin.late.waiting + paliadin.late.marker (DE/EN).
- CSS: --late-pending opacity tweak, --late neutral background,
  italic-grey "verspätet" tag.
2026-05-08 20:56:53 +02:00
m
97d49898b7 fix(paliadin): 200ms settle delay between paste and Enter so submit registers
m's dogfood 2026-05-08 20:35: "the paliadin hook does not always work — it
does not confirm the claude / terminal command... like lacking an enter
key. Or too fast."

Race between two consecutive tmux send-keys calls: the first writes the
prompt literally with `-l`; the second sends an Enter key event. Claude
Code's TUI debounces keyboard input. When the Enter lands while the
paste is still being absorbed, the carriage-return collapses into the
input buffer as a literal newline character instead of registering as a
"submit" gesture — the prompt sits typed but unsubmitted, and the
backend's pollForResponse then times out on the missing response file.

Fix: sleep 200ms between the literal paste and the Enter. Below the
human-perceptible threshold but well above tmux's pty flush window and
the TUI's input-debounce window. Applied to both code paths:

- scripts/paliadin-shim:send_to_pane (the SSH/RPC production path)
- internal/services/paliadin.go:LocalPaliadinService.sendToPane
  (the laptop-only direct-tmux path)

The Go-side variant uses a context-aware sleep so request cancellation
still propagates correctly.

Production shim copy at /home/m/.local/bin/paliadin-shim refreshed
locally on mRiver so the next turn picks up the fix without waiting
for redeploy. (The Dokploy container does not run paliadin — gate on
PaliadinOwnerEmail is owner-only and prod has no claude+tmux anyway —
so no deploy step required for the shim path.)
2026-05-08 20:37:40 +02:00
m
5b08bfcb96 fix(views/sidebar): pad fieldset sections + consolidate Ansichten / Meine Sichten
m's dogfood findings 2026-05-08 20:32:

1. /views/new: form sections (.form-section fieldsets) had no
   container CSS, so content rendered against the bare browser-default
   fieldset border with effectively zero padding. Adds proper padding
   (1.25rem 1.5rem 1.5rem), 8px radius, surface background, and tidies
   the legend / hint typography. Reused by every entity-form fieldset
   that adopts .form-section.

2. Sidebar nav: collapses the prior split between the "Ansichten"
   group (Fristen + Termine) and the "Meine Sichten" group (user-
   defined views + "+ Neue Sicht") into a single "Ansichten" group.
   Same DOM hook (#sidebar-views-items, .sidebar-views-new) so
   client/sidebar.ts's user-view hydration keeps working unchanged —
   the entries just sit alongside the built-ins now instead of in
   their own labelled section.
2026-05-08 20:35:14 +02:00
m
fc048c578e fix(paliadin-widget): render markdown + chips in inline bubbles, fix lime-trigger contrast
m's dogfood findings on the inline drawer:

1. Assistant responses (markdown headings, bold, lists, [chip:nav:…]
   tokens, [#deadline-OPEN:<id>] tokens) showed as raw text. The widget
   was setting body.textContent and skipping the renderer. Extracted
   the standalone /paliadin page's pipeline into client/paliadin-render.ts
   (renderResponseHTML + chip helpers + block markdown parser) so both
   surfaces share one source of truth. The widget now feeds assistant
   bubbles through innerHTML; user bubbles still go through textContent
   (no point parsing the user's typed markup).

2. Floating trigger button rendered the sparkle glyph in white-on-lime
   in dark mode — color: var(--color-text) inherits the dark-mode light
   foreground and washes out completely on a lime background. Lime is
   inherently a light-background colour, so the trigger pins its
   foreground to --hlc-midnight in both themes.

Bubble CSS additions: assistant bubbles get white-space: normal (the
base pre-wrap rule was forcing every source newline to a literal break
and breaking <p>/<h2>/<ul> spacing) plus tight h2/h3/p/ul margins so
the rendered markdown reads as a chat bubble, not a doc page.
2026-05-08 20:31:44 +02:00
m
d0e8c995fe Merge: t-paliad-157 Determinator Slices 3b + 3c — proceeding-type + perspective narrowing
2f27620 — Slice 3b: B1 cascade narrows by the project's proceeding
type. Three-input priority chain (inbox chip > ad-hoc context >
project's proceeding_type). cachedProceedingTypes lookup via
/api/proceeding-types-db; forumFromProceedingCode maps UPC_/DE_/EPA_/
EP_/DPMA_ → upc/de/epa/dpma. /tools/fristenrechner?project=<uuid>&path=b
auto-narrows the cascade without needing chip clicks.

6fcf34a — Slice 3c: perspective chip (Klägerseite/Beklagtenseite/Beide)
at top of the B1 panel. Mig 071 adds paliad.event_categories.party
text[] (claimant|defendant|both|court) with conservative backfill —
claimant on klage.* + replik-*, defendant on widerklage.* + duplik-*.
Cross-appeal/Anschlussberufung leaves stay NULL pending dogfood
(role flips depending on who appealed first). Cascade hides leaves
whose party tag contradicts the chip; both/court tags always pass;
NULL stays neutral. URL-only state (?role=claimant|defendant).

Live tracker is already at v71 (feynman applied 071 during dev).
Deploy will see tracker=71 with file 071 present — no work to apply.

The full Determinator scaffold is now in place:
  Step 1 (Akte/ad-hoc) → Step 2 (do/happened) → Step 3a (File/Draft/Enter)
  for outgoing or Pathway B with auto-narrowing for incoming.

Open follow-ups (small, can wait for dogfood):
- Tag cms-eingang.gegenseite.upc-rev/upc-app/de-bgh-* leaves with party
  (currently neutral; appellate leaves should arguably be tagged based
  on who appealed)
- Persistence for perspective if dogfood says it's wanted
- /drafts route + Step 3a Draft card wiring (proper drafting surface
  is a separate workstream)
2026-05-08 20:22:50 +02:00
m
dd0cee226d Merge: t-paliad-162 — sidebar reorg, inline Agenda on dashboard, collapsible sections
e824898 — feat(navbar/dashboard): per m's 2026-05-08 20:05 design
decisions:

- Sidebar restructured into named groups: Home → Paliadin (gated to
  PaliadinOwnerEmail) → Overview (Projekte) → Views (Fristen,
  Termine) → Tools (Fristenrechner et al). Group headers render as
  small uppercase muted labels.
- Agenda removed from sidebar + BottomNav. Direct link /agenda still
  routes; the dashboard now renders Agenda inline as a section.
- "Letzte Aktivität" relocated to sit under Agenda on the dashboard.
- All dashboard sections become collapsible with a chevron toggle;
  open/collapsed state persists per-section in localStorage under
  paliad:dashboard:collapse:<section>.
- Agenda rendering primitives extracted into client/agenda-render.ts
  so the standalone /agenda page and the dashboard's inline Agenda
  share identical rendering with no fork.

Pure frontend change — no Go work, no migrations.
2026-05-08 20:21:49 +02:00
m
6fcf34a3e3 feat(determinator/slice-3c): perspective chip + party-tagged cascade narrowing
m's 2026-05-08 18:09 spec — Slice 3c. Adds a Klägerseite / Beklagtenseite
chip strip at the top of the B1 cascade panel; cascade leaves tagged
with a contradictory party get hidden. Klägerseite never files
Klageerwiderung; Beklagtenseite never files Klageschrift.

Migration 071 adds `paliad.event_categories.party text[]` (CHECK on
{claimant, defendant, both, court}) plus a partial GIN index. Backfill
is conservative — only the obvious leaves get tagged on this pass:

  - claimant   ich-moechte-einreichen.klage.* (9 leaves)
                ich-moechte-einreichen.spaetere-schriftsaetze.replik-*
  - defendant  ich-moechte-einreichen.widerklage.*
                ich-moechte-einreichen.spaetere-schriftsaetze.duplik-*

cms-eingang.* (incoming) and frist-verpasst.* (anyone misses a
deadline) stay NULL because the user can be on either side and still
receive the same court communication. Cross-appeal / Anschluss-
berufung / Reply-to-cross-appeal also stay NULL — the role flips
depending on who appealed first; the cascade doesn't have that
context yet. Tag in a follow-up once dogfood validates the chip.

Backend: EventCategoryNode JSON gains optional `party` array;
EventCategoryService.Tree SELECT picks it up via pq.StringArray.

Frontend: new Perspective type + URL state (?role=claimant|defendant)
+ perspective chip strip styled identically to the inbox-channel chip
strip. perspectiveAllowsParty(party) gates each cascade child;
"both"/"court" tagged nodes always pass; neutral nodes always pass.
Persistence is URL-only — dogfood will tell us whether to add a saved
default later.

Migration applied to live Supabase; tracker at v71.

Refs t-paliad-157 / m/paliad#15.
2026-05-08 20:21:13 +02:00
m
e824898a6d feat(navbar/dashboard): t-paliad-162 reorg sidebar groups + inline Agenda + collapsible sections
Sidebar:
- Paliadin lifted out of Übersicht to a top-level entry directly under
  Home (owner-only reveal logic unchanged — same id reused).
- Agenda removed from sidebar; the standalone /agenda route stays for
  direct-link compatibility but the dashboard hosts its content inline.
- Projekte moved into Übersicht; Fristen + Termine moved into a new
  Ansichten group; the Arbeit group is gone.
- Werkzeuge / Wissen / Ressourcen collapsed into one Werkzeuge group
  per m's brief order (calculators → reference → content).
- BottomNav agenda slot repointed to /events?type=deadline so the
  overdue+today badge still has a sensible target on mobile.

Dashboard:
- Agenda renders inline as a new collapsible section between the
  upcoming-rails grid and Letzte Aktivität, with a "Vollständige Agenda
  öffnen →" link to the standalone page.
- Letzte Aktivität moved under Agenda per m's design call.
- Sections (summary, deadlines, appointments, agenda, activity) become
  collapsible via a chevron toggle; state persists in
  localStorage[paliad:dashboard:collapse:<section>]. Matters card stays
  whole-card-tappable, so it's intentionally left non-collapsible.
- Inline agenda fetches /api/agenda directly with a 30-day window and
  refreshes on the existing 60s dashboard poll.

Render primitives:
- New client/agenda-render.ts hosts renderAgendaTimeline + AgendaItem
  type, shared by client/agenda.ts and client/dashboard.ts. Standalone
  agenda.ts shrinks accordingly; behaviour is identical.

i18n:
- Added nav.group.ansichten + dashboard.agenda.* + dashboard.section.*
  keys (DE/EN). Removed nav.group.{arbeit,wissen,ressourcen} (no other
  callers; i18n-keys.ts auto-regenerated).
2026-05-08 20:20:57 +02:00
m
2f27620a5b feat(determinator/slice-3b): scope B1 cascade by project's proceeding type
m's 2026-05-08 18:09 spec: "if we have the project type defined, we
should only have events available that match the type of project /
type of case." Slice 3b wires the project's proceeding_type into the
cascade narrowing alongside the inbox chip and ad-hoc context.

Three inputs feed the cascade now, in priority order:

  1. Inbox chip            (cms / bea / posteingang) — user override.
  2. Ad-hoc Step 1 chip    (upc / de / epa / dpma).
  3. Project's proceeding  (Step 1 picked Akte → proceeding_type_id →
     proceeding_types.code → forum prefix).

activeForumOnPage() returns the first non-null value. The B1
cascade's inboxFilterAllowsForums consults this so a user landing on
/tools/fristenrechner?project=<uuid>&path=b&mode=tree gets the
narrowed cascade automatically — no chip clicks required. The chip
can still override at the top of the panel.

Pieces:

  - ProjectOption gains optional proceeding_type_id (already on the
    JSON; just declared so TypeScript can read it).
  - cachedProceedingTypes Map<int, string> is populated once on init
    via /api/proceeding-types-db and cached for the page lifetime.
  - forumFromProceedingCode() maps "UPC_INF" / "DE_NULL" / "EPA_OPP"
    / "EP_GRANT" / "DPMA_OPP" → upc / de / epa / dpma. EP_ and EPA_
    both hit the EPA branch since EP_GRANT belongs to the EPA forum.
  - triggerCascadeRefresh() is called from selectProject /
    selectAdhoc / clearStep1Context + after the async load completes
    so the cascade re-renders when the context changes.

The role variants (Klägerseite vs Beklagtenseite, Berufungskläger vs
-beklagte) are Slice 3c — they require fetching the user's
project_teams.responsibility for the selected project. Project's
forum lands first; role layers on after.

Refs t-paliad-157 / m/paliad#15. Folds in part of #18 (Item A
rule-vs-event collapse) — when the project context narrows the cascade
to one jurisdiction, the rule-vs-event mismatch surface shrinks.
2026-05-08 20:15:50 +02:00
m
75dc842b8e feat(team-broadcast): add "open in mail client" mailto link to broadcast modal
m's request 2026-05-08 20:12: alongside Paliad's per-recipient
"E-Mail an Auswahl" broadcast (which sends individual envelopes from
the server), users want a one-click way to compose a single multi-
recipient email in their own mail client. Common use case: writing
to a specific team where the response thread should stay client-side
and be visible to every recipient (unlike the privacy-preserving
broadcast where each recipient sees only themselves).

Adds a "Im Mail-Client öffnen" / "Open in mail client" link to the
broadcast modal's recipient summary, alongside the existing
"Alle anzeigen" toggle. Clicking it opens a `mailto:` URL with every
selected recipient comma-separated in the To: line per RFC 6068.

`buildMailtoHref` is exported so it can be unit-tested independently
and reused by other selection surfaces (admin team table, project
team tab) without a refactor.

The existing server-driven broadcast path is unchanged — both options
coexist.
2026-05-08 20:13:49 +02:00
m
6224898f9e Merge: t-paliad-161 — inline Paliadin chat modal + agent-suggested write path
Six commits from mai/dirac/inventor-inline-paliadin (all sliced per
the design's §10 phasing):

  142edca docs(paliadin): t-paliad-161 inventor design
  282e0bb feat(paliadin/migration-070): Slice A — schema + relay seam
  0d1a7ba feat(paliadin/context): Slice B — structured page-context payload
  ba2408e feat(paliadin/inline-widget): Slice C — floating button + drawer
  a3052eb feat(paliadin/suggest): Slice D — agent-suggested write path
  4ecea7a feat(paliadin/agent-glyph): Slice E —  alongside 👀

What ships:

- Floating Paliadin trigger bottom-right + Cmd/Ctrl-K shortcut, opening
  a 420px right slide-out drawer (full-screen on mobile). Visible on
  every authenticated page except /paliadin, /login, /onboarding.
  Same PaliadinOwnerEmail gate as today — no scope expansion.
- Per-route starter-prompt registry in client/paliadin-starters.ts —
  context-aware empty-state nudges users into useful first prompts.
- Structured PaliadinContext payload (route_name + primary_entity_type
  + primary_entity_id + user_selection_text + view hints) flowing from
  the widget through Go into the tmux envelope. SKILL.md gains [ctx …]
  parsing so the persona can use it.
- Agent-suggested write path: paliad__suggest_deadline +
  paliad__suggest_appointment + paliad__suggest_note tools that draft
  rows straight into the existing approval pipeline. Suggestions land
  as approval_requests with requester_kind='agent' and an
  agent_turn_id pointer back to the originating turn.
- Visual provenance:  glyph alongside 👀 on pending-approval rows
  whose request was agent-drafted; persistent  on approved-from-agent
  rows in the audit log. Lives in events.ts/agenda.ts/inbox.ts.

Migration 070 is idempotent (every ALTER guarded by IF NOT EXISTS,
constraints/index inside DO blocks). Live tracker is at v69; deploy
will apply 070 cleanly. Adds:
  paliad.approval_requests.requester_kind text + xor-check
  paliad.approval_requests.agent_turn_id uuid
  paliad.paliadin_turns.context jsonb

m greenlit all 5 inventor decisions (a-a-a-a-a) on 2026-05-08 19:39:
owner-only gate, tmux relay v1, create-only suggestion verbs,
-alongside-👀 visual, selection-text default-on.

Refs m/paliad#20, design doc docs/design-paliadin-inline-2026-05-08.md.
2026-05-08 20:06:07 +02:00
m
4ecea7a4bb feat(paliadin/agent-glyph): t-paliad-161 Slice E — alongside 👀
When a pending row was drafted by Paliadin (requester_kind='agent' on
its in-flight approval_request), surface a sparkle  next to the
existing eye-pill 👀. The two glyphs are orthogonal: 👀 = "needs
approval",  = "Paliadin drafted this". Either can change without the
other, so the visual taxonomy stays decomposable for any future
autopilot mode where 👀 disappears but  stays.

Read-path:

- DeadlineService.ListVisibleForUser + AppointmentService.ListVisibleForUser
  LEFT JOIN paliad.approval_requests on pending_request_id and project
  ar.requester_kind into the row. NULL when no request is pending.
- models.DeadlineWithProject + AppointmentWithProject grow
  RequesterKind *string. The list-projection helpers
  (projectDeadline / projectAppointment in event_service.go) carry it
  into EventListItem.
- /api/events response now includes requester_kind on every pending
  row; /api/inbox already does (Slice D extended approvalRequestViewColumns).

Render-path:

- frontend/src/client/events.ts — new AGENT_PILL_GLYPH constant (""),
  agentPill rendered into the title cell next to the existing
  pendingPill when item.approval_status='pending' AND
  item.requester_kind='agent'. EventListItem TS shape gains
  `requester_kind?: "user" | "agent"`.
- frontend/src/client/agenda.ts — same pattern, agendaItem TS shape
  + agentPill rendered next to pendingPill in the headline span.
- frontend/src/client/inbox.ts — ApprovalRequestView gains
  requester_kind + agent_turn_id; the meta line replaces the
  requester's plain name with "Anna  Paliadin" when the request was
  drafted by the agent.

CSS: new .approval-pill--agent modifier in global.css using only
existing tokens (--color-bg-lime-tint / --color-surface-2 /
--color-text), mirroring the .approval-pill--icon shape so the two
glyphs sit side-by-side at the same baseline.

i18n: 3 new keys × 2 langs (approvals.agent.label /
approvals.agent.byline / approvals.agent.suggestion_pending) — total
1966 → 1969.

Build clean (frontend + go), tests green.

Refs: docs/design-paliadin-inline-2026-05-08.md §8.
2026-05-08 20:04:10 +02:00
m
a3052eb085 feat(paliadin/suggest): t-paliad-161 Slice D — agent-suggested write path
Paliadin can now draft deadlines + appointments through two new
owner-gated HTTP endpoints. Drafted entities land in the existing
approval pipeline as approval_status='pending' with
requester_kind='agent' + agent_turn_id linking back to the chat turn
that produced the suggestion. The user reviews via the same eye-pill
👀 surface (with  added in Slice E).

  POST /api/paliadin/suggest/deadline
  POST /api/paliadin/suggest/appointment

Wiring:

- ApprovalService.SubmitAgentCreate — agent variant of SubmitCreate;
  always creates an approval_request (bypassing policy lookup) and
  stamps requester_kind='agent' + agent_turn_id. Required-role defaults
  to 'associate' so the deadlock check has a non-NULL threshold; m's
  lock-in for Q11 (every agent suggestion needs the user's eye) means
  bypassing the policy gate is correct here, not a regression.

- The shared `submit` kernel takes an optional agent_turn_id pointer.
  All four lifecycle entry points (SubmitCreate / SubmitUpdate /
  SubmitComplete / SubmitDelete) pass nil; SubmitAgentCreate passes
  the turn id. INSERT to approval_requests now writes both
  requester_kind + agent_turn_id atomically (xor-check on the schema
  enforces consistency).

- models.ApprovalRequest grows the two columns + their JSON tags so
  the inbox view + Verlauf renderer can read provenance without an
  extra fetch.

- approvalRequestViewColumns adds ar.requester_kind + ar.agent_turn_id
  to the SQL projection; both surfaces (ListPendingForApprover,
  ListSubmittedByUser, GetRequest) inherit the new fields free.

- CreateDeadlineInput + CreateAppointmentInput each get an optional
  AgentTurnID *uuid.UUID. When non-nil, the create-tx routes through
  SubmitAgentCreate instead of the regular SubmitCreate. Default-zero
  behaviour is unchanged for every existing caller.

- handlers/paliadin_suggest.go is the new HTTP layer. Owner-gated via
  requirePaliadinOwner (same gate /paliadin uses), JSON-bodied,
  RFC3339 + ISO-date validation, 409 + a useful message on
  ErrNoQualifiedApprover.

- Project-event audit metadata gains requester_kind + agent_turn_id so
  the project's Verlauf can render "Paliadin hat eine Frist
  vorgeschlagen " without joining approval_requests (Slice E reads
  this).

SKILL.md (~/.claude/skills/paliadin/SKILL.md) gains an "Agent-suggested
writes" section with the tool catalog, behaviour rules ("never write
directly", confirmation in the response file, project_id lookup
discipline, RFC3339 dates, no chained tool calls per turn), and the
409 error contract.

go build + go vet + go test all clean. No frontend changes in this
slice — Slice E lights up the  on existing eye-pill surfaces.

Refs: docs/design-paliadin-inline-2026-05-08.md §7.
2026-05-08 19:59:44 +02:00
m
75cfe914ce Merge: t-paliad-157 Determinator Slice 3a — File/Draft/Enter chooser
34e82ea — Step 2 "Etwas einreichen" no longer drops straight into
Pathway A; it now shows a 3-card chooser:

  File  → Pathway A wizard (existing).
  Draft → v1 placeholder, disabled button + "kommt bald" pill. No
          /drafts route exists yet; the chooser slot is reserved.
  Enter → /projects/<id>/deadlines/new, or /deadlines/new in ad-hoc.

Pathway type extends with "outgoing" intent.

Back-button policy:
  Step 3a → Step 2 fork
  Pathway A → Step 3a
  Pathway B → Step 2 fork
2026-05-08 19:58:49 +02:00
m
34e82ead06 feat(determinator/slice-3a): outgoing-intent chooser (File / Draft / Enter)
m's 2026-05-08 18:09 spec: Step 3a is itself a 3-option fan-out. When
the user picks "Etwas einreichen" on Step 2 we no longer drop straight
into the Pathway A wizard; we ask "what kind of einreichen?" first.

Three cards:

  - **File** (Schriftsatz einreichen) → navigates to Pathway A — the
    existing wizard with proceeding picker, trigger date, flags,
    timeline, save modal. The rule-library entry point.
  - **Draft** (Schriftsatz entwerfen) → v1 placeholder. Disabled
    button with a "kommt bald" pill in the corner. m specced this
    as a link to a future drafting surface; for now we show the
    intent without doing anything so the surface exists in the IA.
  - **Enter** (Frist manuell erfassen) → routes to
    `/projects/{id}/deadlines/new` (or `/deadlines/new` in ad-hoc
    mode where there's no project to anchor against).

Pathway type extends to include "outgoing"; readPathwayFromURL +
showPathway both handle it. The Step 3a panel reuses .fristen-step2-
card visuals so File / Draft / Enter look consistent with the parent
Step 2 cards but distinct from Pathway A's proceeding picker.

Back-button policy:

  - Step 3a back → Step 2 (the new "fork" state).
  - Pathway A back → Step 3a (since that's where the user came from
    in the new flow). Two clicks back to the fork.
  - Pathway B back → fork directly (Step 2 happened-card jumps
    straight to Pathway B; no intermediate chooser).

Out of scope for this slice:

  - Step 3b's project-type-scoped event picker (Slice 3b).
  - Klägerseite/Beklagtenseite role variants (Slice 3c).
  - Real /drafts route — Draft stays a soft placeholder.

Refs t-paliad-157 / m/paliad#15.
2026-05-08 19:58:21 +02:00
m
2cd7266198 Merge: t-paliad-157 Determinator Slice 2 — /projects/new return-bounce
dba8ad3 — feat(determinator/slice-2): /projects/new now honours a
?return=<path> query param. After a successful POST it bounces to
that path with ?project=<new_uuid> appended. Sanitization rejects
protocol-relative (//foo), absolute (https://…), and non-rooted
paths to avoid open-redirect.

Step 1 of the Determinator's "Neue Akte anlegen" link sends
?return=/tools/fristenrechner. Step 1's existing URL hydration
(Slice 1) picks up the ?project= and preselects — no new server
work needed.
2026-05-08 19:54:44 +02:00
m
ba2408eb51 feat(paliadin/inline-widget): t-paliad-161 Slice C — floating button + slide-out drawer
The inline Paliadin chat surface — reachable from every authenticated
page, replacing the standalone /paliadin route as the primary entry
point. The standalone page survives as the dedicated full-screen mode
(the drawer's "↗ fullscreen" action links to it).

Components:

- frontend/src/components/PaliadinWidget.tsx — emits the floating
  trigger button (bottom-right, lime , owner-revealed by JS), a
  scrim, and the right-edge slide-out drawer with header (reset /
  fullscreen / close), context chip, message stream, empty-state
  starter list, and textarea+send form. Loads /assets/paliadin-widget.js.

- frontend/src/client/paliadin-widget.ts — runtime. /api/me probe
  reveals the trigger when caller matches PaliadinOwnerEmail (with
  optional is_paliadin_owner flag fast-path); Cmd+J / Ctrl+J shortcut
  toggles open/close (Cmd+K stays reserved for global search per
  client/search.ts). Uses computePaliadinContext() (Slice B) per send
  so route + entity + selection flow into every turn. SSE consumer
  writes assistant bubbles; localStorage persists per-session history.

- frontend/src/client/paliadin-starters.ts — per-route starter prompt
  registry. 14 routes covered (dashboard, projects.*, deadlines.*,
  appointments.*, agenda, events, inbox, tools.*, glossary, courts) +
  a _default fallback. Bilingual (DE/EN); prompts ending in `: ` seed
  the textarea for the user to finish; fully-formed prompts auto-send.

- 39 authenticated TSX pages get a `<PaliadinWidget />` element after
  `<Footer />` via a mechanical pass. paliadin.tsx (the standalone)
  is intentionally excluded — its dedicated UI is the widget's
  fullscreen escape hatch, not a place to overlay another widget.

- frontend/build.ts registers the new bundle.
- frontend/src/styles/global.css gains ~280 lines of widget CSS
  (trigger / scrim / drawer / header / context-chip / messages /
   bubbles / starters / form / send-btn) using only existing tokens.
   Mobile (≤640px): drawer goes full-screen; trigger lifts above
   bottom-nav slots.
- 11 new i18n keys × 2 langs = 22 entries under paliadin.widget.*.

Visibility predicate (paliadin-context.shouldSendContext) hides the
widget on /paliadin, /login, /onboarding. Owner-only gate stays on
PaliadinOwnerEmail.

Build clean: i18n 1955 → 1966 keys, IIFE-wrapped 218KB bundle, go test
green.

Refs: docs/design-paliadin-inline-2026-05-08.md §3, §5.
2026-05-08 19:54:18 +02:00
m
dba8ad3fdd feat(determinator/slice-2): /projects/new return-bounce + Step 1 preselect
m's 2026-05-08 Slice 2: "Neue Akte anlegen" on the Fristenrechner now
round-trips cleanly. The Step 1 link sends `?return=/tools/fristenrechner`
on the way out; projects-new.ts honours the param after a successful
POST and redirects back with `?project=<new_uuid>` appended so the
just-created Akte preselects itself in Step 1.

Two pieces:

  - frontend/src/client/projects-new.ts — new sanitizeReturnUrl()
    rejects anything that could escape to a different origin
    (protocol-relative `//foo`, absolute `https://...`, non-rooted
    relative paths). On submit success, if a sanitized return URL
    exists, build the destination via URL() so existing query params
    on the return path stay intact and ?project= is set without
    clobbering, then redirect there. Falls back to /projects/{id}
    when no return param is present (existing behaviour preserved).
  - frontend/src/fristenrechner.tsx — Step 1 link gets the
    ?return=/tools/fristenrechner query string so the bounce-back
    knows where to land.

Step 1 hydration from Slice 1 already handles `?project=<uuid>` —
fetchProjects() repopulates cachedAkten, the projectId looks up its
ProjectOption record, renderStep1Summary() renders the collapsed
state, Step 2 cards become visible. No client-side state coordination
needed; the URL is the contract.

Refs t-paliad-157 / m/paliad#15.
2026-05-08 19:54:11 +02:00
m
d4c129f0d6 Merge: t-paliad-157 Determinator Slice 1 — project picker + do/happened bifurcation
df04e50 — feat(fristenrechner/determinator): the legacy "Was möchten
Sie tun?" landing fork is replaced by:

  Step 1: filtered Akte picker + "Neue Akte anlegen" link (bare; the
    bounce-back to the wizard after creation is Slice 2 scope) +
    4 ad-hoc chips driving ?ad_hoc=upc|de|epa|dpma.
  Step 2: "Etwas einreichen" / "Etwas ist passiert" cards driving
    showPathway('a' | 'b'). Quick-pick chips moved here from the old
    fork. Pathway A/B back buttons return to Step 2.

Save CTA on Pathway A's wizard disables in ad-hoc mode with hint
"Ad-hoc — kein Projekt, kein Speichern" (DE+EN). The locked context
collapses to a one-line summary; Reselect re-expands.

URL contract:
  ?project=<uuid> | ?ad_hoc=upc|de|epa|dpma  — Step 1 result
  ?path=a|b                                   — Step 2 result (back-compat)
  ?mode=tree|filter                           — Pathway B sub-mode

Pathway A/B sub-routing primitives (showPathway, showBMode) unchanged
— Step 2 cards just drive the same hooks.

Still open:
  Slice 2 — /projects/new return-bounce on save.
  Slice 3+ — scoping the picker / cascade by project's proceeding-type
    + role; replacing the wizard with the Step 3a File/Draft/Enter
    chooser.
2026-05-08 19:52:32 +02:00
m
df04e500f7 feat(fristenrechner/determinator): Slice 1 — project picker + do/happened bifurcation
m's 2026-05-08 18:08 Determinator redesign Slice 1. Replaces the
legacy "Was möchten Sie tun?" fork (Pathway A vs B) with a two-step
funnel that puts the project (Akte) at the foundation:

  Step 1 — Welche Akte?
    - Filtered list of visible projects, search-as-you-type.
    - "Neue Akte anlegen" link → /projects/new (bare; the bounce-back
      with auto-preselect lands as Slice 2 per Maria's gating).
    - Four ad-hoc explore-mode chips (Custom UPC / DE / EPA / DPMA
      proceeding) for users who just want to look up a rule. No DB
      write; URL becomes ?ad_hoc=upc|de|epa|dpma.

  Step 2 — Was möchten Sie tun?
    - Two cards: "Etwas einreichen" → Pathway A (Verfahrensablauf
      wizard) and "Etwas ist passiert" → Pathway B (cascade, mode=tree).
    - Quick-pick chips moved here from the old fork's shortcut row.

Once Step 1 picks a context, the picker collapses to a one-line
summary "Akte: X · [Andere Akte]" mirroring the proceeding-summary
collapse pattern (097e21c). Reselect re-expands and clears downstream
state.

State on URL:
  ?project=<uuid>     project context
  ?ad_hoc=upc|...     ad-hoc explore-mode
  ?path=a|b           Step 2 outcome (kept for back-compat)
  ?mode=tree|filter   Pathway B sub-mode (kept)

The legacy back-from-Pathway buttons now return to Step 2 (the new
"fork" state). showPathway() / showBMode() unchanged — Step 2 cards
just drive the same primitive.

Save-to-project CTA on Pathway A's wizard detects ad-hoc mode and
disables itself with the hint "Ad-hoc — kein Projekt, kein Speichern"
(EN: "Ad-hoc — no matter, no save"). Hiding the CTA would leave the
user wondering where the action went; disabling makes the constraint
legible (per m's lock #2).

Frontend pieces:
  - fristenrechner.tsx — Step 1 + Step 2 markup; legacy
    fristen-pathway-fork removed wholesale.
  - client/fristenrechner.ts — new Step1Context type + URL hydration
    + render helpers; initPathwayFork rewired to drive the new
    cards; renderProcedureResults gates the save CTA on
    isAdhocMode().
  - client/i18n.ts — 19 new keys (DE+EN) under deadlines.step1.* +
    deadlines.step2.* + the save CTA hint.
  - styles/global.css — .fristen-step1 / .fristen-step2 block + chip
    + summary styles, all bound to the existing --color-* token
    palette. Mobile breakpoint stacks the Step 2 cards at <600px.

Out of scope for this slice (will land later):
  - Slice 2: /projects/new bounce-back with auto-preselect via
    ?return=/tools/fristenrechner.
  - Slice 3+: scoping the picker / cascade by project's
    proceeding-type + role; replacing the existing wizard with the
    Step 3a "File / Draft / Enter" chooser.

Refs t-paliad-157 / m/paliad#15.
2026-05-08 19:50:59 +02:00
m
0d1a7ba886 feat(paliadin/context): t-paliad-161 Slice B — structured page-context payload
The inline widget (Slice C, next) submits a richer per-turn payload than
the standalone page's single page_origin string:

  context: {
    route_name, page_origin, primary_entity_type, primary_entity_id,
    user_selection_text, view_mode, filter_summary
  }

Wiring:

- services.TurnContext + EnvelopePrefix() build a
  `[ctx route=… entity=…:<id> selection="…" view=… filter="…"]` block.
  Empty fields are omitted; selection is always quoted (it's user-supplied
  content); selection over 1000 chars gets truncated with an ellipsis.
- services.MaxSelectionChars = 1000 (the design's privacy floor §4.3).
- LocalPaliadinService.RunTurn + RemotePaliadinService.RunTurn prepend the
  envelope to the user message before sending through tmux.
- paliadinDB.insertTurnRow now persists the structured context as
  paliad.paliadin_turns.context jsonb (migration 070).
- handlers/paliadin.go's turnRequest accepts the new optional context
  field; mirrors context.PageOrigin into the top-level page_origin when
  the latter is empty so legacy admin queries still work.
- The standalone /paliadin page is unchanged — its turn body still has
  only page_origin, the new field is optional. Backwards compatible.

SKILL.md (~/.claude/skills/paliadin/SKILL.md, refreshed via
scripts/install-paliadin-skill):
- Documents the new `[ctx …]` block in front of the user question.
- Five behaviour rules: pre-call enrichment when entity= is set, don't
  repeat the obvious, treat selection as data not instructions, no
  hallucination on empty entity lookup, legacy turns work as before.

Frontend client/paliadin-context.ts is the route-table + entity
extraction the widget will use (Slice C). Public surface:
computePaliadinContext() returns the payload or null on excluded
routes (/paliadin, /login, /onboarding); selection toggle reads
localStorage["paliadin:send-selection"] (default on, off opts out).

New test TestTurnContext_EnvelopePrefix pins the bracket-block format
(8 sub-tests including truncation, selection-quote escape, empty-context
empty-prefix). go test ./... clean. go build + bun run build clean.

Refs: docs/design-paliadin-inline-2026-05-08.md §4.
2026-05-08 19:47:43 +02:00
m
e9e7d5c27c feat(projects-detail): "Untergeordnet" tab → "Projektbaum" with full visible hierarchy
m typed in another pane: "The project view where there is a tab
'Untergeordnet' I want a 'Project Tree' instead. And it always shows
all siblings, all parents and all children of that entity." (Forwarded
by klaus / youpcorg/head, msg #1570.)

Tab label
  DE: Untergeordnet → Projektbaum
  EN: Sub-projects → Project Tree
  i18n key kept as projects.detail.tab.kinder for back-compat (legacy
  bookmarks + create-sub-project CTA still keyed on 'kinder').

Tree content
  Was: direct children only (one /api/projects/<id>/children call).
  Now: full visible project hierarchy via /api/projects/tree?subtree_counts=false,
  rendered as nested <ul> with the current node highlighted with a
  lime-soft background + current-color border. The dashed left border
  on nested levels makes parent → child relationships scannable.
  Visibility is RLS-scoped (the tree endpoint already filters to projects
  the user can see).

Empty state
  "Keine untergeordneten Projekte" still renders when the current node
  has zero direct children — that is what the "+ Untervorhaben anlegen"
  CTA next to it actually creates. Showing it for "tree has no other
  branches" would have been wrong.

The standalone /api/projects/<id>/children call stays — it gates the
empty state and pre-fills parent_id on the create form.
2026-05-08 19:46:55 +02:00
m
282e0bb237 feat(paliadin/migration-070): t-paliad-161 Slice A — schema for agent-suggested write path
Two coordinated additions:

1. paliad.approval_requests gets requester_kind text NOT NULL DEFAULT
   'user' CHECK ('user','agent') + agent_turn_id uuid REFERENCES
   paliadin_turns(turn_id) ON DELETE SET NULL. The xor-check pins
   (kind='agent' ↔ agent_turn_id IS NOT NULL) so agent rows can't lose
   provenance and user rows can't accidentally pick up a turn id.
   Existing rows backfill cleanly via the DEFAULT.

2. paliad.paliadin_turns.context jsonb — structured page-context
   payload (route_name + primary_entity + selection + view hints) the
   inline widget submits with every turn. Old page_origin column stays
   as the cosmetic URL field.

Idempotent — every ALTER uses IF NOT EXISTS and the constraints/index
are guarded by DO blocks. Verified live via BEGIN..ROLLBACK on the
production DB: cols + 3 constraints + index land cleanly, second apply
is a no-op, xor-check rejects ('agent', NULL).

Skipped the optional PaliadinRelay interface extraction per the design
doc's own §6.4 caveat: paliadin.go + paliadin_remote.go already share
the paliadinDB substrate cleanly; introducing an interface now would
duplicate without removing duplication. Reserves the seam for the
future API cutover without paying its cost today.

Refs: docs/design-paliadin-inline-2026-05-08.md §7.1, §4.2, §6.4.
2026-05-08 19:42:05 +02:00
m
142edca401 docs(paliadin): t-paliad-161 inventor design — inline modal + agent-suggested write path
Two intertwined Paliadin upgrades, scoped together because the chat
surface is where the write path is triggered and the write path is what
makes the chat non-trivial:

1. Inline slide-out modal reachable from every authenticated paliad
   page, with structured page-context payload (route_name +
   primary_entity + selection text) and per-route starter prompts.
2. Agent-suggested write path that drafts deadlines/appointments/notes
   into the existing pending_create lifecycle (t-paliad-160) with new
   provenance columns on approval_requests (requester_kind + agent_turn_id);
   approved-from-agent rows render alongside 👀 with a sparkle .

Hard call: keep the existing tmux relay for v1; recommend (but do not
commit) the Anthropic API cutover as a prerequisite for opening beyond
owner-only. Single Paliadin persona — no scope-bouncer pre-design.

Inventor parked. DESIGN READY FOR REVIEW. Awaiting m's go/no-go before
any coder shift.

Refs: m/paliad#20, t-paliad-146, t-paliad-160, t-paliad-138.
2026-05-08 19:35:39 +02:00
m
caa76d2925 Merge: t-paliad-157 Item 4 — opponent-side cascade additions (mig 069)
fdbbc74 — feat(fristenrechner/cascade): more opponent-side proceeding
types in the B1 cascade. Adds 5 parents (UPC Berufung, UPC einstweilige
Maßnahmen, DE BGH Revision/NZB, DE BGH Berufung Nichtigkeit, DPMA
Rechtsbeschwerde) + 17 leaves under cms-eingang.gegenseite. Forums-
tagged for the inbox-channel chip and proceeding_type_code-narrowed
so result cards land on the right rules. Migration is idempotent
(ON CONFLICT clauses).

Live tracker is already at v69 (feynman applied during dev). Deploy
will see tracker=69 with file 069 present and have nothing to apply.

Role variants (Klägerseite vs Beklagtenseite) deferred to the
Determinator redesign per m's earlier scope decision.
2026-05-08 19:22:04 +02:00
m
fdbbc74c15 feat(fristenrechner/cascade): more opponent-side proceeding types
m's 2026-05-08 17:41 batch Item 4: today
`cms-eingang.gegenseite` exposes UPC INF/REV, DE INF/NULL, EPA OPP/APP,
DPMA OPP — but is missing the appellate / interim-measures arms m
named. m: "we need a lot more proceeding types for Opponent submission
— we currently only see Verletzungsverfahren."

Migration 069 adds 5 new parent nodes and 17 leaves under
`cms-eingang.gegenseite`:

  - upc-app             UPC Berufungsverfahren — 5 leaves: Berufungs-
                        schrift, -begründung, -erwiderung, Anschluss-
                        berufung (R.237), Erwiderung dazu (R.238).
                        Concepts: notice-of-appeal, statement-of-
                        grounds-of-appeal, response-to-appeal,
                        cross-appeal, reply-to-cross-appeal.
  - upc-pi              UPC einstweilige Maßnahmen — 2 leaves: PI-
                        Antrag, PI-Erwiderung. Concepts: application-
                        for-provisional-measures, statement-of-defence.
  - de-bgh-inf          DE Revision / NZB BGH (Verletzung) — 5 leaves:
                        NZB, NZB-Begründung, Revisionsschrift,
                        Revisionsbegründung, Revisionserwiderung.
                        Concepts: nichtzulassungsbeschwerde,
                        nichtzulassungsbeschwerde-begruendung,
                        revisionsfrist, revisionsbegruendung,
                        response-to-appeal.
  - de-bgh-null         DE Berufung BGH (Nichtigkeit) — 3 leaves:
                        Berufungsschrift, -begründung, -erwiderung.
                        Concepts: notice-of-appeal,
                        statement-of-grounds-of-appeal,
                        response-to-appeal.
  - dpma-bgh            DPMA Rechtsbeschwerde BGH — 2 leaves:
                        Rechtsbeschwerde, RB-Begründung. Concepts:
                        rechtsbeschwerde, rechtsbeschwerde-begruendung.

Each parent + leaf carries the matching forums tag so the
inbox-channel chip (#15) hides / shows the subtree correctly. The
event_category_concepts junction sets proceeding_type_code per leaf
(UPC_APP / UPC_PI / DE_INF_BGH / DE_NULL_BGH / DPMA_BGH_RB) so the
result card pills only the relevant proceeding's rules.

All INSERTs use ON CONFLICT (slug) DO UPDATE so re-running the
migration after a partial apply is safe. Mig applied to live Supabase;
tracker at v69.

Refs m/paliad#15.
2026-05-08 19:20:52 +02:00
m
e2907db760 Merge: t-paliad-157 dogfood batch — eye glyph 👀, optional deadlines, Verfahrensablauf collapse
Three commits from mai/feynman/fristenrechner:

- 614f9af fix(approval-pill): two-eyes glyph 👀 instead of single SVG eye
  on /deadlines + /appointments + /agenda. m's preference: emoji denotes
  "being looked at" closer to "wartet auf Genehmigung" semantics.

- 2d6ea3e feat(deadline-rules/is-optional): conditional rules opt-in via
  save modal. Adds paliad.deadline_rules.is_optional. Distinct from
  is_mandatory: a rule can be statutorily fixed when it applies AND
  conditional on whether it applies (RoP.151 cost-decision request,
  appeal-related deadlines). Save-modal pre-unchecks optional rows;
  user toggles to opt in. Timeline shows "auf Antrag" pill.

- 097e21c feat(fristenrechner): proceeding-picker collapses to one-line
  "Verfahren: X · [Reselect]" pill after pick (saves vertical space).
  Column view becomes the default for the timeline (was previously
  whichever-default; m wants Column on first render).

Migration housekeeping:
  feynman's migration was authored as 066 on his branch but main has
  already taken 066/067 via shannon's t-paliad-160 (approval policy
  split + drop required_role). Renumbered to 068 during merge to
  resolve the same-number collision. Added ADD COLUMN IF NOT EXISTS
  to make the up-migration idempotent (defensive for environments
  where the column was already applied out-of-band during dev). The
  RoP.151 backfill UPDATE is naturally idempotent.

  Live tracker bumped from 66 → 68 to reflect schema reality before
  this merge: shannon's 066+067 effects and feynman's is_optional
  column are all already present in the live youpc Supabase. The
  next deploy will see tracker=68 and have nothing to apply.

Refs m/paliad#15, m/paliad#18 (rule-Typ contradiction filed against
Item A scope, not part of this batch).
2026-05-08 19:15:44 +02:00
m
097e21c8db feat(fristenrechner): collapse proceeding-picker after pick + columns view default
m's 2026-05-08 18:26 dogfood batch — two pure UX tweaks on the
Verfahrensablauf wizard:

1) Collapse the proceeding-picker once a Verfahren is chosen. Replaces
   the four-group block (UPC / DE / EPA / DPMA, ~25 buttons total)
   with a one-line "Verfahren: X · [Anderes Verfahren wählen]" pill.
   Reselect re-expands without throwing away the rest of the wizard
   state (trigger date, flags, calc result stay put until the user
   actually picks again). reset() also re-expands.

2) Column view as the default for step 3. The proactive / court /
   reactive grid reads more naturally for the HLC team than the
   single vertical timeline. URL semantics flipped: ?view=timeline
   now opts back into the legacy view; absence of ?view= yields
   columns. Share links stay clean.

Files:
  - frontend/src/fristenrechner.tsx — new .proceeding-summary
    markup; the view-toggle radio order swapped so "Spalten" is the
    first / checked option.
  - frontend/src/client/fristenrechner.ts — setProceedingPickerCollapsed
    helper toggles the four .proceeding-group blocks vs the summary;
    selectProceeding collapses, reset() + Reselect re-expand.
    procedureView default flipped to "columns"; initViewToggle URL
    semantics inverted.
  - frontend/src/client/i18n.ts — 2 new keys (DE+EN) for the
    summary label + Reselect button.
  - frontend/src/styles/global.css — .proceeding-summary +
    .proceeding-summary-reselect styles, all bound to the existing
    --color-* token palette.

Refs m/paliad#15 dogfood thread (m's 2026-05-08 18:26 batch).
2026-05-08 18:31:35 +02:00
m
2d6ea3ee33 feat(deadline-rules/is-optional): conditional rules opt-in via save modal
m's 2026-05-08 batch Item 2: some rules don't always apply per-instance.
Antrag auf Kostenentscheidung (RoP.151) only fires when a party files
for it; some appeal-related deadlines depend on specific facts. Today
they render in the timeline as if always applicable; the save-to-
project modal pre-checks them so the user has to remember to uncheck.

New paliad.deadline_rules.is_optional bool flag (default false). Threads
through the Go model, ruleColumns SELECT, UIDeadline JSON, and the
frontend save modal:

  - Migration 066 adds the column + comment + a starter UPDATE that
    flips RoP.151 to is_optional=true. m can flip more via SQL as he
    reviews the rule library — distinct from is_mandatory, which is
    about statutory strictness once the rule applies (an "auf Antrag"
    rule can be is_mandatory=true once requested).
  - Save modal: optional rows pre-uncheck (the user opts in) and a
    small "auf Antrag" / "on request" pill renders in the meta line.
    Court-determined rows still pre-uncheck via the existing disabled
    path; isOptional doesn't override that.

Migration applied to live Supabase; tracker at v66.

Refs m/paliad#15 (m's 2026-05-08 18:21 follow-up batch Item 2).
2026-05-08 18:26:26 +02:00
m
614f9af753 fix(approval-pill): two-eye glyph instead of single SVG eye
m's 2026-05-08 18:21 follow-up: "two eyes instead of the one." The
single-eye SVG read as a watching-Eye-of-Sauron glyph; 👀 reads as
"under review" / "being looked at" — closer to "wartet auf
Genehmigung" semantics.

Drops the inline SVG + the .approval-pill--icon svg sizing rule;
replaces with the literal emoji as the pill's text content. CSS
modifier becomes a small auto-width text pill (min-width 28px so
single emoji stays nicely round-ish at higher densities).

Renamed APPROVAL_PILL_EYE_SVG → APPROVAL_PILL_GLYPH in both events.ts
and agenda.ts since the constant is no longer SVG.
2026-05-08 18:23:40 +02:00
m
6008d36a13 Merge: t-paliad-160 §C cosmetic — eye-pill on approval-pending entities (feynman, 4bab520, icon-only eye glyph on /deadlines + /appointments + /agenda; tooltip retains lifecycle labels pending_create/update/complete/delete; inbox surface unchanged with --historic text-pill variant; pure frontend) 2026-05-08 18:19:15 +02:00
m
4bab520119 feat(approval-pill): icon-only eye pill on /deadlines + /appointments + /agenda
m's 2026-05-08 cosmetic ask: the "Wartet auf Genehmigung" badge ate
row width and read as a noisy block of text on every pending row.
Replace with a 22px eye-icon pill; the lifecycle label moves to the
hover tooltip (title attr + aria-label so screen readers still get
the full text).

Three pieces:

  - global.css — new .approval-pill--icon modifier sets the pill to
    a circular 22×22 hit target with a centered SVG. Base
    .approval-pill (text-pill behavior) and --historic (inbox status
    pill) stay untouched so the inbox surface keeps rendering the
    full status + decider name.
  - client/events.ts (the /deadlines + /appointments shell) and
    client/agenda.ts each get a tiny APPROVAL_PILL_EYE_SVG constant
    + the new --icon class on the pending pill. Two definitions
    (no shared icons module today; no other surfaces need this glyph
    yet) — the duplication is two lines, easier to read than yet
    another import.

What it looks like: 👁 in a soft amber circle, hovers to "Änderung
wartet auf Genehmigung" / "Erledigung wartet auf Genehmigung" / etc.
The lifecycle-specific label kept (no schema work) — Maria gated this
slice as pure-frontend; the richer "wartet auf Genehmigung von
<role>; angefragt am <date>" tooltip needs a backend join we're not
doing here.

Refs t-paliad-160 §C / m's 2026-05-08 18:15 batch Item B.
2026-05-08 18:18:16 +02:00
m
c06be27cce Merge: t-paliad-157 — Fristenrechner items 3 + 1 stopgap (feynman, ac15911 moves the inbox-channel chip from /tools/fristenrechner page-top into the B1 cascade panel + drops the Pathway A picker filter — persistence + URL override + B1 cascade narrowing + B2 fine-chip sync still apply, just no longer page-level prefilter; ef78f59 Item-1 stopgap for chained court-set rules — RoP.151-style rules whose trigger is itself a court-set event now render 'unbestimmt' instead of 'wird vom Gericht bestimmt' via a new IsCourtSetIndirect flag, direct court events keep the original label. Items 2 + 4 from m's 2026-05-08 17:41 batch still pending.) 2026-05-08 17:56:11 +02:00
m
ef78f59d25 feat(fristenrechner): "unbestimmt" for chained court-set rules (m's R.151 case)
m's 2026-05-08 17:50 feedback: 'Antrag auf Kostenentscheidung' (RoP.151)
labels itself "wird vom Gericht bestimmt" but the rule is actually
"1 Monat ab Hauptentscheidung". The court doesn't directly determine
this date — it determines the parent's date (Hauptentscheidung) and
this rule chains off that. Calling it "vom Gericht bestimmt" overstates
the relationship; "unbestimmt" reads correctly: derived from a
not-yet-known anchor.

Two failure modes split:

  - Direct court-set    rule itself is hearing / decision / order
                        (or primary_party='court'). Label stays
                        "wird vom Gericht bestimmt" — strictly correct.
  - Indirect court-set  rule has a real duration but its anchor is a
                        court-set parent (RoP.151 case), or it's a
                        zero-duration rule whose parent is court-set
                        without a real date. Label flips to
                        "unbestimmt".

Backend: new `IsCourtSetIndirect bool` on UIDeadline, set on the three
indirect cases inside FristenrechnerService.Calculate. Direct cases
keep IsCourtSetIndirect=false so their label stays unchanged. JSON
omits the field when false, no consumer churn.

Frontend: deadlineCardHtml + the save-modal row both consult
IsCourtSetIndirect to pick between two i18n keys (deadlines.court.set
"vom Gericht bestimmt" and deadlines.court.indirect "unbestimmt"; EN
falls back to "set by court" / "tbd"). The override edit affordance
keeps working unchanged — user types the actual parent date, downstream
re-flows.

Refs m/paliad#15 (m's 2026-05-08 17:50 feedback Item 1).
2026-05-08 17:55:22 +02:00
m
ac15911e4f refactor(fristenrechner/inbox-chip): move chip into B1 cascade, drop Pathway A filter
m's 2026-05-08 feedback: the inbox-channel chip is a Determinator step,
not a page-level prefilter — "Verlauf does not need to see that so it
cant be outside of that."

Changes:

  - frontend/src/fristenrechner.tsx — strip the .fristen-inbox-bar
    markup from above the pathway fork; mount it instead at the top
    of #fristen-b1-panel, before the cascade. The chip is now visible
    only when the user enters Pathway B → tree mode.
  - frontend/src/client/fristenrechner.ts — drop the .proceeding-group
    visibility loop from applyInboxFilter. Pathway A's wizard is no
    longer filtered by the chip. The data-forum attributes stay on
    the markup as documentation of intent but no longer drive
    visibility.

What stays:

  - persistence (paliad.users.forum_pref via PATCH /api/me)
  - URL ?inbox= override
  - B1 cascade narrowing via paliad.event_categories.forums
  - B2 fine-bucket activeForums sync (B2 lives inside the
    Determinator too)

Refs m/paliad#15 (m's 2026-05-08 17:50 feedback).
2026-05-08 17:53:06 +02:00
m
f1889fabcf Merge: t-paliad-160 — Approval system rework (shannon, 3 slices + renumber:
- Slice 1+2 (3a41aa9): schema split required_role → requires_approval boolean + min_role text via mig 066 with two-step dual-read; resolver most-strict-wins via approval_role_level(); ErrAlreadyPendingApproval / ErrNoQualifiedApprover / ErrInvalidInput → 409 Conflict with structured body via mapApprovalError helper.
- Slice 2 (073af97): /admin/approval-policies UI flip — 2-control checkbox + role dropdown replacing the 'none' sentinel option; pending-approval badge on deadline + appointment detail pages; 'Genehmigungsanfrage zurückziehen' button wired to existing Revoke endpoint; /approvals 'Meine Anfragen' visibility hardening (filter regression + inbox count badge sync).
- Slice 3 / M2 (aec6cf6): drop required_role column once writers cut over via mig 067.
- 3368aa5: renumber 064/065 → 066/067 to avoid collision with feynman's t-157 migrations 064 (users.forum_pref) + 065 (event_categories.forums) that landed first.
- 9350cd0: merge origin/main into branch to absorb feynman's slices.

Closes m's open dogfood gap from t-138 (cronus) + t-154 (hilbert): the 500 on already-pending now becomes a friendly 409, the entity detail page surfaces the pending state, the user can withdraw their own request explicitly, and /approvals lists their pending-mine entries. m's locked redesign (2026-05-08 16:40) of split + most-strict-wins shipped end-to-end. NOT cronus.)
2026-05-08 17:17:25 +02:00
m
9350cd0e87 Merge remote-tracking branch 'origin/main' into mai/shannon/approval-rework 2026-05-08 17:16:57 +02:00
m
3368aa58a6 chore(migrations): renumber shannon t-160 migrations 064/065 → 066/067 to avoid collision with feynman's t-157 migrations 064 (users.forum_pref) + 065 (event_categories.forums) which landed first 2026-05-08 17:16:57 +02:00
m
aec6cf6104 refactor(approvals/t-paliad-160 slice3 / M2): drop required_role column
Cleanup of the t-paliad-160 dual-read shim. After slice 1+2 every writer
hits both `required_role` and the new `(requires_approval, min_role)`
columns, and every reader prefers the new ones. M2 (migration 065) drops
the legacy column from `paliad.approval_policies` and rewrites
`paliad.approval_policy_effective()` to a 4-column return shape.

`paliad.approval_requests.required_role` is intentionally untouched —
that's the in-flight policy snapshot at submission time, a separate
concern from the policy authoring grammar.

Go side:
  - models.ApprovalPolicy and models.EffectivePolicy lose RequiredRole.
    The MinRole pointer is now the only seniority-threshold surface.
  - LookupPolicy / GetEffectivePolicyOne / List* / snapshotProjectRows
    drop the required_role SELECT projection.
  - UpsertProjectPolicySplit / UpsertUnitPolicySplit /
    DeleteProjectPolicy / DeleteUnitPolicy / ApplyMatrixToDescendants
    drop the required_role write. The audit-log row still uses the
    legacy string format ('partner|...|none'); composed via
    legacyFromSplit() from the new columns so the audit table layout
    keeps working without a parallel migration.
  - submit() reads policy.MinRole directly (LookupPolicy guarantees
    non-nil when a non-nil policy is returned).
  - nullToPtr helper retired (no remaining callers).

Frontend side:
  - admin-approval-policies.ts UnitPolicy / EffectivePolicy lose the
    legacy required_role optional. The 2-control UI was already on the
    split-grammar path.
  - deadlines-new.ts + appointments-new.ts form-time hint readers prefer
    requires_approval+min_role. They keep a soft-fall back to the
    legacy required_role for one cycle in case any cached pre-M2 server
    is still serving the old shape — that path is dead-code post-deploy
    and can be dropped later.

Test:
  - TestApprovalService_PolicyCRUD asserts MinRole instead of
    RequiredRole after re-upsert.

Build: bun build OK, go build ./... OK, go test ./... OK.

Deploy ordering: this slice MUST land after slice 2 is merged so the
pre-deploy code paths that still reference required_role have already
been retired.
2026-05-08 17:15:05 +02:00
m
073af975f7 feat(approvals/t-paliad-160 slice2): admin UI flip + badge + withdraw + inbox visibility hardening
A3 — admin/approval-policies 2-control flip:
  Each cell becomes [✓] requires_approval checkbox + role select + clear
  button. The "none" option in the role dropdown is gone — the checkbox
  replaces it. Role select is greyed when the checkbox is off (gate
  closed). Clear button explicitly drops the cell back to inheritance.
  Project matrix surfaces inherited "no approval" state with its own
  attribution chip ("Geerbt · keine Genehmigung") so admins can tell a
  silently-inherited off-state from a never-authored cell.

  PUT /api/.../approval-policies/{entity}/{lifecycle} accepts the new
  shape `{requires_approval: bool, min_role: string|null}` while still
  honouring the legacy `{required_role: "..."}` body during the M1
  dual-read window (decodePolicyBody routes to UpsertProjectPolicySplit
  vs UpsertProjectPolicy accordingly).

C+E — Pending-approval badge + Withdraw button:
  deadlines-detail + appointments-detail surface a "Wartet auf
  Genehmigung" badge when approval_status='pending'. Hover-tooltip
  carries requested_at + required_role + requester_name. Action
  controls (Complete, Edit, Delete) freeze while pending — caller
  would get a 409 anyway, no point letting them try.

  Withdraw button visible only to the requester (me.id ===
  pending_request.requested_by). Click → POST /api/approval-requests/
  {id}/revoke (existing endpoint, no new server route). On success,
  the entity flips back to approval_status='approved' and the page
  re-renders with normal controls.

  Complete button now handles 409 from the server gracefully:
  surfaces the new mapApprovalError body's `message` instead of
  silently disabling itself.

D — /inbox "Meine Anfragen" visibility hardening:
  Three defence-in-depth fixes for the "tab shows empty" report:
    1. handlers force `[]` (not Go-nil → JSON null) on every inbox
       endpoint so the frontend never trips on `rows.length` of null.
    2. parseInboxFilter validates ?status= against an allowlist
       (pending|approved|rejected|revoked|superseded). Anything else
       is silently dropped — a stray ?status=foo from a stale
       frontend build can no longer shadow rows out of the result.
       entity_type filter same treatment (deadline|appointment).
    3. Frontend inbox.ts coerces null body → [] so older / cached
       builds talking to the new server still don't crash.

  Test coverage: TestParseInboxFilter_DropsUnknownStatus +
  TestApprovalService_ListSubmittedByUser_PendingVisible (live-DB,
  skipped without TEST_DATABASE_URL).

Build clean: bun build OK, go test ./... OK.

Defers: M2 (drop required_role column) — only fires once all
in-tree writers are confirmed off the legacy column path.
2026-05-08 17:07:46 +02:00
m
8c58783cd3 Merge: t-paliad-157 / m/paliad#15 B1 follow-up — Fristenrechner cascade-entry-point narrowing (feynman, migration 065 paliad.event_categories.forums text[] with CHECK on {upc,de,epa,dpma} + partial GIN; two-step backfill via regex on slug + explicit straggler list for BGH/BPatG/Versäumnisurteil/Hinweisbeschluss/r116-eingaben; NULL stays neutral; EventCategoryNode JSON exposes forums; applyInboxFilter re-renders cascade in place; inboxFilterAllowsForums gates each child node — m/paliad#15 acceptance complete across schema + chip + Pathway A picker + B2 fine-bucket + B1 cascade) 2026-05-08 16:55:34 +02:00
m
3a41aa9209 feat(approvals/t-paliad-160 slice1+2): split policy + 409 handler
m's locked redesign (2026-05-08 16:40): replace `required_role` (with
'none' sentinel) with two columns — `requires_approval boolean` (the
gate) + `min_role text` (the seniority threshold). Cleanly separates
"approval applies at all" from "who's allowed to approve".

M1 phase: additive migration 064 adds the columns, backfills from the
legacy required_role ('none' → false/NULL; else → true/role), and
rewrites paliad.approval_policy_effective() to most-strict-wins:
  - requires_approval := bool_or across project + ancestor + unit_default
  - min_role          := MAX(approval_role_level) among requires_approval=true
The legacy required_role column survives this slice as a dual-read
mirror (resolver returns it too) so any caller that hasn't cut over
keeps working. M2 will drop required_role.

Service layer (approval_service.go): LookupPolicy + GetEffectivePolicyOne
read the new columns; UpsertProjectPolicySplit / UpsertUnitPolicySplit
accept the new shape directly; legacy UpsertProjectPolicy /
UpsertUnitPolicy stay as thin shims that map required_role through
splitFromLegacy(). ApplyMatrixToDescendants writes both columns.

Handler 409 mapping (§B): writeServiceError now consults a shared
mapApprovalError() helper before falling through to the generic 500.
ErrConcurrentPending → HTTP 409 with body
{code: "awaiting_approval", message, request_id?, required_role?}.
PendingApprovalError wraps ErrConcurrentPending with the in-flight
request id + role so the UI knows which request to point a withdraw
button at. ErrNoQualifiedApprover, ErrSelfApproval, ErrNotApprover,
ErrRequestNotPending all mapped consistently. writeApprovalError
now defers to the same helper for shape consistency.

Models: ApprovalPolicy + EffectivePolicy gain RequiresApproval/MinRole
fields. RequiredRole stays as a dual-read mirror until M2.

Tests: TestMapApprovalError_* covers the four 409/403 branches and the
"no match — fall through" case. Existing approval service tests pass
unchanged.

Defers per task spec to follow-up slices:
  - A3 (admin UI 2-control flip)
  - C+E (badge + withdraw button on detail pages)
  - D   (/inbox Meine Anfragen visibility fix)
  - M2  (drop required_role column)
2026-05-08 16:54:45 +02:00
m
6ef14ddc39 feat(fristenrechner/inbox-chip): wire inbox into B1 cascade narrowing
Completes the #15 vision: the inbox chip now narrows the B1 decision
tree alongside Pathway A's picker and B2's fine-bucket forum filter.
Picking CMS hides DE / EPA / DPMA cascade entries; picking beA /
Posteingang hides UPC / EPA / DPMA entries. Neutral nodes (top-level
branches, Mündliche Verhandlung sub-states, court-generic events like
Ladung / Kostenfestsetzung) stay visible from every inbox setting so
the user can always reach the cross-jurisdictional middle of the tree.

Migration 065 adds paliad.event_categories.forums (text[]) with a
CHECK on {upc, de, epa, dpma}, a partial GIN index, and a two-step
backfill:

  1. Regex on slug for nodes that carry the forum token explicitly.
     Token-bounded by ^/./- so .dpma doesn't trip the de pattern.
  2. Explicit slug list for stragglers (BGH / BPatG / Versäumnisurteil /
     Hinweisbeschluss are DE-only; r116-eingaben is EPA-only).

NULL stays neutral. Migration applied to live Supabase; tracker at v65.

Backend: EventCategoryNode JSON gains an optional `forums` array;
EventCategoryService.Tree SELECT includes the column and threads it
through to the response.

Frontend: new module-level currentInboxChannel mirrors the chip state
so renderB1Cascade can ask "which forum is active?" without re-deriving
from the URL on every step. inboxFilterAllowsForums(forums) gates each
child node — neutral arrays (undefined / empty) always pass; tagged
arrays must include the active forum. applyInboxFilter re-renders the
cascade so chip clicks reflow B1 in place. Pathway A picker filter
and B2 fine-bucket sync remain orthogonal — same chip, three filters.

Refs m/paliad#15 (B1 follow-up).
2026-05-08 16:54:34 +02:00
m
9339148ef5 Merge: t-paliad-157 / m/paliad#15 — Fristenrechner inbox-channel pre-filter (feynman, 3 slices: 06bd276 schema migration 064 paliad.users.forum_pref + UpdateProfileInput backend wiring; 1df1bc7 frontend chip strip on /tools/fristenrechner + Pathway A picker filter with URL ?inbox= override + PATCH on click; e879298 wire inbox into B2 fine-bucket forum chips — cms→upc_cfi+upc_coa, bea/posteingang→de_lg+de_olg+de_bgh+de_bpatg, URL ?forum= still wins so share links honor recipient-local discipline; B1 cascade entry-point narrowing deferred — event_categories has no forum metadata column today) 2026-05-08 16:43:47 +02:00
m
e87929885d feat(fristenrechner/inbox-chip): wire inbox into B2 fine-forum chips
The page-top inbox chip now drives the existing 10-bucket B2 forum
filter so search results inherit the same narrowing as the Pathway A
picker. CMS auto-selects upc_cfi + upc_coa; beA / Posteingang
auto-select de_lg + de_olg + de_bgh + de_bpatg; "Alle" clears both the
inbox and the fine chips.

State-priority discipline:

  - User chip click               always replaces activeForums with
                                  the inbox-derived set.
  - Hydrate (URL ?inbox=, no
    ?forum=)                      derives activeForums from inbox.
                                  initForumFilter (which runs first)
                                  has already loaded URL ?forum= when
                                  present, so the explicit forum= wins.
  - Hydrate (no URL ?inbox=,
    /api/me forum_pref set)       same as above: derive when ?forum=
                                  is empty.
  - popstate                      re-applies the same rule so browser
                                  back/forward stays consistent.

The "explicit URL forum= wins" rule means a colleague's CMS-narrowed
share link still works when the recipient has a different saved pref —
they see the shared narrowing, not their own.

Refs m/paliad#15.
2026-05-08 16:41:50 +02:00
m
1df1bc7e40 feat(fristenrechner/inbox-chip): persisted forum pre-filter on /tools/fristenrechner
Adds a slim chip strip above the pathway fork on /tools/fristenrechner
so the user can pick the inbox channel they typically work in (CMS for
UPC, beA / Posteingang for national-DE). Three behaviours stack:

  - URL ?inbox=cms|bea|posteingang  per-visit override; lets a colleague
                                    share a CMS-narrowed link without
                                    flipping the recipient's saved pref.
  - /api/me forum_pref              the user's persisted default,
                                    fetched on hydrate when no URL.
  - unset                            picker shows all four groups.

Click behaviour: write URL → apply filter (hide non-matching
.proceeding-group via the new data-forum attributes) → PATCH /api/me.
The "Alle" chip clears both URL and the saved pref. EPA / DPMA fall
out under cms / bea / posteingang; users still reach those via B2
search or by clearing the chip.

Frontend pieces:

  - frontend/src/fristenrechner.tsx — new .fristen-inbox-bar markup
    above the pathway fork; data-forum attributes on each
    .proceeding-group so the filter knows which to hide.
  - frontend/src/client/fristenrechner.ts — initInboxFilter() hydrates
    from URL → /api/me, wires chip clicks (write URL, apply filter,
    PATCH /api/me opportunistically), restores on popstate.
  - frontend/src/client/i18n.ts — 6 new keys (deadlines.inbox.*) DE+EN.
  - frontend/src/i18n-keys.ts — codegen picked up the new keys.
  - frontend/src/styles/global.css — .fristen-inbox-bar /
    .fristen-inbox-chip / --active / --clear styles, all bound to the
    existing --color-* / --color-accent token palette.

The chip writes "" to forum_pref to clear (matching the
EscalationContactID convention from the previous slice). The B2 forum
filter (the 10-bucket finer-grained chip set further down the page)
stays untouched and orthogonal — this slice is the page-top coarse
pre-filter only.

Refs m/paliad#15.
2026-05-08 16:31:31 +02:00
m
b23a08867b Merge: t-paliad-158 — deadline data model proceedings-as-DAG analysis (einstein, 947 lines, m co-drove the design via AskUserQuestion sequence; maps current paliad.deadlines + deadline_rules + event_categories shape against m's framing 'natural sequence of proceedings which belong to a court system' with conditional triggers; proposes target schema for proceedings + court_systems + event-types-as-DAG-nodes with conditional edges; migration sketch for additive overlay vs destructive cutover; honest tradeoff analysis) 2026-05-08 16:27:40 +02:00
m
7be8511833 docs(t-paliad-158): deadline data model — proceedings-as-DAG analysis
Consultant analysis of paliad's deadline data model per m's framing
(court system → proceeding → ordered event types → conditional
trigger edges). Maps current 5-table fragmentation, identifies gaps
G1–G7, locks 5 structural decisions via AskUserQuestion, proposes
target shape with mermaid example, sketches 4-phase additive→cutover
migration. Pure design — no code or schema changes in this branch.

Locked decisions (verbatim):
- Q1: Reuse courts.court_type as court-system identity
- Q2: Project IS proceeding instance (sub-projects when needed)
- Q3: Separate proceeding_event_edges table (multi-parent natural)
- Q4: Typed if_flags/unless_flags/requires_event_id columns
- Q5: Subsume deadline_concepts into event_types.concept_slug
2026-05-08 16:27:04 +02:00
m
06bd276a9c feat(users/forum-pref): persisted Fristenrechner inbox-channel column
Adds paliad.users.forum_pref so /tools/fristenrechner can pre-narrow
the proceeding picker to the user's typical inbox channel without
re-asking on every visit. The new column threads through the User
model, the userColumns SELECT, and UpdateProfileInput so the existing
PATCH /api/me handler accepts it without a new endpoint.

Allowed values mirror the channel chips m named in t-paliad-157:

  - cms          → UPC
  - bea          → national-DE
  - posteingang  → national-DE (slower channel, same forums)

NULL means "no preference, picker shows everything"; URL ?inbox=
overrides per-visit (frontend lands in the next commit). The CHECK
constraint enforces the 3-value enum at the DB layer; isValidForumPref
mirrors it in the service so callers see a typed error instead of a
raw pq violation. Empty string in the PATCH body clears the
preference, consistent with the EscalationContactID convention.

Migration 064 applied to the live Supabase pool; tracker bumped to
v64 so the boot-time runner skips re-applying.

Refs m/paliad#15.
2026-05-08 16:23:12 +02:00
m
f84bce1359 Merge: t-paliad-159 — UPC RoP deadline audit (curie, 417 lines, 8 RoP section families covering INF/REV/PI/DAM/DEC/APP/MISC/ORAL; cross-referenced against paliad.deadline_rules; surfaces 2 duration bugs in R.49.1 + R.52, gap list ordered by frequency-of-use, scope explicitly excludes EPO/DPMA/BGH families for follow-up audits; companion to docs/audit-fristenrechner-completeness-2026-04-30.md) 2026-05-08 16:22:14 +02:00
m
afe4fc2efe docs(t-paliad-159): UPC RoP deadline audit
8 RoP sections cross-referenced against paliad's deadline_rules library
via the youpc data.laws_contents authoritative text.

Two high-impact duration bugs found:
- rev.defence: 3 months seeded, RoP R.49.1 says 2 months
- rev.rejoin: 2 months seeded, RoP R.52 says 1 month

Both UPC_REV pleadings rules — every active Nichtigkeitsverfahren
tracked in paliad has miscalibrated reminders today. Single-row
UPDATEs fix both.

Plus rule_code drift on UPC_APP (R.220.1 used where R.224.1.a /
R.224.2.a / R.235.2 should be cited), R.51 / R.52 NULLs on REV
chain, and 25 missing rules ordered by frequency (R.19, R.262.2,
R.224.2.b, R.235.1, R.333.2, R.353, registry-correction family,
saisie + PI gaps, R.109 oral-hearing prep, R.245 rehearing, etc).

Plus an anchoring nuance on UPC_APP_ORDERS.app_ord.discretion
(R.220.3) — Pathway A may compute up to 15d too early because
the rule anchors on order, not on leave-refusal event.

Wave 0 (duration bugs) is the recommended first migration.
Wave 1+ orderings, tooling-blocked rules (R.198/R.213/R.245.2),
and m's open questions (proceeding-code naming, R.245 scope,
DNI scope) listed in §6, §7.
2026-05-08 16:21:25 +02:00
m
7614748243 Merge: t-paliad-157 — Fristenrechner column-view sequence_order + UPC R.320 path (609da9e Columns view preserves sequence_order for undated events so Urteil renders above Berufungseinlegung instead of stacking; 7c75161 migration 063 — Frist verpasst → UPC R.320 cascade path with trigger event 207, leaf frist-verpasst.upc, junction to existing Wiedereinsetzung concept; live-applied during dev, v63 in tracker) 2026-05-08 16:04:12 +02:00
m
7c751617e5 fix(fristenrechner/cascade): add UPC R.320 path under "Frist verpasst"
The "Frist verpasst" cascade covered DE PatG/ZPO, EPA Art.122 and DPMA
Wiedereinsetzung paths but had no UPC option, even though UPC R.320 RoP
grants re-establishment of rights with the same shape (2 months from
removal of the obstacle, 12-month outer limit).

Migration 063 adds:

  - trigger_event id 207 "Wegfall des Hindernisses (UPC R.320)" tied to
    the existing wiedereinsetzung concept, so the concept card picks
    up a UPC pill alongside DE / EPA / DPMA.
  - event_categories leaf frist-verpasst.upc at sort_order 50 so UPC
    reads first under "Frist verpasst" (national + EPA siblings stay
    at 100/200/300/400).
  - event_category_concepts junction linking the new leaf to the
    wiedereinsetzung concept; NULL proceeding_type_code mirrors the
    sibling pattern (cross-cutting trigger pills bypass the forum
    filter by design — per-leaf narrowing is part of the IA-reframe
    issue #16).

Migration applied to live Supabase; matview refreshed; tracker bumped
to v63 so the boot-time runner skips re-applying.

Refs m/paliad#14 section C.
2026-05-08 16:00:21 +02:00
m
609da9e86b fix(fristenrechner/columns): preserve sequence_order for undated events
Undated events (Urteil, Beschluss, court-set placeholders) were keyed by
the empty string and collapsed into a single trailing row, so Urteil and
Berufungseinlegung ended up adjacent even though Urteil precedes Berufung
in the proceeding's sequence_order. Each undated event now gets its own
row keyed by its index in the backend response (which is already sorted
by sequence_order), and dated/unscheduled keys are sorted into separate
buckets before concatenation so the dateless tail still sits below the
dated rows.

Refs #14 (section D).
2026-05-08 15:54:35 +02:00
m
7daa70aaad feat(paliadin/projects-cards): markdown rendering + grid compactness + overdue-pending in NextEvents
Three issues from m's dogfood (2026-05-08 15:02–15:14):

## A. /projects-cards on desktop overflowed the right column

.projects-cards-grid.is-grid-2 used grid-template-columns: repeat(2, 1fr)
which is shorthand for repeat(2, minmax(auto, 1fr)). 'auto' resolves to
max-content so any card with content wider than the track expands the
track and pushes the right column past the parent's right edge.

Switched is-grid-2/3/4 to repeat(N, minmax(0, 1fr)) which clamps the
floor to zero — overflow now wraps/clips inside the card instead of
blowing out the layout. Bonus: the auto-fill default also got the
min(320px, 100%) treatment so narrow viewports collapse the floor and
spare us horizontal scroll on mobile (mirrors t-paliad-155's earlier
views-cards fix).

## B. "Nächste Termine" empty while "5 offen" showed

CardsPreview's deadline source filtered WHERE f.status = 'pending'
AND f.due_date >= today::date. m's 5 pending deadlines are all in the
past — overdue — so they were excluded from NextEvents while still
counted in the "X offen" badge.

Dropped the >= today predicate. Now any pending deadline lands in
NextEvents, sorted ASC by due_date, so most-overdue surfaces first
(which matches m's mental model: an overdue Frist is more urgent than
tomorrow's, not less). Appointments keep the >= now filter (past
appointments are history, not next). Cleaned up the args[] threading
since deadlines no longer needs the temporal bound.

## C. Chat bubbles ignored Markdown formatting (## h2, **bold**, lists)

renderResponseHTML only handled chip markers + the new (today)
markdown-link / bare-URL passes; everything else fell through as raw
text. "## Projekte" rendered with the literal hashes visible.

Added renderBlocks() — a small block-level parser that turns:

-  → <h2>H</h2>
-  → <h3>H</h3>
-  lines → <ul class=paliadin-list><li>...</li></ul>
-  → <hr>
- blank-line-separated runs → <p>...<br>...</p>

and inline emphasis passes that wrap **bold** in <strong> and *italic*
in <em>. Block-level runs before the link passes so the regexes only
operate inside a block; emphasis runs after links so a bold link works.
Pipeline is still: escape → chip-stage → blocks → md-links → bare-urls
→ emphasis → unstage chips.

## D. (carrying over from earlier in this commit) /admin/paliadin monitor — show user + response preview + page origin + per-tool row counts
2026-05-08 15:09:24 +02:00
m
05d14d5e5a feat(admin/paliadin): show user + response preview + page origin + per-tool row counts on the monitor
m's ask (2026-05-08 15:02): the Paliadin monitor should show which user
made each turn, and ideally log more than just timing/classifier.

Backend:
- PaliadinTurn gains UserEmail + UserDisplayName fields (json:omitempty
  so user-facing API paths don't leak unrelated identity info; only
  populated by the admin LIST query).
- ListRecentTurns LEFT JOINs paliad.users to surface email +
  display_name on each row. The existing global_admin OR caller-owns
  visibility predicate on the WHERE clause stays unchanged.

Frontend (admin-paliadin):
- Recent-turns table grows from 5 → 8 columns:
  Zeit · Nutzer · Art · Anfrage · Antwort · Tools · Seite · Dauer
- Nutzer cell shows display_name (fallback email, fallback first 8 of
  user_id), with the full email in the title attribute on hover.
- Antwort cell renders the first 80 chars of the response with the full
  cleanBody available on hover. Useful for spot-checking what Paliadin
  actually wrote without clicking through every turn.
- Tools cell now pairs each tool name with its rows_seen count
  ("list_my_projects (11), search_my_deadlines (18)") so the data
  density is legible at a glance.
- Seite cell exposes page_origin (where in Paliad m kicked off the
  turn) — was already audited but never surfaced.
- DE/EN i18n keys added for the four new column headers.
2026-05-08 15:05:24 +02:00
m
925a377c8b feat(paliadin): markdown links + auto-link bare URLs in chat responses
m's ask (2026-05-08 14:18): chat should render arbitrary links, not
just internal navigation chips.

Extends renderResponseHTML with two link passes after the existing chip
substitution:

1. Markdown link syntax — [label](url) becomes <a class=paliadin-link>.
   Internal /paths stay same-tab; external http(s) URLs open in a new tab
   with rel=noopener,noreferrer.

2. Auto-linkify bare URLs — any free-standing https?:// becomes a link.
   The leading-character class on the regex avoids re-matching URLs that
   are already inside an href attribute (like the chip URLs from stage 1
   or the markdown-link URLs from stage 2).

Pipeline order: HTML-escape → chip markers replaced with SOH-bounded
sentinels → markdown links → bare URLs → sentinels swapped back. Done in
that order so chip URLs never go through the link passes (which would
double-anchor them) and the SOH boundary characters can't collide with
user text.

fix(views/cards): collapse min-width floor on mobile to prevent overflow

m's report (2026-05-08): on mobile-portrait the views-cards layout
forced horizontal scrolling because grid-template-columns had a 280px
floor on every column. Replaced minmax(280px, 1fr) with
minmax(min(280px, 100%), 1fr) so on viewports narrower than 280px the
floor collapses to the available width — cards span 100% of the stream
on mobile, return to the 280px-min auto-fill once there's room.
2026-05-08 14:22:18 +02:00
m
7935fee7bf fix(paliadin): preserve chip/link markers when saving chat history
When a Paliadin response contains chip markers like [#deadline-OPEN:c47bd2-1]
they get rendered to anchor tags by renderResponseHTML in finishBubble. The
'end' handler then saved the bubble to localStorage history via getBubbleText,
which returns textContent — i.e. the anchor text *only*, with the original
[#deadline-OPEN:...] markers gone.

On a page reload, history.forEach replays each entry: appendBubble puts h.text
back into textContent, then finishBubble runs again and tries to re-render via
renderResponseHTML — but the markers are already gone, so the links don't come
back (m, 2026-05-08 14:11 — links disappeared on second load).

Fix: save placeholder.dataset.fullText (the raw Markdown body cached at
content-event time) instead of the post-render textContent. On reload the raw
markers survive, finishBubble re-runs renderResponseHTML, and the chips/links
reappear identically to the first render.

Refs t-paliad-155, m/paliad#12.
2026-05-08 14:12:42 +02:00
m
be2150c17d fix(paliadin): used_tools NOT NULL violation + frontend response truncation
Two bugs surfaced in m's dogfood of t-paliad-155 (2026-05-08 13:55).

## A. used_tools NOT NULL constraint violation on casual turns

paliad.paliadin_turns.used_tools is text[] NOT NULL DEFAULT '{}'. parseTrailer
leaves trailerMeta.UsedTools as nil when Claude omits the trailer ("Heyhey!")
or sends an empty list. completeTurn passed pq.StringArray(nil) which the pq
driver writes as NULL — UPDATE failed with constraint 23502 on every casual
chat turn, leaving the row half-finalized.

Fix: coerce UsedTools to a non-nil empty pq.StringArray before the UPDATE,
mirroring the existing rowsSeen pattern in the same function.

## B. Frontend rendered "## Proje" instead of the full 1408-byte response

m saw the first 8 characters of his Markdown response in the chat bubble,
plus the full meta row underneath. The DB row had the complete cleanBody
in 'response'. Truncation lived entirely in the browser.

Root cause: finishBubble read textNode.textContent at the moment of the
'end' event — but typewriter() animates the text 8 chars at a time, so
textContent was "## Proje" (one tick into 1408 bytes) when finishBubble
fired. renderResponseHTML(raw) baked in the partial state, then the
typewriter's next tick saw streaming='false' and ran 'node.textContent =
text' which overwrote the rendered HTML with the raw string — except in
this case the second tick never ran in time, leaving the partial render.

Fix:
1. Cache the full SSE-delivered text on placeholder.dataset.fullText at
   content-event time. finishBubble prefers that over textContent.
2. Typewriter's abort branch no longer overwrites the node — finishBubble
   already owns the final rendered HTML, so a delayed tick should just
   return rather than blow away the rendered Markdown.

Both fixes verified locally: go build clean, bun build clean.

Refs t-paliad-155, m/paliad#12.
2026-05-08 14:00:03 +02:00
m
5893c45e5e Merge: t-paliad-155 — Paliadin skill-as-skill + per-user tmux session + project-MCP cwd + 120s timeout (skill at ~/.claude/skills/paliadin/SKILL.md replaces paliadin_prompt.go's keystroke-bootstrap; per-user session keying paliad-paliadin-<user_id_short>; shim spawns claude in /home/m/dev/paliad so project MCPs incl. supabase load; PALIADIN_TIMEOUT_S default 60→120s for cold-start safety; SKILL.md bans psql/curl fallbacks; install-paliadin-skill script for repo-as-source-of-truth; paliadin_prompt.go deprecated) 2026-05-08 13:43:23 +02:00
m
3e1f4eee4b fix(t-paliad-155): cold-start timeout headroom + ban DB fallbacks in skill
Shim's run-turn hard timeout: 60s → 120s (PALIADIN_TIMEOUT_S default).
First turn after a fresh tmux session stacks claude boot + skill load
+ MCP discovery + first reasoning, which can blow past 60s before the
response file lands.

Aligned the surrounding timeouts so 120s is actually reachable:
- callShim ctx (paliadin_remote.go): 70s → 130s (shim 120 + 10 SSH).
- runPaliadinTurnAsync handler ctx: 120s → 150s (shim 120 + 10 SSH +
  20 paliad-side overhead).

SKILL.md hard rule #6 added: never fall back to psql / curl PostgREST /
nix-shell — mcp__supabase__execute_sql is the only DB tool. If it's
unavailable, write a short 'DB nicht erreichbar — bitte paliad neu
deployen oder PALIADIN_REMOTE_CWD prüfen' response immediately with
classifier_tag=meta. Saves the 60s-fallback-dance failure mode m hit
on the cwd-misconfig turn.
2026-05-08 13:19:27 +02:00
m
e75a71fb34 fix(t-paliad-155): spawn claude pane in paliad repo root for project MCPs
claude in the shim's tmux pane was being launched from $HOME, so it
loaded only global MCPs (mai, mai-memory, mgeo) and missed the
project-scoped Supabase MCP at /home/m/dev/paliad/.mcp.json. SKILL.md's
SQL recipes therefore had no DB tool — m saw 'no DB access' on every
real Paliadin turn.

Fix: tmux new-window -c $CLAUDE_CWD when spawning the pane. New env
var PALIADIN_REMOTE_CWD (default /home/m/dev/paliad) lets a host
override the path if the repo lives elsewhere; shim fast-fails with
exit 3 if the directory doesn't exist.

CLAUDE.md updated. Verified by spawning a fresh session via the shim
and inspecting #{pane_current_path}.
2026-05-08 13:03:50 +02:00
m
9579032f94 feat(t-paliad-155): re-author paliadin skill via /write-a-skill conventions
Splits the 250-line hand-rolled SKILL.md into a 96-line SKILL.md
(under the 100-line soft cap from agentskills-extras) plus
references/sql-recipes.md (134 lines). Description rewritten in
imperative voice with explicit pushy triggers — including the short-
message case ('Hey', 'wer bin ich?') so Claude doesn't second-guess
when the prefix [PALIADIN:<uuid>] is present but the body looks like
normal chat.

SKILL.md keeps: persona, response-file format, classifier table,
action chips, hard rules, full example, first-turn rule. Out: 8 SQL
recipes, moved to references/sql-recipes.md with a concrete pointer
trigger ('Read before any project / deadline / appointment / court /
glossary / deadline-rule / UPC-judgment lookup').

install-paliadin-skill now mirrors the entire skill tree (SKILL.md +
references/) and clears stale aux files on each run. Manual one-shot
— m's call to skip a post-merge auto-refresh hook for now.
2026-05-08 12:48:00 +02:00
m
97a412498d feat(t-paliad-155): real Claude SKILL.md + per-user tmux session
Move Paliadin's persona + response protocol from a tmux-keystroke-injected
system prompt into a real Claude skill at ~/.claude/skills/paliadin/SKILL.md
(repo source: scripts/skills/paliadin/SKILL.md, install script:
scripts/install-paliadin-skill). Claude's skill router auto-matches the
[PALIADIN:<uuid>] envelope on every turn, so the protocol contract
survives /clear, fresh sessions, and pane restarts — root-cause fix for
the post-/clear stuck-spinner that triggered this task.

Per-user tmux session keying: each Paliad user gets a session named
<prefix>-<userid8> (first 8 hex chars of UUID). One persistent session
per user, conversation history accumulates per visit, ResetSession kills
the session entirely. Health-check cache becomes per-session.

Service-side simplifications:
- paliadin_prompt.go (paliadinSystemPrompt) deleted; trailer parser stays
  in paliadin.go.
- paliadin_remote.go: ensureBootstrapped removed; healthGate takes a
  session arg + caches per-key; ResetSession derives session from UserID
  and shells out to 'reset <session>'.
- paliadin.go (LocalPaliadinService): per-user pane cache, ensurePane
  takes UserID, no more in-process system-prompt send.
- Paliadin interface: ResetSession now takes UserID.

Shim refactor (scripts/paliadin-shim):
- All verbs accept the tmux session as their first positional arg.
- 'bootstrap' verb removed (skill replaces it).
- 'reset' kills the named session via tmux kill-session.
- Session name validated against [A-Za-z0-9_.-]{1,64}.

Env var rename: PALIADIN_TMUX_SESSION -> PALIADIN_SESSION_PREFIX (semantic
shift from literal session name to per-user prefix); CLAUDE.md updated.

Tests cover per-session health caching, session-name derivation,
ResetSession kill-session shape, and health-cache eviction on reset.
2026-05-08 12:42:57 +02:00
m
319221ff83 Merge: t-paliad-151 fix — base64-decode PALIADIN_SSH_PRIVATE_KEY env var
Dokploy's .env mechanism truncates multi-line env vars to first line.
Empirically: the multi-line PEM arrived as just `-----BEGIN OPENSSH
PRIVATE KEY-----\n` (36 bytes) inside the container, ssh -i failed
with `Load key: error in libcrypto`.

Go now decodes the env value as either raw PEM (multi-line) or
base64-encoded PEM. Whitespace inside base64 stripped before decode.
Dokploy secret already updated to the base64 form alongside this
merge.

Refs m/paliad#12
2026-05-08 11:28:53 +02:00
m
4c47819da8 fix(t-paliad-151): base64-decode PALIADIN_SSH_PRIVATE_KEY env var
Dokploy stores compose env vars in a single-line `.env` file, which
silently truncates multi-line values to their first line. Empirically
verified inside the running paliad container: a multi-line PEM
arrived as just `-----BEGIN OPENSSH PRIVATE KEY-----\n` (36 bytes)
and `ssh -i …` failed with `Load key: error in libcrypto`.

decodePaliadinPrivateKey now accepts either:
  - raw PEM (multi-line, starts with `-----` and contains a newline) —
    used as-is for local-dev convenience
  - base64-encoded PEM — decoded into raw PEM. Survives the .env
    one-line-per-key round-trip.

Whitespace (spaces / line breaks) inside the base64 blob is stripped
before decoding so an OpenSSH-keygen-helper-style 64-char-wrap is
also accepted.

After deploy, m needs to update the Dokploy PALIADIN_SSH_PRIVATE_KEY
secret to the base64-encoded form:
  base64 -w0 < ~/.paliad-staging/paliad-prod-key
…and redeploy. Then sshd's libcrypto loads the key correctly and the
shim's command= path runs.

Refs m/paliad#12
2026-05-08 11:28:02 +02:00
m
db3514c4db Merge: t-paliad-151 Phase A.5 — env-var passthrough for Paliadin remote-routing
Drops the original network_mode: host approach (incompatible with
Dokploy's compose-network injection) in favour of a far simpler
discovery: docker bridge + mLake's host-side tailscale0 + Docker NAT
already routes container outbound to mRiver:22022. Source IP NAT'd to
mLake's tailnet IP, matches the from=100.99.98.201 clause on mRiver's
authorized_keys.

Compose change is therefore JUST the 5 PALIADIN_* env entries pulled
through from already-registered Dokploy secrets. No traefik conflict.

Phase A.5 verified empirically before this merge (2026-05-08 11:23):
plain alpine container on Dokploy's default bridge SSHs to mriver:22022
via the paliadin-shim and gets "ok" in ~3s.

Refs m/paliad#12
2026-05-08 11:25:13 +02:00
m
a0d1e77ef2 feat(t-paliad-151) Phase A.5: compose env-var passthrough for Paliadin remote routing
Adds the 5 PALIADIN_* env entries to docker-compose.yml so paliad's
container picks them up from Dokploy secrets. With PALIADIN_REMOTE_HOST
set, paliad's main.go switches to RemotePaliadinService (already in
main from B5/0c8a2f1) and shells out to ssh m@mriver paliadin-shim.

**Phase A.5 finding (overrides design §4.2/§4.5 + decision 1):**

The original design assumed `network_mode: host` was needed so paliad
inherited mLake's tailscale0. The first attempt at that (a80652a,
reverted in 82faa3d) failed Dokploy's compose validation:

  service web declares mutually exclusive `network_mode` and `networks`:
  invalid compose project

Dokploy auto-injects `networks: [dokploy-network, default]` on the
primary service for traefik routing — irreconcilable with `network_mode:
host`. So design decision 1 (host mode) is fundamentally incompatible
with this Dokploy app's compose lifecycle.

But: empirically, paliad does NOT need host mode at all. Verified
(2026-05-08 11:23) by running a plain alpine container on Dokploy's
default bridge:

  $ docker run --rm -v /tmp/paliad-prod-key:/tmp/k:ro \
                  -v /tmp/paliad-known_hosts:/tmp/kh:ro alpine:3.21 \
      sh -c 'apk add openssh-client && \
             ssh -p 22022 -i /tmp/k -o UserKnownHostsFile=/tmp/kh \
                 -o IdentitiesOnly=yes m@100.99.98.203 health'
  → ok

Why this works: Docker's outbound NAT masquerades the container's
bridge IP onto mLake's host IPs, including tailscale0
(100.99.98.201). Linux routing on mLake sends 100.99.98.0/24 to
tailscale0. mRiver's sshd sees the connection coming from
100.99.98.201, which matches the from="100.99.98.201" clause on the
paliad-prod authorized_keys entry. No tailscale-in-container, no
sidecar, no host networking — the kernel does it for free.

Resulting compose change is therefore minimal: 5 env entries pulled
through from Dokploy secrets. expose: ["8080"] preserved (no host-mode
side-effects). traefik routing untouched (no network_mode collision).

The amended commit message clarifies what changed; the design doc
needs an A.5 amendment in a follow-up — design §4 (host-mode shape)
is empirically wrong and §7 Phase A.5 needs an "M3: kernel does the
masquerade for you" entry.

Refs m/paliad#12
2026-05-08 11:25:02 +02:00
m
d519363c8d fix(admin/approval-policies): preserve <details> open state across re-renders
Changing any required_role cell saves the policy and re-renders the units
list to refresh the attribution chips, but the re-render rebuilt every
<details> closed — collapsing the accordion the admin was actively
editing (m, 2026-05-08 11:19).

Capture the set of open data-unit-ids before innerHTML overwrites them,
then re-apply the open attribute on the rendered nodes for those ids.
Adds data-unit-id to the <details> as the stable identity. No behavior
change for first render or for units the admin hadn't expanded.
2026-05-08 11:20:39 +02:00
m
82faa3d8bd Revert "Merge: t-paliad-151 Phase A.5 — compose network_mode: host + Paliadin env-var plumbing. Lifts the DO-NOT-MERGE-before-A.5 gate from da971a7. Dokploy secrets PALIADIN_SSH_PRIVATE_KEY + PALIADIN_KNOWN_HOSTS already registered on mlake (validated SSH key roundtrip via ssh-keygen -y); single-line vars PALIADIN_REMOTE_HOST=100.99.98.203 / PORT=22022 / USER=m also staged. Next deploy is the M1-vs-M2 traefik gate (design §4.2): if paliad.de returns 200/3xx after redeploy, traefik routes under host mode (M2) and the route ships; if 502, revert this merge and revisit decision 1."
This reverts commit a80652a085, reversing
changes made to f820aa8316.
2026-05-08 02:39:36 +02:00
m
a80652a085 Merge: t-paliad-151 Phase A.5 — compose network_mode: host + Paliadin env-var plumbing. Lifts the DO-NOT-MERGE-before-A.5 gate from da971a7. Dokploy secrets PALIADIN_SSH_PRIVATE_KEY + PALIADIN_KNOWN_HOSTS already registered on mlake (validated SSH key roundtrip via ssh-keygen -y); single-line vars PALIADIN_REMOTE_HOST=100.99.98.203 / PORT=22022 / USER=m also staged. Next deploy is the M1-vs-M2 traefik gate (design §4.2): if paliad.de returns 200/3xx after redeploy, traefik routes under host mode (M2) and the route ships; if 502, revert this merge and revisit decision 1. 2026-05-08 02:37:32 +02:00
m
f820aa8316 Merge: t-paliad-154 — approval-policy authoring UI (migration 062 paliad.approval_policies unit-defaults + 'none' sentinel + tree-walking resolver + 88 unit-default seed rows + paliad.policy_audit_log; ApprovalService rewire with resolver delegation + scope-split CRUD + audit emission; HTTP handlers admin APIs + form-hint endpoint + audit-log union; /admin/approval-policies admin page + admin-index card + form-time hints on deadline/appointment new pages + inbox empty-state nudge for admins; 13 m-locked design decisions honoured verbatim per docs/design-approval-policy-ui-2026-05-07.md §2) 2026-05-08 02:33:25 +02:00
m
5df4285e1d feat(t-paliad-154) commit 5/5: inbox empty-state nudge + form-time hints
Three remaining surfaces from the locked design (Q9 + Q13):

/inbox empty-state admin nudge (Q9):
- New conditional block (.inbox-admin-nudge) revealed only when:
  * /api/me reports global_role='global_admin'
  * the inbox tab returned zero rows
  * /api/admin/approval-policies/seeded reports any=false (no policies firm-wide)
- Card links to /admin/approval-policies. Hidden in every other case so the
  ordinary post-rollout state (admins with active policies) sees nothing.

Form-time 4-eye hint on /projects/{id}/deadlines/new + /appointments/new (Q13):
- New .approval-hint container above the Speichern button on each form;
  hidden by default.
- Client TS fires GET /api/projects/{id}/approval-policies/effective on
  page load + on project change, reveals the hint when required_role is
  non-null and not 'none'. Renders role label + source attribution
  ('· Standard: Munich Lit') so the user knows where the rule comes from.
- Hides in every 'no policy applies' case (no candidates / 'none' suppression
  / project change to a project with no policy / fetch error).

i18n: 6 new keys × 2 langs (3 inbox-nudge keys + 2 form-hint keys + the
inbox-nudge title/body/cta wired in inbox.tsx). Total i18n keys: 1929.

Dynamic-key call sites use tDyn (admin-approval-policies.ts +
deadlines-new.ts + appointments-new.ts) so the typed t() barrier stays
intact for static keys.

Build: bun run build clean, go build + vet + test clean (no DB tests
require TEST_DATABASE_URL — those run in CI).
2026-05-08 02:31:35 +02:00
m
028423b32f feat(t-paliad-154) commit 4/5: admin /admin/approval-policies page
New TSX page shell + client orchestration + admin-index card + CSS for
the matrix + i18n keys (DE+EN).

Page structure:
- Section 1 'Partner-Unit-Standards': accordion list, each <details>
  block expandable into the 8-cell matrix for that partner unit.
- Section 2 'Projekt-spezifisch': search-driven project picker → matrix
  showing the EFFECTIVE policy per cell with attribution chips
  (Projekt / Geerbt / Standard) per source.
- Bulk-apply modal: 'Auf Unterprojekte anwenden' button per project; lists
  affected descendants; POST to /api/admin/approval-policies/apply-to-descendants.

Cell semantics:
- Select per cell with options: '— keine Regel —' (= DELETE), partner /
  of_counsel / associate / senior_pa / pa / 'Keine Genehmigung' (= 'none'
  sentinel, project-row only).
- Change → PUT for any value, DELETE for empty. Re-fetch the affected
  scope so attribution chips reflect the new state.

CSS: matrix grid on desktop (≥700px); two stacked sections (Fristen /
Termine) below 700px via media query — both rendered in DOM, CSS toggles.
All tokens are existing --color-* / --status-* / --hlc-*-rgb (no bare
--surface / --text-muted / --bg-subtle).

i18n: 42 new keys × 2 languages = 84 entries. Total i18n keys: 1924.

Build: bun run build clean (i18n codegen updated, IIFE wrapping enforced).
2026-05-08 02:27:54 +02:00
m
1d7c7d7246 Merge: t-paliad-151 Phase B code (env-var-gated, compose flip held for A.5) — Paliadin remote-routing via Tailscale SSH to mRiver. Includes Phase A.0 design doc + scripts/paliadin-shim from earlier shift. Production behavior unchanged: without PALIADIN_REMOTE_HOST in env, paliad never invokes ssh and uses local-tmux PoC path byte-identically. Refactor: Paliadin interface + LocalPaliadinService + RemotePaliadinService + DisabledPaliadinService stub. main.go env-var switch (remote/local/disabled). Dockerfile +openssh-client. 14 unit tests via callShimHook. Frontend friendlyErrorMessage for mriver_unreachable/shim_auth_failed/shim_error/bootstrap_failed/timeout (DE+EN). NOT included: docker-compose network_mode: host flip — held on branch as da971a7 pending Phase A.5 traefik test by m. NOT cronus. 2026-05-08 02:23:38 +02:00
m
0f87d73b1b feat(t-paliad-154) commit 3/5: HTTP handlers — admin APIs + form-hint endpoint + audit-log union
8 new endpoints under /api/admin/* (admin-gated) and /api/projects (gated
on per-user authentication for the form-time hint):

Admin APIs (gated by adminGate):
- GET    /admin/approval-policies                                                  — page shell
- GET    /api/admin/partner-units/{unit_id}/approval-policies                      — list unit defaults
- PUT    /api/admin/partner-units/{unit_id}/approval-policies/{entity}/{lifecycle} — upsert unit default
- DELETE /api/admin/partner-units/{unit_id}/approval-policies/{entity}/{lifecycle} — clear unit default
- GET    /api/admin/approval-policies/seeded                                       — exists check (gates inbox nudge)
- GET    /api/admin/approval-policies/matrix?project_id=...                        — 8 effective rows w/ attribution
- POST   /api/admin/approval-policies/apply-to-descendants                         — bulk fanout

Form-time hint (NOT admin-gated — every user authoring a deadline /
appointment needs to know whether their save will trigger 4-eye):
- GET /api/projects/{id}/approval-policies/effective?entity_type=&lifecycle=

AuditService extension:
- New AuditSourcePolicyAuditLog source string.
- Fifth UNION ALL branch in auditUnionSQL queries paliad.policy_audit_log,
  packs description as 'entity/lifecycle: old → new'. project_id forwarded
  for project-scoped rows so /admin/audit-log filters work — but
  policy_audit_log is NOT a /verlauf source (the verlauf SELECT in
  ProjectService.ListProjectEvents reads project_events directly), so
  Q8's no-leak constraint is preserved.

Build + go vet clean. The new handler functions register with the existing
adminGate / gateOnboarded patterns; no new middleware.
2026-05-08 02:22:19 +02:00
m
da971a7466 DO NOT MERGE before Phase A.5 — compose: network_mode: host + Paliadin env vars
Stages the docker-compose.yml change so m can flip it together with
the Phase A.5 traefik validation (design §7). Three deltas:

1. network_mode: host on the web service. paliad inherits mLake's
   tailnet interface so the Go RemotePaliadinService can reach
   mRiver:22022 over Tailscale.

2. Removed the now-meaningless `expose: ["8080"]` block (host-mode
   binds the port on the host directly).

3. Five new env entries plumbing the Paliadin remote-routing knobs:
   PALIADIN_REMOTE_HOST=100.99.98.203
   PALIADIN_REMOTE_PORT=22022
   PALIADIN_REMOTE_USER=m
   PALIADIN_SSH_PRIVATE_KEY=...   (multi-line; register as Dokploy secret)
   PALIADIN_KNOWN_HOSTS=...       (one-line; register as Dokploy secret)

   The two secret values are staged at ~/.paliad-staging/ on mRiver
   from Phase A.0 — see issue #12 issuecomment-6886.

**This commit must NOT merge to main until Phase A.5 confirms traefik
still routes paliad.de under host mode.** Per the design's §4.2
honest trade-off acknowledgement: if the test surfaces M1 (traefik
can't discover via Docker DNS → 502), revert this commit and revisit
decision 1 (sidecar variant) in a follow-up issue. Per maria's
non-negotiable head rule, m drives the merge.

A.5 procedure (m's hands):
1. Branch this commit (or cherry-pick onto a temp branch off main)
2. Push to trigger Dokploy redeploy
3. curl --connect-timeout 5 -sSI https://paliad.de/
4. PASS (200/3xx): keep the merge; register Dokploy secrets; redeploy
5. FAIL (502): git revert HEAD && git push; file follow-up issue

Refs m/paliad#12
2026-05-08 02:20:39 +02:00
m
e6067c74db feat(t-paliad-154) commit 2/5: ApprovalService rewire — resolver delegation + scope-split CRUD + audit emission
Service-layer changes implementing the locked design (Q5/Q6/Q8):

LookupPolicy (existing, called by SubmitCreate/Update/Complete/Delete)
delegates to paliad.approval_policy_effective() resolver. Returns nil
for the 'none' sentinel — explicit project-level suppression of inherited
defaults. Synthesizes a *models.ApprovalPolicy carrying the actual
project_id so the existing submit chain branches don't change.

Policy CRUD split into project + unit scope methods:
- ListProjectPolicies / ListUnitPolicies — read-only per scope.
- UpsertProjectPolicy / DeleteProjectPolicy — project-scoped writes,
  audit-emitting (writes paliad.policy_audit_log inside the same tx).
- UpsertUnitPolicy / DeleteUnitPolicy — unit-default writes, same shape.
- All four use validatePolicyTuple for entity_type/lifecycle/required_role
  ranges. IsValidPolicyRole accepts the 'none' sentinel; the existing
  IsValidRequiredRole keeps rejecting 'none' (gate-only contract).

Effective-policy reads:
- GetEffectivePolicyOne(projectID, entity, lifecycle) — single-cell,
  used by the form-time hint endpoint above /projects/{id}/deadlines/new.
- GetEffectivePoliciesMatrix(projectID) — 8 cells in stable display order
  (Fristen/Termine × create/update/complete/delete), each w/ attribution.
- lookupSourceName resolves source_id to projects.title or partner_units.name.

ApplyMatrixToDescendants — bulk-apply (Q10): copies source project's
effective matrix down to listed descendants as project-specific rows,
inside one tx. Validates targetIDs are actual descendants via path-prefix
NOT LIKE check. Idempotent fanout: deletes target's project rows first
then writes the source's effective values. Self-target skipped. Audit
row per affected target.

PoliciesExist() — bool, used by /inbox empty-state nudge.

Models:
- ApprovalPolicy.ProjectID is now *uuid.UUID (was uuid.UUID); new
  *uuid.UUID PartnerUnitID. Existing handler code only reads RequiredRole
  so no upstream breakage.
- New EffectivePolicy struct (resolved cell + source attribution).
- New PolicyAuditEntry struct (paliad.policy_audit_log row).

Handlers:
- handleListApprovalPolicies → ListProjectPolicies (renamed).
- handlePutApprovalPolicy → UpsertProjectPolicy (caller-id reordering).
- handleDeleteApprovalPolicy → DeleteProjectPolicy (now needs uid for
  audit; took the existing requireUser path).

Tests:
- Existing TestApprovalService_PolicyCRUD updated for new method names
  + post-148 enum (partner, not lead) + new 'none' sentinel acceptance.
- New TestIsValidPolicyRole pins the helper that gates writes.
- TestIsValidRequiredRole extended with 'none' rejection (gate-only).

Build + go vet + role-tests clean.

Q8: audit emission writes to paliad.policy_audit_log only — never to
project_events — so /admin/audit-log surfaces the change while /verlauf
stays focused on entity-level lifecycle.
2026-05-08 02:20:15 +02:00
m
e4110cf2db feat(t-paliad-151) frontend: friendly errors for remote-Paliadin codes
Extends the SSE error switch in frontend/src/client/paliadin.ts'
friendlyErrorMessage to map four new error codes from RemotePaliadin
Service into localised messages:

- mriver_unreachable: mRiver is offline / paliadin-shim unreachable
  (DE: "mRiver ist offline — Paliadin nicht erreichbar. Mach mRiver an,
  oder nutze Paliadin lokal mit ./paliad."
   EN: "mRiver is offline — Paliadin can't reach it. Wake mRiver, or
  run Paliadin locally with ./paliad.")
- shim_auth_failed: SSH key / authorized_keys mismatch (Permission
  denied)
- shim_error / bootstrap_failed: generic remote-shim failure
- timeout: Claude didn't write the response file in 60 s

Adds the matching i18n keys (DE + EN) plus the type-union entries in
i18n-keys.ts so the t() typecheck stays sound. The old codes
(tmux_unavailable, connection_lost, upstream) are unchanged — local-PoC
deployments keep their existing UX.

Frontend `bun run build` clean: 1886 keys (unchanged sync).

Refs m/paliad#12
2026-05-08 02:19:48 +02:00
m
68c56ea920 test(t-paliad-151): paliadin_remote_test.go — RemotePaliadinService unit tests
14 tests covering:
- NewRemotePaliadinService default values (SSHPort=22022, SSHUser="m")
- NewRemotePaliadinService honours overrides
- classifySSHError mapping (nil / explicit + wrapped ErrMRiverUnreachable
  / context.DeadlineExceeded / shim exit-124 timeout / Connection
  refused/timed out / Permission denied / unknown fallback)
- healthGate caches OK results for 10 s
- healthGate does NOT cache failures (every call re-probes)
- healthGate rejects unexpected shim replies (returns wrap of
  ErrMRiverUnreachable)
- healthGate cache expires after 10 s wall clock
- ensureBootstrapped runs exactly once on success (idempotent)
- ensureBootstrapped retries after failure, then caches the success
- DisabledPaliadinService returns ErrPaliadinDisabled from RunTurn +
  ResetSession
- compile-time Paliadin interface conformance for all three impls
- callShim forwards args verbatim through the test hook
- callShim error-wrapping path preserves stderr (so classifySSHError
  can pattern-match Permission denied / Connection refused etc.)

All tests bypass exec via the callShimHook field — no real ssh, no
real DB. RunTurn audit-row tests are out of scope (paliad has no
sqlx mock; existing paliadin_test.go also stays on pure functions).

Refs m/paliad#12
2026-05-08 02:18:08 +02:00
m
0c8a2f1a95 feat(t-paliad-151) RemotePaliadinService + main.go env-var routing
Phase B step 2: lands the Paliadin backend that talks to mRiver via
ssh + paliadin-shim. Local backend untouched — selection happens in
cmd/server/main.go based on PALIADIN_REMOTE_HOST.

Files:
- internal/services/paliadin_remote.go (new) — RemotePaliadinService
  + RemotePaliadinConfig, with five SSH knobs (Host/Port/User/KeyPath/
  KnownHostsPath). RunTurn does insertTurnRow → healthGate → bootstrap
  → callShim run-turn → splitTrailer → completeTurn, mirroring the
  local path's audit-row contract. ResetSession sends shim 'reset'.
  callShim runs `ssh -F /dev/null -i <key> -p <port> -o … host -- verb
  args`; ControlMaster intentionally not enabled (design §6.8).
- internal/services/paliadin_remote.go also adds DisabledPaliadinService
  (returns ErrPaliadinDisabled from RunTurn/ResetSession; DB methods
  inherited from paliadinDB still work) so cmd/server/main.go can wire
  a non-nil Paliadin even when neither local tmux nor remote SSH is
  available.
- ErrMRiverUnreachable sentinel for the friendly error code.
- classifySSHError translates ssh exit 124 / Permission denied /
  network errors into the audit-row error_code field.
- Compile-time conformance: var _ Paliadin = (*Local|*Remote|*Disabled)
  PaliadinService(nil).

cmd/server/main.go switch:
  PALIADIN_REMOTE_HOST set → NewRemotePaliadinService
  else: tmux on PATH → NewLocalPaliadinService
  else: NewDisabledPaliadinService

buildPaliadinRemoteConfig materialises PALIADIN_SSH_PRIVATE_KEY +
PALIADIN_KNOWN_HOSTS (multi-line Dokploy secrets) into chmod-600/644
tmpfiles at boot. Defaults: SSHUser=m, SSHPort=22022 (bypasses
Tailscale SSH on :22, see design §4.5). Fails fast on a configured
remote-host without the matching key/known_hosts secrets.

Local-tmux mode now requires `tmux` actually be on PATH at boot
(exec.LookPath gate); previously the constructor unconditionally
returned a service whose RunTurn would fail at runtime with
ErrTmuxUnavailable. The handler-level "friendly error" UX is
unchanged: DisabledPaliadinService surfaces ErrPaliadinDisabled which
the frontend renders the same way.

Build green; existing paliadin_test.go still passes (it tests
package-level helpers, untouched). Remote-specific tests land in B4.

Refs m/paliad#12
2026-05-08 02:16:50 +02:00
m
56a3dc961e refactor(t-paliad-151): extract Paliadin interface; rename PaliadinService → LocalPaliadinService
Phase B step 1 of the Tailscale-SSH route to mRiver. Splits the existing
local-tmux PoC into a Paliadin interface with two implementations; the
remote-SSH backend lands in a follow-up commit (paliadin_remote.go).

Surface:
- Paliadin interface — RunTurn, ResetSession, ListRecentTurns, Stats,
  IsOwner. The handler at internal/handlers/paliadin.go now talks to
  this instead of the concrete struct.
- paliadinDB — embedded base type carrying the audit-table I/O
  (insertTurnRow, completeTurn, markTurnError, markTurnAbandonedOrError)
  plus the read-side queries (IsOwner, ListRecentTurns, Stats). Both
  Local and Remote impls inherit these by embedding paliadinDB so the
  remote path doesn't have to duplicate any DB code.
- LocalPaliadinService — the renamed PoC backend. Identical behaviour
  to the previous PaliadinService; only the type name and method
  receivers change. Method receivers split: tmux-specific operations
  (RunTurn, ResetSession, ensurePane, sendToPane, pollForResponse, etc.)
  stay on *LocalPaliadinService; DB-only operations promote to
  *paliadinDB.

Wiring:
- internal/handlers/handlers.go — Paliadin field becomes the interface
  type; Register() unchanged.
- cmd/server/main.go — calls NewLocalPaliadinService instead of
  NewPaliadinService. The remote-vs-local switch on PALIADIN_REMOTE_HOST
  lands in B5.

Tests in paliadin_test.go all green — they test package-level functions
(splitTrailer, countChips, approxTokenCount, sanitiseForTmux,
PaliadinOwnerEmail) and don't touch the renamed struct. No behaviour
change on the local-tmux path.

Refs m/paliad#12
2026-05-08 02:14:12 +02:00
m
e92c56b5f8 feat(t-paliad-154) commit 1.5: extend migration 062 with policy_audit_log
Q8 of locked design: policy CRUD audits to /admin/audit-log only, NOT
to per-project /verlauf. The 4 existing audit sources (project_events,
caldav_sync_log, reminder_log, partner_unit_events) don't fit cleanly:
project_events would surface on /verlauf (rejected by Q8); partner_unit_events
constrains event_type and requires unit_name + a non-null partner_unit_id
which doesn't fit project-scoped policy changes.

Added paliad.policy_audit_log as a fifth audit source — admin-only, scoped
either to a project or a partner unit, snapshots scope_name so post-cascade
rows still render. RLS: select for any authenticated user (route gate is
the actual control); write for global_admin only.

AuditService.ListEntries will union this source in commit 2 of this PR.

Validated insert/select live in BEGIN ... ROLLBACK.
2026-05-08 02:13:58 +02:00
m
f7908f03ad feat(t-paliad-154) commit 1/5: migration 062 — approval_policies unit-defaults + 'none' sentinel + resolver + seed
Schema:
- ALTER paliad.approval_policies: project_id nullable, ADD partner_unit_id
  uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE.
- XOR check: exactly one of (project_id, partner_unit_id) is set.
- Replace UNIQUE composite with two partial unique indexes (one per scope).
- Extend required_role CHECK with 'none' sentinel.
- approval_role_level('none') already returns 0 via existing ELSE branch
  in 059_profession_vs_responsibility.up.sql:218 — no function update.

Resolver paliad.approval_policy_effective(project, entity_type, lifecycle):
- Step 1: project-specific row wins outright (any value, including 'none').
- Step 2: MAX(approval_role_level) across ancestor rows on project's path
  + unit-default rows for partner units attached to project. Tied levels
  break alphabetically ('ancestor' beats 'unit_default') for stable
  attribution.
- Step 3: zero rows (no candidates) — caller treats as 'no policy applies'.

Returns (required_role, source, source_id) — source ∈ {project, ancestor,
unit_default}; source_id is project_id or partner_unit_id depending.

Seed:
- 8 rows × every existing partner_unit (currently 11): deadline+appointment
  × create/update/delete = associate; complete = none.
- ON CONFLICT (partner_unit_id, entity_type, lifecycle_event)
  WHERE partner_unit_id IS NOT NULL DO NOTHING — idempotent on re-run
  (verified live: 11 units → 88 seed rows, second run is no-op).
- Safe on a DB with 0 partner_units (SELECT returns no rows).

Down migration: reverse-order. Coerces 'none' rows to 'associate' before
restoring CHECK so rollback works without data loss. Drops seeded unit
rows; preserves project rows that pre-date 062.

Validated end-to-end against the live DB inside BEGIN ... ROLLBACK; the
existing project policy (deadline:create=partner) is preserved by the
DO NOTHING clause and the partial-index scope.

Design: docs/design-approval-policy-ui-2026-05-07.md §3.1.

No RAISE EXCEPTION. No bare CSS tokens (no CSS in this commit).
2026-05-08 02:11:23 +02:00
m
f62bf9f8fb feat(t-paliad-151) Dockerfile: openssh-client for remote Paliadin
paliad's RemotePaliadinService shells out to `ssh m@mriver paliadin-shim`
to deliver Paliadin turns from prod (paliad.de Dokploy container) to
mRiver where the long-lived tmux+claude pane lives. The alpine final
stage didn't ship an SSH client; add openssh-client (~1.1MB compressed).

The Go service wires this up in a follow-up commit (Paliadin interface
split). When PALIADIN_REMOTE_HOST is unset, the binary still picks up
the local-tmux PoC path and never invokes ssh, so this change is safe
on its own.

Refs m/paliad#12
2026-05-08 02:10:40 +02:00
m
dd139a3536 Merge remote-tracking branch 'origin/main' into mai/noether/inventor-paliadin 2026-05-08 02:08:12 +02:00
m
01fa4b1287 Merge remote-tracking branch 'origin/main' into mai/hilbert/inventor-approval-policy 2026-05-08 02:07:46 +02:00
m
bb035558be design(t-paliad-154): approval-policy authoring UI
Inventor pass for m/paliad#13. Surfaces the dormant t-138 4-eye system
(zero policies in DB → silent bypass) by adding /admin/approval-policies
with project-picker → 8-cell matrix + partner-unit-defaults section.

12 design questions surfaced sequentially via AskUserQuestion (per dogma)
and locked in §2 of the doc:

1. Surface: /admin/approval-policies only (admin page card on /admin index)
2. Defaults concept: per-partner-unit defaults
3. Multi-unit conflict: most-restrictive wins
4. Tree inheritance: yes (ancestors contribute candidates)
5. Cross-source precedence: most-restrictive across project+ancestor+unit;
   project row overrides outright
6. Suppression sentinel: 'none' value in required_role enum
7. Soft-disable: no, delete-only
8. Audit emission: /admin/audit-log only, not project verlauf
9. Empty-state: admin-only nudge card on /inbox when zero pending+policies
10. Bulk-apply: per-project "Auf Unterprojekte anwenden" button
11. Seed defaults: yes — conservative associate baseline for all partner units
12. Mobile shape: stacked sections per entity_type
13. Form hint: yes, above Speichern button on deadline/appointment new+edit

Migration 062 adds partner_unit_id (XOR with project_id),
'none' to required_role enum, paliad.approval_policy_effective() resolver,
and seeds 8 rows × N partner_units. ApprovalService.LookupPolicy delegates
to the resolver while preserving its calling contract (existing submit/
decide chain unchanged). New admin endpoints for unit-defaults, matrix
view, bulk-apply, and form-time effective lookup. ~3500-4500 LoC, single
PR, 5 commits.

Inventor parked. NOT cronus per memory directive. Awaiting m go/no-go.
2026-05-07 23:51:38 +02:00
m
f952fb85c3 design(t-paliad-151) amend: port 22022 bypass + Phase A.0 results
Phase A.0 revealed Tailscale SSH on mRiver intercepts :22 from tailnet
peers and bypasses OpenSSH's authorized_keys entirely (banner
"SSH-2.0-Tailscale", auth method "none", command= restriction never
fires). The fix is port 22022 via a systemd ssh.socket drop-in:
Tailscale SSH only intercepts :22, so :22022 hits real OpenSSH where
the design's command=/from= shim restriction works as specified.

Updated:
- §3 locked decisions: row 5 added (port 22022, m's call 23:35)
- §4.5 new subsection: Tailscale SSH bypass via socket drop-in
  + records the "Address already in use" first-attempt failure as a
  "don't retry without cleaning sshd_config Port directives first"
  lesson
- §5.2/5.3: ssh-keyscan now uses -p 22022; known_hosts is host:port
  keyed for non-22 ports
- §6.1/6.2/6.3: SSHPort field on RemotePaliadinService config, -p
  flag in callShim, PALIADIN_REMOTE_PORT env (default 22022)
- §7 phasing: A.0 completion checked off step-by-step with concrete
  fingerprints; A.5/A.6/A.7 split out as m-driven
- §8 security: Tailscale-SSH-on-:22 risk explicitly tabled with
  port-22022 mitigation
- §10 deliverables: mRiver host-setup artifacts noted
- §12 new Phase A.0 completion summary with the three secrets m
  needs to register in Dokploy

Phase A.0 verified end-to-end:
- ssh -p 22022 paliad-prod-key m@mriver health → ok
- run-turn UUID base64msg → 3.4 s including a real Claude response
- from="100.99.98.201" correctly rejects connections from mRiver
  itself

mRiver host state in place (not in repo): authorized_keys with
restrictions, /home/m/.local/bin/paliadin-shim, ssh.socket drop-in.
Three secrets staged at ~/.paliad-staging/ on mRiver for m to copy
into Dokploy: paliad-prod-key (PALIADIN_SSH_PRIVATE_KEY),
known_hosts (PALIADIN_KNOWN_HOSTS), and the three plain env vars.

Refs m/paliad#12
2026-05-07 23:37:26 +02:00
m
b78941e293 Merge: t-paliad-152 — /api/events honours direct_only (Fristen/Termine subtree toggle works again — handleListEvents + handleEventsSummary parse direct_only via parseDirectOnly; threaded as DirectOnly bool through EventListFilter / EventSummaryFilter / ListFilter / AppointmentListFilter; project predicate swaps from projectDescendantPredicate to direct project_id eq when set; 3 new DirectOnly subtests in project_filter_descendants_test.go) 2026-05-07 23:21:01 +02:00
m
55c93c9de3 Merge: t-paliad-153 — Frist due_date 02:00 leak (consolidate views/format.ts with UTC-anchored date-only detection + kind-aware formatRowTime/formatRelative; shape-cards skips time slot under day-grouped headings; shape-list reduces deadline relatives to day precision; tests pass under TZ=Berlin/LA/UTC) 2026-05-07 23:08:18 +02:00
m
f90bfeda9b fix(t-paliad-153): deadline due_date renders 02:00 in CEST (UTC-midnight leak)
Substrate marshals deadline.due_date as time.Date(...,0,0,0,0,UTC), so the
JSON arrives as "YYYY-MM-DDT00:00:00Z" — UTC midnight, no real time. Feeding
that into new Date() + toLocaleTimeString() produced "02:00" in CEST,
"01:00" in CET, "20:00 the day before" in EST, etc.

Pattern A: don't render time for date-only fields.

- Centralised the date/time formatters used by the views shapes into
  frontend/src/client/views/format.ts. parseDateOnly recognises both
  "YYYY-MM-DD" and the substrate's "YYYY-MM-DDT00:00:00Z" form; formatDate
  formats those in UTC so the day matches the source day in every timezone.
- shape-cards.ts: per-row time slot is empty for deadlines when the day is
  already in the heading (groupBy=day). Falls back to formatDate when
  groupBy=week|none. Bucketing now anchors date-only inputs to UTC so a
  deadline can't slip into the previous day in negative-offset zones.
- shape-list.ts: formatRelative is kind-aware — deadlines reduce to
  day-precision ("morgen" / "in 3 Tagen") instead of leaking hour math
  ("in 2h") off the UTC midnight.
- Appointments and other timestamped sources are untouched.
- format.test.ts: regression coverage in CEST / PST / UTC. 14 tests pass.
2026-05-07 23:07:26 +02:00
m
024841129f feat(t-paliad-151) shim: scripts/paliadin-shim
Server-side RPC for paliad's remote-tmux turns. Invoked via mRiver's
~/.ssh/authorized_keys command= restriction; dispatches on the verb in
$SSH_ORIGINAL_COMMAND. Four verbs: health, bootstrap, run-turn, reset.

Per the design (§5.4), this is the single SSH entry point for paliad-prod
on mLake. The Go service in cmd/server/main.go later constructs
RemotePaliadinService with this script as the only command the
authorized_keys entry permits.

Multi-character payloads (system prompt, user message) are base64-encoded
by the caller so they never have to be quoted through ssh's argv. The
shim validates UUID turn_ids, base64 decodes inputs, and never evals
$SSH_ORIGINAL_COMMAND.

Smoke-tested on mRiver:
- empty / unknown verb → exit 2 with clear stderr
- bootstrap with bad base64 → exit 2 BEFORE creating any pane
- health → "ok" on a clean tmux session

Refs m/paliad#12
2026-05-07 23:02:52 +02:00
m
befa41c00e design(t-paliad-151): Paliadin Tailscale SSH route to mRiver
Inventor design for routing Paliadin from paliad.de's Dokploy container
on mLake to mRiver via Tailscale + SSH, preserving m's Claude Code
subscription instead of paying Anthropic API tokens.

Three sub-designs covering m's four locked decisions (2026-05-07 22:35):
- network_mode: host on paliad (m overrode the sidecar recommendation;
  Phase A explicitly tests traefik compatibility under host mode)
- server-side paliadin-shim with one RPC per turn (run-turn / reset /
  health / bootstrap), authorized_keys command= restriction, from=mlake
- env-var routing trigger (PALIADIN_REMOTE_HOST) + Paliadin interface
  split: LocalPaliadinService keeps the laptop PoC, RemotePaliadinService
  shells out to ssh m@mriver paliadin-shim
- ed25519 keypair via Dokploy secret PALIADIN_SSH_PRIVATE_KEY, written
  to a chmod 600 tmpfile at startup; pinned host key via
  PALIADIN_KNOWN_HOSTS

Verified live before designing: mRiver tmux+claude present, mLake
Tailscale active and sees mRiver, paliad Dockerfile is alpine-minimal,
no authorized_keys on mRiver yet. No assumptions left from CLAUDE.md.

Includes: friendly error code mriver_unreachable extending t-paliad-150,
single-flight rate limit, security review (defence-in-depth via
command=/from= restrictions), three-phase rollout (manual proof →
Dockerfile bake → polish), file-level deliverables for the coder shift.

Inventor stops here — no code shipped. Awaiting m's go/no-go.

Refs m/paliad#12
2026-05-07 22:47:30 +02:00
195 changed files with 37842 additions and 1798 deletions

View File

@@ -47,7 +47,8 @@ Paliad — the patent paladin. All-in-one patent practice platform for HLC (form
| `PALIAD_BASE_URL` | optional | Public origin used in email links. Defaults to `https://paliad.de`; override for staging/preview. |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_FROM_NAME` / `SMTP_USE_TLS` | for email | SMTP credentials for Paliad's transactional mail (reminders, invitations). Port 465 uses implicit TLS. `MailService` silently no-ops when any required var is missing — the server still boots for knowledge-platform-only deployments. |
| `ANTHROPIC_API_KEY` | not used in PoC | Reserved for the eventual production-v1 Paliadin (the Anthropic Messages API path, see `docs/design-paliadin-2026-05-07.md` §2). The Phase 0 PoC (t-paliad-146) does NOT use this — it shells out to a local `claude` CLI via tmux instead, which uses m's existing Claude Code subscription. Set this env var only after the PoC validates and we cut over to the API-backed path. The earlier "Phase H Frist-Extraktion" reservation is dead — that feature is deferred separately (memory `b6a11b55…`). |
| `PALIADIN_TMUX_SESSION` | optional (default `paliad-paliadin`) | tmux session name the Paliadin service uses for its long-lived `claude` pane. |
| `PALIADIN_SESSION_PREFIX` | optional (default `paliad-paliadin`) | Prefix for the per-user tmux session names the Paliadin service uses (t-paliad-155). Each Paliad user gets their own session named `<prefix>-<userid8>` (first 8 hex chars of the user's UUID); conversation history accumulates per visit, `ResetSession` kills the session entirely. The persona + response protocol now live in `~/.claude/skills/paliadin/SKILL.md` (installed via `scripts/install-paliadin-skill`) — no in-process system prompt is sent. |
| `PALIADIN_REMOTE_CWD` | shim env (default `/home/m/dev/paliad`) | Working directory `paliadin-shim` uses when spawning the long-lived `claude` pane on mRiver. Must be the paliad repo root so claude picks up `.mcp.json` (project-scoped Supabase MCP); without it, the SKILL.md SQL recipes have no DB tool. Set on mRiver only — paliad's Go side never reads this. |
| `PALIADIN_RESPONSE_DIR` | optional (default `/tmp/paliadin`) | Directory where Claude writes its per-turn response files. The Go service polls this directory for `{turn_id}.txt` files. |
> *Note on Paliadin gating (t-paliad-146):* there is **no** `PALIADIN_ENABLED` env var. Access is gated in code via `services.PaliadinOwnerEmail` (currently `matthias.siebels@hoganlovells.com`). Every other authenticated user gets a 404 on `/paliadin` and `/admin/paliadin`. This means the routes register on every paliad deploy (including paliad.de prod), but only m can reach them — and even then, prod only works if the host has `tmux` + a `claude` CLI in PATH (which the Dokploy container does not). PoC remains a laptop-only feature; the gate is in the code, not the deploy.

View File

@@ -11,7 +11,7 @@ COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /paliad ./cmd/server
FROM alpine:3.21
RUN apk add --no-cache ca-certificates
RUN apk add --no-cache ca-certificates openssh-client
WORKDIR /app
COPY --from=backend /paliad /app/paliad
COPY --from=frontend /app/frontend/dist /app/dist

View File

@@ -2,10 +2,15 @@ package main
import (
"context"
"encoding/base64"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"strconv"
"strings"
"syscall"
// Embed Go's IANA tz database into the binary so time.LoadLocation works
@@ -163,22 +168,43 @@ func main() {
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
Pin: services.NewPinService(pool, projectSvc),
CardLayout: services.NewCardLayoutService(pool),
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
}
// t-paliad-146 — Paliadin PoC. Always wired when DATABASE_URL
// is set; the per-request handler gate (requirePaliadinOwner)
// restricts access to the single owner email
// (services.PaliadinOwnerEmail). All other authenticated users
// get a 404 — the route effectively does not exist for them.
// On hosts without tmux + the `claude` CLI (e.g. the Dokploy
// container), the owner gate still applies; if m ever hits the
// route from such a host, the service returns "tmux unavailable"
// without ever invoking shell-out.
tmuxSession := os.Getenv("PALIADIN_TMUX_SESSION")
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
svcBundle.Paliadin = services.NewPaliadinService(pool, users, tmuxSession, responseDir)
log.Printf("paliadin: wired (owner=%s; gate is per-request, not per-deploy)",
services.PaliadinOwnerEmail)
// Paliadin backend selection (t-paliad-146 + t-paliad-151):
// PALIADIN_REMOTE_HOST set → RemotePaliadinService (ssh to mRiver)
// else: local tmux available → LocalPaliadinService (PoC path)
// else: DisabledPaliadinService (handlers still 404 for non-owners
// via the gate; for m, RunTurn returns ErrPaliadinDisabled
// which surfaces as a friendly error).
//
// All three implement services.Paliadin; the per-request handler
// gate (requirePaliadinOwner) is unchanged and applies to every
// backend.
if remoteHost := os.Getenv("PALIADIN_REMOTE_HOST"); remoteHost != "" {
cfg, err := buildPaliadinRemoteConfig(remoteHost)
if err != nil {
log.Fatalf("paliadin: remote config: %v", err)
}
svcBundle.Paliadin = services.NewRemotePaliadinService(pool, users, cfg)
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
} else if _, err := exec.LookPath("tmux"); err == nil {
sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
local := services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
// Late-response janitor — patches rows when Claude writes the
// response file after the 60 s pollForResponse window expires.
// Runs for the process lifetime; cleaned up when bgCtx
// cancels on SIGTERM.
local.StartJanitor(bgCtx)
svcBundle.Paliadin = local
log.Printf("paliadin: local tmux mode (owner=%s, janitor=on)", services.PaliadinOwnerEmail)
} else {
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
log.Printf("paliadin: disabled (no PALIADIN_REMOTE_HOST, no local tmux; owner=%s)",
services.PaliadinOwnerEmail)
}
// Wire ApprovalService into the entity services so Create / Update /
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
// Without this wiring, the policies and request tables exist but no
@@ -217,3 +243,134 @@ func main() {
log.Fatal(err)
}
}
// buildPaliadinRemoteConfig assembles a RemotePaliadinConfig from
// environment variables, materialising the SSH private key and
// known_hosts blobs into chmod-600/644 tmpfiles for OpenSSH to read.
//
// The blobs travel as Dokploy secrets (multi-line env vars). We never
// persist them to disk — tmpfiles live for the process lifetime in
// /tmp and disappear on container restart. Re-creating them every boot
// is fine; the keys themselves rotate independently via Dokploy
// secret updates.
//
// Required: PALIADIN_REMOTE_HOST, PALIADIN_SSH_PRIVATE_KEY, PALIADIN_KNOWN_HOSTS.
// Optional: PALIADIN_REMOTE_USER (default "m"), PALIADIN_REMOTE_PORT
// (default 22022 — bypasses Tailscale SSH on :22, see design §4.5).
func buildPaliadinRemoteConfig(host string) (services.RemotePaliadinConfig, error) {
cfg := services.RemotePaliadinConfig{
SSHHost: host,
SSHUser: cmpOr(os.Getenv("PALIADIN_REMOTE_USER"), "m"),
SSHPort: 22022,
SessionPrefix: os.Getenv("PALIADIN_SESSION_PREFIX"),
}
if p := os.Getenv("PALIADIN_REMOTE_PORT"); p != "" {
n, err := strconv.Atoi(p)
if err != nil || n <= 0 || n > 65535 {
return cfg, fmt.Errorf("PALIADIN_REMOTE_PORT %q: not a valid port", p)
}
cfg.SSHPort = n
}
// Dokploy stores compose env vars in a single-line .env file: multi-line
// PEM bodies get truncated to the first line. Base64-encode the
// private key in the secret to survive that round-trip; here we
// detect base64 vs raw PEM and decode either way.
keyBlob, err := decodePaliadinPrivateKey(os.Getenv("PALIADIN_SSH_PRIVATE_KEY"))
if err != nil {
return cfg, fmt.Errorf("PALIADIN_SSH_PRIVATE_KEY: %w", err)
}
keyPath, err := writeSecretFile("paliadin-id_ed25519-", keyBlob, 0o600)
if err != nil {
return cfg, fmt.Errorf("PALIADIN_SSH_PRIVATE_KEY: %w", err)
}
if keyPath == "" {
return cfg, fmt.Errorf("PALIADIN_REMOTE_HOST set but PALIADIN_SSH_PRIVATE_KEY empty")
}
cfg.SSHKeyPath = keyPath
knownHostsPath, err := writeSecretFile("paliadin-known_hosts-", os.Getenv("PALIADIN_KNOWN_HOSTS"), 0o644)
if err != nil {
return cfg, fmt.Errorf("PALIADIN_KNOWN_HOSTS: %w", err)
}
if knownHostsPath == "" {
return cfg, fmt.Errorf("PALIADIN_REMOTE_HOST set but PALIADIN_KNOWN_HOSTS empty")
}
cfg.KnownHostsPath = knownHostsPath
return cfg, nil
}
// decodePaliadinPrivateKey accepts either a raw PEM (multi-line) or a
// base64-encoded PEM. Returns the raw PEM bytes ready to write to a
// keyfile. Empty input → ("", nil) so the caller can distinguish
// "secret not set" from "decode failed".
//
// Why base64: Dokploy stores compose env vars in a one-line-per-key
// .env file, which silently truncates multi-line values to their first
// line. Empirically, a multi-line `-----BEGIN OPENSSH PRIVATE KEY-----`
// arrived inside the container as just the BEGIN header (36 bytes).
// Base64-encoding the key in the Dokploy secret survives that
// round-trip. We still accept raw PEM for local-dev convenience.
func decodePaliadinPrivateKey(blob string) (string, error) {
blob = strings.TrimSpace(blob)
if blob == "" {
return "", nil
}
// Raw PEM: starts with ----- and contains a newline. Use as-is.
if strings.HasPrefix(blob, "-----") && strings.Contains(blob, "\n") {
return blob + "\n", nil
}
// Otherwise treat as base64. Strip any whitespace OpenSSH keygen
// helpers might insert (line breaks every 64 chars in some tools).
clean := strings.Map(func(r rune) rune {
if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
return -1
}
return r
}, blob)
decoded, err := base64.StdEncoding.DecodeString(clean)
if err != nil {
return "", fmt.Errorf("not raw PEM (no newline) and base64 decode failed: %w", err)
}
out := string(decoded)
if !strings.HasPrefix(out, "-----BEGIN") {
return "", fmt.Errorf("decoded body does not look like a PEM key (no -----BEGIN prefix)")
}
if !strings.HasSuffix(out, "\n") {
out += "\n"
}
return out, nil
}
// writeSecretFile writes blob to a tmpfile with the given mode and
// returns its path. Returns ("", nil) when blob is empty so callers
// can distinguish "not set" from real I/O errors.
func writeSecretFile(prefix, blob string, mode os.FileMode) (string, error) {
if blob == "" {
return "", nil
}
f, err := os.CreateTemp("", prefix+"*")
if err != nil {
return "", err
}
if _, err := f.WriteString(blob); err != nil {
_ = f.Close()
_ = os.Remove(f.Name())
return "", err
}
if err := f.Close(); err != nil {
return "", err
}
if err := os.Chmod(f.Name(), mode); err != nil {
return "", err
}
return f.Name(), nil
}
func cmpOr(s, fallback string) string {
if s != "" {
return s
}
return fallback
}

View File

@@ -20,5 +20,19 @@ services:
- SMTP_FROM=${SMTP_FROM}
- SMTP_FROM_NAME=${SMTP_FROM_NAME}
- SMTP_USE_TLS=${SMTP_USE_TLS}
# Paliadin remote routing (t-paliad-151). When PALIADIN_REMOTE_HOST
# is set, paliad forwards each turn to mRiver via SSH on port 22022.
# The container reaches mRiver over Tailscale via mLake's host-side
# tailscale0 + Docker source NAT — no network_mode override needed
# (verified Phase A.5: a plain alpine container on Dokploy's
# default bridge SSHs to mriver:22022 in 3 s, source IP NAT'd to
# mLake's tailnet IP, matches the from="100.99.98.201" clause on
# mRiver's authorized_keys).
# PRIVATE_KEY and KNOWN_HOSTS are multi-line Dokploy secrets.
- PALIADIN_REMOTE_HOST=${PALIADIN_REMOTE_HOST}
- PALIADIN_REMOTE_PORT=${PALIADIN_REMOTE_PORT}
- PALIADIN_REMOTE_USER=${PALIADIN_REMOTE_USER}
- PALIADIN_SSH_PRIVATE_KEY=${PALIADIN_SSH_PRIVATE_KEY}
- PALIADIN_KNOWN_HOSTS=${PALIADIN_KNOWN_HOSTS}
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred
restart: unless-stopped

View File

@@ -0,0 +1,799 @@
# Audit — Fristen logic (rules, triggers, conditionals)
**Author:** pauli (inventor)
**Date:** 2026-05-13
**Task:** t-paliad-157 (reactivated 2026-05-13 21:23 with broader scope)
**Phase:** 1 of 3 — Audit. Phase 2 = iterative refinement against m. Phase 3 = ship.
**Branch:** `mai/pauli/fristen-logic-audit` (fresh from `origin/main` @ `7d9935d`).
**Status:** AUDIT READY FOR REVIEW — m gates the audit → Phase 2 transition.
m's framing (paliad/head 11:46):
> the main roadmap thing now is "Fristen". We need the full "Fristen logic" and I am happy to work together with an AI to further design it. Most of it should be "straightforward" as specific events trigger specific deadlines, sometimes multiple and sometimes conditional. It is all in the Rules so we should be able to manage.
The audit answers: **is m's mental model already encodable in the existing data model, and where are the gaps?**
Short answer: the rule corpus is substantially richer than the brief implied, **three parallel deadline-generation systems coexist** (with overlapping responsibilities), and the main friction is *managing* rules (SQL-only today) rather than the expressive grammar of the rules themselves.
---
## 0. Premises verified live (live DB + live code, not migration files)
Live state queried via `mcp__supabase__execute_sql` against the `paliad` schema on the youpc Supabase Postgres. Code reads against `mai/pauli/fristen-logic-audit` baseline (origin/main @ `7d9935d`).
### 0.1 Rule corpus is ~5× richer than the brief implied
| Table | Rows | Note |
|---|---|---|
| `paliad.proceeding_types` | **27** | 20 `category='fristenrechner'` + 7 `category='litigation'`. All 27 carry rules. |
| `paliad.deadline_rules` | **172** | 132 against fristenrechner codes + 40 against litigation codes. |
| `paliad.deadline_concepts` | **56** | The "noun" layer (Klageerwiderung, Berufungsschrift, …) above rules. |
| `paliad.event_category_concepts` | **153** | Cascade-leaf → concept junction (with optional `proceeding_type_code` for context-conditional outcomes). |
| `paliad.deadline_concept_event_types` | **32** | Concept → event_type default suggestion (per jurisdiction). |
| `paliad.trigger_events` | **110** | youpc.org legacy import. Used by the "Was kommt nach…" mode. |
| `paliad.event_deadlines` | **77** | trigger_event → deadline_row, with `combine_op` ∈ {min,max} for composite leads. |
| `paliad.event_types` | **40+** | Concrete event types (upc_oral_hearing, upc_statement_of_defence, …). |
| `paliad.event_categories` | **125** (103 leaves) | Cascade taxonomy. Already audited in t-paliad-166. |
| `paliad.courts` | **41** | Forum picker for holiday-calendar regime resolution. |
| `paliad.holidays` | **55** | Seed of public holidays + court closures. |
| `paliad.deadlines` (live) | **26** | Persisted deadline instances. **Only 1 has `rule_id`.** |
| `paliad.project_events` | **89** | Audit-log entries. |
| `paliad.events` | **does not exist** | The brief mentioned `paliad.events`; the actual audit table is `paliad.project_events`. |
### 0.2 Three parallel deadline-generation systems exist today
| Pipeline | Data source | Calculator | Wire surface |
|---|---|---|---|
| **A — Proceeding-driven** | `paliad.deadline_rules` (172 rows) | `FristenrechnerService.Calculate(proceedingCode, triggerDate, opts)` (`internal/services/fristenrechner.go:139`) | POST `/api/tools/fristenrechner` (Pathway A wizard, Pathway B cascade, SmartTimeline projection via `ProjectionService.computeProjections`). |
| **B — Single-rule (subset of A)** | Same table | `FristenrechnerService.CalculateRule` (around line 480) | POST `/api/tools/fristenrechner/calculate-rule` (Pathway B cascade card-click → inline calc). |
| **C — Event-driven (youpc legacy)** | `paliad.trigger_events` + `paliad.event_deadlines` (separate tables) | `EventDeadlineService.Calculate(triggerEventID, triggerDate, courtID)` (`internal/services/event_deadline_service.go:92`) | POST `/api/tools/event-deadlines` (Pathway A wizard's "Was kommt nach…" trigger panel, `frontend/src/client/fristenrechner.ts:833`). |
Pipelines A and C have **disjoint data**, **disjoint capability sets**, and **overlapping intent**. See §2 for the full picture; §6.1 calls out the redundancy.
### 0.3 m's "it is all in the Rules so we should be able to manage" — the false premise
The rule corpus IS in one table (`paliad.deadline_rules`) — 172 rows, 32 columns, expressive. **But there is no application-level rule-management surface.** Every rule edit today is a SQL migration: `internal/db/migrations/{009,012,028,029,031,040,043,044,050,068,…}_*.up.sql`. The Calculate engine reads what's in the table, but the table is seeded by developers, not by m or any user.
m's "we should be able to manage" reads as a call for a first-class rule-editor in the app (see §8). That's the biggest unfilled deliverable in his framing.
### 0.4 Production data is sparse — demand-side largely empty
- **11/11 live projects have NULL `proceeding_type_id`** (per kelvin's t-paliad-178 §0 audit; re-confirmed). The projection pipeline (`projection_service.computeProjections:813`) early-returns when this is NULL, so the SmartTimeline forecast doesn't fire for any production project today.
- **Only 1/26 live deadlines has `rule_id` populated.** The rule → deadline linkage is barely exercised. Most deadlines were created manually (free-text title + due_date) before the rule-anchored flow existed.
- **89 project_events**: structural milestones + audit-log entries. No tight coupling to rule_ids today.
- **trigger_events / event_deadlines** carry 110+77 youpc-legacy rows. Whether they are exercised in production needs Pathway-A "Was kommt nach…" telemetry; out of audit scope.
### 0.5 Anchor files
Backend services that consume / produce rules:
- `internal/services/fristenrechner.go` — Pipeline A + B. The main calculator. **735 LoC.**
- `internal/services/deadline_calculator.go` — pure date-math used by Pipeline C.
- `internal/services/deadline_rule_service.go` — CRUD-ish read API. `List`, `GetRuleTree`, `Get`. Hydrates `ConceptDefaultEventTypeID` from `deadline_concept_event_types` for the create-form's Typ chip.
- `internal/services/event_deadline_service.go` — Pipeline C. **~300 LoC.**
- `internal/services/deadline_service.go` — persistence of `paliad.deadlines` instances.
- `internal/services/event_category_service.go` — cascade leaf → concept resolution (t-paliad-133).
- `internal/services/projection_service.go` — SmartTimeline (consumes Pipeline A).
- `internal/services/holidays.go` + `courts.go` — non-working-day adjustment.
Handlers:
- `internal/handlers/fristenrechner.go` — wires Pipelines A/B/C to HTTP routes.
- `internal/handlers/deadlines.go` — paliad.deadlines persistence.
- `internal/handlers/deadline_rules_db.go` — admin-style rule list endpoint (read-only).
Key migration history (rule corpus evolution):
- **009** (342 LoC) — initial seed (Tier 1, hand-coded).
- **012** (230 LoC) — Fristenrechner seed extension.
- **028** (353 LoC) — youpc.org rules import (Pipeline C tables).
- **029** (128 LoC) — Tier 1 rule fixes.
- **031** (193 LoC) — Tier 2 ports (more proceedings).
- **037 + 038** — concept layer addition.
- **040** (449 LoC) — concept seed + backfill.
- **043** (348 LoC) — DE_INF_OLG / DE_INF_BGH split (instance dimension).
- **044** (280 LoC) — DPMA proceedings.
- **048 + 049** — event_categories taxonomy (cascade).
- **050** — `is_bilateral` backfill (4 rules).
- **052** — Determinator ROP coverage audit fixes.
- **068** — `is_optional` column.
- **073 + 074** — `deadline_concept_event_types` (concept → event_type config layer).
Net rule-related migrations: **>20 files, >3000 LoC of SQL.** The rule corpus has accreted across many small migrations; no single canonical seed.
If anything in this audit conflicts with the live state, the live state wins.
---
## 1. The rule shape today — `paliad.deadline_rules` column-by-column
**32 columns.** Most are used; a few are vestigial. Every column verified against live row distribution.
### 1.1 Identity + relations
| Column | Type | Nullable | Role |
|---|---|---|---|
| `id` | uuid PK | NO | Primary key. Referenced by `paliad.deadlines.rule_id`, `paliad.deadline_rules.parent_id` (self-FK), `paliad.deadline_rules.condition_rule_id` (self-FK; unused — see §1.6). |
| `proceeding_type_id` | int FK → `proceeding_types.id` | YES | Almost always set; NULL would mean a cross-proceeding rule but **no live rule is NULL** today. |
| `parent_id` | uuid self-FK | YES | Rule depends on parent's calculated date as anchor. **108 / 172 rules have parent_id set** (= 63%). Forms a forest, one tree per proceeding. |
| `concept_id` | uuid FK → `deadline_concepts.id` | YES | Links the rule to a concept (cross-proceeding noun). **171 / 172 rules linked** (= 99.4%); the one un-linked rule is a stray. |
### 1.2 Identity strings + labels
| Column | Note |
|---|---|
| `code` | Rule-local code (e.g. `inf.sod`, `ccr.amend`). Used by `AnchorOverrides` map keys (rule_code → date). Mostly unique within a proceeding. |
| `name` (NOT NULL) | DE display name. |
| `name_en` (NOT NULL, default `''`) | EN display name. Empty for some older rules; UI falls back to `name`. |
| `description` | Optional long-form. Sparse. |
| `rule_code` | The *legal-citation* rule code (e.g. `RoP.23.1`, `§276(1) ZPO`). The UI shows this as the `RuleRef`. NOT the rule's identity — `code` is. |
| `legal_source` | Structured citation (e.g. `UPC.RoP.23.1`). Added by mig 038 + 040. **171/172 rules have it.** |
| `deadline_notes` / `deadline_notes_en` | Free-text legal-context notes shown in the UI. |
| `spawn_label` | Used with `is_spawn=true`: human label for "spawned rule" pattern. |
### 1.3 The math: anchor + offset + adjustment
| Column | Note |
|---|---|
| `duration_value` (NOT NULL, default 0) | Integer offset. `0` = court-set / root anchor / filed-with-parent (see §4). |
| `duration_unit` (NOT NULL, default `months`) | Live values: `days`, `months`, `weeks`. **No `working_days`** in live data (`EventDeadlineService` supports it; `deadline_rules` does not). |
| `timing` (default `after`) | Live value: **only `after`** in every row. `before` semantic is theoretically there but unused by Pipeline A. (Pipeline C honours `before` via `applyDuration`.) |
| `anchor_alt` | Single live value: `priority_date`. Used by exactly **one rule**: `EP_GRANT.ep_grant.publish` (Art. 93 EPÜ, 18mo from priority). Otherwise NULL → use parent's date / triggerDate. |
| `alt_duration_value` / `alt_duration_unit` / `alt_rule_code` | Swap-on-flag: when condition_flag is satisfied, the rule renders against the alt values instead of base. Used by UPC_INF `inf.reply` and `inf.rejoin` for the with_ccr swap (RoP.029.a / RoP.029.d). |
### 1.4 Conditional gating
| Column | Note |
|---|---|
| `condition_flag` | `text[]` array. **4 distinct value-sets live**: `[with_amend]`, `[with_cci]`, `[with_ccr]`, `[with_ccr, with_amend]`. Only on UPC_INF + UPC_REV (the 2 richest proceedings). Semantics: rule renders iff **every** element of the array is in caller's `Flags` set. AND semantics; **no OR/NOT today**. |
| `condition_rule_id` | uuid self-FK to another rule. **0 / 172 rows populated**. Dead column. Was intended as "rule X applies only if rule Y was triggered" but never wired up. |
### 1.5 Party + bilateral
| Column | Note |
|---|---|
| `primary_party` | Live values: `claimant`, `defendant`, `both`, `court`. Drives the timeline column / row color. NULL allowed. |
| `is_bilateral` (NOT NULL, default false) | When `primary_party='both'`, this column tells the renderer whether to **mirror the rule into both party columns** in the timeline (true), or **resolve to one side via perspective + appeal_filed_by** (false). Backfilled by mig 050 — only 4 rules carry `true`: DE_NULL r79, DE_NULL r116, EPA_OPP r79, EPA_APP r116. |
### 1.6 Flags + lifecycle
| Column | Note |
|---|---|
| `is_mandatory` (NOT NULL, default true) | "User must address this." Surfaces in UI badge. |
| `is_optional` (NOT NULL, default false) | Added by mig 068. **Distinct from is_mandatory** — semantics today: "the save-modal pre-unchecks these rows; the timeline still renders them." Live: e.g. UPC_INF `inf.cost_app` (RoP.151 Antrag auf Kostenentscheidung) — visible-but-defaulted-off. Naming is confusing (is_mandatory=true + is_optional=true would be self-contradictory); see §6.3. |
| `is_spawn` (NOT NULL, default false) | Marks the rule as a "spawn" — emitted when its parent decision fires, but the spawn itself starts a NEW timeline branch (e.g. Appeal off Decision). Used by 8 live rules: APP/AMD/CCR cross-proceeding spawns. **Spawn execution is half-wired**: `projection_service.go:896-901` notes "Cross-proceeding spawn — the calculator can return rules from another proceeding type (Appeal off Decision). We don't have that rule in our map; skip the dependency annotation but still surface the row." — i.e. the row appears in the response but the dependency-annotation graph breaks. |
| `is_active` (NOT NULL, default true) | Soft-delete. All 172 live rules have `is_active=true`; soft-delete unused so far. |
| `sequence_order` (NOT NULL, default 0) | Calculator walks rules in this order. Must be consistent with topological order on `parent_id` (parents before children). |
| `created_at` / `updated_at` | timestamps. |
| `event_type` (text, nullable) | One of `decision`, `filing`, `hearing`, `order`. **A category, NOT an FK** to `paliad.event_types`. Distinct from concept-level event_type linkage in §3. |
### 1.7 Vestigial / under-used
- `condition_rule_id` — 0 rows populated. Dead column.
- `description` — sparse, used as fallback notes.
- `is_mandatory` vs `is_optional` — overlapping semantics that need a clean re-think (§6.3).
---
## 2. Trigger model today — events to deadlines
There are **three parallel paths** from a user-observable event to a calculated deadline list. Understanding the redundancy is the most important takeaway of this audit.
### 2.1 Path A — Proceeding-driven (the main spine)
Caller: `/tools/fristenrechner` Pathway A (wizard), Pathway B B1 leaf click + B2 search, `ProjectionService.computeProjections` (SmartTimeline).
Flow:
1. User (or projection) picks a **proceeding_code** (e.g. `UPC_INF`) and a **trigger_date**.
2. `FristenrechnerService.Calculate(proceedingCode, triggerDate, opts)` runs.
3. Calculator loads `deadline_rules WHERE proceeding_type_id = $pt AND is_active`.
4. Walks rules in `sequence_order`. For each:
- Apply `condition_flag` gate (suppress if flags missing AND alt_duration_value is NULL; otherwise swap to alt_*).
- Resolve anchor: `anchor_alt='priority_date'` → use priorityDate; else `parent_id` → parent's computed date; else triggerDate.
- Apply `AnchorOverrides[rule_code]` if user set an override.
- 4-bucket court-set classification (§4).
- Calculate offset, apply holiday/weekend adjustment via `HolidayService`, store in `computed[code]` map.
5. Returns `UIResponse{Deadlines: []UIDeadline}` — the full timeline.
Strengths:
- Rich (condition flags, parent chains, anchor_alt, override map, court-set semantics).
- Single source of truth for /tools/fristenrechner + SmartTimeline.
- Backed by 172 rules across 27 proceedings.
Weaknesses:
- Returns the **whole proceeding** every call. No "give me only the rules triggered by event X" mode.
- Cross-proceeding spawn (is_spawn rules) is half-wired (§1.6).
- `condition_flag` is AND-only; no OR, NOT, or compound expression.
### 2.2 Path B — Single-rule (subset of A)
Caller: Pathway B cascade-card click → inline calc panel.
Flow:
1. User clicks a concept card; system picks the rule_id linked to that concept (via `event_category_concepts → deadline_rules`).
2. POST `/api/tools/fristenrechner/calculate-rule` with `{rule_id, trigger_date, flags?}`.
3. `FristenrechnerService.CalculateRule` walks the rule's parent chain only (no siblings), returns one `RuleCalculation`.
Strengths:
- Lightweight (no full-proceeding compute).
- Lets the cascade UI surface "click → see this rule's date" without rebuilding the whole timeline.
Weaknesses:
- Doesn't include side-effects (sibling rules in the proceeding that the user might also care about).
- Shares the same expressiveness limits as Path A.
### 2.3 Path C — Event-driven (youpc legacy, redundant)
Caller: Pathway A wizard's "Was kommt nach…" tab; `frontend/src/client/fristenrechner.ts:833` calls POST `/api/tools/event-deadlines`.
Flow:
1. User picks a **trigger_event** (e.g. "Klageerhebung UPC", "Berufungsschrift OLG", from a 110-row picker list).
2. POST `/api/tools/event-deadlines` with `{triggerEventID, triggerDate, courtID}`.
3. `EventDeadlineService.Calculate` loads `paliad.event_deadlines WHERE trigger_event_id = $te`.
4. For each row: apply `duration_value × duration_unit (+ timing: before/after)`. Supports `working_days` unit (Path A doesn't). Handles `alt_duration_value × combine_op (min/max)` composite leads.
5. Returns flat list of computed deadlines + rule_codes.
Strengths:
- Has the `before` timing semantic (Path A doesn't use it).
- Has `working_days` unit (Path A doesn't have it).
- Has `combine_op` (min/max) for composite duration math (Path A doesn't).
- Trigger-event picker is more discoverable than "pick a proceeding": user says "Klageerhebung happened on date X, what comes after?" without first navigating to the proceeding tree.
Weaknesses:
- **Disjoint corpus.** The 77 `event_deadlines` rows do NOT join to `paliad.deadline_rules`. Changing a rule in Path A doesn't update Path C.
- **No parent_id chains.** Each event_deadline is a single-leg calc off the trigger date. No multi-stage timelines.
- **No condition flags.** No with_ccr / with_amend gating.
- **No SmartTimeline integration.** ProjectionService only knows Path A.
- **Origin:** youpc.org ported (mig 028). Implicitly "legacy", but actively wired.
### 2.4 The concept layer (orthogonal to all three paths)
`paliad.deadline_concepts` (56 rows) is the **noun layer** that lets the cascade + search talk about "Klageerwiderung" without knowing which of the 9 jurisdiction-specific Klageerwiderung rules it means. Every rule has `concept_id` (171/172); every cascade leaf has zero or more `event_category_concepts` rows linking to concepts (153 rows, 100 distinct leaves of 103 → 97% coverage).
`paliad.deadline_concept_event_types` (32 rows, added mig 073/074) maps `(concept_id, jurisdiction) → event_type_id` so when the user creates a Deadline via the form by picking a Regel, the system can pre-fill the Typ chip with the canonical event_type. This is a **CONFIG layer, not a trigger model** — it doesn't say "when event X fires, these deadlines spawn." See §6.4.
### 2.5 Multi-deadline triggers
m's "specific events trigger specific deadlines, sometimes multiple" is implemented via **`parent_id` chains in Path A**. One root event (e.g. UPC_INF `inf.soc` = Klageerhebung) triggers a tree of dependent rules. Today the deepest live chain is **3 levels**:
```text
inf.soc (root, anchor)
├─ inf.sod (3mo after, Klageerwiderung)
│ ├─ inf.def_to_ccr ([with_ccr], 2mo after sod, Erwiderung auf CCR)
│ │ └─ inf.reply_def_ccr ([with_ccr], 2mo after, Replik auf Erwid CCR)
│ │ └─ inf.rejoin_reply_ccr ([with_ccr], 1mo after, Duplik)
│ ├─ inf.app_to_amend ([with_ccr,with_amend], 2mo after sod, Antrag Patentänderung)
│ │ ├─ inf.def_to_amend ([with_ccr,with_amend], 2mo after, Erwiderung)
│ │ └─ inf.reply_def_amd ([with_ccr,with_amend], 1mo after Reply, Replik Amend)
│ ├─ inf.reply (with_ccr → 2mo after sod RoP.029.a; without_ccr → swap to alt)
│ └─ inf.rejoin (with_ccr → 1mo after reply RoP.029.d)
└─ inf.interim (court-set, Zwischenverfahren)
└─ inf.oral (court-set, Mündliche Verhandlung)
└─ inf.decision (court-set, Entscheidung)
└─ inf.cost_app (1mo after decision, is_optional, Antrag Kostenentscheidung)
```
15 rules, 4 condition-flag-gated, 4 court-set placeholders (inf.interim / inf.oral / inf.decision are 0-duration court-set; inf.soc is 0-duration root), 1 optional. The structural fidelity is high.
### 2.6 Conditional triggers — the AND-only ceiling
`condition_flag` is `text[]` with **AND-of-array** semantic. To render the rule, every flag in the array must be in the caller's `Flags` set.
Live flag space: `{with_amend, with_ccr, with_cci}` — three flags, four combinations used. The empty array is the unconditional default.
This is enough to express:
- "with counterclaim for revocation" (with_ccr alone)
- "with counterclaim for revocation AND with amendment" (with_ccr + with_amend)
- "with counterclaim for infringement" (with_cci alone)
But not:
- "with_ccr OR with_cci" — would need OR, today not supported. (Live workaround: duplicate rules with each gate.)
- "NOT with_ccr" — also not supported.
- Compound: "with_ccr AND NOT expedited".
§6 flags this as a real coverage gap.
---
## 3. The 27 proceeding types — what's covered, what's a stub
### 3.1 Inventory
| Category | Code | Jurisdiction | Rule count | Notes |
|---|---|---|---|---|
| **fristenrechner** | DE_INF | DE | 9 | Verletzungsverfahren LG. |
| | DE_INF_OLG | DE | 7 | Berufung OLG. |
| | DE_INF_BGH | DE | 8 | Revision / NZB BGH. |
| | DE_NULL | DE | 10 | Nichtigkeit BPatG. |
| | DE_NULL_BGH | DE | 6 | Berufung BGH (Nichtigkeit). |
| | DPMA_OPP | DPMA | 4 | DPMA Einspruch. |
| | DPMA_BPATG_BESCHWERDE | DPMA | 5 | BPatG-Beschwerde nach DPMA. |
| | DPMA_BGH_RB | DPMA | 4 | Rechtsbeschwerde BGH. |
| | EPA_OPP | EPA | 8 | EPA Einspruch. |
| | EPA_APP | EPA | 8 | EPA Beschwerde. |
| | EP_GRANT | EPA | 7 | EP-Erteilung. One rule uses `anchor_alt='priority_date'`. |
| | UPC_INF | UPC | **15** | Verletzung. Richest corpus. |
| | UPC_REV | UPC | **15** | Nichtigkeit. Richest. |
| | UPC_APP | UPC | 7 | Berufung UPC. |
| | UPC_APP_ORDERS | UPC | 5 | Berufung gegen Anordnungen. |
| | UPC_COST_APPEAL | UPC | 2 | Kostenberufung. |
| | UPC_DAMAGES | UPC | 4 | Schadensbemessung. |
| | UPC_DISCOVERY | UPC | 4 | Bucheinsicht. |
| | UPC_PI | UPC | 4 | Einstweilige Maßnahmen. |
| **litigation** | INF | UPC | 8 | Infringement. |
| | REV | UPC | 7 | Revocation. |
| | CCR | UPC | 7 | Counterclaim for Revocation. |
| | APM | UPC | 4 | Provisional Measures. |
| | APP | UPC | 8 | Appeal. |
| | AMD | UPC | 2 | Application to Amend. |
| | ZPO_CIVIL | DE | 4 | ZPO Civil. |
Total: **172 rules across 27 proceeding types** (132 fristenrechner + 40 litigation).
### 3.2 Litigation vs Fristenrechner — the dual-corpus problem
The **same conceptual proceeding** (e.g. UPC Infringement) appears twice in `paliad.proceeding_types`:
- `INF` (category=`litigation`) — 8 rules, generic UPC labels (Statement of Claim, Statement of Defence, Reply, Rejoinder, Oral Hearing, Interim Conference, Decision, Preliminary Objection).
- `UPC_INF` (category=`fristenrechner`) — 15 rules, German labels + condition_flag variants.
The brief calls this out as "two parallel vocabularies." Live confirms:
- `paliad.projects.proceeding_type_id` accepts BOTH categories (no CHECK constraint enforces one or the other). Today all 11 projects are NULL anyway.
- `FristenrechnerService.Calculate(proceedingCode, …)` is **category-agnostic** — pass it `INF` or `UPC_INF`, you get back the respective corpus's timeline. No category guard.
- The Pathway-A wizard surfaces ONLY `category='fristenrechner'` codes (`internal/services/fristenrechner.go:735`: `WHERE category = 'fristenrechner' AND is_active = true`). So users can't pick `INF` from the wizard.
- `ProjectionService.computeProjections` resolves `proj.ProceedingTypeID → code` and calls Calculate with whatever code is on the project. So a project with `INF` would render the 8-rule litigation timeline; a project with `UPC_INF` would render the 15-rule fristenrechner timeline.
**This is a latent footgun.** Whichever code lands on a project first dictates which corpus drives its SmartTimeline. The two corpuses disagree on:
- Rule count (8 vs 15).
- Granularity (litigation has 1 ccr.defence row; fristenrechner has 7 with_ccr/with_amend gated rows).
- Language (litigation labels are English; fristenrechner German).
No code path treats this divergence intentionally. The likely intent at seed-time was:
- `litigation` codes = "the project model's coarse type enum" (Mandant-level taxonomy).
- `fristenrechner` codes = "the calculator's fine-grained variants".
But the actual schema doesn't enforce that contract. **Flagged as §6.2.**
### 3.3 Coverage observations
- **UPC corpus dominates fristenrechner.** 9 of the 20 fristenrechner codes are UPC (66 rules); 5 are DE (40); 3 are DPMA (13); 3 are EPA (23). Bias matches HLC's mandate mix.
- **DE_INF_OLG, DE_INF_BGH, DE_NULL_BGH** were split out late (mig 043). The instance dimension (LG / OLG / BGH) is NOT on `paliad.projects`, so you can't currently derive whether a DE project is at first instance, OLG, or BGH from the project model. This blocks fine-grained Akte → proceeding-code mapping (cross-referenced in t-paliad-166 §4.2).
- **EP_GRANT** is the only proceeding that uses `anchor_alt`. Other priority-date-anchored rules don't exist (yet).
- **UPC_REV.with_cci** — the [with_cci] flag is used for "revocation action with counterclaim for infringement" — i.e. when the defendant in a revocation files a CCI. Only UPC_REV uses with_cci today (4 rules).
### 3.4 Concept linkage gaps
9 of 56 deadline_concepts have `rule_count = 0` — i.e. cascade-reachable concepts that produce zero calculated deadlines:
| Concept slug | Why it's empty |
|---|---|
| `counterclaim-for-revocation` | The CCR flow is modelled inside UPC_INF via `[with_ccr]` flag-gated rules, not as a separate concept-linked rule. |
| `schriftsatznachreichung` | ZPO §296a "Schriftsatznachreichung" — cross-cutting concept, no rule encoding yet. |
| `versaeumnisurteil-einspruch` | ZPO §339 "Einspruch gegen Versäumnisurteil" — no rule. |
| `weiterbehandlung` | EPA Art. 121 EPÜ / R.135 — no rule. |
| `wiedereinsetzung` | Re-establishment of rights — cross-cutting; no rule. |
| `notice-of-defence-intention` | DE ZPO Verteidigungsanzeige — only ZPO_CIVIL has it; not linked. |
| Plus 3 more sparse concepts. | |
For each, the cascade can route the user to the concept card, but the card has no rule pills underneath. This is a real coverage gap surfaced as §6.
---
## 4. Anchor semantics — the 4-bucket model
Encoded in `fristenrechner.go:272-369`. For each rule with `duration_value = 0`:
| Bucket | parent_id | court-determined? | Behaviour |
|---|---|---|---|
| **1. Root anchor** | NULL | no | Due date = trigger date. `IsRootEvent=true`. The proceeding's "day zero" (e.g. SoC filing). |
| **2. Court-set absolute** | NULL | yes | Due date empty; UI shows "wird vom Gericht bestimmt". `IsCourtSet=true, IsCourtSetIndirect=false`. Used for top-level hearings / decisions that don't follow from another rule. |
| **3. Court-set chained** | set | yes | Due date empty (court determines); ancestor anchor. `IsCourtSet=true`. Used for derivative court actions. |
| **4. Filed-with-parent** | set | no | Inherits parent's calculated date. Used for "X is bundled into Y" (e.g. UPC_REV.rev.app_to_amend, rev.cc_inf — included in the Defence to revocation). |
For rules with `duration_value > 0`:
- **Override wins.** `AnchorOverrides[rule_code]` provided by user → use it; mark `IsOverridden=true`.
- **Parent court-set + no override** → mark `IsCourtSet=true, IsCourtSetIndirect=true`. The rule isn't directly court-determined, but its anchor (the court-set parent) hasn't been bound yet. UI shows "unbestimmt".
- **Otherwise:** baseDate = (anchor_alt=priority_date → priorityDate) || (parent_id → computed[parent.code]) || triggerDate. Add `duration_value × duration_unit`. Apply holiday adjustment. Done.
**Court-set detection** (`isCourtDeterminedRule` in calculator) keys on:
- `primary_party='court'`, OR
- `event_type ∈ {'hearing','decision','order'}`, OR
- Heuristic name match (legacy from migration 028).
This is brittle — the boolean is computed from columns that aren't strictly designed for it. §6.5 suggests promoting a real `is_court_set` column.
### 4.1 `AnchorOverrides` — the override map
The override surface is the bridge between "calculated forecast" and "real ground truth." Two consumers:
- **SmartTimeline (`ProjectionService.collectActualsForOverrides`)** — bind a real `paliad.deadlines` row's date back into the calculator: if a saved deadline has `rule_id=X` and `completed_at='2026-04-10'`, the next projection uses 2026-04-10 as the anchor for any rule whose parent is X.
- **Pathway A wizard "Anchor edits"** — the user can override a per-rule date inline in the timeline (paliad-088 era feature). Applies to court-set rules where the user finally knows the decision date.
The override map propagates **downstream**: child rules see the override as their parent's date.
This is a strong, well-implemented mechanism. No gap.
---
## 5. Adjustment semantics — weekends, holidays, court calendars
### 5.1 `HolidayService.AdjustForNonWorkingDaysWithReason(endDate, country, regime)`
Called after every offset computation. Returns `(adjusted, _, wasAdjusted, reason)`.
- If endDate is a weekend → roll to next Monday. Reason: `kind=weekend, original_weekday`.
- If endDate is a public holiday (region match in `paliad.holidays`) → roll to next business day. Reason: `kind=public_holiday, holidays=[…]`.
- If endDate is inside a court vacation (regime-specific date range) → roll to first non-vacation business day. Reason: `kind=vacation, vacation_name, vacation_start, vacation_end`.
Live `paliad.holidays`: **55 rows**, mix of public holidays and vacation periods. `region` axis covers DE federal + state-specific + UPC court-specific.
### 5.2 `CourtService.CountryRegime(courtID, defaultCountry, defaultRegime)`
`paliad.courts` (41 rows) carries `country` and `regime` per court. Defaults via jurisdiction:
- UPC-flavoured proceedings → DE+UPC (UPC München is the default venue).
- DE proceedings → DE.
- EPA / DPMA → DE.
Live regimes inferred from queries: DE state codes (BY, BW, …), UPC court-specific tags. No formal CHECK constraint listing valid regimes.
### 5.3 Working-day arithmetic — split between calculators
Pipeline C (`EventDeadlineService.addWorkingDays`) supports `duration_unit='working_days'`: step forward N business days, skipping weekends + holidays.
Pipeline A (`FristenrechnerService`) does NOT support working_days; only calendar days/weeks/months. Adjustment is post-hoc (compute the calendar date, then roll forward if it lands on a non-business day).
**The two calculators are not equivalent.** Some real-world deadlines are "10 working days after Z" — those can only be expressed in Pipeline C today. Cross-references §6.6.
---
## 6. Coverage gaps (the heart of the audit)
What m's mental model wants ("specific events trigger specific deadlines, sometimes multiple, sometimes conditional") that the data model cannot express today.
### 6.1 Two trigger systems — Pipeline A vs Pipeline C
**Symptom.** Two disjoint data corpuses (`deadline_rules` 172 vs `trigger_events`+`event_deadlines` 110+77) with overlapping intent. A change to a rule in Pipeline A doesn't propagate to Pipeline C. The user-facing "Was kommt nach…" tab (Pipeline C) renders different numbers than the wizard timeline (Pipeline A) for nominally-similar trigger events.
**Impact.** Pipeline C has capabilities Pipeline A lacks (`before` timing, `working_days` unit, `combine_op` min/max) — but no parent chains, no condition_flag, no court-set semantic. Choosing the "right" pipeline today means picking which subset of capabilities the user actually needs for that case.
**Root cause.** Pipeline C is a youpc.org port (mig 028). Pipeline A is paliad-native (mig 009 → 050 evolution). They were never reconciled.
### 6.2 Litigation vs fristenrechner corpus drift
**Symptom.** `paliad.projects.proceeding_type_id` accepts both `litigation` and `fristenrechner` codes. The same conceptual proceeding has rule corpuses of different size, granularity, and language depending on which category the project lands on.
**Impact.** SmartTimeline forecast for a project depends on which code is chosen at project-create time. Two HLC partners filing identical UPC infringement cases could see different timelines if one picked `INF` and the other `UPC_INF`.
**Root cause.** No CHECK constraint, no documentation, no UI guard. Likely intent: `litigation` for project-model coarse classification, `fristenrechner` for fine-grained calculator — but the contract was never formalised.
### 6.3 `is_mandatory` vs `is_optional` semantic overlap
**Symptom.** Two boolean columns with overlapping meaning. Current usage:
- `is_mandatory=true, is_optional=false` — default (most rules).
- `is_mandatory=true, is_optional=true` — surfaces in timeline but pre-unchecked in save-modal (only UPC_INF.inf.cost_app + a few others).
- `is_mandatory=false` — unclear semantics today; sparsely used.
**Impact.** Confusing for both developers and future rule authors. A rule with `is_mandatory=false, is_optional=true` (legal "may file but not required") versus `is_mandatory=true, is_optional=true` (legal "should file but isn't a hard deadline") versus `is_mandatory=true, is_optional=false` (legal "must file") — the four-way matrix isn't well-defined.
**Root cause.** `is_optional` was added late (mig 068) as a UX hack ("pre-uncheck in save modal") rather than a semantic axis.
### 6.4 `deadline_concept_event_types` is a config layer, not a trigger model
**Symptom.** The table maps `(concept, jurisdiction) → event_type` for the create-form's chip suggestion. It DOES NOT support "when an event of type X fires, spawn deadlines for these rules."
**Impact.** m's "specific events trigger specific deadlines" implies a directional pipeline: user logs an event → system computes the deadlines that flow from it. That pipeline today exists only via:
- Pipeline A's full-proceeding compute (heavy: gives everything, not just X's children).
- Pipeline C's trigger_event picker (decoupled corpus).
There's no event_type-keyed entry point into Pipeline A. The cascade gets close — leaf → concept → rules — but stops at "show the cards"; firing the rules requires the user to manually click a card → calculate-rule.
**Root cause.** Pipeline A was designed proceeding-first (mig 009, 2024). The event-first paradigm came later via concepts (mig 037+) but never produced a dedicated trigger endpoint.
### 6.5 Court-set detection is heuristic
**Symptom.** `isCourtDeterminedRule()` decides court-set status from `primary_party='court' OR event_type IN ('hearing','decision','order') OR name-heuristic`. No dedicated boolean column.
**Impact.** False positives possible if a rule names "decision" but isn't court-set (e.g. "preliminary decision to amend"). False negatives possible if a court-set rule isn't tagged with one of these signals.
**Root cause.** Court-set semantic was never formalised as a first-class column. Inferred at runtime.
### 6.6 Pipeline A lacks `before`, `working_days`, `combine_op`
**Symptom.** Specific gaps in expressive power:
- `before` timing: useful for "must be filed Y days BEFORE oral hearing." Pipeline C honours `timing='before'`; Pipeline A only renders `timing='after'` rules.
- `working_days` unit: useful for procedural deadlines like UPC R.220.3 ("3 working days from notification"). Pipeline C supports it; Pipeline A doesn't.
- `combine_op` (min/max): useful for "earlier of X or Y" (compound deadlines, e.g. EPC R.36 — "shortest of priority date+24mo or filing date+18mo"). Pipeline C supports it; Pipeline A doesn't.
**Impact.** Some legal deadlines can only be expressed in Pipeline C, fragmenting the rule corpus.
**Root cause.** Pipeline A grew from a "tree of forward offsets" model; backward / composite deadlines weren't anticipated.
### 6.7 Condition-flag grammar is AND-only
**Symptom.** `condition_flag` is `text[]` with AND semantics. No OR, no NOT, no nested expression.
**Impact.** Real legal scenarios that need OR (e.g. "rule X applies if CCR OR CCI is filed") get encoded as **two duplicate rules** today — one for each branch. Painful to maintain; easy to drift.
**Root cause.** The flag axis was designed for the small set of UPC variant flags (`with_ccr`, `with_amend`, `with_cci`); compound expressions weren't anticipated.
### 6.8 Cross-proceeding spawn is half-wired
**Symptom.** `is_spawn=true` rules exist (8 live), intended to express "when X happens in proceeding A, also trigger Y in proceeding B." The calculator code at `projection_service.go:896-901` explicitly notes: "Cross-proceeding spawn … We don't have that rule in our map; skip the dependency annotation but still surface the row."
**Impact.** A UPC_INF decision firing an APP proceeding (cross-proceeding) renders the spawned row, but the dependency-graph annotation breaks. SmartTimeline can't fully chain across proceedings.
**Root cause.** Cross-proceeding spawn was a late addition; the calculator's `ruleByID` map is per-proceeding, so it can't resolve spawns from other proceedings. Needs either a global rule index or a smarter resolver.
### 6.9 Nine orphan concepts with `rule_count=0`
Per §3.4: `counterclaim-for-revocation`, `schriftsatznachreichung`, `versaeumnisurteil-einspruch`, `weiterbehandlung`, `wiedereinsetzung`, `notice-of-defence-intention`, plus 3 more.
**Impact.** Cascade leaves can reach these concepts, but the user sees an empty result card. UX feels broken even though it's an unrelated coverage gap (no rules seeded yet).
**Root cause.** Cascade taxonomy was seeded ahead of the rule corpus for some concepts. The seed work never caught up.
### 6.10 No way to express "X is conditional on Y having fired"
**Symptom.** `condition_rule_id` exists as a column but is 0% populated. Was intended for "rule X applies only if rule Y was previously triggered" but never wired.
**Impact.** Today's flag mechanism (condition_flag) gates on **caller-supplied flags** (e.g. user toggles "with_ccr" in the UI). It doesn't gate on **runtime rule firing**. So you can't express "if the defendant filed Preliminary Objection (rule X), then rule Y is suspended for 2mo."
**Root cause.** Column added speculatively; never wired into the calculator.
### 6.11 The instance dimension (LG/OLG/BGH) isn't on `paliad.projects`
**Symptom.** The proceeding_types `DE_INF_OLG` / `DE_INF_BGH` exist, but a project can't carry "I'm at first instance" / "I'm on appeal at OLG" as data. The user has to manually pick a different `proceeding_type_id` if the case moves up the instances.
**Impact.** SmartTimeline forecast can't auto-advance from DE_INF → DE_INF_OLG when a Berufungsschrift fires on the actuals side.
**Root cause.** Project model treats proceeding-type as a static attribute, not a state machine.
### 6.12 No rule audit log
**Symptom.** Rules are modified by SQL migrations only. There's no `paliad.deadline_rule_audit` table tracking "rule X changed from 3mo to 2mo on 2026-04-15 by m, because Y." Migrations are technically the audit trail, but they aren't queryable in-app.
**Impact.** Rule-management UX (§8) needs an answer for "who changed this rule and why." Without an audit trail, rule-editing in-app is a step backward in compliance.
**Root cause.** Never needed before, because rules were never user-editable.
### 6.13 Zero deadline → rule linkage in live data
**Symptom.** Only **1 of 26** live deadlines has `rule_id` populated.
**Impact.** SmartTimeline's "anchor real deadlines into projection" feature (Pipeline A's strongest UX) is unusable on existing data. New deadlines saved via the wizard *do* get rule_id; legacy deadlines don't.
**Root cause.** Schema migrated incrementally; backfill never happened.
---
## 7. Extension proposals (one concrete change per §6 gap)
Each gap from §6 gets a concrete schema / service change, costed (migration + service + UI ripples).
### 7.1 Reconcile Pipelines A and C
**Proposal.** Migrate `paliad.event_deadlines` into `paliad.deadline_rules` with a new column `trigger_event_id` (nullable FK to `paliad.trigger_events`). A rule with `trigger_event_id NOT NULL` is event-triggered (Pipeline C semantics); with NULL it stays proceeding-triggered (Pipeline A).
Add the Pipeline-C-only columns to `deadline_rules`:
- `timing` already exists; backfill non-NULL `before` values.
- `combine_op``{min, max, NULL}` — new column.
- `working_days` as a valid `duration_unit` value — already a string column, no schema change.
Then deprecate Pipelines C, redirecting `/api/tools/event-deadlines` to the unified calculator.
**Cost.**
- Migration: 1 file, ~120 LoC SQL (column adds + data move + idx).
- Service: `FristenrechnerService.Calculate` extends to honour `timing='before'`, `working_days`, `combine_op`. ~80 LoC Go.
- Service: `EventDeadlineService` either deletes (clean) or proxies to FristenrechnerService (transitional).
- Handler: `/api/tools/event-deadlines` either deletes or 302s.
- Frontend: `client/fristenrechner.ts:833` — the "Was kommt nach…" tab can call the unified endpoint.
- Tests: a fresh table-driven test fixture covers the union behaviour.
**Ripple.** No data loss; trigger_event_id is additive. Frontend mostly transparent.
### 7.2 Formalise litigation vs fristenrechner contract
**Proposal.** Two options:
- **(a) Hard-split.** Add `CHECK constraint` to `paliad.projects.proceeding_type_id`: only `category='litigation'` codes allowed. Migrate the 8-rule litigation corpus to be the canonical "project-level proceeding type". Move the fine-grained `category='fristenrechner'` rules under each litigation code via a new `variant` column.
- **(b) Soft-merge.** Drop the `category` discriminator entirely. Every proceeding_type carries its full rule corpus. The dual-corpus today (8-rule INF + 15-rule UPC_INF) merges into ONE 15-rule UPC_INF, with the project model referencing only the rich variant.
**Cost.** (a) is invasive — migration to move 40 litigation-corpus rules under the fristenrechner codes; (b) is less invasive but means projects switch to picking `UPC_INF` instead of `INF`.
**Recommendation.** **(b)**. The dual-corpus is legacy from a project-model + calculator-model that grew separately. One canonical proceeding_type per case is cleaner.
**Ripple.** Project-create form picker changes from "INF / REV / CCR / APM / APP / AMD / ZPO_CIVIL" to the full 20-code fristenrechner picker (or a curated subset). t-paliad-166's mapping helper becomes unnecessary.
### 7.3 Clean up `is_mandatory` vs `is_optional`
**Proposal.** Replace both with a single `deadline_kind` enum:
- `mandatory` — must be addressed.
- `recommended` — should be addressed (pre-checked in save-modal but not required).
- `optional` — may be addressed (pre-unchecked in save-modal).
- `informational` — never saves as a deadline, surfaces as info.
Backfill: `is_mandatory=true, is_optional=false → mandatory`; `is_mandatory=true, is_optional=true → optional`; `is_mandatory=false → recommended`.
**Cost.** Migration ~30 LoC SQL. Service: `UIDeadline` exposes `Kind` instead of `IsMandatory`+`IsOptional`. Frontend: badge logic + save-modal pre-check.
### 7.4 Add a real event-driven trigger endpoint
**Proposal.** `POST /api/tools/event-trigger` with `{event_type_slug, trigger_date, project_id?}`. Resolves:
1. `event_types.slug → event_types.id`
2. `deadline_concept_event_types.event_type_id → concept_id` (per jurisdiction from project or explicit)
3. `deadline_rules.concept_id → rules`
4. Calculate the rules + their parent chains via Pipeline A.
Returns just the rules that flow from this event (filtered Pipeline A response).
**Cost.** Handler + service method, ~100 LoC. No schema change; uses existing junction.
**Ripple.** Lets the cascade UI offer "I just logged this event — here are the deadlines that follow" in one click. Also unlocks Phase-H-style email parsing → deadline spawn.
### 7.5 Promote court-set to a real column
**Proposal.** Add `is_court_set boolean NOT NULL DEFAULT false` to `paliad.deadline_rules`. Backfill from the heuristic. Calculator reads the column instead of inferring.
**Cost.** Migration ~20 LoC SQL (incl. backfill DO$$ block). Service: 1-line change in `isCourtDeterminedRule`.
**Ripple.** Faster + correct + no behaviour surprise. Cheap win.
### 7.6 Pipeline A gains `before` / `working_days` / `combine_op`
Covered in §7.1 (reconciliation).
### 7.7 Compound condition grammar
**Proposal.** Replace `condition_flag text[]` with `condition_expr jsonb`. Schema:
```json
{"op":"and", "args":[{"flag":"with_ccr"},{"op":"not","args":[{"flag":"expedited"}]}]}
```
Backfill: `['with_ccr','with_amend']``{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`.
**Cost.** Migration with backfill ~80 LoC. Service: small recursive evaluator (~50 LoC Go). UI: condition picker for rule-editor (§8) — more involved.
**Ripple.** Future rule authors can express OR / NOT cleanly. No data drift; backward-compatible eval.
### 7.8 Wire cross-proceeding spawn
**Proposal.** Change `DeadlineRuleService.List(proceedingTypeID *int)` to allow a "follow spawn" mode that returns rules from spawned proceedings as well. Or: in `projection_service.computeProjections`, when a rule has `is_spawn=true` and the calculator returns a row from a different proceeding code, load the target proceeding's rule corpus lazily.
**Cost.** Service: ~50 LoC. Calculator: ~30 LoC. Risk: cycle prevention (don't infinite-loop A→B→A).
**Ripple.** SmartTimeline can fully chain across proceedings. The dependency-annotation breakage at `projection_service.go:896-901` resolves.
### 7.9 Seed the 9 orphan concepts with rules
**Proposal.** Per concept, add 13 rules to the appropriate proceeding_types. e.g. `wiedereinsetzung` → UPC R.320.1 (`UPC_INF.wiedereinsetzung`), EPA R.136 (`EPA_OPP.wiedereinsetzung`), DE PatG §123 (`DE_INF.wiedereinsetzung`).
**Cost.** Per orphan concept: ~20 LoC SQL. Total ~150 LoC across 9 concepts. Legal review required per rule.
**Ripple.** Cascade no longer dead-ends. This is the "coverage" gap m's t-paliad-167 explicitly called for.
### 7.10 Wire `condition_rule_id` or drop it
**Proposal.** Either:
- (a) Implement: when calculator walks rules, gate a rule's render on `condition_rule_id`'s presence in the `computed` map.
- (b) Drop the dead column.
**Recommendation.** **(b)**. The semantic is rarely needed; `condition_flag` covers most variant cases. Future need can resurrect.
### 7.11 Add `instance_level` to `paliad.projects`
**Proposal.** New column `instance_level text``{first, appeal_olg, appeal_bgh, NULL}`. Combined with `proceeding_type.code` + `jurisdiction`, lets us derive `DE_INF_OLG` vs `DE_INF` from a project.
**Cost.** Migration ~10 LoC SQL. Project form: new picker. SmartTimeline forecast: small refactor in `proceedingCodeForProject`.
### 7.12 Rule audit log
**Proposal.** New table `paliad.deadline_rule_audit (id, rule_id, changed_by, changed_at, before_json, after_json, reason text)`. Trigger on UPDATE/INSERT/DELETE captures the diff. Required if §8 lands.
**Cost.** Migration ~40 LoC SQL (table + trigger). Read API for compliance review.
### 7.13 Backfill `rule_id` on existing deadlines
**Proposal.** One-time migration: for each `paliad.deadlines` row, fuzzy-match `title` against `paliad.deadline_concepts.aliases` + `paliad.deadline_rules.name`, link the highest-confidence match, leave low-confidence unlinked.
**Cost.** Migration ~100 LoC SQL. Run once.
**Ripple.** SmartTimeline anchor-from-actuals starts working for existing data. Bigger UX win than it sounds.
---
## 8. Rule-management UX — does m need an in-app rule editor?
m's "all in the Rules so we should be able to manage" reads as a direct ask.
### 8.1 The case for an in-app rule editor
- **Today: SQL migration only.** Every rule add/edit/disable requires a developer to write a migration, get reviewed, merge, deploy. The feedback loop is hours-to-days.
- **Domain experts ≠ developers.** m is the rule expert. He shouldn't need to write `INSERT INTO paliad.deadline_rules (proceeding_type_id, code, name, duration_value, …)` SQL.
- **Coverage gaps are persistent** (§3.4, §6.9). They stay open longer because the workflow is high-friction.
- **Real-world law changes.** Procedural rules update (e.g. UPC R.49 just had a 2026-Q1 revision). Capturing those in SQL migrations is fragile.
### 8.2 The case against
- **Compliance / audit.** Rules are legal infrastructure. Any user-edit must be auditable, reviewable, reversible.
- **Schema complexity.** 32 columns with semantic nuances (court-set heuristic, parent_id topology, condition_flag grammar). Naive form UI = footgun heaven.
- **Cross-rule validation.** parent_id chains must remain DAGs. sequence_order must be topologically consistent. condition_flag values must be in a valid vocabulary. No live constraint catches all of these today.
- **Build cost.** A real rule-editor with audit log, validation, preview, dry-run, and rollback is 46 PRs of work.
### 8.3 Three options
| Option | Description | Effort | When right |
|---|---|---|---|
| **(A) Status quo: SQL only** | Keep migrations as the rule-edit surface. Build tooling around migration authoring (mAi-assisted SQL gen, schema validators). | Low (~1 sprint of tooling). | If m's rule velocity is < 1 edit/week and audit trail is non-negotiable. |
| **(B) Read-only admin surface** | Add `/admin/rules` page that lists rules, lets m search/filter/inspect. No edits in-app; "edit this rule" links to a Gitea issue template that drafts the migration. | Medium (~1 PR backend listing + 1 PR frontend). | If the friction is "I can't see what's in there" more than "I can't change what's there". |
| **(C) Full rule editor** | `/admin/rules/{id}/edit` with form, validation, audit log, preview-on-trigger-date, "ship draft" migration generator. | High (~4-6 PRs). | If m is genuinely going to edit rules weekly and the rule corpus is going to grow significantly. |
### 8.4 Inventor recommendation
**Start with (B), graduate to (C).**
- (B) immediately removes the "I can't see what's in there" friction, which today requires running SQL by hand or asking a developer. Low risk.
- (B) makes the rule corpus discoverable inside the app which is itself a win for transparency and for spotting coverage gaps 3.4).
- The Gitea-issue handoff preserves the audit trail and review workflow.
- Once the corpus is browsable, the "I keep wanting to edit this thing" pressure tells us whether (C) is worth building.
- **(C) without (B) is over-engineering** we'd be building the form before we know which fields are actually edited often.
Hard requirement for (C) if we get there: `paliad.deadline_rule_audit` table 7.12) with mandatory `reason` field, reviewer workflow, and migration-export so changes still land in version control.
§9 Q5 surfaces this for m's call.
---
## 9. Open questions for m (Phase 2 steering)
These are the 1015 picks for m to make before Phase 2 starts.
**Q1 — Reconciliation of Pipelines A and C.** §6.1 + §7.1. Three options:
- (a) Merge into one table (recommended; ~120 LoC migration + 80 LoC Go).
- (b) Keep both but document the contract (cheap, but the drift continues).
- (c) Deprecate Pipeline C entirely (deletes "Was kommt nach…" tab UX loss).
**Q2 — Litigation vs fristenrechner corpus.** §6.2 + §7.2. Two options:
- (a) Hard-split with CHECK constraint + rule migration (invasive).
- (b) Soft-merge: drop the category discriminator, projects use fristenrechner codes only (recommended).
**Q3 — `is_mandatory` / `is_optional` cleanup.** §6.3 + §7.3. Pick the 4-value enum (`mandatory` / `recommended` / `optional` / `informational`) or keep the two booleans with formal docs.
**Q4 — Event-driven trigger endpoint.** §6.4 + §7.4. Build `POST /api/tools/event-trigger` (concept-keyed) now, or defer until rule corpus is reconciled?
**Q5 — Rule-management UX.** §8. Pick:
- (A) status quo SQL only,
- (B) read-only admin surface (recommended start),
- (C) full editor with audit log.
**Q6 — Compound condition grammar.** §6.7 + §7.7. Move to `condition_expr jsonb` with AND/OR/NOT, or stay with `condition_flag text[]` AND-only and live with duplicate rules?
**Q7 — Cross-proceeding spawn.** §6.8 + §7.8. Wire it (let SmartTimeline chain across proceedings), or accept the current half-wired state?
**Q8 — Orphan concept seed.** §3.4 + §7.9. Priority order for the 9 missing-rule concepts? My guess: wiedereinsetzung > schriftsatznachreichung > versaeumnisurteil > weiterbehandlung > others. Legal review per concept.
**Q9 — Instance level on `paliad.projects`.** §6.11 + §7.11. Add `instance_level` column to support the DE_INF / DE_INF_OLG / DE_INF_BGH ladder, or accept that users manually re-pick proceeding_type on appeal?
**Q10 — Backfill `rule_id` on existing deadlines.** §6.13 + §7.13. Run the one-time fuzzy-match migration, or live with the broken anchor-from-actuals on legacy rows?
**Q11 — `working_days` and `before` semantics in Pipeline A.** §5.3 + §6.6. Add (recommended) or live without them?
**Q12 — Court-set as a real column.** §6.5 + §7.5. Promote (cheap win), or keep the heuristic?
**Q13 — Drop `condition_rule_id` dead column.** §1.6 + §7.10. Drop or wire?
**Q14 — Phase 2 cadence.** How should we structure the iterative refinement? Options:
- (a) m drives via the worker pane — m raises concrete cases ("counterclaim with amendment in expedited proceedings"), worker proposes encoding, commits incrementally.
- (b) Inventor (pauli) drafts a Phase 2 design for the §7 extensions in priority order m picks here, m gates.
- (c) Mixed: m picks the top 2 from §9 (Q1Q13) for Phase 2, the rest deferred to Phase 3.
**Q15 — Phase 3 framing.** Once Phase 2 lands the data-model changes, is the goal:
- (a) Build the rule editor (§8 option C), or
- (b) Backfill coverage gaps (§7.9), or
- (c) Wire SmartTimeline cross-proceeding chains (§7.8), or
- (d) Some other priority m has in mind?
---
## AUDIT READY FOR REVIEW
Awaiting m's go/no-go on §9 Q1Q15 before Phase 2 starts. Inventor (pauli) parks after this commit — no implementation kickoff, no other-skill autoload, m gates the audit → Phase 2 transition.
Recommended Phase 2 worker: depends on m's Q14 pick. If (a) interactive pair-prog, then pauli or feynman. If (b) inventor design pass, pauli has the freshest context. If (c) mixed, pauli for design, hand off to a Sonnet coder for each landed extension. **NOT cronus per memory directive 2026-05-06.**

View File

@@ -0,0 +1,417 @@
# Audit — UPC Rules of Procedure deadline coverage in paliad
**Author:** curie (researcher)
**Date:** 2026-05-08
**Task:** t-paliad-159 (Gitea m/paliad#14, RoP audit aspect)
**Mode:** read-only research; produces a gap-list, not migrations.
Companion to `docs/audit-fristenrechner-completeness-2026-04-30.md`. That audit drove from youpc's existing deadline corpus (~64 RoP codes referenced); this one drives **from the UPC Rules of Procedure themselves**, taking a frequency-weighted slice of what a real INF/REV/APP proceeding has to track.
---
## 1. Scope
**RoP sections audited (8 sections):**
| Code | Section | RoP rules in scope (deadline-creating) |
|---|---|---|
| A | Pleadings — Infringement (UPC_INF) | R.13, R.17, R.19, R.23, R.24, R.25, R.29.a/b/c/d/e, R.30, R.32 |
| B | Pleadings — Revocation + CCR + DNI (UPC_REV) | R.42, R.44, R.49.1, R.49.2.a, R.49.2.b, R.50, R.51, R.52, R.55, R.56, R.61, R.63, R.65, R.67, R.68, R.69, R.70 |
| C | Provisional measures + evidence preservation | R.197.3, R.198, R.205, R.207.6, R.207.9, R.211.2, R.213 |
| D | Damages + lay-open books | R.125, R.131.2, R.137, R.139, R.141, R.142.2, R.142.3 |
| E | Decisions, costs, default judgment | R.111, R.118.4, R.151, R.157, R.221.1 |
| F | Appeals | R.220.1.a/b/c, R.220.2, R.220.3, R.221.1, R.224.1.a/b, R.224.2.a/b, R.229.2, R.234.1, R.235.1, R.235.2, R.237, R.238.1, R.238.2, R.245.2 |
| G | Re-establishment, case-management, miscellaneous | R.262.2, R.295, R.320, R.321.3, R.331, R.333.2, R.353 |
| H | Oral-hearing prep + translations | R.109.1, R.109.4, R.109.5 |
**Out of scope here** (deferred to a follow-up audit):
- EPO opposition + Beschwerde (Art. 99 / R.99 EPÜ — paliad models these via EPA_OPP / EPA_APP, separate concern)
- DPMA / BPatG / BGH families (modelled via DE_*, DPMA_* — separate concern)
- ZPO civil-procedure deadlines around stay/severance
- UPC court-fee deadlines (Art. 70 UPCA / R.370)
- R.32(2) extensions of time (judge-set, no fixed duration)
- Judicial discretion items without a fixed period (stays under R.295, choice-of-language under R.323, joinder, intervention R.313)
- "Notice of intent to defend" (1mo, R.23 reaction): explicitly excluded by migration 052 §2 — no UPC rule exists for that concept.
---
**Authoritative source for RoP text:** the youpc Postgres `data.laws_contents` table (law_type = `UPCRoP`, English language). Cross-checked the in-scope rules against the actual rule text rather than relying on prior summaries — this is what surfaced the two duration bugs in §B (R.49.1 and R.52). All other high-frequency durations (R.23, R.29.a/b/c/d/e, R.32.1/3, R.43.3, R.56.1/3/4, R.137.2, R.139, R.142.2/3, R.151, R.220.2, R.221.1, R.224.1.a/b, R.224.2.a/b, R.235.1/2, R.238.1/2) were cross-checked and confirmed.
## 2. Methodology
For every RoP rule in the in-scope list:
1. Identify the trigger event (what starts the period).
2. Identify the duration + unit (calendar days / months / before-or-after).
3. Look up paliad's rule library. Three lookup paths:
- `paliad.deadline_rules` (the proceeding-tree shape used by Fristenrechner Pathway A — "pick proceeding type, see whole timeline").
- `paliad.deadline_concepts` × `paliad.event_category_concepts` (the cascade shape used by Pathway B — "pick what just landed in the CMS, see what reacts").
- `paliad.trigger_events` (the youpc-style event list, 90+ rows, used by the search/autocomplete surface).
4. Assign a status: `present-correct` / `present-wrong` / `partial` / `missing` / `n/a`.
Status definitions:
- **present-correct** — paliad has a row with the right RoP code, duration, anchor, and (where relevant) primary_party.
- **present-wrong** — paliad has a row that fires for this rule but with a wrong duration / anchor / condition.
- **partial** — paliad has the rule for one branch (e.g. proactive only, or one party only) but is missing the symmetric branch.
- **missing** — no rule, no concept-card, no trigger-event entry covers this.
- **n/a** — RoP rule doesn't create a tracked deadline (e.g. judge sets it ad hoc, or rule is purely structural).
Frequency tag (column "Freq" in §3 tables):
- **★★★** — every UPC infringement / revocation / appeal will hit this.
- **★★** — common (most cases at some stage).
- **★** — specialist (PI, saisie, damages-only, rehearing).
The 2026-04-30 audit (§3, §4) already enumerated the core youpc gaps. Where a finding here overlaps that audit, I cite it (`see 2026-04-30 §X`) and avoid restating.
---
## 3. Findings
### Section A — Infringement pleadings (R.13R.32)
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|---|---|---|---|---|---|---|
| R.13 | Klageerhebung (filing of SoC) | — (anchor event, duration 0) | `inf.soc` | present-correct | ★★★ | UPC_INF root. |
| R.17 | Decision on language of proceedings | judge-discretion | n/a (no auto-deadline) | n/a | ★ | Court-set; no Fristenrechner row needed. |
| R.19 | Service of SoC → Preliminary Objection | 1 month | **missing** | missing | ★★ | No `inf.prelim_objection` row in UPC_INF. The cascade has no "Vorgängige Einrede" leaf either. Trigger event 68 (`preliminary_objection`) exists in `paliad.trigger_events` but has no rule attached. |
| R.20.2 | PO → Reply to PO | 14 days | missing | missing | ★ | Fringe but real — defendant's PO triggers a 14d response window. |
| R.23 | Service of SoC → Statement of Defence | 3 months | `inf.sod` (RoP.023) | present-correct | ★★★ | Rule code format is `RoP.023` — was normalised since 2026-04-30 §4.3. |
| R.24 / R.25 | SoD with CCR | (CCR rolled into SoD, 3mo) | `cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr` (cascade leaf) | present-correct | ★★★ | Cascade leaf maps to `defence-to-counterclaim-for-revocation` concept. |
| R.29.a | SoD-with-CCR served → Defence to CCR + Reply to SoD | 2 months | `inf.def_to_ccr` (RoP.029.a) | present-correct | ★★★ | |
| R.29.b | SoD-without-CCR served → Reply to SoD | 2 months | `inf.reply` (RoP.029.b, alt RoP.029.a) | present-correct | ★★★ | Adaptive: alt branch flips for with-CCR. Migration 050 wired the bilateral backfill. |
| R.29.c | Reply served → Rejoinder | 1 month | `inf.rejoin` (RoP.029.c, alt RoP.029.d) | present-correct | ★★★ | Adaptive. |
| R.29.d | Reply-to-defence-to-CCR served → Reply to that | 2 months | `inf.reply_def_ccr` (RoP.029.d) | present-correct | ★★ | |
| R.29.e | That reply served → Rejoinder | 1 month | `inf.rejoin_reply_ccr` (RoP.029.e) | present-correct | ★★ | |
| R.30.1 | Defendant filing Application to Amend (with CCR) | 0 (ride along with SoD) | `inf.app_to_amend` (RoP.030.1) | present-correct | ★★ | |
| R.32.1 | Application to Amend served → Defence to Amend | 2 months | `inf.def_to_amend` (RoP.032.1) | present-correct | ★★ | |
| R.32.3 | Defence-to-Amend served → Reply | 1 month | `inf.reply_def_amd` (RoP.032.3) | present-correct | ★★ | |
| R.32.3 | Reply-on-Amend served → Rejoinder | 1 month | `inf.rejoin_amd` (RoP.032.3) | present-correct | ★★ | Same code, different rule row (rejoinder branch). Reused code is intentional. |
**Section A net:** 13 rules covered correctly. **2 missing** (R.19 Preliminary Objection 1mo, R.20.2 Reply to PO 14d).
---
### Section B — Revocation + CCR + DNI (R.42R.70)
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|---|---|---|---|---|---|---|
| R.42 | Filing of Nichtigkeitsklage (REV) | 0 anchor | `rev.app` | present-correct | ★★★ | UPC_REV root. |
| **R.49.1** | Service of REV → Defence to Revocation | **2 months** | `rev.defence` (**3 months**, RoP.49.1) | **present-wrong (DURATION + rule_code)** | ★★★ | **Confirmed via youpc UPCRoP.049.1 text:** *"The defendant shall lodge a Defence to revocation within two months of service of the Statement for revocation."* paliad seeded 3 months — copy-paste from R.23 (UPC_INF Defence which is correctly 3mo). **High-impact bug — REV Defence is the most-used revocation deadline.** Rule_code also drift (`RoP.49.1``RoP.049.1`). |
| R.49.2.a | REV → Application to Amend | 0 (rides along with Defence) | `rev.app_to_amend` (RoP.049.2.a) | present-correct | ★★ | |
| R.49.2.b | REV → Counterclaim for Infringement (CCI) | 0 (rides along) | `rev.cc_inf` (RoP.049.2.b) | present-correct | ★★ | |
| **R.51** | Defence to Revocation served → Reply | 2 months | `rev.reply` (2mo, no rule_code) | present-wrong (rule_code only) | ★★★ | **Confirmed via youpc UPCRoP.051.p1 text:** *"Within two months of service of the Defence to revocation the claimant may lodge a Reply…"* Duration correct. Rule_code is NULL — add `RoP.051`. (Note: my initial draft cited "R.50" for this; the actual rule is R.51 — R.50 is "Contents of the Defence to revocation" with no duration.) |
| **R.52** | Reply served → Rejoinder | **1 month** | `rev.rejoin` (**2 months**, no rule_code) | **present-wrong (DURATION + rule_code)** | ★★★ | **Confirmed via youpc UPCRoP.052.p1 text:** *"Within one month of the service of the Reply the defendant may lodge a Rejoinder…"* paliad seeded 2 months — bug. Rejoinder is symmetric with R.29.c (UPC_INF rejoinder, also 1mo). Add rule_code `RoP.052`. **Second high-impact bug.** |
| R.43.3 | Application-to-Amend served → Defence to Amend (in REV) | 2 months | `rev.def_to_amend` (RoP.043.3) | present-correct | ★★ | |
| R.32.3 | Reply on Amend (in REV) | 1 month | `rev.reply_def_amd` (RoP.032.3) | present-correct | ★★ | |
| R.32.3 | Rejoinder on Amend (in REV) | 1 month | `rev.rejoin_amd` (RoP.032.3) | present-correct | ★★ | |
| R.56.1 | CCI served → Defence to CCI | 2 months | `rev.def_cci` (RoP.056.1) | present-correct | ★★ | |
| R.56.3 | Defence-to-CCI served → Reply | 1 month | `rev.reply_def_cci` (RoP.056.3) | present-correct | ★★ | |
| R.56.4 | Reply served → Rejoinder | 1 month | `rev.rejoin_cci` (RoP.056.4) | present-correct | ★★ | |
| R.61 | Pre-CCI standalone Counterclaim (CCR-only proceedings) | n/a (overlap with CCR mechanics) | n/a | n/a | ★ | UPC_REV with CCI already covers this; R.61 is a structural cross-ref. |
| R.63 | Filing of Application for DNI | 0 anchor | **missing** | missing | ★ | No UPC_DNI proceeding type exists. Cascade has no DNI leaf. |
| R.67.1 | DNI served → Defence to DNI | 2 months | **missing** | missing | ★ | |
| R.69.1 | Defence-to-DNI served → Reply | 1 month | **missing** | missing | ★ | |
| R.69.2 | Reply served → Rejoinder | 1 month | **missing** | missing | ★ | |
| R.70 | Application of UPC_INF rules to DNI | 0 (cross-ref) | n/a | n/a | ★ | DNI inherits all of UPC_INF's chain after the initial 4 rules. |
**Section B net:** 8 rules covered correctly, **2 high-impact duration bugs** (R.49.1 Defence-to-Revocation 3mo seeded but RoP says 2mo; R.52 Rejoinder 2mo seeded but RoP says 1mo), **2 rule_code drift** (R.51 NULL, R.52 NULL — fixing alongside the duration corrections), **4 missing** (DNI family — R.63, R.67.1, R.69.1, R.69.2). Both duration bugs surfaced from cross-referencing the authoritative RoP text via `data.laws_contents` (the youpc law database) — they were invisible in the rule-code-format-only review.
DNI is a low-frequency proceeding (zero filings in published UPC orders 2026-Q1) — flag, but Tier 3 priority.
---
### Section C — Provisional measures + evidence preservation (R.190R.213)
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|---|---|---|---|---|---|---|
| R.197.3 | Saisie order served on respondent → Application for review | 30 days | **missing** | missing | ★ | Trigger event 65 (`request_for_review_of_the_order_to_preserve_evidence`) exists; no rule attached. |
| R.198 | Saisie executed → Start proceedings on the merits | **31 calendar days OR 20 working days, whichever is longer** | **missing** | missing | ★ | Requires arithmetic primitive paliad doesn't have (see 2026-04-30 §5.1). Trigger event 81 (`start_of_proceedings_on_the_merits`) exists. |
| R.205 | Application for PI filed | 0 anchor | `pi.app` | present-correct | ★★ | UPC_PI root. |
| R.207.6.a | Notification of deficiency in PI application | 14 days | **missing** | missing | ★★ | Registry-correction family. Trigger event 71 (`notification_by_the_registry_to_correct_deficiencies`) exists; no rule. |
| R.207.9 | PI filed → Renewal of protective letter | 6 months | **missing** | missing | ★ | Trigger event 46 (`renewal_of_protective_letter`) exists; no rule. |
| R.211.2 | PI granted ex parte → Inter partes hearing | judge-set (typ. ≤30d) | `pi.response` (court-set, duration=0) | present-correct (court-set) | ★★ | Modelled as duration=0 with parent → UI shows "vom Gericht gesetzt". Correct shape. |
| R.211.4 | PI granted → Service on respondent | judge-set | n/a | n/a | ★★ | No fixed period. |
| R.213 | PI granted → Start proceedings on the merits | **31 calendar days OR 20 working days, whichever is longer** | **missing** | missing | ★★ | Same arithmetic primitive as R.198. |
| R.196.5 | Saisie review → Damages application by respondent | 31d / 20wd | n/a | n/a | ★ | Conditional on PI being revoked; specialist. |
**Section C net:** PI happy-path covered. **5 missing** (R.197.3, R.198, R.207.6.a, R.207.9, R.213). The two "31d / 20wd whichever is longer" rules are blocked on a missing arithmetic primitive (see §5.1).
---
### Section D — Damages + lay-open books (R.125R.144)
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|---|---|---|---|---|---|---|
| R.125 | Decision on the merits incl. damages-in-principle | 0 anchor | n/a | n/a | ★★ | Structural — triggers R.131. |
| R.131.2 | Final decision on validity → Application for damages, indication | judge-set (typ. by court order) | `damages.app` (duration=0, court-set shape) | present-correct | ★★ | Trigger event 82 covers the indication. |
| R.137.2 | Application for damages served → Defence | 2 months | `damages.defence` (RoP.137.2) | present-correct | ★★ | |
| R.139 | Defence served → Reply | 1 month | `damages.reply` (RoP.139) | present-correct | ★★ | |
| R.139 | Reply served → Rejoinder | 1 month | `damages.rejoin` (RoP.139) | present-correct | ★★ | |
| R.141 | Order to lay open books filed | 0 anchor | `disc.app` (UPC_DISCOVERY) | present-correct | ★★ | |
| R.142.2 | Order served → Defence | 2 months | `disc.defence` (RoP.142.2) | present-correct | ★★ | |
| R.142.3 | Defence served → Reply | 14 days | `disc.reply` (RoP.142.3) | present-correct | ★★ | |
| R.142.3 | Reply served → Rejoinder | 14 days | `disc.rejoin` (RoP.142.3) | present-correct | ★★ | |
| R.144 | Final decision on damages quantum | 0 anchor (court event) | missing | partial | ★ | No `damages.decision` row analogous to `inf.decision`. UPC_DAMAGES tree ends at `damages.rejoin`. |
| R.118.4 | Final decision on validity → Application for orders consequential | 2 months | **missing** | missing | ★★ | Trigger event 36 exists; no rule attached. Common after EPO-or-CD validity ruling. |
**Section D net:** Damages happy-path covered. **2 missing** (R.118.4 application for consequential orders, R.144 damages decision tree-end). Lay-open books covered cleanly.
---
### Section E — Decisions, costs, default judgment (R.111R.157)
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|---|---|---|---|---|---|---|
| R.111 | Decision on the merits delivered | 0 anchor | `inf.decision` / `rev.decision` / `app.decision` | present-correct | ★★★ | Tree-end events. |
| R.118 | Decision on validity (final) | 0 anchor | (covered as `inf.decision`) | present-correct | ★★★ | |
| R.118.4 | Final decision on validity → Application for orders consequential | 2 months | missing | missing | ★★ | Repeated from §D — tracked as a single gap. |
| R.118.5 | Default judgment served → Set-aside ("Einspruch") | **missing in UPC** | n/a | n/a | ★ | UPC has no German-style Versäumnisurteil-Einspruch; closest is R.355 review of contumacy. Concept `versaeumnisurteil-einspruch` exists in paliad (DE-only proceedings). |
| R.151 | Final decision (with cost order) → Application for cost decision | 1 month | `inf.cost_app` (RoP.151) | present-correct | ★★★ | |
| R.157 | Cost decision delivered | 0 anchor | `cost.decision` (UPC_COST_APPEAL) | present-correct | ★★ | |
| R.221.1 | Cost decision served → Application for leave-to-appeal | 15 days | `cost.leave_app` (RoP.221.1) | present-correct | ★★ | Migration 052 §3 wired the leaf cascade. |
| R.155 | Cost-decision app served → Defence + Reply chain | 1 month / 14 days | partial | partial | ★ | UPC_COST_APPEAL only has the leave-to-appeal step; no Defence-to-cost-app row. |
**Section E net:** Cost happy-path covered. **1 missing** (R.118.4), **1 partial** (R.155 cost-decision opposition chain).
---
### Section F — Appeals (R.220R.246)
The single biggest section. paliad models this across **three proceeding types**: UPC_APP (the main 2mo/4mo appeal), UPC_APP_ORDERS (the 15d orders/with-leave track), UPC_COST_APPEAL (the 15d cost-decision-leave track).
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|---|---|---|---|---|---|---|
| R.220.1.a | Final decision served (decision on merits) | — (anchor) | n/a (anchor on `app.notice`) | n/a | ★★★ | Anchor row, not a deadline. |
| R.220.1.b | Final decision served (review of CMO etc.) | — (anchor) | (same) | n/a | ★★ | |
| R.220.1.c | Order referred to in R.220.1.c (case-mgmt) | — (anchor) | `app_ord.order` | present-correct | ★★ | |
| R.220.2 | Order with leave to appeal granted → Statement of Appeal | 15 days | `app_ord.with_leave` (RoP.220.2) | present-correct | ★★ | Migration 052 §4 fixed the leaf wiring. |
| R.220.3 | Order, leave-to-appeal refused → Discretionary review request | 15 days | `app_ord.discretion` (RoP.220.3) | present-correct | ★★ | |
| R.221.1 | Cost decision → Leave-to-appeal | 15 days | `cost.leave_app` (RoP.221.1) | present-correct | ★★ | |
| R.224.1.a | Final decision served → Statement of Appeal (main track) | 2 months | `app.notice` (RoP.220.1) | **present-wrong (rule_code)** | ★★★ | Rule_code is `RoP.220.1` but the actual citation is **R.224.1.a**. R.220.1 is the trigger-classifier rule, not the duration rule. **Cosmetic but technically wrong code.** |
| R.224.1.b | Order in R.220.1.c served → Statement of Appeal (orders track) | 15 days | `app_ord.with_leave` (RoP.220.2) | partial | ★★ | Fires from R.220.2 (with leave) but no separate row for R.224.1.b standalone (orders without leave-grant requirement, e.g. R.220.1.c orders). Same 15 days, but the citation is different. |
| R.224.2.a | Decision served → Statement of Grounds (main track) | **4 months** | `app.grounds` (4mo, RoP.220.1) | present-correct (with code drift) | ★★★ | Duration corrected from 2mo to 4mo since the 2026-04-30 audit (§4.4). Rule_code still says `RoP.220.1` — should be `RoP.224.2.a`. |
| R.224.2.b | Order in R.220.1.c served → Statement of Grounds (orders track) | 15 days | **missing** | missing | ★★ | UPC_APP_ORDERS has the appeal-itself row but **no separate Grounds row**. R.224.2.b explicitly creates a 15-day grounds period for the orders track. |
| R.229.2 | Notification of appeal-deficiency → Correction | 14 days | **missing** | missing | ★ | Registry-correction family. |
| R.234.1 | Statement of Appeal received → Court rejects as inadmissible | 1 month | n/a (court action, no party deadline) | n/a | ★ | Court window, not party deadline. |
| R.235.1 | Statement of Appeal served → Response (orders track) | 15 days | `app_ord.cross_reply` partially overlaps; standalone response missing | partial | ★★ | UPC_APP_ORDERS has cross + cross_reply but no response-to-the-appeal row. R.235.1 specifically covers the response-to-appeal in the orders/with-leave track. |
| R.235.2 | Statement of Appeal served → Response (main track) | 3 months | `app.response` (3mo, no rule_code) | present-wrong (rule_code) | ★★★ | Duration is correct (3 months). Rule_code is NULL — should be `RoP.235.2`. |
| R.237 | Response to Appeal served → Cross-Appeal | 3mo (main) / 15d (orders) | `app.cross_a` (3mo, RoP.237) + `app_ord.cross` (15d, RoP.237) | present-correct | ★★ | |
| R.238.1 | Cross-Appeal served → Reply (main track) | 2 months | `app.cross_a_reply` (RoP.238.1) | present-correct | ★★ | |
| R.238.2 | Cross-Appeal served → Reply (orders track) | 15 days | `app_ord.cross_reply` (RoP.238.2) | present-correct | ★★ | |
| R.245.1 | Final decision served → Application for rehearing (main 2mo) | 2 months | **missing** | missing | ★ | |
| R.245.2.a | Discovery of fundamental defect → Application for rehearing | 2 months | **missing** | missing | ★ | "Whichever is later" of decision-service vs defect-discovery (cf. trigger event 98). Outer cap 12mo from final decision. |
| R.245.2.b | Discovery of criminal offence → Application for rehearing | 2 months | **missing** | missing | ★ | Trigger event 88. Same outer cap. |
| R.245.2 cap | Outer cap | 12 months from final decision | **missing** | missing | ★ | Outer-bound logic, not a calendar deadline; needs a "max-of-anchors" capability. |
**Section F net:** Main happy-path covered. **3 present-wrong (rule_code drift)** on R.224.1.a, R.224.2.a, R.235.2 — the durations are right, the citation strings are wrong/missing. **1 missing** R.224.2.b grounds-on-orders 15d (genuine functional gap). **1 partial** R.235.1 response-on-orders-track. **3 missing** rehearing family (R.245). **1 missing** R.229.2 registry-correction.
---
### Section G — Re-establishment, case-management, miscellaneous
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|---|---|---|---|---|---|---|
| R.262.2 | Receipt of opposing party's application for confidentiality | 14 days | **missing** | missing | ★★ | Trigger event 25 (`application_to_request_confidentiality_from_the_public`) exists; no rule. Common in HLC infringement work where competitor secrets are filed. |
| R.262A | Confidentiality club application | judge-set | n/a | n/a | ★ | No fixed deadline. |
| R.295 | Stay of proceedings | n/a (judicial discretion) | n/a | n/a | ★★ | No deadline. |
| R.320 | Wegfall des Hindernisses → Wiedereinsetzung | 2 months (cap 12mo from missed deadline) | concept `wiedereinsetzung` + trigger event 207 + leaf `frist-verpasst.upc` | present-correct (cascade-only) | ★★ | Migration 063 added the cascade path. **No `paliad.deadline_rules` row** — Wiedereinsetzung has no proceeding-tree rule because it bridges proceedings. The 2mo / 12mo logic only lives in description text. **If we want to compute the deadline,** a rule row is needed; today the user gets a concept-card but not a calendar entry. |
| R.321.3 | Filing → Referral to central division (preliminary objection sub-case) | 10 days | **missing** | missing | ★ | |
| R.331 | Court summons to oral hearing | judge-set | `cms-eingang.gericht.ladung` cascade leaf (no rule) | partial | ★★ | Cascade leaf exists but no fixed period; this is correct since R.331 is judge-set. Mentioned for completeness. |
| R.333.2 | Case-management order served → Application for review | 15 days | **missing** | missing | ★★ | Trigger event 16 exists; no rule. Common in busy LDs (review-of-CMO requests are routine). |
| R.353 | Decision/order delivered → Application for rectification | 1 month | **missing** | missing | ★ | Trigger event 41 exists; no rule. |
**Section G net:** R.320 covered cascade-only (computational gap). **5 missing** (R.262.2 confidentiality, R.321.3 referral, R.333.2 review-of-CMO, R.353 rectification, R.320 calendar arithmetic).
---
### Section H — Oral-hearing prep + translations (R.109)
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|---|---|---|---|---|---|---|
| R.109.1 | Oral hearing date → Request for simultaneous translation | **1 month before** | **missing** | missing | ★★ | The whole "before"-mode family. paliad's `paliad.deadline_rules` has a `timing` column (values `before`/`after`) and `internal/services/deadline_calculator.go` reads it, but **no rule today populates `timing='before'`** — verified via SQL `SELECT DISTINCT timing FROM paliad.deadline_rules WHERE is_active = true` returning `{after}` only. |
| R.109.4 | Oral hearing date → Notification of interpreter cost intent | **2 weeks before** | **missing** | missing | ★★ | |
| R.109.5 | Oral hearing date → Lodging of translations | 2 weeks **after** summons | **missing** | missing | ★★ | Trigger event 113 (`order_of_the_judge_rapporteur_to_lodge_translations`) exists; no rule. |
| R.116 | Oral hearing → Final written submissions (EPO-style cap) | 1 month before (typically) | n/a (UPC has no formal R.116-equivalent — EPC-only) | n/a | ★★ | Concept `r116-final-submissions` is mapped to EPA_OPP / EPA_APP only — correct. |
**Section H net:** **All 3 R.109 rules missing.** This was flagged in 2026-04-30 §3.6 as a tier-2 port; no migration since. The schema and Go code already support `before`-mode, just no data.
---
## 4. Gap list
Ordered by frequency × user-impact. Each entry is one sentence, sufficient for a coder to spec a migration row.
### Critical — **★★★** ("real duration bugs verified against RoP text — fix before any further migration work")
1. **`UPC_REV.rev.defence` duration is 3 months — RoP §49.1 says 2 months.** Single-row UPDATE: `duration_value=2`. Also fix rule_code `RoP.49.1``RoP.049.1`. Verified via `data.laws_contents` for `UPCRoP.049.1` (youpc law database).
2. **`UPC_REV.rev.rejoin` duration is 2 months — RoP §52 says 1 month.** Single-row UPDATE: `duration_value=1`, set `rule_code='RoP.052'`. Verified via `data.laws_contents` for `UPCRoP.052.p1`.
3. **`UPC_REV.rev.reply` rule_code is NULL.** Set `rule_code='RoP.051'`. Duration (2mo) is correct.
4. **`UPC_APP.app.notice` / `app.grounds` / `app.response` rule_code drift.** `app.notice` cites `RoP.220.1` (trigger-classifier); should be `RoP.224.1.a` (duration rule). `app.grounds` same drift (→ `RoP.224.2.a`). `app.response` NULL (→ `RoP.235.2`). Cosmetic-but-wrong; durations all correct.
### High-priority — **★★** ("every case will hit this at some stage")
5. **R.19 Preliminary Objection (1 month) missing in UPC_INF.** Defendant's first move when challenging jurisdiction/competence/language. No rule, no cascade leaf, no concept card — just a dangling trigger event 68. Add rule + cascade leaf + concept.
6. **R.224.2.b Statement of Grounds on orders track (15 days) missing in UPC_APP_ORDERS.** With-leave appeal has the appeal-itself row but no separate grounds row.
7. **R.235.1 Response to Appeal on orders track (15 days) missing in UPC_APP_ORDERS.**
8. **R.118.4 Application for orders consequential on validity (2 months) missing.** Common follow-on after central-division revocation decision.
9. **R.262.2 Confidentiality response (14 days) missing.** Daily occurrence in HLC infringement work.
10. **R.333.2 Review of CMO (15 days) missing.** Routine in busy local divisions.
11. **R.207.6.a Notification of PI deficiency → Correction (14 days) missing.** Registry-correction family.
12. **R.197.3 Saisie review request (30 days) missing.** Standard saisie practice.
13. **R.198 / R.213 Start proceedings on the merits (31d OR 20wd, whichever is longer) — blocked on arithmetic primitive.** Needs `working_days` unit or a `combine='max'` operator. Document blocked-on-tooling in the gap-list; do not migrate until the primitive lands.
14. **R.207.9 Renewal of protective letter (6 months) missing.**
15. **R.109.1 / R.109.4 / R.109.5 Oral-hearing translation prep (1mo / 2w / 2w; first two are `before`-mode) missing.** First two are the only `before`-mode rules in the whole UPC corpus — schema supports it, no data populates it.
16. **R.353 Rectification of decision (1 month) missing.**
### Medium-priority — **★** ("specialist / fringe but real")
17. **R.20.2 Reply to Preliminary Objection (14 days) missing.**
18. **R.229.2 Notification of appeal-deficiency → Correction (14 days) missing.**
19. **R.245.1, R.245.2.a, R.245.2.b Rehearing applications (2mo / outer 12mo cap) missing.** Plus the "max-of-two-anchors" arithmetic for R.245.2.
20. **R.321.3 Referral to central division (10 days) missing.**
21. **R.144 Damages decision tree-end row missing.** Cosmetic — UPC_DAMAGES tree just stops at rejoinder.
22. **R.155 Cost-decision opposition chain (Defence + Reply) missing in UPC_COST_APPEAL.** Tree currently jumps from cost decision to leave-to-appeal without modelling the substantive opposition.
23. **R.63 / R.67.1 / R.69.1 / R.69.2 DNI family (4 rules) missing.** No UPC_DNI proceeding type. Fringe in HLC practice.
24. **R.320 Wiedereinsetzung calendar arithmetic missing.** Cascade card exists; no rule row that computes the 2mo / 12mo deadline. Needs either a `paliad.deadline_rules` row or a special-case Go helper. Touches the "outer cap" arithmetic gap (same pattern as R.245.2).
### Tooling gaps (block multiple rules)
25. **`working_days` duration unit + `combine='max'` operator.** Blocks R.198, R.213, and arguably R.198 cross-cuts saisie.
26. **`outer_cap_value` + `outer_cap_unit` columns** (or a separate table). Blocks R.320 (12mo cap), R.245.2 (12mo cap).
27. **Multi-anchor "whichever is later" trigger events.** Blocks R.245.2.a/b. Trigger events 88 + 98 already encode the OR semantics in their *names* but no Go-side helper picks the later of two user-provided dates.
---
## 5. Cross-cutting observations
### 5.1 Rule-code citation drift is widespread
The 2026-04-30 audit (§4.3) noted the format drift (`RoP 23` vs `RoP.023`). That part is now resolved (no `RoP 23` rows exist — all migrated to `RoP.023` style). But a second-order drift remains: **the rule_code field cites the wrong rule** in several places (R.220.1 used for R.224.1.a / R.224.2.a, NULLs on REV reply/rejoinder and `app.response`).
Recommendation: an audit pass over `paliad.deadline_rules` to align `rule_code` to the *duration-creating* rule, not the *trigger-classifier* rule. Roughly 5-7 rows to update.
### 5.2 The cascade and the rule-tree drift independently
paliad has two surfaces:
- **Pathway A** (proceeding tree, `paliad.deadline_rules`): "I'm running an UPC infringement case, what's the timeline?"
- **Pathway B** (cascade, `paliad.event_categories` + `paliad.event_category_concepts` + `paliad.deadline_concepts`): "the CMS just landed X, what reacts?"
Both surfaces *should* cover the same RoP universe; in practice they don't. R.320 Wiedereinsetzung is in Pathway B (after migration 063) but not in Pathway A. R.262.2 confidentiality is in neither.
A single matrix `(RoP rule × Pathway A coverage × Pathway B coverage)` would help future audits. Out of scope here, but worth adding to the rule-library doc once the gaps below are filled.
### 5.3 Trigger-event corpus is much richer than the rule corpus
`paliad.trigger_events` has ~90 active rows; `paliad.deadline_rules` references only ~50 distinct UPC scenarios. Many trigger events have no attached rule (R.197.3 review, R.207.9 renewal, R.262.2 confidentiality, R.353 rectification, R.207.6.a deficiency-correction…). The corpus was clearly imported from youpc with the events but without the rules.
This is the single biggest "missing data" pattern: triggers without rules.
### 5.4 `before`-mode rules — schema supports, no data populates
`paliad.deadline_rules.timing` accepts `'before'`/`'after'`. SQL: `SELECT DISTINCT timing FROM paliad.deadline_rules WHERE is_active = true` returns `{after}`. Three R.109 rules need `before`. That's the *only* user need for `before` mode in the entire UPC corpus.
Verify the date-arithmetic does **subtract** not push-forward — `internal/services/deadline_calculator.go:addDuration` should already handle negative values, but any rule that lands on a non-working day should snap **backward** to the previous working day for `before`-mode (the deadline is "by 1 month before hearing", so a Sunday must move to Friday, not the next Monday — which would be the hearing day or after). 2026-04-30 §5.4 flagged this; verify before adding R.109 rules.
### 5.4b R.220.3 anchoring nuance (Pathway A vs B drift)
`UPC_APP_ORDERS.app_ord.discretion` is 15 days, parented to `app_ord.order` (the original CFI order). RoP §220.3 reads: *"within 15 calendar days from the end of [the 15-day refusal] period"*. So the "15 days" duration is anchored on **the day leave-to-appeal was refused (or the day-15 cutoff if no refusal yet)**, not on the order date. The cascade shape (Pathway B) handles this correctly via trigger event 99 (`leave_to_appeal_refused_within_15_days_of_the_order`); the user picks the actual refusal date and the 15d clock runs from there. The proceeding-tree shape (Pathway A) hangs the deadline directly off the order — a user who enters the order date in Pathway A will compute the wrong deadline (15d too early, since the worst-case real deadline is 30d after the order).
**Recommendation:** either rename `app_ord.discretion`'s anchor to `app_ord.refusal` and add a `app_ord.refusal` court-set node (duration=0, parent=order) for the trigger date, or document in the Fristenrechner UI that the user must enter the refusal date, not the order date. **Not a duration bug — an anchoring/UI bug.** Low-impact (≤30d off in the worst case, only matters at the edge), but worth fixing.
### 5.5 "Whichever is longer / later" arithmetic
Three rules need it: R.198, R.213 (max of calendar-days vs working-days), R.245.2.a/b (max of decision-service date vs defect-discovery date). The R.198/R.213 case needs working-days arithmetic (a function of holiday data, which paliad has). The R.245.2 case needs a two-input UI (the user supplies both dates).
Both can be deferred until a real R.198 or R.245 case lands at HLC. Listing in the gap-list with a `[blocked on tooling]` tag is the right move; no migrations should be drafted until the primitive exists.
---
## 6. Recommended sequencing for a follow-up coder
(Not a request for migration here — orientation for whoever picks up the gap-fill task.)
**Wave 0 — DURATION BUGS (must ship first; 2 UPDATE rows + 4 rule_code fixes):**
- Fix `rev.defence`: `duration_value` 3 → 2, `rule_code` `RoP.49.1``RoP.049.1` (gap 1).
- Fix `rev.rejoin`: `duration_value` 2 → 1, set `rule_code='RoP.052'` (gap 2).
- Fix `rev.reply`: set `rule_code='RoP.051'` (gap 3).
- Fix `app.notice` / `app.grounds` / `app.response` rule_code drift (gap 4).
- **Why first:** existing UPC_REV deadlines computed via paliad today are wrong by a month for both Defence and Rejoinder. Any user who set up a Nichtigkeitsverfahren in the last 4 months has miscalibrated reminders. Fix this before any other work.
**Wave 1 — new rule rows, single migration (~6 rows):**
- Add R.19 Preliminary Objection (gap 5).
- Add R.262.2 confidentiality (gap 9).
- Add R.333.2 review-of-CMO (gap 10).
- Add R.224.2.b Grounds-on-orders (gap 6) + R.235.1 response-on-orders (gap 7).
- Add R.353 rectification (gap 16).
**Wave 2 — registry-corrections family (56 rows, all 14d):**
- R.207.6.a (gap 11), R.229.2 (gap 18), and the rest of the "Mängelbeseitigung" family (R.16.3.a, R.27.2, R.89.2, R.253.2 — already noted in 2026-04-30 §3.1).
**Wave 3 — saisie + PI gaps (4 rows):**
- R.197.3 (gap 12), R.207.9 (gap 14), and document R.198/R.213 as blocked on tooling.
**Wave 4 — translation prep (3 rows, `before`-mode):**
- R.109.1, R.109.4, R.109.5 (gap 15). Test backward-snap to working day before merging.
**Wave 5 — rare/specialist (5 rows):**
- R.20.2 (gap 17), R.144 (gap 21), R.155 (gap 22), R.321.3 (gap 20), R.118.4 (gap 8).
**Out of scope until tooling lands:**
- R.198 / R.213 (working-days arithmetic).
- R.245.2 family (multi-anchor arithmetic).
- R.320 calendar arithmetic (outer-cap).
**Out of scope, fringe in HLC practice:**
- DNI family (gap 23). Defer until first DNI case at the firm.
---
## 7. Open questions for m
Scope/policy questions, not substantive UPC-rule ambiguities. Listed for the next shift.
1. **R.245 rehearing — in or out of scope for paliad?** Rare remedy; if a case ever needs it, the lawyer will look it up in the RoP directly. Do we want a tile, or accept that paliad's Fristenrechner is for the 95% common case?
2. **R.198 / R.213 working-days arithmetic — implement the primitive, or document as "manual calculation required"?** Real R.198 cases are rare enough that a doc-string + manual override may be cheaper than the schema/code work.
3. **R.320 Wiedereinsetzung — should the cascade card produce a calendar entry?** Today migration 063 surfaces the concept (UI tile) but no Frist row gets created. The 2mo / 12mo math is non-trivial because it involves the *missed* deadline as anchor, not a forward-looking event.
4. **DNI (R.63R.70) — is HLC seeing any DNI cases?** Zero published in 2026-Q1; if no internal demand, defer indefinitely.
5. **R.262.2 confidentiality 14d — Pathway A or Pathway B only?** It's a reactive deadline (defendant gets opponent's confidentiality request → 14d to respond). Cascade-only seems right; no UPC_CONFIDENTIALITY proceeding type needed.
6. **Proceeding-code naming convention — m raised in shift-1 chat.** Today paliad uses underscore-separated codes (`UPC_INF`, `UPC_REV`, `UPC_PI`, `UPC_APP`, `UPC_DAMAGES`, `UPC_DISCOVERY`, `UPC_COST_APPEAL`, `UPC_APP_ORDERS`). m suggested a hierarchical dot-notation scheme: `UPC.INF`, `UPC.REV`, `UPC.APM` (= PI), `UPC.A2A` (= application to amend), with instance qualifiers `UPC.INF.CFI` / `UPC.INF.COA` and national-equivalents `DE.INF.1` / `.2` / `.3`. **Trade-off:** consistent grammar across the matrix and trivial parent-child lookup vs. a bulk rename across `paliad.proceeding_types.code`, all `paliad.deadline_rules` rows, all migration files, all front-end strings, and all references in `paliad.event_category_concepts.proceeding_type_code`. **Recommendation:** if we go ahead, do it as a single migration that renames the codes via a mapping table (one column update per affected row) and add a forward-compatibility view aliasing the old codes for any in-flight queries. Don't merge with the duration-bug fixes (Wave 1) — that's two unrelated diff scopes. Worth its own task ticket.
---
## Appendix A — file references
**paliad code paths consulted:**
- `internal/db/migrations/012_fristenrechner_rules.up.sql` — original UPC_INF / UPC_REV / UPC_PI / UPC_APP / DE_* / EPA_* seed.
- `internal/db/migrations/049_event_categories_seed.up.sql` — Pathway B cascade seed.
- `internal/db/migrations/050_bilateral_rules_backfill.up.sql` — bilateral / both-party rule seed.
- `internal/db/migrations/052_event_categories_rop_audit.up.sql` — prior cascade-side RoP audit fix-pass (R.221 cost-appeal, R.220.3 discretionary review, R.237/238 cross-appeal coverage).
- `internal/db/migrations/053_courts_and_countries.up.sql` and onwards — unrelated to deadline_rules.
- `internal/db/migrations/063_frist_verpasst_upc.up.sql` — R.320 cascade leaf (no rule row).
- `internal/services/deadline_calculator.go` — arithmetic. Reads `timing`, supports `before`/`after`, doesn't yet handle `working_days` or `combine='max'`.
- `internal/services/holidays.go` — DB-driven holidays (good shape; would carry working-days arithmetic when added).
- `frontend/src/client/fristenrechner.ts` — UI; supports `condition_rule_id` toggle for adaptive rules.
**RoP citations** are paraphrased from the official UPC Rules of Procedure (consolidated version, in force from 2026-01-01, available at unified-patent-court.org/sites/default/files/upc_documents/rop_consolidated_2026.pdf — verified against the deadline-creating rules I list, without quoting verbatim).
**Companion audits:**
- `docs/audit-fristenrechner-completeness-2026-04-30.md` — youpc-vs-paliad comparison (curie, t-paliad-084).
- `docs/audit-polish-2026-04-27.md` and `docs/audit-polish-2-2026-04-29.md` — UI/UX polish audits, not rule-data.
---
## Appendix B — coverage tally
Rules audited in scope: **~80 deadline-creating UPC RoP rules** across 8 sections.
| Status | Count | Share |
|---|---|---|
| present-correct | 38 | 47% |
| present-wrong (DURATION) | 2 | 3% |
| present-wrong (rule_code drift only) | 5 | 6% |
| partial | 4 | 5% |
| missing | 25 | 31% |
| n/a (no deadline) | 8 | 10% |
**Most-important findings:** the 2 duration bugs (R.49.1 Defence-to-Revocation, R.52 REV Rejoinder) — both ★★★, both impact every active UPC_REV proceeding tracked in paliad today.
The 25 missing represent the gap list. Of those, **16 are ★★★ / ★★ frequency** (high priority); 9 are ★ specialist.

View File

@@ -0,0 +1,912 @@
# Approval-policy authoring UI — design
**Task:** t-paliad-154
**Issue:** m/paliad#13
**Inventor:** hilbert (2026-05-07)
**Branch:** mai/hilbert/inventor-approval-policy
**Status:** READY FOR REVIEW
---
## §0 — One-paragraph summary
cronus shipped the t-138 4-eye backend on 2026-05-06: tables, service layer,
HTTP API, audit events, the `/inbox` shell. The whole thing has been **dormant
in production since** because `paliad.approval_policies` has zero rows, and no
UI exists to author policies. m hit this hard 2026-05-07 22:55 — created a
deadline expecting a request on `/approvals`, got nothing. This design fills
the gap with **two coordinated changes**: (a) a backend extension to support
**per-partner-unit defaults** layered with **project-tree inheritance**, both
resolved most-restrictive, with an explicit `'none'` sentinel for project-level
opt-out; (b) a single new admin page `/admin/approval-policies` with a
project-picker → 8-cell matrix and a partner-unit defaults section, plus
in-context hints on the deadline/appointment forms when 4-eye applies. v1
ships seeded conservative defaults for every existing partner unit so the gate
starts working on next deploy without per-project authoring.
---
## §1 — What's already built (verified live, 2026-05-07)
cronus's t-138 implementation is complete and merged. Verified premises:
- **Schema (migration 054, applied):** `paliad.approval_policies` with
`(id, project_id, entity_type, lifecycle_event, required_role, created_at,
updated_at, created_by)` + UNIQUE composite on `(project_id, entity_type,
lifecycle_event)`. RLS enforces SELECT via `can_see_project(project_id)`,
WRITE via `global_role='global_admin'`. Read-only check on the live DB
via the migration file at `internal/db/migrations/054_approvals.up.sql:75`.
- **Required-role enum (post-059):** `partner | of_counsel | associate |
senior_pa | pa`. The `'lead' → 'partner'` rename happened in migration 059
(t-148, kepler) — verified at `internal/db/migrations/059_profession_vs_responsibility.up.sql:166-172`.
Mirrors `paliad.users.profession` (firm-wide career tier), not
`paliad.project_teams.responsibility` (project-level role) — the gate keys
on profession because that's how the strict ladder
`paliad.approval_role_level()` works.
- **HTTP API (admin-gated):** three handlers in
`internal/handlers/approvals.go` register at `internal/handlers/handlers.go:421-426`:
- `GET /api/projects/{id}/approval-policies` → list
- `PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}` → upsert
- `DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}` → clear
All three wrapped with `auth.RequireAdminFunc(users, ...)`.
- **`LookupPolicy`** (`internal/services/approval_service.go:69-83`) does
**not walk the project tree** today. It SELECTs the exact
`(project_id, entity_type, lifecycle_event)` tuple and returns the row or
nil. Tree inheritance is brand-new in this design.
- **Audit:** approval-request submission and decisions emit
`paliad.project_events` rows; **policy CRUD does not**. Verified at
`internal/services/approval_service.go:255` (request emits) — no
`insertProjectEvent` call inside `UpsertPolicy`/`DeletePolicy` at lines
913-948.
- **Partner-unit substrate (t-139, migration 055, applied):**
- `paliad.partner_units (id, name, lead_user_id, office, ...)` — verified
at `internal/services/partner_unit_service.go:29`.
- `paliad.project_partner_units (project_id, partner_unit_id,
derive_grants_authority, derive_unit_roles)` — verified at
`internal/db/migrations/055_hierarchy_aggregation.up.sql:47`.
- **Admin index pattern:** `/admin` is a card-grid of single-purpose admin
sub-pages — team, partner-units, audit-log, email-templates, event-types,
broadcasts. Verified at `frontend/src/admin.tsx:60-91`. New approval-policy
card slots into the same grid.
- **Migration tracker:** last applied is **061**
(`paliad.user_card_layouts`). Next is **062** — this design's migration.
---
## §2 — m's locked decisions (2026-05-07 23:00)
12 questions surfaced via AskUserQuestion (per dogma, not as a markdown
list). Locked verbatim — quoted as-asked + answer:
### Q1 — Surface placement
> Where should approval policies be authored? The backend admin-gates the
> CRUD endpoints, so anywhere we surface authoring is admin-only by
> definition.
**Locked: Admin page only.** New `/admin/approval-policies` card on the
admin index. Single page with two sections: (a) Partner-unit defaults,
(b) Project picker → 8-cell matrix. Per-project tab is **out**. Project
visibility into effective rules happens at form-time (Q12 below), not as a
permanent tab.
### Q2 — Default-policy concept
> With ~30 projects and 8 cells each, authoring is tedious. Should we add
> firm-wide defaults that individual projects override?
**Locked: Per partner-unit defaults.** Schema gets a nullable
`partner_unit_id`, project_id becomes nullable, XOR check enforces a row
applies to one or the other. Reuses the t-139 partner-unit infra. No
firm-wide defaults — one less concept.
### Q3 — Multi-unit conflict resolution
> A project attached to multiple partner units with conflicting unit
> defaults — e.g. Munich Lit unit defaults to deadline:create=partner,
> Düsseldorf to deadline:create=associate. What does the gate require?
**Locked: Most-restrictive wins.** Take MAX(`approval_role_level`) across
all unit defaults for the project. Conservative — 4-eye exists to prevent
quiet errors, the higher bar wins.
### Q4 — Tree inheritance
> Projects also live in a tree. Should an ancestor project's policy inherit
> DOWN the project tree to descendants when they have no own row, or only
> via partner-unit defaults?
**Locked: Both — tree inheritance AND unit defaults.** Three sources
contribute to the candidate set: project-specific rows, ancestor rows,
unit defaults.
### Q5 — Cross-source precedence
> When tree-inheritance and unit-defaults both produce a candidate, which
> wins?
**Locked: Most-restrictive across ALL sources.** Project-specific row
overrides outright (any value, including `'none'`). When no project row,
take MAX(level) across all ancestor rows + all unit defaults. Symmetric
with the multi-unit rule.
### Q6 — Explicit suppression sentinel
> A project-specific row always wins. To set 'this project explicitly
> bypasses 4-eye on deadline:create' overriding a partner-unit default of
> 'partner', we need a sentinel.
**Locked: `'none'` value in `required_role` enum.** Add `'none'` to the
CHECK constraint. Cell renders as "Keine Genehmigung erforderlich". Project
row with `required_role='none'` returns nil from `LookupPolicy` —
suppresses defaults explicitly. Single column, single concept.
### Q7 — Soft-disable vs delete
> Per-policy enable/disable toggle vs delete-only. With audit-log emission
> already locked in (Q8), do we still need soft-disable?
**Locked: Delete-only.** One row = one rule. "This rule used to apply" is
answered by the audit log. KISS.
### Q8 — Audit emission
> Should policy changes emit project_events?
**Locked: Only on `/admin/audit-log`, not on per-project `/verlauf`.**
New event types `approval_policy_set` and `approval_policy_cleared`
emitted via the existing audit-log path (not via the project-events
union). Project verlauf stays focused on entity-level history.
### Q9 — Empty-state on /inbox
> When admin opens /inbox and pending list is empty AND no policies exist,
> show a one-tap nudge?
**Locked: Yes — admin-only card.** Conditional on `me.global_role ===
'global_admin' && pending.length === 0 && !any_policies_exist`. Card links
to `/admin/approval-policies`. Solves the discoverability gap m hit.
### Q10 — Bulk-apply
> Bulk action on the admin page so an admin can fan a Mandant's matrix
> down to its 12 sub-projects without 96 clicks?
**Locked: Yes — "Auf Unterprojekte anwenden" button per project row.**
Click → confirm modal listing affected descendants → applies the source
project's full matrix to all descendants. Idempotent.
### Q11 — Seed defaults on first deploy
> Should v1 ship seeded defaults, or strictly opt-in?
**Locked: Seed conservative defaults for every partner_unit.** Migration
inserts 8 rows per existing partner_unit:
| entity | lifecycle | required_role |
| :--- | :--- | :--- |
| deadline | create | associate |
| deadline | update | associate |
| deadline | delete | associate |
| deadline | complete | none |
| appointment | create | associate |
| appointment | update | associate |
| appointment | delete | associate |
| appointment | complete | none |
Rationale: marking-as-done is low-risk; the planning ops (create/edit/delete
the date itself) need 4-eye. `none` on `complete` is an explicit "no gate"
sentinel, not a missing row — so MAX-across-sources still works correctly.
### Q12 — Mobile shape
> 8-cell matrix is too wide for narrow viewports.
**Locked: Two stacked sections — Fristen, Termine, each as 4-row list.**
On viewports ≥ 700px: 2-row × 4-col matrix. On viewports < 700px: vertical
section per entity_type with full-width dropdown rows.
### Q13 — Form-time hint visibility
> Should we surface 4-eye to users authoring deadlines, before they save?
**Locked: Yes — hint on the deadline-form.** Above the Speichern button on
`/projects/{id}/deadlines/new` and `/projects/{id}/appointments/new`,
render: "4-Augen-Prüfung erforderlich: nach dem Speichern wird ein
Genehmigungsantrag (associate-Level) ausgelöst." Pulled from new
`GET /api/projects/{id}/approval-policies/effective` endpoint at form load.
---
## §3 — Backend extensions
### §3.1 — Migration 062
`internal/db/migrations/062_approval_policy_unit_defaults.up.sql`:
```sql
-- t-paliad-154: approval-policy authoring UI substrate.
--
-- Extends t-138's paliad.approval_policies with:
-- 1. partner_unit_id column for unit-default rows (XOR with project_id)
-- 2. 'none' sentinel value for required_role (explicit suppression)
-- 3. paliad.approval_policy_effective() resolver — tree + unit + most-restrictive
-- 4. Conservative seed defaults for every existing partner_unit
-- 1. partner_unit_id column + nullable project_id + XOR check.
ALTER TABLE paliad.approval_policies
ALTER COLUMN project_id DROP NOT NULL,
ADD COLUMN partner_unit_id uuid
REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
ADD CONSTRAINT approval_policies_scope_xor CHECK (
(project_id IS NOT NULL AND partner_unit_id IS NULL) OR
(project_id IS NULL AND partner_unit_id IS NOT NULL)
);
-- Replace UNIQUE (project_id, ...) with two partial unique indexes since
-- project_id is now nullable.
ALTER TABLE paliad.approval_policies
DROP CONSTRAINT IF EXISTS approval_policies_project_id_entity_type_lifecycle_event_key;
CREATE UNIQUE INDEX approval_policies_project_unique
ON paliad.approval_policies (project_id, entity_type, lifecycle_event)
WHERE project_id IS NOT NULL;
CREATE UNIQUE INDEX approval_policies_unit_unique
ON paliad.approval_policies (partner_unit_id, entity_type, lifecycle_event)
WHERE partner_unit_id IS NOT NULL;
CREATE INDEX approval_policies_unit_idx
ON paliad.approval_policies (partner_unit_id);
-- 2. 'none' sentinel.
ALTER TABLE paliad.approval_policies
DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
ALTER TABLE paliad.approval_policies
ADD CONSTRAINT approval_policies_required_role_check
CHECK (required_role IN (
'partner', 'of_counsel', 'associate', 'senior_pa', 'pa', 'none'
));
-- approval_role_level('none') already returns 0 (the ELSE branch). No
-- function change needed.
-- 3. Resolver function.
--
-- Returns the effective policy for (project, entity_type, lifecycle):
-- 1. project-specific row → wins outright (any value including 'none')
-- 2. else MAX(approval_role_level) across:
-- - all ancestor project rows on the path
-- - all unit-default rows for partner units attached to project
-- 3. else NULL (no candidates) → no policy applies
--
-- Returns at most one row. Caller can detect "no policy" via empty result.
CREATE OR REPLACE FUNCTION paliad.approval_policy_effective(
p_project_id uuid,
p_entity_type text,
p_lifecycle text
) RETURNS TABLE (
required_role text,
source text, -- 'project' | 'ancestor' | 'unit_default'
source_id uuid -- project_id for project/ancestor, partner_unit_id for unit_default
)
LANGUAGE plpgsql STABLE AS $$
BEGIN
-- Step 1: project-specific row.
RETURN QUERY
SELECT ap.required_role, 'project'::text, ap.project_id
FROM paliad.approval_policies ap
WHERE ap.project_id = p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle;
IF FOUND THEN
RETURN;
END IF;
-- Step 2: MAX across ancestor + unit_default.
RETURN QUERY
WITH path AS (
SELECT string_to_array(p.path, '.')::uuid[] AS ids
FROM paliad.projects p WHERE p.id = p_project_id
),
ancestor_rows AS (
SELECT ap.required_role,
'ancestor'::text AS src,
ap.project_id AS sid,
paliad.approval_role_level(ap.required_role) AS lvl
FROM paliad.approval_policies ap, path
WHERE ap.project_id = ANY(path.ids)
AND ap.project_id <> p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
),
unit_rows AS (
SELECT ap.required_role,
'unit_default'::text AS src,
ap.partner_unit_id AS sid,
paliad.approval_role_level(ap.required_role) AS lvl
FROM paliad.approval_policies ap
JOIN paliad.project_partner_units ppu
ON ppu.partner_unit_id = ap.partner_unit_id
WHERE ppu.project_id = p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
)
SELECT a.required_role, a.src, a.sid
FROM (SELECT * FROM ancestor_rows
UNION ALL
SELECT * FROM unit_rows) a
ORDER BY a.lvl DESC, a.src ASC -- 'ancestor' < 'unit_default' alphabetically; ancestor wins ties for stable attribution
LIMIT 1;
END;
$$;
COMMENT ON FUNCTION paliad.approval_policy_effective(uuid, text, text) IS
'Effective approval policy resolver (t-paliad-154). '
'project-specific row wins outright; else MAX(level) across ancestors '
'and unit-defaults attached to project; else no policy.';
-- 4. Seed conservative defaults for every existing partner_unit.
INSERT INTO paliad.approval_policies (
project_id, partner_unit_id, entity_type, lifecycle_event, required_role
)
SELECT NULL, pu.id, t.entity_type, t.lifecycle_event, t.required_role
FROM paliad.partner_units pu
CROSS JOIN (
VALUES
('deadline', 'create', 'associate'),
('deadline', 'update', 'associate'),
('deadline', 'delete', 'associate'),
('deadline', 'complete', 'none'),
('appointment', 'create', 'associate'),
('appointment', 'update', 'associate'),
('appointment', 'delete', 'associate'),
('appointment', 'complete', 'none')
) AS t(entity_type, lifecycle_event, required_role)
ON CONFLICT DO NOTHING;
```
`062_approval_policy_unit_defaults.down.sql` reverses each step
(deletes seeded rows, drops the function, drops indexes, drops the
column + constraint, restores the original UNIQUE + CHECK).
### §3.2 — Service-layer changes
`internal/services/approval_service.go` changes (additive — existing
callers keep working):
- **Rewire `LookupPolicy`** to call the resolver. New body:
```go
func (s *ApprovalService) LookupPolicy(ctx, tx, projectID, entityType, lifecycleEvent) (*models.ApprovalPolicy, error) {
var row struct {
RequiredRole string `db:"required_role"`
Source string `db:"source"`
SourceID uuid.UUID `db:"source_id"`
}
q := `SELECT required_role, source, source_id
FROM paliad.approval_policy_effective($1, $2, $3)`
err := txOrDB(tx, s.db).GetContext(ctx, &row, q, projectID, entityType, lifecycleEvent)
if errors.Is(err, sql.ErrNoRows) || row.RequiredRole == "none" {
return nil, nil // no policy applies
}
if err != nil { return nil, fmt.Errorf("lookup approval policy: %w", err) }
// Synthetic ApprovalPolicy — preserves the calling contract.
return &models.ApprovalPolicy{
ProjectID: projectID,
EntityType: entityType,
LifecycleEvent: lifecycleEvent,
RequiredRole: row.RequiredRole,
}, nil
}
```
The submit/decide chain at lines 142-380 continues to work unchanged.
`'none'` returning nil means: project explicitly opted out, no request
is created on save.
- **New `GetEffectivePoliciesMatrix(ctx, projectID)`** returns 8 rows
(one per `entity_type × lifecycle_event`), each with attribution. Used
by the admin page and the form-hint endpoint.
```go
type EffectivePolicy struct {
EntityType string
LifecycleEvent string
RequiredRole *string // nil if no policy
Source *string // nil if no policy
SourceID *uuid.UUID
}
func (s *ApprovalService) GetEffectivePoliciesMatrix(ctx, projectID) ([]EffectivePolicy, error)
```
Implementation: 8 calls to the resolver in a single round-trip via
`unnest()` join, or a small batch loop — both fine for ≤8 cells.
- **Extend `UpsertPolicy` signature** to accept `partnerUnitID *uuid.UUID`
alongside `projectID *uuid.UUID`. Existing callers pass projectID + nil.
New callers (unit-default endpoints) pass nil + unit ID.
```go
func (s *ApprovalService) UpsertPolicy(ctx, callerID,
projectID, partnerUnitID *uuid.UUID,
entityType, lifecycle, requiredRole string) (*models.ApprovalPolicy, error)
```
Same for `DeletePolicy`. Validates exactly one of (projectID, partnerUnitID)
is set.
- **New `ApplyMatrixToDescendants(ctx, callerID, sourceProjectID,
targetIDs []uuid.UUID)`**: copies all eight rows of `sourceProjectID`'s
effective matrix to each `targetIDs[i]` as project-specific rows. Inside
one transaction. Validates `targetIDs` are actual descendants via the
ltree path predicate. Returns the count of (project, cell) writes
performed. Skips cells where source is `'none'` and target already has
no row (idempotent). Emits one audit-log event per write.
- **Audit emission** in `UpsertPolicy` + `DeletePolicy` + `ApplyMatrixToDescendants`:
call existing `AuditService.Record` (the same path `/admin/audit-log`
uses). New event type strings: `approval_policy_set`, `approval_policy_cleared`.
Metadata: scope (project|partner_unit), scope_id, entity_type, lifecycle,
old_required_role (for set), new_required_role (for set). The audit
service already handles JSON metadata; no schema change.
**No project_events emission** (per Q8 lock-in). Project verlauf stays
focused on entity-level lifecycle.
### §3.3 — HTTP handlers
`internal/handlers/approvals.go` extensions:
- **Existing routes stay** at `handlers.go:421-426` (gated by
`RequireAdminFunc`).
- **New unit-default routes** (also `RequireAdminFunc`-gated, registered
in the same admin block at handlers.go:386-427):
- `GET /api/admin/partner-units/{unit_id}/approval-policies` — list
all rows for that unit.
- `PUT /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}` — upsert.
- `DELETE /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}` — clear.
- `GET /api/admin/approval-policies/seeded` — quick existence check
used by the `/inbox` admin nudge ("are any policies set firm-wide?").
- **New endpoint for matrix view** (admin page):
- `GET /api/admin/approval-policies/matrix?project_id=...` — returns
`[]EffectivePolicy` (8 rows with attribution).
- **New endpoint for form hint** (gateOnboarded, NOT admin-only — every
user authoring a deadline needs to see this):
- `GET /api/projects/{id}/approval-policies/effective?entity_type=deadline&lifecycle=create`
— returns one `EffectivePolicy` row.
- **New endpoint for bulk apply**:
- `POST /api/admin/approval-policies/apply-to-descendants` — body
`{source_project_id: uuid, target_project_ids: [uuid, ...]}`. Validates,
applies, returns counts.
- **New endpoint for project tree** (admin page picker — already exists
in part):
- `GET /api/admin/projects/tree-flat` — flat array of all projects with
`id, name, parent_id, depth, path` for the picker. Reuses
`ProjectService.ListAllForAdmin` (already present at
`internal/services/project_service.go` — admin-scoped tree).
- **New page handler**:
- `GET /admin/approval-policies` → `dist/admin-approval-policies.html`
(server-static shell, hydrated on load).
---
## §4 — Frontend
### §4.1 — Admin page `/admin/approval-policies`
New files:
- `frontend/src/admin-approval-policies.tsx` — page shell. Sections:
1. Header: "Genehmigungsrichtlinien" + tool-subtitle.
2. **"Partner-Unit-Standards"** — accordion list of partner units
(fetched from `/api/partner-units`). Each row expandable into the
8-cell matrix (Fristen × 4 lifecycle, Termine × 4 lifecycle), each
cell a `<select>` with options `partner | of_counsel | associate |
senior_pa | pa | none | ❌ keine Regel` (last = delete the row).
3. **"Projekt-spezifisch"** — project picker (search + flat tree dropdown
reusing `ProjectIndentRow` component from t-149). Below, the same
8-cell matrix for the selected project, each cell showing the
**effective** value with a small attribution chip:
`Projekt` (own row, dark) / `Geerbt von Mandant Acme Corp` (light,
italic) / `Standard von Partner Unit Munich Lit` (light, italic) /
`Keine Regel` (faint).
4. **"Auf Unterprojekte anwenden"** button per project row, opens
confirm modal with descendant list.
- `frontend/src/client/admin-approval-policies.ts` — orchestration.
Fetches partner-units, project tree, matrix on selection. Saves on
cell change (`PUT` with required_role; `DELETE` when set to "keine
Regel"). Re-fetches matrix after save for fresh effective view.
Bulk-apply confirm modal + POST.
### §4.2 — Admin index card
`frontend/src/admin.tsx`: add a new card to the available section:
```tsx
<a href="/admin/approval-policies" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_SHIELD }} />
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.</p>
</a>
```
`ICON_SHIELD` (new SVG) — small shield icon, matches the visual weight of
ICON_USERS / ICON_BUILDING.
### §4.3 — `/inbox` empty-state nudge
`frontend/src/inbox.tsx`: extend the `<div className="entity-empty"
id="inbox-empty">` block with a hidden admin-only sub-block:
```tsx
<div className="inbox-admin-nudge" id="inbox-admin-nudge" style="display:none">
<h3 data-i18n="inbox.empty.admin.title">Noch keine Richtlinien aktiv?</h3>
<p data-i18n="inbox.empty.admin.body">Konfiguriere, welche Lifecycle-Events 4-Augen-Prüfung erfordern.</p>
<a href="/admin/approval-policies" className="btn-primary btn-cta-lime" data-i18n="inbox.empty.admin.cta">
Genehmigungspflichten konfigurieren
</a>
</div>
```
`frontend/src/client/inbox.ts`: when rendering empty state, fire
`/api/admin/approval-policies/seeded`. If response says `{any: false}` AND
user is `global_admin`, reveal the nudge. Otherwise hide.
### §4.4 — Form-time hint on deadline + appointment new/edit
`frontend/src/deadlines-new.tsx` + `frontend/src/appointments-new.tsx`
(also the edit forms): add a hint container above the form-actions:
```tsx
<div className="approval-hint" id="approval-hint" style="display:none">
<span className="approval-hint-icon" dangerouslySetInnerHTML={{ __html: ICON_SHIELD_SMALL }} />
<span id="approval-hint-text" />
</div>
```
Client TS: on form load, GET
`/api/projects/{project_id}/approval-policies/effective?entity_type=deadline&lifecycle=create`
(or `update` for edit). If result is non-null and `required_role !== 'none'`,
fill the hint:
> 4-Augen-Prüfung erforderlich: nach dem Speichern wird ein
> Genehmigungsantrag (associate-Level) ausgelöst. Geerbt von Partner Unit
> Munich Lit.
Same for appointments.
### §4.5 — Mobile shape
CSS in `frontend/src/styles/global.css`:
```css
/* Desktop: 2-row × 4-col matrix */
.approval-matrix {
display: grid;
grid-template-columns: 8rem repeat(4, 1fr);
gap: 0.5rem;
}
@media (max-width: 700px) {
.approval-matrix { display: block; }
.approval-matrix-section {
margin-bottom: 1.5rem;
}
.approval-matrix-section h3 {
margin: 0 0 0.5rem 0;
font-size: 1.05rem;
}
.approval-matrix-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--paliad-border-soft);
}
.approval-matrix-row select { width: 50%; }
}
```
The TSX renders BOTH structures (matrix grid + section list); CSS toggles
based on viewport. Same pattern as the entity-table → entity-list mobile
flip in `frontend/src/client/projects-detail.ts`.
### §4.6 — i18n keys
~75 new keys in `frontend/src/client/i18n.ts` (DE primary, EN secondary).
Major buckets:
- `admin.card.approval_policies.title` / `.desc`
- `approvals.policy.heading` / `.subtitle` / `.empty`
- `approvals.policy.section.units` / `.projects`
- `approvals.policy.entity.deadline` / `.appointment`
- `approvals.policy.lifecycle.create` / `.update` / `.complete` / `.delete`
- `approvals.policy.required.partner` / `.of_counsel` / `.associate` / `.senior_pa` / `.pa` / `.none` / `.no_rule`
- `approvals.policy.source.project` / `.ancestor` / `.unit_default`
- `approvals.policy.bulk.cta` / `.modal.title` / `.modal.confirm` / `.modal.cancel` / `.modal.target_count` / `.modal.affected_list`
- `approvals.policy.unit_picker.placeholder` / `.project_picker.placeholder`
- `approvals.policy.cell.save_msg` / `.delete_msg` / `.error_msg`
- `inbox.empty.admin.title` / `.body` / `.cta`
- `deadlines.form.approval_hint.create` / `.update`
- `appointments.form.approval_hint.create` / `.update`
- `approvals.policy.audit.set` / `.cleared` (for `/admin/audit-log` rendering)
---
## §5 — Resolution semantics (worked examples)
Helps the implementer + reviewers reason about edge cases.
### Example A — straight unit default
**Setup:** Project P attached to one partner unit U. U has unit-default
`deadline:create=associate`. P has no own row, no ancestor with a row.
**Effective for P, deadline:create:**
- Step 1: no project row.
- Step 2: ancestor_rows = ∅. unit_rows = [{associate, level=3}]. MAX = associate.
- Result: `(required_role='associate', source='unit_default', source_id=U.id)`.
LookupPolicy returns `&ApprovalPolicy{RequiredRole: "associate", ...}`.
SubmitCreate creates a pending request needing associate sign-off.
### Example B — most-restrictive across two unit defaults
**Setup:** Project P attached to U1 (deadline:create=partner) and U2
(deadline:create=associate). No project row, no ancestor row.
**Effective for P, deadline:create:**
- Step 1: no project row.
- Step 2: unit_rows = [{partner, lvl=5}, {associate, lvl=3}]. MAX = partner.
- Result: `(required_role='partner', source='unit_default', source_id=U1.id)`.
### Example C — most-restrictive across tree + unit
**Setup:** Project hierarchy: Mandant M (deadline:create=of_counsel) → Litigation L → Patent P. P attached to unit U (deadline:create=partner).
**Effective for P, deadline:create:**
- Step 1: no row on P.
- Step 2: ancestor_rows = [{of_counsel, lvl=4 (from M)}]. unit_rows = [{partner, lvl=5}]. MAX = partner.
- Result: `(required_role='partner', source='unit_default', source_id=U.id)`.
### Example D — explicit suppression at project level
**Setup:** Same as Example C, but admin sets P's own row to
`required_role='none'` (carve-out for this single Patent — e.g. a low-stakes
auxiliary case).
**Effective for P, deadline:create:**
- Step 1: project row exists with `required_role='none'`. RETURN.
- Result: `(required_role='none', source='project', source_id=P.id)`.
LookupPolicy returns nil (the `'none'` short-circuit). SubmitCreate skips.
### Example E — most-restrictive incl. ancestor
**Setup:** Mandant M (deadline:create=partner). Litigation L below M, no
own row, attached to unit U (deadline:create=pa).
**Effective for L, deadline:create:**
- Step 1: no row on L.
- Step 2: ancestor_rows = [{partner, lvl=5}]. unit_rows = [{pa, lvl=1}]. MAX = partner.
- Result: `(required_role='partner', source='ancestor', source_id=M.id)`.
The Mandant-level rule cascades down — the typical "set once at the
client root" pattern.
---
## §6 — Implementation phasing
Single PR (~3500-4500 LoC). Five commits, ordered for readability:
1. **Migration 062 + resolver function + seed.** No Go code change.
Schema is forward-compatible: existing `LookupPolicy` (still scanning
the table directly) keeps working until commit 2 swaps it. Verify
migration with TEST_DATABASE_URL + reset.
2. **ApprovalService rewire.** New `LookupPolicy` body via resolver, new
`GetEffectivePoliciesMatrix`, extended `UpsertPolicy`/`DeletePolicy`
signatures, new `ApplyMatrixToDescendants`, audit emission. Unit
tests (table-driven): resolver fall-through cases A-E above; bulk-apply
idempotency; `'none'` short-circuit; XOR check.
3. **HTTP handlers.** Wire new admin routes + form-hint endpoint +
matrix endpoint. Hand-roll `models.ApprovalPolicy` extensions
(PartnerUnitID, Source, SourceID nullable fields). Update existing
`handleListApprovalPolicies` to return matrix shape (with attribution)
instead of raw rows.
4. **Frontend admin page.** `admin-approval-policies.tsx` + `.ts`. Cells
render with attribution chips. Bulk-apply confirm modal. Build wires
the new bundle into `frontend/build.ts`. CSS for the matrix grid +
mobile sections.
5. **Frontend touch-ups + i18n.** Admin index card. Inbox empty-state
admin nudge. Deadline/appointment form hints (`/api/projects/{id}/approval-policies/effective`
call + hint render). ~75 i18n keys DE+EN. CSS finalization.
Optional split point: 1+2+3 (backend + schema, "policies authoring works
via curl") and 4+5 (UI). Recommended single PR — 4+5 are the part that
makes the feature reachable to m, and shipping backend-only re-exposes
the issue m hit.
---
## §7 — Tests
**Backend (Go, table-driven):**
- `approval_service_test.go` extensions for the resolver:
- Project row only → returns project row.
- Project row 'none' → returns nil from LookupPolicy.
- Two unit defaults → most-restrictive.
- Ancestor row + unit default → most-restrictive across both.
- Project row + ancestor + unit defaults → project row wins.
- No candidates → returns nil.
- 'none' as unit-default value (low-priority — unusual but allowed) →
loses to any non-none.
- `ApplyMatrixToDescendants` tests:
- Source has 8 cells → target gets 8 cells.
- Source has 5 cells (3 cleared) → target gets 5 cells; existing target
rows for the other 3 are deleted (idempotent fanout, not append).
- Target is not actually a descendant → returns ErrInvalidInput.
- Self-target (target == source) → no-op.
- `UpsertPolicy` XOR validation: both NULL → ErrInvalidInput; both set →
ErrInvalidInput.
- Audit emission: each set/clear writes one `paliad.audit_log` row with
the right event type + scope.
**Live-DB integration tests (TEST_DATABASE_URL):**
- Migration 062 up + seed populates 8 rows × N partner_units. Down
reverses. Idempotent on re-up.
- Resolver function returns expected attribution for the 5 worked
examples above.
**Frontend:**
- `admin-approval-policies` smoke tests (Playwright): load page, select
partner unit, change a cell, verify save → DB. Select project, verify
attribution chips. Bulk-apply happy path.
- Form-hint on `/projects/{id}/deadlines/new` shows when policy applies,
hides when it doesn't.
---
## §8 — Trade-offs flagged
1. **Seed defaults touch live data on first deploy.** Every existing
partner_unit gains 8 policy rows. m's locked-in choice (Q11) — but
worth flagging that the moment migration 062 runs in production, the
4-eye gate becomes active for every project attached to a partner
unit. Mitigation: deploy after announcing to the team. Conservative
`associate` baseline means most users (associate, of_counsel, partner)
can both submit AND approve, so the operational impact is "your save
creates a pending request that any teammate can sign off in /inbox"
rather than "your save is blocked". The bell-icon + sidebar badge
from t-138 surfaces it.
2. **Seed `'none'` on `complete` is structurally invisible.** A
unit-default of `'none'` always loses MAX to any non-none source
(level 0 vs ≥1). So the seed `appointment.complete=none` rows are
essentially "no rule" — they don't appear in `LookupPolicy` results.
We seed them anyway for **UI consistency**: when an admin opens the
matrix, they see 8 cells filled with values, not 4 cells filled +
4 cells empty. Documenting this as intentional.
3. **'ancestor' source attribution can be ambiguous when multiple
ancestors have rows.** The resolver picks the highest-level row;
if Mandant=of_counsel and Litigation=partner, attribution surfaces
`source='ancestor', source_id=Litigation`. The Mandant rule is
silently overridden. The UI chip says "Geerbt von Litigation X" with
no hint that the Mandant also has a rule. Cost: minor — admin can
navigate to the Mandant's matrix and see its row directly. Mitigation
option (deferred): the matrix-endpoint for the admin page returns
the FULL stack of contributing rows per cell, so the chip can say
"Strengste von 3 Quellen". Worth doing if v1 attribution feels
confusing in practice.
4. **Audit lives only in `/admin/audit-log`, not in project verlauf.**
Per Q8 lock-in. Minor side effect: a non-admin user wondering "why
does my deadline now need approval?" can't see the policy-set event
on the project's verlauf. They have to check the deadline-form hint
(which says "Geerbt von Partner Unit Munich Lit") and ask an admin
for the change history. Acceptable trade-off — most users don't need
policy change history, only admins who set them.
5. **Bulk-apply destroys target's existing project-specific rows for the
8 cells.** Idempotent fanout means setting source to "matrix M" makes
targets match M, including DELETE of any pre-existing target rows
that aren't in M. This is by design (otherwise re-applying a partially-
reduced source wouldn't actually reduce). Confirm modal lists the
affected rows clearly: "12 Projekte, 8 Felder pro Projekt, ggf.
bestehende Werte überschrieben". One audit-log row per write so the
change is fully traceable.
6. **Mobile section list duplicates the matrix data structure in the
DOM.** TSX renders both the grid table and the stacked sections; CSS
toggles based on viewport. Slight DOM bloat (16 cells × 2 = 32 form
nodes per partner unit) but matches the entity-table → entity-list
pattern already used elsewhere. Alternative (single DOM rendered
responsively via flex/grid-flow) is uglier in TSX.
---
## §9 — Files the implementer will touch
**Backend (Go):**
- `internal/db/migrations/062_approval_policy_unit_defaults.up.sql` (new)
- `internal/db/migrations/062_approval_policy_unit_defaults.down.sql` (new)
- `internal/services/approval_service.go` (rewire `LookupPolicy`, add
`GetEffectivePoliciesMatrix`, `ApplyMatrixToDescendants`, extend
`UpsertPolicy`/`DeletePolicy`)
- `internal/services/approval_service_test.go` (new resolver tests, bulk-apply tests, XOR tests)
- `internal/models/approval.go` (extend `ApprovalPolicy` with optional
`PartnerUnitID`, `Source`, `SourceID`)
- `internal/handlers/approvals.go` (new unit-default + matrix + form-hint + bulk-apply handlers)
- `internal/handlers/handlers.go` (route registration for the new endpoints + `/admin/approval-policies` page)
**Frontend (TS/TSX):**
- `frontend/src/admin-approval-policies.tsx` (new)
- `frontend/src/client/admin-approval-policies.ts` (new)
- `frontend/src/admin.tsx` (add card)
- `frontend/src/inbox.tsx` (admin-nudge block)
- `frontend/src/client/inbox.ts` (gate + reveal nudge)
- `frontend/src/deadlines-new.tsx` + `frontend/src/client/deadlines-new.ts` (hint render)
- `frontend/src/appointments-new.tsx` + `frontend/src/client/appointments-new.ts` (hint render)
- `frontend/src/styles/global.css` (matrix grid + mobile sections + attribution chip)
- `frontend/src/client/i18n.ts` (~75 new keys × 2 langs)
- `frontend/build.ts` (new bundle entry: admin-approval-policies)
**Estimate:** ~3500-4500 LoC (matches t-138 + t-144 design phases — small
admin page, small migration, mostly mechanical wiring + CSS + i18n).
---
## §10 — Recommended implementer
Pattern-fluent Sonnet — substrate is well-trodden:
- Admin-page pattern → `frontend/src/admin-partner-units.tsx` is the
canonical reference (partner-unit picker → details panel; same shape
here with project picker → matrix panel).
- Project-detail edit-in-place → `client/projects-detail.ts` for the
`<select>`-on-row-click affordance pattern.
- ltree path-walk in SQL → `internal/services/visibility.go` and the
existing `paliad.can_see_project()` are the reference pattern.
- Audit emission → `internal/services/audit_service.go` (already plumbed).
- Form-hint above Speichern → similar to the t-148 profession hint
on `frontend/src/projects-detail.tsx:130` (`team-profession-hint`).
**NOT cronus** per memory directive (paliad). **NOT noether** (parked on
t-151 and t-144). **NOT godel** (just fired on t-149). **NOT hilbert
(me)** — I'm parked after this design; head decides if I take the
coder shift on the same worktree (mai/hilbert/inventor-approval-policy)
or hands it to a fresh coder.
---
## §11 — Out of scope (deferred to follow-ups)
- **Per-policy time-window** — "this rule applies only MonFri 917, after
hours skip 4-eye". Some firms do this. Deferred: another column would
be cheap, but no signal yet that anyone wants it.
- **Per-user exemptions** — "Alice is on PTO, route around her". Same
shape as today's `decision_kind='admin_override'` escape hatch — already
available via global_admin.
- **Multi-step approvals** — "needs partner THEN of_counsel sign-off".
cronus's t-138 is single-step by design (Q3 of t-138 locked it). Not
in scope here.
- **Policy templates / copy-from-other-project** — beyond bulk-apply-to-
descendants. If needed, would slot into the admin page as a
"Vorlage anwenden" affordance. Not v1.
- **Per-event_type policies** — "deadline.create with event_type='Klage'
needs partner; everything else of_counsel". The existing schema is
per-(entity_type, lifecycle_event); event-type granularity would
require an extra column + index. No signal yet.
---
**END OF DESIGN.**
Inventor stays parked. Awaits m's go/no-go on the 12 locked decisions
before any coder shift. Hand-off via head once green.

View File

@@ -0,0 +1,947 @@
# Deadline Data Model — Proceedings-as-DAG
**Author:** einstein (consultant)
**Date:** 2026-05-08
**Task:** t-paliad-158 ([Consultant] Deadline data model — proceedings-as-DAG analysis + recommendation)
**Branch:** `mai/einstein/consultant-deadline-data`
**Status:** DESIGN — analysis only, no schema changes in this branch.
**Predecessors read:** docs/audit-fristenrechner-completeness-2026-04-30.md (curie), docs/plans/unified-fristenrechner.md + docs/plans/unified-fristenrechner-v3.md (cronus, archived author), docs/design-courts-per-country-holidays-2026-05-05.md (cronus, on-hold).
**Companion:** feynman is in flight on `mai/feynman/fristenrechner` (t-paliad-157). Read that branch's WIP if pushed; do not take dependencies on it. This analysis is upstream of any in-flight implementation.
---
## 0. Executive summary
**The problem.** paliad's deadline knowledge today is fragmented across five tables and two parallel calculators. The structural truth m wants — *court system → proceeding → ordered event types → conditional trigger edges* — is mostly *implicit*: it lives partly in `deadline_rules.parent_id` (one-parent tree per proceeding), partly in `trigger_events`+`event_deadlines` (flat YouPC import), partly in `deadline_concepts` (cross-proceeding semantic bridge), partly in `event_categories` (Pathway-B navigation taxonomy), and partly in free-text columns on `paliad.projects`. Conditions are encoded *twice* — once via `condition_rule_id` (FK to a sibling rule), once via `condition_flag text[]` (named flags). Multi-parent triggers cannot be expressed cleanly. The court-system axis is missing entirely.
**What m wants** (verbatim, 2026-05-08 16:01):
> All I want is a natural sequence of proceedings which belong to a court system. And of course we can classify deadlines into concepts and make it easier for the AI to understand, but in its core I need event types that are related to proceedings and connected as a sequence, one triggering the other, with some conditions possibly changing the resulting sequence.
**Locked m decisions (this doc, AskUserQuestion 2026-05-08 16:1316:18):**
| Q | Subject | Lock |
|---|---|---|
| Q1 | Court-system axis | **Reuse `courts.court_type` as the system identity.** Promote it to a `paliad.court_types` lookup. FK `paliad.courts.court_type``court_types.code`. Retire `paliad.proceeding_types.jurisdiction`. |
| Q2 | Proceeding instance | **Project (or sub-project) IS the proceeding instance.** Verbatim m: *"Each UPC proceeding should be its own (sub-)project. And as such can be one proceeding or multiple if necessary. Flexibility is key."* No new `paliad.proceedings` table. Multi-proceeding cases use sub-projects in the existing project tree. |
| Q3 | Edge model | **First-class `paliad.proceeding_event_edges` table.** Multi-parent triggers natural. `parent_id` on the legacy `deadline_rules` table retired. |
| Q4 | Conditions | **Typed columns per edge:** `if_flags text[]` (all must be set), `unless_flags text[]` (none may be set), `requires_event_id uuid REFERENCES proceeding_event_types(id)`. SQL-queryable; no expression evaluator. |
| Q5 | Concept layer | **Subsume `deadline_concepts` into `proceeding_event_types.concept_slug` column.** Drop `deadline_concepts` table after backfill. Keep `event_categories` recursive tree as Pathway-B navigation overlay only — re-FK its junction onto `concept_slug`. |
**Headline shape change.** Today's *two-rule-libraries-bridged-by-a-mat-view* becomes *one rule library: a graph of typed event-types connected by typed edges, scoped to proceedings, scoped to court systems*. The instance side stays where it is (project tree). The AI/UX layers (concept tags, navigation tree) ride on top of the graph rather than parallel to it.
**Migration shape.** Additive build → atomic cutover per surface (Fristenrechner, deadline-search, /deadlines/new picker), all on the same boot. The 26 production `paliad.deadlines` rows survive untouched (their `rule_code` text already carries the citation; `rule_id` re-points to the new event-type/edge tuple post-cutover).
---
## 1. Map of current state
### 1.1 The five tables that carry deadline knowledge
```
┌──────────────────────────────┐
│ paliad.proceeding_types (26) │ jurisdiction text
│ ─ INF, REV, CCR, APM, … │ ('UPC'|'DE'|'EPA'|'DPMA')
│ ─ UPC_INF, UPC_REV, UPC_PI… │
│ ─ DE_INF, DE_NULL, DE_*_BGH │
│ ─ EPA_OPP, EPA_APP, EP_GRANT│
│ ─ DPMA_OPP, DPMA_*_BPATG… │
└──────────┬───────────────────┘
│ 1:N
┌──────────────────────────────────────────────────────┐
│ paliad.deadline_rules (172) │
│ ─ uuid PK │
│ ─ proceeding_type_id int FK │
│ ─ parent_id uuid → self (one-parent tree) │
│ ─ code, name_de, name_en, description │
│ ─ primary_party (claimant|defendant|both|court) │
│ ─ event_type (filing|decision|order|hearing) │
│ ─ duration_value int, duration_unit text │
│ (months|weeks|days|working_days) │
│ ─ timing (after|before) │
│ ─ rule_code text, deadline_notes text+_en │
│ ─ legal_source text ← t-paliad-131 Phase A │
│ ─ concept_id uuid FK ← t-paliad-131 Phase A │
│ ─ condition_rule_id uuid ─┐ │
│ ─ condition_flag text[] ├ TWO mechanisms, │
│ ─ alt_duration_* / unit │ one structural idea │
│ ─ alt_rule_code │ │
│ ─ anchor_alt text ─┘ │
│ ─ is_spawn bool, spawn_label text │
│ ─ is_bilateral bool ← t-paliad-133 Phase A │
│ ─ sequence_order int │
└──────┬───────────────────────────────────────────────┘
│ concept_id (uuid)
┌──────────────────────────────┐
│ paliad.deadline_concepts (57)│ the Unifier layer
│ ─ slug UNIQUE │ (t-paliad-131 Phase A)
│ ─ name_de, name_en │
│ ─ aliases text[] │
│ ─ party text │
│ ─ category (submission| │
│ decision|order|hearing) │
└──────┬───────────────────────┘
│ concept_id (uuid)
▼ (junction)
┌──────────────────────────────────────────┐
│ paliad.event_category_concepts (136) │ decision-tree leaf
│ ─ event_category_id FK │ → concept overlay
│ ─ concept_id FK │ (t-paliad-133)
│ ─ proceeding_type_code text -- narrow │
└──────┬───────────────────────────────────┘
┌──────────────────────────────┐
│ paliad.event_categories (103)│ recursive tree (parent_id self-FK)
│ ─ slug, label_de, label_en │ Pathway-B navigation taxonomy
│ ─ step_question_de/_en │ (t-paliad-133, depth unlimited)
│ ─ icon, sort_order, is_leaf │
└──────────────────────────────┘
```
**Parallel rule library — YouPC import (UPC-only, flat):**
```
┌──────────────────────────────┐ ┌──────────────────────────────────┐
│ paliad.trigger_events (110) │ 1:N │ paliad.event_deadlines (77) │
│ ─ bigint PK (verbatim from │ ───────▶│ ─ bigint PK (verbatim ids) │
│ youpc.data.events) │ │ ─ trigger_event_id FK │
│ ─ code, name, name_de │ │ ─ duration_value, duration_unit │
│ ─ concept_id text │ │ (days|weeks|months| │
│ ↑ slug (text), NOT FK │ │ working_days) │
└──────────────────────────────┘ │ ─ alt_duration_* + combine_op │
│ (max|min — composite │
│ rule for R.198/R.213) │
│ ─ timing (before|after) │
│ ─ title, title_de, notes, _en │
└──────────┬───────────────────────┘
│ 1:N
┌──────────────────────────────────┐
│ paliad.event_deadline_rule_codes │
│ (72 — one row per RoP citation) │
│ ─ event_deadline_id, rule_code, │
│ sort_order │
└──────────────────────────────────┘
```
The two rule libraries are bridged at search time only by `paliad.deadline_search` (mat-view, t-paliad-131 Phase C, migration 047): one row per (concept × context) where context is `kind='rule'` for `deadline_rules` rows or `kind='trigger'` for `trigger_events` rows. They share **no FK**.
**Instance side** (the per-case audit row):
```
┌─────────────────────────────────────────────────┐
│ paliad.deadlines (26 in production) │
│ ─ uuid PK │
│ ─ project_id uuid FK → paliad.projects(id) │
│ ─ rule_id uuid FK → deadline_rules(id) NULL │
│ ─ rule_code text -- citation, free-text │
│ (t-paliad-111 — survives rule rename) │
│ ─ title, description, due_date, original_due_ │
│ date, warning_date │
│ ─ status (pending|completed|cancelled|waived) │
│ ─ source (manual|imported|caldav|paliadin) │
│ ─ caldav_uid, caldav_etag │
│ ─ approval_status (approved|pending|legacy) │
│ + pending_request_id, approved_by/_at │
│ (t-paliad-138 dual-control, migration 054)│
│ ─ created_by, created_at, updated_at │
└─────────────────────────────────────────────────┘
│ deadline_id
┌─────────────────────────────────────────────────┐
│ paliad.deadline_event_types (junction, 0..N) │
│ ─ deadline_id, event_type_id (composite PK) │
│ (t-paliad-088, migration 030) │
└─────────────────────────────────────────────────┘
│ event_type_id
┌─────────────────────────────────────────────────┐
│ paliad.event_types (45) │
│ ─ uuid PK, slug, label_de, label_en │
│ ─ category (submission|decision|order|service| │
│ fee|hearing|other) │
│ ─ jurisdiction (UPC|EPO|DPMA|DE|any) NULL │
│ ─ trigger_event_id bigint NULL ─ loose linkage│
│ (NO FK constraint — youpc resync-safe) │
│ ─ created_by, is_firm_wide, archived_at │
└─────────────────────────────────────────────────┘
```
`paliad.event_types` is the user-facing classifier on per-case `paliad.deadlines` rows. It overlaps with `paliad.trigger_events` by ~70% (UPC submissions) and carries an optional `trigger_event_id` linkage column without an FK constraint by design (so a future YouPC re-sync can drop trigger ids without breaking event_types). It is **distinct from** `paliad.event_categories` (the Pathway-B decision tree) and **distinct from** `paliad.deadline_rules.event_type` (which is just a text column, values `filing|decision|order|hearing`).
So today the word "event type" identifies three different things in three different tables. Not necessarily wrong, but worth flagging.
### 1.2 Court / venue / jurisdiction
```
┌─────────────────────────────────────────────────────────────┐
│ paliad.courts (41 — t-paliad-122 migration 053) │
│ ─ id text PK (kebab, mirrors handlers/courts.go) │
│ ─ code, name_de, name_en │
│ ─ country text FK → paliad.countries(code) -- ISO-3166 │
│ ─ regime text NULL -- 'UPC'|'EPO'|NULL │
│ ─ court_type text -- 'UPC-LD'|'UPC-CD'|'UPC-CoA'| │
│ 'DE-LG'|'DE-OLG'|'DE-BGH'| │
│ 'DE-BPatG'|'DE-DPMA'|'EPA'|'NAT' │
│ ─ parent_id text FK → self │
│ ─ sort_order int, is_active bool │
└─────────────────────────────────────────────────────────────┘
```
The `court_type` column is currently **free text** (no constraint, no FK target). 41 rows are seeded across 11 distinct values. This is the column m's Q1 lock promotes to be the court-system identity.
`paliad.holidays` (55 rows) carries `country` ISO-3166 + `regime` ('UPC'|'EPO'|NULL). Federal DE public holidays = country='DE', regime=NULL; UPC summer/winter judicial vacations = country=NULL, regime='UPC'. The check constraint `country IS NOT NULL OR regime IS NOT NULL` enforces every row carries at least one.
### 1.3 Project side — what links a case to a proceeding today
```
┌─────────────────────────────────────────────────────────┐
│ paliad.projects (11 active in prod) │
│ ─ id uuid PK │
│ ─ type text -- 'mandat'|'litigation'|'patent'| │
│ 'verfahren'|'projekt' │
│ ─ parent_id uuid → self (project tree) │
│ ─ path text NOT NULL -- materialised ltree path │
│ (t-paliad-023, GiST-indexed, RLS-load-bearing) │
│ ─ title, reference, description, status │
│ ─ proceeding_type_id integer -- single FK │
│ → paliad.proceeding_types(id) │
│ ─ court text -- FREE TEXT, no FK to paliad.courts │
│ ─ country text │
│ ─ patent_number, filing_date, grant_date │
│ ─ case_number, billing_reference, client_number, │
│ matter_number, netdocuments_url │
│ ─ industry, ai_summary, metadata jsonb │
└─────────────────────────────────────────────────────────┘
```
So the project row carries:
- ONE proceeding-type FK (an integer, not nullable on `verfahren` projects but nullable in the schema).
- ONE court — but as **free text**, not FK'd to `paliad.courts.id` despite that table being seeded six days ago in migration 053.
- NO trigger_date column. The trigger date is implicit in the `paliad.deadlines.original_due_date` of whichever Frist anchored the calc.
- NO live-state column. There is no "currently at stage X" pointer.
There's no `paliad.proceedings` table. The conceptual link "this project IS a UPC infringement action" is the pair (project_id → proceeding_type_id), no further structure.
### 1.4 What lives where — by jurisdiction
| Jurisdiction | proceeding_types | deadline_rules | trigger_events | event_deadlines |
|---|---:|---:|---:|---:|
| UPC (legacy: INF/REV/CCR/APM/APP/AMD) | 6 | 36 | 0 | 0 |
| UPC (modern: UPC_INF/UPC_REV/UPC_PI/…) | 8 | 56 | 110 | 77 |
| DE (ZPO/PatG, LG/OLG/BGH/BPatG) | 5 | 40 | 0 | 0 |
| EPA (OPP/APP/EP_GRANT) | 3 | 23 | 0 | 0 |
| DPMA | 3 | 13 | 0 | 0 |
| Cross-cutting (Wiedereinsetzung, …) | 0 | 0 | 7 | 7 |
| Legacy ZPO_CIVIL placeholder | 1 | 4 | 0 | 0 |
| **Total** | **26** | **172** | **110** | **77** |
The two UPC generations (`INF/REV/CCR/APM/APP/AMD` from migration 008 vs `UPC_INF/UPC_REV/UPC_PI/UPC_APP/UPC_DAMAGES/UPC_DISCOVERY/UPC_COST_APPEAL/UPC_APP_ORDERS` from migration 012) coexist in production. Fristenrechner v3+ uses the modern set; the legacy six are unreferenced sediment kept "in case". This is technical debt orthogonal to the model question, flagged here for the migration plan in §4.
### 1.5 How conditional triggers are encoded today (concrete)
| Mechanism | Rules using it | Example |
|---|---:|---|
| `condition_rule_id` (FK to a sibling rule) | 2 | INF tree's `inf.reply` and `inf.rejoin` reference `ccr.counterclaim` — when CCR was filed in the same case, swap rule_code RoP.029.b → RoP.029.a (Reply) or duration 1mo → 2mo (Rejoinder). |
| `condition_flag text[]` (named flags from request) | 17 | UPC_INF tree's `with_ccr` rules render only when the request includes `with_ccr` flag; UPC_REV's `with_amend`/`with_cci` parallel flags. |
| `alt_duration_value` + `alt_duration_unit` + `alt_rule_code` | 4 | Swap-on-flag fallback (R.198/R.213 max-of-31d-or-20wd is encoded similarly on `event_deadlines.alt_duration_*` + `combine_op`). |
| `anchor_alt text` (named alternate anchor) | 1 | EP_GRANT publish anchors on `priority_date` instead of parent rule's date. |
| `is_spawn` + `spawn_label` (cross-tree edge) | 6 | INF tree's `inf.appeal` lives in APP tree but `parent_id` points into INF.decision — the rule itself sits in proceeding APP, the parent sits in proceeding INF. Implicit cross-proceeding edge. |
| `condition_flag` AND `alt_duration_value` together | 3 | UPC_INF Replik has `condition_flag=['with_ccr']` swapping duration via `alt_duration_value` rather than gating render. |
The two-mechanism split is what bites every contributor. `condition_rule_id` was the Phase-A approach; `condition_flag` was added by t-paliad-086 PR-3 because `condition_rule_id` couldn't model "user told me they ARE in CCR mode without there being a rule of mine to point at." Both still in production. New rules should use `condition_flag`; the 2 legacy `condition_rule_id` rules are equivalent to single-element flag arrays and were not migrated.
### 1.6 The two calculators
- **Tree calculator** — `internal/services/fristenrechner.go` (803 lines): walks `deadline_rules` parent_id chain, anchors on input trigger_date, applies condition_flag gates, swaps `alt_*` columns when flags are set, classifies court-determined nodes (`isCourtDeterminedRule`: `primary_party='court' OR event_type IN ('hearing','decision','order')`) so they render as "no date — court will set it". Used by `/tools/fristenrechner` for the 16 modern proceeding-tree views.
- **Flat calculator** — `internal/services/event_deadline_service.go` (315 lines): single trigger_event ID + trigger_date → list of event_deadlines, no parent chain. Composite `combine_op='max'`/`'min'` resolves R.198/R.213. Working-days math via `addWorkingDays` over `paliad.holidays`. Used by Pathway-B "Was kommt nach…" tab.
The two share `holidays.go` for working-day skip logic. Otherwise the code paths are independent.
---
## 2. Gaps vs proceedings-as-DAG framing
m's framing decoded into structural facts the data model SHOULD support:
| m says | Data model needs |
|---|---|
| "court system" is the outer container | One row per court system the firm practises in (UPC-CFI, UPC-CoA, DE-LG-Patentkammer, DE-OLG, DE-BGH, DE-BPatG, EPO, DPMA, …). Procedural rules belong to a court system. |
| "a natural sequence of proceedings" | One row per *named procedural shape* (UPC infringement action, UPC revocation action, EPO opposition, DE LG patent action). A proceeding belongs to ONE court system. |
| "event types … related to proceedings" | Each event-type node belongs to a proceeding. Some nodes may be shared across proceedings (final-decision, oral-hearing). |
| "connected as a sequence, one triggering the other" | Edges between event-types within a proceeding. Multi-parent allowed (one node may be triggered by either of two predecessors). |
| "with some conditions possibly changing the resulting sequence" | Edges carry conditions. Conditions are first-class (queryable, AI-readable). |
| "classify deadlines into concepts and make it easier for the AI" | Concept tag layer on each event-type. Rides on top of the graph, doesn't compete with it. |
### 2.1 Concrete gaps
#### Gap G1 — Court system is not in the data model
**Today:** `proceeding_types.jurisdiction text` ('UPC'|'DE'|'EPA'|'DPMA') conflates court-system regime with national jurisdiction. The 41 `paliad.courts` rows carry `court_type` ('UPC-LD'|'UPC-CoA'|'DE-LG'|'DE-OLG'|'DE-BGH'|'DE-BPatG'|'EPA'|'DPMA'|'NAT'|…) as free text. There is no FK between the two.
**Why it bites:** "Show me every UPC procedural rule" requires `proceeding_types.jurisdiction='UPC'`. "Show me every rule that fires in a German LG patent chamber" requires reasoning about court_type='DE-LG' AND a proceeding that runs there — but the proceeding doesn't carry a court_type, the *project's court* does, and that's free text. The DE-LG and DE-OLG patent appeal proceedings (`DE_INF`, `DE_INF_OLG`) BOTH have jurisdiction='DE' on `proceeding_types`; nothing tells you DE_INF runs at LG and DE_INF_OLG runs at OLG except the proceeding name.
**Concrete fail:** today, the holiday lookup for "deadline computed for a UPC infringement action filed in München LD" needs UPC summer vacation + DE federal holidays. The intermediate join (project.court_type → applicable holiday set) is hardcoded in `internal/services/holidays.go` because there's no FK chain to walk.
#### Gap G2 — One project = one proceeding-type FK; multi-proceeding cases are forced into the project tree
**Today:** `paliad.projects.proceeding_type_id integer` is single-valued. A project that hosts BOTH a UPC infringement action and a separate revocation counterclaim must either:
(a) Tag itself with one of the two and lose half its proceeding context, or
(b) Be split into two child `verfahren` projects under a common litigation parent.
**m's lock (Q2):** Sub-projects are the right answer. *"Each UPC proceeding should be its own (sub-)project."* This is consistent with the project-tree model already in place since t-paliad-023 (data-model-v2). The fix isn't to add a `paliad.proceedings` table; it's to *honour* the existing tree by FK-tightening `projects.proceeding_def_id` on `verfahren`-typed projects.
#### Gap G3 — Edges are one-parent only; multi-parent triggers cannot be expressed cleanly
**Today:** Each `deadline_rules` row has at most one `parent_id`. A node like UPC `inf.rejoin` has TWO real-world predecessors:
- After Reply-to-SoD when no CCR was filed (1 month, RoP.029.c)
- After Reply-to-Defence-to-CCR when CCR was filed (1 month, RoP.029.e)
The current model collapses these into ONE rule with `condition_flag=['with_ccr']` swapping `alt_*` columns, but that masks the true graph: there are two distinct edges into `inf.rejoin`, with different `from_event_type` and different `rule_code`. Today the calculator papers over this by anchoring `inf.rejoin` on whichever parent the `parent_id` points at and pretending the other parent doesn't exist for purposes of the chain walk.
Cross-proceeding edges (the legacy `is_spawn` flag, 6 rules) are an even uglier symptom — `inf.appeal` lives in proceeding APP but its `parent_id` points into INF. Two different proceedings, one edge. Today this is fine for tree traversal but breaks any "show me proceeding APP's structure" query because you have to know the edge crosses.
#### Gap G4 — Conditions encoded in two mechanisms
**Today:** 2 rules use `condition_rule_id` (FK to a sibling rule whose presence flips alt_duration / alt_rule_code), 17 rules use `condition_flag text[]` (named flags). Both still load-bearing in the calculator. Same idea, two columns.
**Why it bites:** Every new contributor has to learn both. The 2 legacy `condition_rule_id` rules are sentinel debt — they couldn't be deleted without rewriting the inf.reply / inf.rejoin classifier_flag dual-encoding (memory `652b856f` t-paliad-086 PR-3 imported the flag-based variant alongside, did NOT migrate the legacy two).
#### Gap G5 — Two parallel rule libraries with no shared FK
**Today:**
- `deadline_rules` (172 rows, UUID PK, parent-tree, condition_flag, alt_*) — the timeline calculator's source.
- `trigger_events` + `event_deadlines` (110+77 rows, bigint PK, flat trigger→deadline map, composite max/min) — the trigger calculator's source.
They are bridged at search time by `paliad.deadline_search` mat-view (concept slug as join key) but share no FK. A rule in `deadline_rules` and a deadline in `event_deadlines` can describe the *same* legal idea (e.g. UPC Klageerwiderung) and the only thing that ties them is whether someone happened to set the same `concept_id`/`concept slug` on both sides.
This costs us:
- **Drift** — when t-paliad-086 PR-3 fixed Tier-1 bugs in `deadline_rules`, equivalent rows in `event_deadlines` were not touched. The two libraries can disagree on the same Frist.
- **Audit difficulty** — "is this Frist correct?" requires reading both tables and the bridge.
- **AI confusion** — feeding the corpus to the LLM means feeding two different shapes of the same knowledge.
#### Gap G6 — Concept layer is a rope-bridge, not a column
**Today:** `paliad.deadline_concepts` (57 rows) is a separate table. `deadline_rules.concept_id uuid FK`. `trigger_events.concept_id text` (slug, NOT FK — string-walked). `event_category_concepts.concept_id uuid FK` (the navigation overlay). Three different referent types for the same entity.
**Why it bites:** Re-naming a concept (slug change) means walking three FK shapes. AI ingestion means joining four tables to get "what does this Frist *mean*." The cross-proceeding semantic identity (one Klageerwiderung in UPC ≅ one Klageerwiderung in DE_INF) is queryable but not load-bearing — the FK exists, but nothing constrains *both* rules to point at the same concept_id. Drift is silent.
#### Gap G7 — Conditional sequence changes are local to one edge
**Today:** A condition on rule X (e.g. `condition_flag=['with_ccr']`) gates whether rule X renders. It does NOT propagate. So if "with_ccr is true" should *also* mean "the Application-to-amend timeline becomes available in this proceeding," that's encoded as separate rules each with their own `condition_flag=['with_ccr']`. No "if condition C, the proceeding switches to track T" semantic.
**Concrete example:** UPC infringement with CCR has its OWN sub-proceeding shape (Defence-to-CCR with its own Reply/Rejoinder cycle, optional Application-to-amend). Today this is encoded as N additional rules in `UPC_INF` each gated on `with_ccr`. Tomorrow it could be one `proceeding_event_edges` row that says "if `with_ccr` then activate the CCR sub-graph rooted at this node."
This is **not** addressed by Q3+Q4 — multi-parent edges + typed conditions. We'll *come closer*, but a true track-switching semantic ("this proceeding has an alternate path that engages under condition X") is one level above the edge model and is **deliberately deferred**. See §6.4.
---
## 3. Target shape
This section translates m's locked decisions into a concrete schema and walks one full UPC infringement action to make the shape tangible.
### 3.1 Court system axis (Q1)
```sql
CREATE TABLE paliad.court_types (
code text PRIMARY KEY,
name_de text NOT NULL,
name_en text NOT NULL,
regime text -- 'UPC'|'EPO'|NULL (national)
CHECK (regime IS NULL OR regime IN ('UPC','EPO')),
sort_order int NOT NULL DEFAULT 0,
is_active bool NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO paliad.court_types (code, name_de, name_en, regime, sort_order) VALUES
-- UPC court systems
('UPC-LD', 'UPC-Lokalkammer', 'UPC Local Division', 'UPC', 10),
('UPC-CD', 'UPC-Zentralkammer', 'UPC Central Division', 'UPC', 20),
('UPC-CoA', 'UPC-Berufungsgericht', 'UPC Court of Appeal', 'UPC', 30),
('UPC-RD', 'UPC-Regionalkammer', 'UPC Regional Division', 'UPC', 40),
-- DE court systems
('DE-LG', 'Landgericht (Patentstreitkammer)',
'German Regional Court (patent chamber)', NULL, 50),
('DE-OLG', 'Oberlandesgericht (Patentsenat)',
'German Higher Regional Court (patent senate)', NULL, 60),
('DE-BGH', 'Bundesgerichtshof (X. Zivilsenat)',
'German Federal Court of Justice (Xth Civil Senate)', NULL, 70),
('DE-BPatG', 'Bundespatentgericht', 'German Federal Patent Court', NULL, 80),
('DE-DPMA', 'Deutsches Patent- und Markenamt',
'German Patent and Trade Mark Office', NULL, 90),
-- EPO
('EPA', 'Europäisches Patentamt', 'European Patent Office', 'EPO', 100),
-- National (non-UPC, non-DE-patent-track)
('NAT', 'Nationales Gericht', 'National Court', NULL, 200);
-- FK from existing courts table
ALTER TABLE paliad.courts
ADD CONSTRAINT courts_court_type_fk
FOREIGN KEY (court_type) REFERENCES paliad.court_types(code);
```
The 41 `paliad.courts` rows already carry the right `court_type` strings (verified live: 11 distinct values, all in the seed list above). The FK addition is a pure constraint upgrade, no data move.
### 3.2 Proceeding definitions (the named-sequence template)
```sql
-- Renamed + restructured from paliad.proceeding_types
CREATE TABLE paliad.proceeding_definitions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code text NOT NULL UNIQUE,
-- 'UPC_INF','UPC_REV','UPC_PI','UPC_APP','EPO_OPP',
-- 'EPO_APP','DE_INF_LG','DE_INF_OLG','DE_INF_BGH',
-- 'DE_NULL_BPATG','DE_NULL_BGH','DPMA_OPP','DPMA_APP','DPMA_RB'
name_de text NOT NULL,
name_en text NOT NULL,
description text,
court_type text NOT NULL
REFERENCES paliad.court_types(code), -- the system axis
category text NOT NULL -- 'litigation'|'opposition'|'examination'|'appeal'
CHECK (category IN ('litigation','opposition','examination',
'appeal','enforcement','provisional')),
default_color text NOT NULL DEFAULT '#3b82f6',
sort_order int NOT NULL DEFAULT 0,
is_active bool NOT NULL DEFAULT true,
is_fristenrechner bool NOT NULL DEFAULT true,
-- whether this proceeding is exposed in /tools/fristenrechner
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX proceeding_definitions_court_type_idx
ON paliad.proceeding_definitions(court_type);
CREATE INDEX proceeding_definitions_category_idx
ON paliad.proceeding_definitions(category);
```
Each row IS a "natural sequence of [a class of] proceedings." `court_type` is the outer container m asked for. The legacy `proceeding_types.jurisdiction` text column is dropped — its information is now derivable via `court_types.regime`.
### 3.3 Event types (the nodes)
```sql
CREATE TABLE paliad.proceeding_event_types (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
proceeding_def_id uuid NOT NULL
REFERENCES paliad.proceeding_definitions(id) ON DELETE CASCADE,
-- Each node belongs to one proceeding. Cross-proceeding shared
-- semantics are expressed via concept_slug (Q5 lock), not by
-- attaching one node to multiple proceedings.
code text NOT NULL,
-- Local code, unique within proceeding_def_id.
-- Examples: 'soc','sod','reply','rejoinder','decision'
name_de text NOT NULL,
name_en text NOT NULL,
description text,
party text NOT NULL
CHECK (party IN ('claimant','defendant','both','court','any')),
kind text NOT NULL
CHECK (kind IN ('filing','decision','order','hearing','service','fee')),
concept_slug text, -- Q5 lock — subsumes paliad.deadline_concepts
-- Free-form slug; matches old concept slugs verbatim post-migration.
-- One LLM-readable identifier shared across proceedings.
-- E.g. 'statement-of-defence' on both UPC_INF.sod and DE_INF_LG.klageerw.
concept_de text, -- denormalised from old deadline_concepts.name_de
concept_en text, -- denormalised from old deadline_concepts.name_en
aliases text[] NOT NULL DEFAULT '{}',
-- Search aliases inherited from old deadline_concepts.aliases.
-- Indexed via gin (aliases) for the search bar.
is_root bool NOT NULL DEFAULT false,
-- True for the trigger node of a proceeding (the Statement of Claim,
-- the Statement for Revocation, the EPO opposition filing). The
-- proceeding instance's trigger_date anchors here.
sort_order int NOT NULL DEFAULT 0,
is_active bool NOT NULL DEFAULT true,
is_bilateral bool NOT NULL DEFAULT false,
-- Carried over from t-paliad-133. When true AND party='both',
-- mirror into both columns of the columns-view.
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (proceeding_def_id, code)
);
CREATE INDEX proceeding_event_types_def_idx ON paliad.proceeding_event_types(proceeding_def_id);
CREATE INDEX proceeding_event_types_concept_idx ON paliad.proceeding_event_types(concept_slug)
WHERE concept_slug IS NOT NULL;
CREATE INDEX proceeding_event_types_aliases_idx ON paliad.proceeding_event_types USING gin (aliases);
CREATE INDEX proceeding_event_types_de_trgm ON paliad.proceeding_event_types USING gin (name_de gin_trgm_ops);
CREATE INDEX proceeding_event_types_en_trgm ON paliad.proceeding_event_types USING gin (name_en gin_trgm_ops);
```
Per Q5: `concept_slug` + `concept_de` + `concept_en` + `aliases` are columns on the node, not a separate table. The 57 `paliad.deadline_concepts` rows distill into ~57 distinct concept_slug values across the ~172+ migrated nodes. Cross-proceeding "all rules with concept_slug='statement-of-defence'" is a single-column index lookup, not a join.
### 3.4 Edges (the typed triggers)
```sql
CREATE TABLE paliad.proceeding_event_edges (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
proceeding_def_id uuid NOT NULL
REFERENCES paliad.proceeding_definitions(id) ON DELETE CASCADE,
from_event_id uuid REFERENCES paliad.proceeding_event_types(id) ON DELETE CASCADE,
-- NULL = root edge (anchors on the proceeding instance's trigger_date).
-- The to_event must have is_root=true for null-from edges.
to_event_id uuid NOT NULL
REFERENCES paliad.proceeding_event_types(id) ON DELETE CASCADE,
duration_value int NOT NULL DEFAULT 0,
duration_unit text NOT NULL DEFAULT 'months'
CHECK (duration_unit IN ('days','weeks','months','working_days')),
timing text NOT NULL DEFAULT 'after'
CHECK (timing IN ('after','before')),
-- 'before' supports countdown deadlines (e.g. "1 month before oral hearing").
combine_op text CHECK (combine_op IS NULL OR combine_op IN ('max','min')),
alt_duration_value int,
alt_duration_unit text CHECK (alt_duration_unit IS NULL
OR alt_duration_unit IN ('days','weeks','months','working_days')),
-- combine_op + alt_* implements composite rules
-- (e.g. R.198/R.213 max(31d, 20wd)). Only set on edges
-- where the rule itself is composite — flag-conditioned
-- variants use sibling edges, not alt_*.
-- ===== Q4 lock — typed conditions =====
if_flags text[] NOT NULL DEFAULT '{}',
-- All flags in this array must be set for the edge to fire.
-- Empty array = unconditional.
unless_flags text[] NOT NULL DEFAULT '{}',
-- None of these flags may be set for the edge to fire.
requires_event_id uuid REFERENCES paliad.proceeding_event_types(id) ON DELETE SET NULL,
-- Edge fires only if this OTHER event was actually filed/recorded
-- in the proceeding instance (replaces today's condition_rule_id).
-- NULL = no occurrence prerequisite.
-- ===== Citation =====
rule_code text, -- 'RoP.029.b','PatG §111(1)','§ 276 ZPO'
legal_source text, -- 'UPC.RoP.029.b' / 'DE.PatG.111.1' / 'EU.EPÜ.108'
is_mandatory bool NOT NULL DEFAULT true,
deadline_notes_de text,
deadline_notes_en text,
sort_order int NOT NULL DEFAULT 0,
is_active bool NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Sanity: from_event must belong to the same proceeding_def
-- (cross-proceeding edges are out-of-scope per §3.6 — modelled
-- via separate root edges in each proceeding instead).
CONSTRAINT edge_from_in_def CHECK (
from_event_id IS NULL OR proceeding_def_id IS NOT NULL
)
);
CREATE INDEX edges_def_idx ON paliad.proceeding_event_edges(proceeding_def_id);
CREATE INDEX edges_to_idx ON paliad.proceeding_event_edges(to_event_id);
CREATE INDEX edges_from_idx ON paliad.proceeding_event_edges(from_event_id)
WHERE from_event_id IS NOT NULL;
CREATE INDEX edges_requires_idx ON paliad.proceeding_event_edges(requires_event_id)
WHERE requires_event_id IS NOT NULL;
CREATE INDEX edges_if_flags_idx ON paliad.proceeding_event_edges USING gin (if_flags);
CREATE INDEX edges_unless_flags_idx ON paliad.proceeding_event_edges USING gin (unless_flags);
CREATE INDEX edges_rule_code_idx ON paliad.proceeding_event_edges(rule_code)
WHERE rule_code IS NOT NULL;
```
**Multi-parent semantics:** when two edges share the same `to_event_id`, both compute candidate dates; the calculator picks per the edges' `if_flags`/`unless_flags`/`requires_event_id` predicates. If multiple edges remain feasible for the same target, the rendered Frist is the LATEST of the candidates (paying lip service to the most-conservative-first principle); a future edge-priority column can refine this if needed.
**Composite within an edge** (`combine_op`): used only when the rule itself is structurally composite (R.198 / R.213 max-of-two-units). Flag-driven variants (`with_ccr` swaps duration 1mo→2mo) become **two sibling edges** with disjoint `if_flags` predicates — the cleaner expression of the same idea.
### 3.5 Project ↔ proceeding linkage (Q2)
```sql
-- Per Q2 lock — project (or sub-project) IS the proceeding instance.
ALTER TABLE paliad.projects
ADD COLUMN proceeding_def_id uuid
REFERENCES paliad.proceeding_definitions(id),
ADD COLUMN court_id text
REFERENCES paliad.courts(id),
ADD COLUMN proceeding_trigger_date date,
-- The date that anchors the root edge of this proceeding.
-- Null until the trigger-event has actually occurred.
ADD COLUMN proceeding_status text NOT NULL DEFAULT 'pending'
CHECK (proceeding_status IN ('pending','active','suspended','concluded','withdrawn'));
-- Backfill from the existing integer FK + free-text court column.
UPDATE paliad.projects p
SET proceeding_def_id = pd.id
FROM paliad.proceeding_definitions pd
JOIN paliad.proceeding_types pt ON pt.code = pd.code
WHERE p.proceeding_type_id = pt.id;
-- Free-text court → FK by best-effort string match.
UPDATE paliad.projects p
SET court_id = c.id
FROM paliad.courts c
WHERE p.court IS NOT NULL
AND lower(p.court) IN (lower(c.id), lower(c.code), lower(c.name_de), lower(c.name_en));
-- After backfill (separate migration, gated on QA):
-- ALTER TABLE paliad.projects DROP COLUMN proceeding_type_id;
-- ALTER TABLE paliad.projects DROP COLUMN court; -- free-text version
-- ALTER TABLE paliad.projects DROP COLUMN country; -- inferred via court → court_type → country
```
A `verfahren`-typed project carries `proceeding_def_id` (the template) + `court_id` (the venue) + `proceeding_trigger_date` (the anchor for downstream edges). A `mandat`/`litigation`-typed project does NOT carry these (NULL is fine). Multi-proceeding cases live as sibling `verfahren` projects under a shared parent — exactly m's lock.
The `proceeding_status` column gives the per-instance live state m wanted (pending → active → concluded) without a separate `paliad.proceedings` table. Future fields (current-stage event_type_id, last_advanced_at, expected-decision-date) extend this column set without disturbing other layers.
### 3.6 Cross-proceeding edges — explicit retirement
The current `is_spawn` flag (6 rules) encodes "filing of A in proceeding X opens proceeding Y" by parking a rule in proceeding Y's tree with `parent_id` pointing into proceeding X. Concretely: `inf.appeal` lives in APP but its parent is INF.decision.
In the new shape: **each proceeding's graph is closed.** Cross-proceeding triggers are modelled at the *instance* layer — when the user records "decision in INF reached on date D," they instantiate a NEW `verfahren` sub-project (proceeding APP) with `proceeding_trigger_date=D`. The graph stays clean; the cross-proceeding step is a project-tree action, not an edge.
This is a small UX shift (today the appeal Frist auto-renders inside the INF timeline; tomorrow the user explicitly spawns the appeal sub-project to see its Fristen) but the alternative — letting `proceeding_event_edges` straddle proceedings — pollutes the model. Defer cross-proceeding-edge support; add a sub-project-creation shortcut on the decision-event UI instead.
### 3.7 Concept layer — what stays, what goes
**Drops:**
- `paliad.deadline_concepts` (57 rows). Content lifts to `proceeding_event_types.concept_slug` + `concept_de` + `concept_en` + `aliases`.
- `paliad.deadline_rules.concept_id` FK. Replaced by `proceeding_event_types.concept_slug` text column.
- `paliad.trigger_events.concept_id text` (already a slug, was never an FK). Migrated to the matching `proceeding_event_types` rows — see §4.
**Stays:**
- `paliad.event_categories` (103 rows) — Pathway-B navigation taxonomy. Recursive tree, decision-tree UI. Re-FK its junction onto `concept_slug`:
```sql
ALTER TABLE paliad.event_category_concepts
DROP CONSTRAINT event_category_concepts_concept_id_fkey;
ALTER TABLE paliad.event_category_concepts
ADD COLUMN concept_slug text;
UPDATE paliad.event_category_concepts ecc
SET concept_slug = dc.slug
FROM paliad.deadline_concepts dc
WHERE ecc.concept_id = dc.id;
ALTER TABLE paliad.event_category_concepts
ALTER COLUMN concept_slug SET NOT NULL,
DROP COLUMN concept_id;
```
The category tree is now a thin overlay that maps "user clicked 'Hinweisbeschluss'" to the set of concept_slugs whose nodes should appear as cards. No separate concept identity required — the slug is the bridge.
**Stays unchanged:**
- `paliad.event_types` (45 rows) — the *instance-side* user-facing classifier on `paliad.deadlines`. Per t-paliad-088 this is firm-wide-or-private, archive-only, with optional loose-linkage `trigger_event_id`. Untouched by this design — it's a different layer (instance tag, not template node). After migration, the loose linkage column can be repurposed: `event_types.proceeding_event_type_id uuid` (still loose, still nullable) — maintained as a follow-up, not in scope for the cutover.
### 3.8 Worked example — UPC infringement action (with CCR variant)
The mermaid below is one full proceeding's graph in the new shape. **Solid edges fire unconditionally; dashed edges fire only when the labelled flag is set.** Multi-parent at `inf.rejoinder` is the headline shape change.
```mermaid
flowchart TD
inf_soc["📄 inf.soc<br/>Statement of Claim<br/>concept-slug: statement-of-claim<br/>kind: filing • party: claimant • is_root: true"]:::root
inf_prelim["⚠️ inf.prelim<br/>Preliminary Objection<br/>concept-slug: preliminary-objection<br/>RoP.019.1"]
inf_sod["📄 inf.sod<br/>Statement of Defence<br/>concept-slug: statement-of-defence<br/>RoP.023"]
inf_ccr["⚖️ inf.ccr_counterclaim<br/>Counterclaim for Revocation<br/>concept-slug: counterclaim-for-revocation<br/>RoP.025 • is_bilateral"]
inf_amend["📐 inf.app_to_amend<br/>Application to amend patent<br/>concept-slug: application-to-amend-patent<br/>RoP.030"]
inf_reply_no["📝 inf.reply<br/>Reply to Defence (no CCR)<br/>concept-slug: reply-to-defence<br/>RoP.029.b"]
inf_reply_w["📝 inf.reply_with_ccr<br/>Defence-to-CCR + Reply<br/>concept-slug: reply-to-defence<br/>RoP.029.a"]
inf_def_amend["📝 inf.defence_to_amend<br/>Defence to App-to-amend<br/>concept-slug: defence-to-amend-patent<br/>RoP.032.1"]
inf_rejoin["📝 inf.rejoinder<br/>Rejoinder<br/>concept-slug: rejoinder-to-reply<br/>RoP.029.c|RoP.029.d"]
inf_interim["🧑‍⚖️ inf.interim<br/>Interim Conference<br/>kind: hearing • party: court"]
inf_oral["⚖️ inf.oral<br/>Oral Hearing<br/>kind: hearing • party: court"]
inf_decision["🏛️ inf.decision<br/>Decision on the merits<br/>concept-slug: decision-on-merits<br/>kind: decision • party: court"]
inf_costs["💰 inf.cost_application<br/>Application for cost decision<br/>concept-slug: application-for-cost-decision<br/>RoP.151 • 1mo from decision"]
inf_soc -- "1mo" --> inf_prelim
inf_soc -- "3mo (RoP.023)" --> inf_sod
inf_soc -- "3mo (RoP.025)<br/>if_flags: with_ccr" -.-> inf_ccr
inf_soc -- "3mo (RoP.030)<br/>if_flags: with_amend" -.-> inf_amend
inf_sod -- "2mo (RoP.029.b)<br/>unless_flags: with_ccr" --> inf_reply_no
inf_ccr -- "2mo (RoP.029.a)" --> inf_reply_w
inf_amend -- "2mo (RoP.032.1)<br/>requires_event: inf.app_to_amend" -.-> inf_def_amend
inf_reply_no -- "1mo (RoP.029.c)<br/>unless_flags: with_ccr" --> inf_rejoin
inf_reply_w -- "1mo (RoP.029.d)<br/>if_flags: with_ccr" --> inf_rejoin
inf_rejoin -.-> inf_interim
inf_interim --> inf_oral
inf_oral --> inf_decision
inf_decision -- "1mo (RoP.151)" --> inf_costs
classDef root fill:#c6f41c,stroke:#000,stroke-width:2px,color:#000
```
**Anatomy of the multi-parent into `inf.rejoinder`:**
```sql
-- Edge from no-CCR Reply → Rejoinder (1 month, RoP.029.c)
INSERT INTO paliad.proceeding_event_edges
(proceeding_def_id, from_event_id, to_event_id,
duration_value, duration_unit, rule_code, legal_source,
unless_flags)
VALUES
(:upc_inf, :inf_reply_no, :inf_rejoin,
1, 'months', 'RoP.029.c', 'UPC.RoP.029.c',
ARRAY['with_ccr']);
-- Edge from CCR-track Reply → Rejoinder (1 month, RoP.029.d)
INSERT INTO paliad.proceeding_event_edges
(proceeding_def_id, from_event_id, to_event_id,
duration_value, duration_unit, rule_code, legal_source,
if_flags)
VALUES
(:upc_inf, :inf_reply_w, :inf_rejoin,
1, 'months', 'RoP.029.d', 'UPC.RoP.029.d',
ARRAY['with_ccr']);
```
The current encoding (one rule with `condition_flag=['with_ccr']` swapping `alt_duration_value=2`) is rewritten as two structurally-clean sibling edges. The calculator's logic simplifies: pick the edge whose `if_flags ⊆ flags AND unless_flags ∩ flags = ∅ AND (requires_event_id IS NULL OR requires_event_id ∈ recorded_events)`. No special-cased `alt_*` swap path.
### 3.9 Five more proceedings spec'd at the DAG-shape level
For each, the **node count** is shown along with the **distinguishing edge feature** that the new model handles cleanly. Full graphs are out of scope for the design doc — the coder shift will port migrations 008/009/012/041046 row-by-row.
| Proceeding | Court system | Nodes | Distinguishing edge feature |
|---|---|---:|---|
| **UPC infringement action** (UPC_INF, §3.8) | UPC-LD / UPC-CD | ~15 | Multi-parent into `inf.rejoinder`; `if_flags`/`unless_flags` carve the with-CCR / no-CCR tracks; `requires_event_id` gates `inf.defence_to_amend` on actual filing of `inf.app_to_amend`. |
| **UPC standalone revocation** (UPC_REV) | UPC-CD | ~15 | TWO independent flags (`with_amend`, `with_cci`) gate the App-to-amend cycle and the Counterclaim-for-Infringement sub-track respectively. Each flag ⇒ ~4 sibling edges activate. Today this is encoded as 8 rules each tagged with one or both flags; tomorrow as edges into a clearly-labelled second-track sub-graph. |
| **EPO opposition** (EPO_OPP) | EPA | ~8 | Root edge from the "Decision to grant EP" external trigger anchors `epo_opp.notice` (9-month opposition period, Art.99 EPC). Subsequent edges (R.79, R.116) are unconditional. Rule data flat — no flag conditions. |
| **DE LG patent action** (DE_INF_LG) | DE-LG | ~9 | Root edge anchors on `klage.einreichung`. The two-step `Verteidigungsanzeige` (§276.1, 2 weeks) followed by `Klageerwiderung` (§276.1.S2, court-set, ≥2 weeks) is two sequential edges, no flag. The **§ 276 deadline regime** maps cleanly to `requires_event_id` if a future feature wants to gate Klageerwiderung on whether Verteidigungsanzeige was timely filed. |
| **DE LG → OLG appeal** (DE_INF_OLG) | DE-OLG | ~7 | Synthetic root node `olg.zustellung_urteil` (party='both', is_root=true) anchors on the LG decision date — bridging the cross-proceeding decision-to-appeal link as a project-tree spawn (§3.6). Berufung 1mo (§517 ZPO), Berufungsbegründung 2mo from filing-of-Berufung (§520.2) — multi-parent edge candidate if the user's date overrides. |
| **DPMA → BPatG Beschwerde** (DPMA_BPATG_BESCHWERDE) | DE-BPatG | ~5 | Two sibling edges from `dpma.beschluss` to `bpatg.beschwerde`: 1mo standard (§73 PatG), 2mo if `if_flags=['ausland']` (foreign-resident extension). The flag-conditioned variant is 100% naturally an edge condition, no `alt_*` plumbing needed. |
| **EPA Beschwerde (Boards of Appeal)** (EPO_APP) | EPA | ~6 | Root node `epo.entsch` anchors a 2-month notice + 4-month grounds chain (Art.108 EPC). The R.106 RPBA Petition for Review fires as a sibling edge with `if_flags=['fundamental_defect']` — clean. |
The edge model collapses all the today's flag/swap encodings into "edges with predicates," which is genuinely simpler to reason about and AI-friendly (each edge is a self-contained legal fact: from-X-to-Y-in-D-units-iff-conditions).
---
## 4. Migration path
### 4.1 Strategy: additive build → cutover per surface, one boot
**NOT** a graph-on-top. The Q3+Q5 locks (separate edges table, drop concept table) are structural — keeping `deadline_rules` AND `proceeding_event_types` AND `proceeding_event_edges` AND `deadline_concepts` simultaneously is the worst of both worlds (more layers, no clarity). The migration is genuinely additive build → cutover.
**NOT** a destructive cutover in one big migration. The 26 production deadlines, the running Fristenrechner, the deadline-search mat-view, and the currently-shipping t-paliad-138 approval flow are all live. We need every one of them to work mid-migration.
**The right shape:** four migrations, four boots, one feature cutover per boot. The prior table stays till the end, then drops.
### 4.2 Phase M1 — additive build (one boot, zero behaviour change)
Single migration. Creates new tables, populates from old, leaves old in place. Fristenrechner + deadline-search keep using the old tables; `paliad.deadlines` keeps `rule_id` pointing to `deadline_rules`. Day-1 deploy = no user-visible change.
```
1. CREATE paliad.court_types + seed 11 rows + FK from paliad.courts.court_type.
2. CREATE paliad.proceeding_definitions; backfill from paliad.proceeding_types
(rows that survive — drop the obsolete legacy 6 INF/REV/CCR/APM/APP/AMD,
keep only the 16 active fristenrechner sets + ZPO_CIVIL).
3. CREATE paliad.proceeding_event_types; backfill from deadline_rules
(one row per surviving rule), with concept_slug + concept_de + concept_en
+ aliases denormalised from deadline_concepts via the concept_id FK.
4. CREATE paliad.proceeding_event_edges; backfill:
- parent_id ⇒ from_event_id (or NULL when parent_id IS NULL).
- condition_flag ⇒ if_flags ([] when NULL).
- condition_rule_id ⇒ requires_event_id (the 2 legacy rules).
- alt_duration_value/_unit/_rule_code present:
emit a SIBLING edge (the alt path) instead of an alt_* column on
the same edge. The 4 rules with alt_* split into 8 rows.
- is_spawn=true rules ⇒ DO NOT migrate the cross-proceeding parent_id;
leave as orphaned root edges in the destination proceeding_def
(these are the §3.6 retirement candidates; flag them for the
project-tree-spawn UX in Phase M3).
5. ALTER paliad.projects ADD proceeding_def_id, court_id,
proceeding_trigger_date, proceeding_status. Backfill via the existing
proceeding_type_id integer + courts string-match heuristic (§3.5).
6. KEEP everything else: deadline_rules, deadline_concepts, trigger_events,
event_deadlines, event_categories — all stay, all readable.
```
**Test gate:** server boots, `/tools/fristenrechner` works (still on old tables), `/deadlines/new` works, `/api/projects/{id}` carries the new project columns (NULL on legacy rows is OK), no user-visible change. Run smoke 6/6 (per t-paliad-088 pattern, see memory `35a08abd`).
### 4.3 Phase M2 — calculator cutover (one boot, behaviour swap)
Switch `internal/services/fristenrechner.go` from `deadline_rules` to `proceeding_event_types` + `proceeding_event_edges`. The walk algorithm changes:
| Today | Tomorrow |
|---|---|
| Walk parent_id chain from a root rule, anchor on triggerDate at root, descend, apply condition_flag gates and alt_* swaps. | BFS from root edge (from_event_id IS NULL) of the proceeding, anchor on triggerDate, for each node enumerate inbound edges, filter by predicates (`if_flags ⊆ flags AND unless_flags ∩ flags = ∅ AND (requires_event_id IS NULL OR requires_event_id ∈ recorded_events)`), pick the edge that fires (LATEST candidate when multiple), compute due_date, recurse. |
| `isCourtDeterminedRule(r)` discriminator. | Same predicate, lifted to the node (`kind IN ('hearing','decision','order') OR party='court'`). |
| Composite max/min via `event_deadline.combine_op`. | Same column on the edge. |
| `anchor_alt='priority_date'` on EP_GRANT publish. | Folded into a per-proceeding-def "anchor_options" enum — Phase M3 problem, NOT M2. EP_GRANT publish stays specially-handled in Go for one boot. |
**Switch the trigger calculator** (`event_deadline_service.go`) at the same time. The `trigger_events` (110) + `event_deadlines` (77) data folds into the new shape:
- Each `trigger_event` becomes a node (concept_slug from the existing slug column).
- Each `event_deadline` becomes a node + an edge from the trigger node to it.
- `event_deadline_rule_codes` (72 RoP citations, multiple per deadline) — the new shape only carries ONE `rule_code` per edge. Per row, pick `sort_order=0` as the canonical citation; remaining 0-2 codes per edge become a separate `paliad.proceeding_event_edge_alt_codes` (loose-linkage table) — out of scope for this design but flagged.
**Search service** (`internal/services/deadline_search_service.go`): rebuild `paliad.deadline_search` mat-view to read from the new tables. The kind discriminator (`'rule'`|`'trigger'`) collapses — every row is a `(node, edge_in)` pair now. UI ranks unchanged.
**Test gate:** Full Playwright smoke walk through the 16 modern proceedings + the trigger-search Pathway-B flow + the with-CCR flag toggle. Recompute spot-check vs t-paliad-086/111 golden results (Klageerwiderung 2026-04-30 → 2026-08-31, etc). If a Frist drifts more than ±1 day across the migration boundary, BLOCK.
### 4.4 Phase M3 — instance-side cutover (one boot)
`paliad.deadlines.rule_id` re-points: today it FKs `deadline_rules.id`; tomorrow it should FK to a tuple (event_type_id, edge_id) — but we can't easily express a 2-column FK. Two options:
- **Option A** (chosen): `deadlines.rule_id` retired entirely. The legal citation already lives in `deadlines.rule_code text` (per t-paliad-111). The structural pointer becomes `deadlines.event_type_id uuid REFERENCES proceeding_event_types(id)` — node-level, since the edge is an implementation detail. The set of edges that *led* to this Frist is recoverable on read by walking edges-into-this-event-type-of-the-proceeding-instance.
- **Option B** (rejected): Keep both rule_id (NULL during transition) AND event_type_id. Adds a deprecation column for unclear value. Skip.
```sql
ALTER TABLE paliad.deadlines
ADD COLUMN event_type_id uuid REFERENCES paliad.proceeding_event_types(id);
UPDATE paliad.deadlines d
SET event_type_id = pet.id
FROM paliad.proceeding_event_types pet
WHERE pet.code IN (SELECT dr.code FROM paliad.deadline_rules dr WHERE dr.id = d.rule_id)
AND pet.proceeding_def_id = (
SELECT pd.id FROM paliad.proceeding_definitions pd
JOIN paliad.proceeding_types pt ON pt.code = pd.code
WHERE pt.id = (SELECT dr.proceeding_type_id FROM paliad.deadline_rules dr
WHERE dr.id = d.rule_id)
);
ALTER TABLE paliad.deadlines DROP COLUMN rule_id;
-- Keep deadlines.rule_code text — it's user-visible and stable.
```
The 26 production deadlines need spot-check; a stale `rule_code` value (e.g. 'RoP.023') survives untouched, and the new `event_type_id` re-anchors the structural reference.
**Project-tree spawn UX** (deferred from §3.6's cross-proceeding-edge retirement): the old `is_spawn`-flagged rules in INF/REV/CCR (e.g. `inf.appeal`) had a one-click "create the next proceeding" affordance via the appeal Frist's spawning. Replace with: at `inf.decision` event-type detail page, show "Spawn Berufung sub-project" button → creates a new `verfahren` project under the same parent with `proceeding_def_id=DE_INF_OLG` and `proceeding_trigger_date` defaulting to the decision date. The graph stays clean; the spawn happens at the project tree, with one explicit click.
### 4.5 Phase M4 — drop legacy (one boot, no behaviour change)
```sql
DROP MATERIALIZED VIEW paliad.deadline_search; -- recreated in M2 against new tables
DROP TABLE paliad.event_deadline_rule_codes;
DROP TABLE paliad.event_deadlines;
DROP TABLE paliad.trigger_events;
DROP TABLE paliad.deadline_rules; -- 172 rows gone
DROP TABLE paliad.deadline_concepts; -- 57 rows gone
DROP TABLE paliad.proceeding_types; -- 26 rows gone
ALTER TABLE paliad.projects DROP COLUMN proceeding_type_id;
ALTER TABLE paliad.projects DROP COLUMN court; -- free-text version
-- Keep projects.country (used by holiday lookup as a fallback).
```
After Phase M4 the schema is the locked target. Total elapsed: 4 migrations, 4 boots. Each boot is reversible up to the M4 drop (which IS destructive).
### 4.6 What about feynman's in-flight branch?
feynman is currently writing migrations on `mai/feynman/fristenrechner` for t-paliad-157. This consultant analysis is upstream of his implementation and **does NOT** change feynman's brief — he ships what's specified there. After feynman lands, this design's M1 migration starts on top of his work; the proceeding_event_types backfill SELECTs from whatever shape `deadline_rules` is in at that point. No coordination required beyond "M1 picks up from feynman's HEAD."
Branch hygiene: nothing committed in `mai/einstein/consultant-deadline-data` touches code. Only `docs/design-deadline-data-model-2026-05-08.md` (this file). Merge to main at any time without conflict potential against feynman's branch.
---
## 5. AI-friendliness layer
### 5.1 What's load-bearing for the AI vs decoration
**Load-bearing:**
- `paliad.proceeding_event_types.concept_slug` (e.g. `'statement-of-defence'`) — the LLM's cross-proceeding identity. *"What's the equivalent of a Klageerwiderung in EPO opposition?"* → search proceedings for nodes with `concept_slug='statement-of-defence'` or matching aliases.
- `paliad.proceeding_event_types.aliases text[]` — the search vocabulary. Lifts directly from old `deadline_concepts.aliases`. Must remain curated; no user-edit in v1.
- `paliad.proceeding_event_types.name_de` + `name_en` — primary surface labels.
- `paliad.proceeding_event_edges.rule_code` + `legal_source` — citation grounding.
- `paliad.proceeding_event_edges.if_flags` / `unless_flags` / `requires_event_id` — the AI can reason about "this edge fires only if user has flagged with_ccr" without needing to evaluate a JSON expression.
**Decoration (riding on top):**
- `paliad.event_categories` (103 nodes) — the navigation tree. The LLM doesn't need this for legal reasoning; it's a UX scaffold for users who don't know the legal vocabulary. Stays intact, re-FK'd to concept_slug.
- `paliad.event_types` (45 rows) — the user-facing instance-side classifier. Operationally useful (filter /deadlines by Type) but not load-bearing for the rule library. Stays unchanged.
### 5.2 The AI prompt lifecycle
- **Search:** "what is Klageerwiderung in UPC?" → trigram match on name_de + aliases column → returns `proceeding_event_types` rows where slug='statement-of-defence'. Result card lists: court_type pills (UPC-LD, UPC-CD, DE-LG, EPA), per-context durations + rule_codes via the inbound edges.
- **Calculate:** user picks UPC-LD München LD + filing date + flags=['with_ccr']. Service walks the proceeding's edges, filters by predicates, returns a date timeline. AI doesn't need to be in this loop; it's deterministic graph walking.
- **Reason:** Paliadin (the LLM-backed assistant) gets fed `proceeding_event_types` + `proceeding_event_edges` for the active proceeding when asked "explain my deadlines" — one self-contained subgraph, ~15-20 nodes, ~20-30 edges per proceeding. Fits comfortably in context.
- **Classify:** when the user types "we got hit with a Hinweisbeschluss yesterday" — Paliadin matches against `aliases + name_de` of `proceeding_event_types`, returns the matched concept_slug (`hinweisbeschluss-stellungnahme`), and uses the project's `proceeding_def_id` to find the right node in the right proceeding's graph.
### 5.3 Where the concept layer's death helps the AI
Today the LLM has to reason about `deadline_rules.concept_id → deadline_concepts.slug` AND `trigger_events.concept_id (text slug)` AND `event_categories → event_category_concepts.concept_id`. Three different shapes for one identity.
Tomorrow there's ONE `concept_slug text` column on the proceeding-event-type node and ONE FK in the navigation junction. Same string, same column name, two query paths. Strictly easier for the LLM (and for human contributors).
### 5.4 Where the concept layer's death costs the AI
The 57 `deadline_concepts` rows had richer metadata than what survives as columns on the node:
- `aliases text[]` — survives.
- `description text` — needs to merge into per-node `description text` (already exists, just needs population).
- `category` (submission/decision/order/hearing) — survives (`kind` column on node).
- `party` — survives (`party` column on node, dominant case).
- `sort_order` — survives.
Net data loss: zero. Net query simplification: substantial.
---
## 6. Tradeoffs
### 6.1 What the migration costs
- **Engineering effort.** ~2 weeks of coder time across the 4 phases. M1 is a long-evening migration. M2 is the heavy lift (calculator rewrites, ~800 lines in fristenrechner.go + ~300 lines in event_deadline_service.go). M3 is shorter but coordinates with t-paliad-138 approval flow + CalDAV sync (the rule_id drop touches every code path that currently joins to deadline_rules — `internal/services/deadline_service.go`, `internal/services/event_service.go`, `internal/services/agenda_service.go`, `internal/handlers/deadlines.go`, plus the frontend).
- **Migration complexity.** 4 boots, each with a smoke gate. The M2 boot is the riskiest — calculator semantics are user-visible and date-precision-sensitive. Need a pre-cutover golden-set test (run BOTH calculators across the 16 active proceedings + 30+ trigger events for a representative trigger date, diff the outputs, fail if any non-trivial drift). t-paliad-086 PR-3 found a 60-day cap bug only because of the SoD-on-2026-04-30-lands-on-Saturday smoke; we'd need similar care here.
- **Fristenrechner UX disruption.** None expected. The Pathway-B navigation, the Verfahrensablauf timeline view, the search bar — all read paths can be preserved exactly because the underlying data shape is the same legal facts in different storage. The only user-visible change is at the spawning moment (§4.4 Phase M3 project-tree spawn instead of in-line appeal Frist).
- **Documentation churn.** docs/audit-fristenrechner-completeness-2026-04-30.md, docs/plans/unified-fristenrechner.md (cronus), docs/plans/unified-fristenrechner-v3.md (cronus) — all reference the old table names. These are historical (cronus retired from paliad per memory `cc28a8ad`) so they don't need active maintenance, but new contributors will read them and get confused. Add a header to each pointing to this design doc as the structural-truth update.
### 6.2 What the migration buys
- **One rule library, not two.** No more deadline_rules + trigger_events drift. No more "did t-paliad-086 fix this in both?" The federated mat-view goes away. Search, calc, and AI all read the same shape.
- **Multi-parent edges become natural.** The CCR cross-flow that took t-paliad-131 Phase B1 a full PR + 7 new rules + condition_flag wiring becomes 7 sibling edges with disjoint `if_flags`. Same semantics, half the schema awareness needed.
- **Court system axis is queryable.** `SELECT * FROM proceeding_event_edges e JOIN proceeding_event_types et ON et.id = e.to_event_id JOIN proceeding_definitions pd ON pd.id = et.proceeding_def_id WHERE pd.court_type='UPC-LD' AND e.if_flags @> ARRAY['with_ccr']` answers a real question that today requires walking three tables and string-matching.
- **The graph fits in an LLM prompt.** ~15 nodes + 25 edges per proceeding, with concept tags + rule codes + party + condition flags inline. No federation, no slug-walking. Paliadin gets a tighter context.
- **Conditions are typed, not stringly.** `if_flags text[]` + `unless_flags text[]` + `requires_event_id uuid` — the schema documents itself. Today's `condition_rule_id` + `condition_flag` mix is two pages of code-comment to explain.
- **Court_type FK eliminates the holiday-lookup hardcoding.** holidays.go's per-court mapping becomes a JOIN: `courts.country` + `court_types.regime` directly produce the holiday set.
- **Extensibility for future condition kinds without further migrations.** New typed columns can be added incrementally (e.g. `min_business_days int` for "Notfrist-only" rules); the JSON-DSL alternative would have meant version-bumping the expression evaluator each time.
### 6.3 Genuine cost: the column-based condition model breaks down at OR-of-3
Per Q4 the cost flagged on the option preview: a flag combination like "fires if (with_ccr AND with_amend) OR (without_ccr AND with_cci)" needs TWO sibling edges (one per branch). For OR-of-3-or-more disjoint branches the table has N edges fanning into the same target. This is OK at today's scale (the most complex rule has 2 flags) but if procedural complexity escalates we'd want to revisit. The natural escape valve is to add a JSON `condition` column **alongside** the typed columns later, evaluated only when present — but that's a future decision, not today's.
### 6.4 What we deliberately don't solve
- **Cross-proceeding edges** (G7 plus old `is_spawn`). Modelled as project-tree spawns instead. Defer until users complain. (A `proceeding_event_edges.cross_to_proceeding_def_id uuid` column would re-open the model, but it muddies the closed-graph invariant. Skip.)
- **Track-switching at proceeding level** (G7 ascended). "If with_ccr, the WHOLE proceeding follows alternate sub-graph rooted at node X." Not modelled — instead, every edge in the alternate sub-graph carries `if_flags=['with_ccr']`. Verbose but explicit. If the verbosity becomes painful (more flag-conditional sub-graphs in DE_INF_BGH cross-appeals?) revisit with a `paliad.proceeding_tracks` overlay table that groups edges into named tracks.
- **First-class `paliad.proceedings` instance row.** Per Q2 lock — project IS the instance. If a future feature needs richer instance state (current-stage event_type_id, paused_at, last_advanced_event), columns extend `paliad.projects` directly. If that bloats the projects table beyond comfort, a separate `paliad.project_proceeding_state` 1:1 side-table is the right surgery — but not today.
- **Schema RLS on the rule library.** Today `paliad.deadline_rules` is reference data, readable to any authenticated user, writable only via migrations. The new tables inherit that posture (no RLS, service-role-only writes). If a future world has firm-private overrides (HLC's house policy on a Frist), revisit.
- **Generic event-types beyond procedural** (contract renewal, IP renewal). These live in `paliad.event_types` (the instance-side classifier). They will not become `proceeding_event_types` rows because they don't belong to a proceeding-DAG. Two layers, two purposes — explicitly OK.
### 6.5 What if m wanted to go bigger — what's the ceiling
The locked design is *appropriately ambitious* — addresses every gap in §2 except G7 (track-switching, deferred per §6.4). A more-ambitious target shape would:
- Make instance state first-class (`paliad.proceedings` table, real timeline log). **Skipped per Q2.**
- Make conditions a typed expression DSL. **Skipped per Q4.**
- Allow proceeding inheritance / template specialisation (e.g. UPC_INF_with_pi extends UPC_INF, adds 4 nodes). **Not asked for.**
- Allow cross-court-system cascades (a UPC LD decision triggers the CoA appeal). **Skipped per §3.6.**
Each of those would be a follow-up design with its own dogma session. None blocks shipping the current design.
---
## 7. Open follow-ups for the coder shift
When m greenlights this design and a coder picks up implementation, surface these explicitly so they don't slip:
1. **Concept slug curation.** The 57 → ~57 mapping is mostly mechanical. ~5 cases need legal eyes: cross-cutting concepts (Wiedereinsetzung, Versäumnisurteil-Einspruch, Schriftsatznachreichung, Weiterbehandlung) where the slug exists but doesn't yet sit on a proceeding-specific node. Resolution: emit a new `proceeding_event_types` row in EACH proceeding where the cross-cutting Frist applies, all sharing the same `concept_slug`. Multiplies the row count by ~10 per cross-cutter, fine.
2. **Legacy proceeding_types pruning.** The 6 unused legacy codes (`INF`,`REV`,`CCR`,`APM`,`APP`,`AMD`,`ZPO_CIVIL`) and their 36+4=40 dead rules should NOT migrate to `proceeding_definitions`. Confirm with m before dropping (they may have been kept "in case"). If yes-drop: M1 SELECT only the active 16 + ZPO_CIVIL (if still desired).
3. **Frontend impact assessment.** Pathway-B decision-tree (t-paliad-133 Phase D-1, in production) reads from `event_categories` + `event_category_concepts` joined to `deadline_concepts`. The junction's concept-side rewires from FK to text. Frontend code that fetches concept_slug stays — backend just speaks the same column under a new FK target.
4. **Approval flow integration.** t-paliad-138 dual-control approvals (migration 054) wraps deadline mutations with `approval_requests`. The new `deadlines.event_type_id` column needs to flow through `payload jsonb` correctly; today the approval pre-image captures `rule_id`. M3 swap touches approval_service.go + ApprovalRequest payload schema.
5. **CalDAV round-trip.** `paliad.deadlines.caldav_uid` + `caldav_etag` survives. The CalDAV title rendering uses `rule_code` (already free-text) — no behavioural change.
6. **Holiday-lookup simplification.** `internal/services/holidays.go` today carries a hardcoded map "courts → applicable holiday sets." After M1 (with `paliad.courts.court_type` FK'd) this becomes a JOIN. Refactor as part of M2 or as a follow-up.
---
## 8. Recommendation summary
**Ship the design.** It addresses every structural gap m's framing exposed (G1G6, deferring G7 explicitly), it lands on locked decisions throughout (Q1Q5 verbatim from the AskUserQuestion pass), and it costs ~2 weeks of focused coder time across 4 boots with smoke-gates between.
**Sequence:** wait for feynman's `mai/feynman/fristenrechner` to land (parallel work, doesn't block this design but does affect the M1 backfill source). Then route Phase M1 to a coder fluent in pgvector + ltree contexts (noether or fritz; cronus excluded per memory `cc28a8ad`). Phase M2 needs Fristenrechner-deep context — same picker. Phase M3 + M4 mechanical, any coder.
**Recommend:** open one Gitea tracking issue for each phase under m/paliad, link to this design doc by anchor (`#42-phase-m1-additive-build`), set them as a 4-step task chain. Mark M4 as gated on M2 + M3 living in production for ≥1 week without rollback.
The right outcome of this design isn't a one-shot 6-week refactor. It's four 3-day-class migrations stretched over 23 weeks, each individually shippable, each individually reversible until the M4 drop. That's how the existing paliad rule-library got built (migrations 003 → 062, ~6 month accretion); that's how it should be reshaped.
---
*End of design doc. ~600 lines target — landing at ~750 with code blocks. NO migration files, NO code edits in this branch — only this design doc per the consultant-mode hard rule.*

View File

@@ -0,0 +1,704 @@
# Design — Determinator B1 row-by-row cascade (replaces breadcrumb drilldown)
**Author:** pauli (inventor)
**Date:** 2026-05-13
**Task:** t-paliad-166
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
**Gitea:** m/paliad#25 (re-opened by m's 2026-05-13 11:17 comment).
---
## 0. Premises verified live (before designing)
CLAUDE.md, mai-memory and the task brief can all be stale by days. Every anchor below is verified against the live codebase or live DB on `mai/pauli/determinator-b1-row-by` (baseline `adf377c` — main as of Slice 1 of t-paliad-179 merge).
### 0.1 The Pathway B markup today
`frontend/src/fristenrechner.tsx:227-310` is the Pathway B shell. Four functionally different layers are stacked with four visually different treatments. Live, in source order:
| Layer | Element | Affordance | Visual |
|---|---|---|---|
| **L1 Mode** | `.fristen-mode-toggle` | `role=radiogroup` with two `<input type="radio">` | Radio buttons. Tree vs Filter. |
| **L2 Perspective** | `.fristen-perspective-bar` | Three `<button>` chips | Pill chips. Kläger / Beklagter / Beide. |
| **L3 Inbox** | `.fristen-inbox-bar` | Four `<button>` chips | Pill chips. CMS / beA / Posteingang / Alle. |
| **L4 Cascade** | `.fristen-b1-cascade` | Breadcrumb + question + button-grid (drill-down) | Cards in a grid, breadcrumb above. |
Below L4 sits `.fristen-b1-results` — the concept-card list that narrows as the cascade descends. That's content, not a decision layer.
**m's critique is exact:** L1/L2/L3/L4 are all "narrow the deadline-rule space" steps with the same conceptual weight, but the user sees a radio, two pill strips, and a card grid. The cascade itself (L4) hides previous steps behind a breadcrumb — so when you've drilled three levels deep you can no longer see "I picked CMS → vom Gericht → Hinweisbeschluss" in one glance unless you read tiny breadcrumb crumbs.
### 0.2 The cascade engine today
`frontend/src/client/fristenrechner.ts:2405-2574` (`renderB1Cascade`). For a given `?b1=<slug>`:
1. Build `trail = buildBreadcrumb(roots, currentSlug)`. The trail is the ancestors of the current node.
2. Render `<nav class="fristen-b1-breadcrumb">` = root-reset + ``-separated crumb buttons.
3. Render `<p class="fristen-b1-question">` = the current node's `step_question_de` (or `"Was ist passiert?"` at root).
4. Render `<div class="fristen-b1-buttons">` = child nodes as button cards (icon + label, `--leaf` modifier on terminal nodes).
5. Render `<button class="fristen-b1-step-back">` = "← Eine Stufe zurück".
Drilling = `navigateB1(child.slug)` = `pushState` + `renderB1Cascade(child.slug)`. The previous question disappears; only the breadcrumb crumb survives as text. **There is no "row of answered decisions."**
### 0.3 Where narrowing happens today
`fristenrechner.ts:2509-2522` filters cascade children by two predicates before rendering:
- `inboxFilterAllowsForums(c.forums)` — hides nodes whose `forums` tag doesn't match `activeForumOnPage()`. The active forum is resolved at `fristenrechner.ts:2960-2970` with a three-input precedence chain:
1. **Inbox chip** (`cms``upc`, `bea` / `posteingang``de`). User override beats everything.
2. **Ad-hoc chip** from Step 1's explore-mode bypass (`upc` / `de` / `epa` / `dpma`).
3. **Project context** (`project.proceeding_type_id``proceeding_types.code` → prefix → `upc` / `de` / `epa` / `dpma`).
- `perspectiveAllowsParty(c.party)` — hides leaves whose `party` tag contradicts the perspective chip. t-paliad-164 already auto-fills the chip from `project.our_side`.
**So project-driven narrowing for the FORUM axis is shipped.** What m is asking for in this task is (a) generalize the pattern so MORE rows get pre-answered, (b) make the answered-state visible in the same row format, (c) hide rows whose answer is fully implied (UPC project + L3 Inbox).
### 0.4 The taxonomy and rule corpus
Live data, `paliad.event_categories` (recursive tree, t-paliad-133):
- **6 root buckets** under `(root)`: `cms-eingang` ("Von wem ist das Schriftstück?"), `muendl-verhandlung` ("Mündliche Verhandlung"), `beschluss-entscheidung` ("Beschluss / Entscheidung"), `frist-verpasst` ("Frist verpasst"), `ich-moechte-einreichen` ("Ich möchte etwas einreichen"), `sonstiges` (terminal leaf).
- **103 leaves total.** 91 carry a `forums` tag (`upc` / `de` / `epa` / `dpma`); 12 are neutral. 16 leaves carry a `party` tag — all under `ich-moechte-einreichen.*` (claimant / defendant) — the perspective filter touches outgoing filings only, never incoming Gegenseiten-Schriftstücke (which are symmetric: you receive what the other side sent regardless of who you are).
- Cascade depth varies 24 levels. Slug encodes the path with dots, e.g. `cms-eingang.gegenseite.upc-inf.klageschrift` is 4 segments deep.
`paliad.proceeding_types`:
- **20 `category='fristenrechner'` codes** (the wizard / B1 cascade vocabulary): `UPC_INF`, `UPC_REV`, `UPC_APP`, `UPC_APP_ORDERS`, `UPC_COST_APPEAL`, `UPC_DAMAGES`, `UPC_DISCOVERY`, `UPC_PI`, `DE_INF`, `DE_INF_OLG`, `DE_INF_BGH`, `DE_NULL`, `DE_NULL_BGH`, `DPMA_OPP`, `DPMA_BPATG_BESCHWERDE`, `DPMA_BGH_RB`, `EPA_OPP`, `EPA_APP`, `EP_GRANT`.
- **7 `category='litigation'` codes** (the project model's vocabulary): `INF`, `REV`, `CCR`, `APM`, `APP`, `AMD`, `ZPO_CIVIL`. All `jurisdiction='UPC'` except `ZPO_CIVIL`.
- **The two vocabularies overlap conceptually but not row-wise.** Mapping `litigation_code × jurisdiction → fristenrechner_code` is required for Akte-derived narrowing beyond the 4-letter forum prefix. The brief lists this mapping; the live data confirms it's the only path.
`paliad.deadline_rules.condition_flag` — 4 distinct flag-sets live in production: `[with_amend]`, `[with_cci]`, `[with_ccr]`, `[with_ccr, with_amend]`. Only on `UPC_INF` and `UPC_REV`. This is a Determinator-style variant axis the cascade does not surface today; out of scope for this design.
### 0.5 Live state of `paliad.projects`
| Column | Live data shape | Used by today's cascade? |
|---|---|---|
| `court` | **Free-text.** 4 non-null values across 4 rows: `LG München I` (1), `UPC` (2), `UPC CoA` (1). 7 rows NULL. | No. |
| `proceeding_type_id` | FK → `proceeding_types.id`. **11/11 live rows are NULL.** | Yes — `forumFromProject` reads it, but it never fires in production today. |
| `our_side` | enum `claimant` / `defendant` / `both` / `court` / NULL. | Yes — t-paliad-164 perspective chip predefine. |
| `counterclaim_of` | uuid FK self-reference. | No (relevant for SmartTimeline, not Determinator). |
| `filing_date` / `grant_date` | dates. | No (relevant to Verfahrensablauf wizard). |
**Critical caveat:** 11/11 live projects have NULL `proceeding_type_id`. Until that's backfilled (a separate cleanup), Akte-driven narrowing degrades to "no opinion" for every existing project. The design honours this — silent degrade, no failed-load toast, the cascade simply doesn't narrow. m locked this v1 behaviour with kelvin on 2026-05-13.
### 0.6 Anchor files for the implementer
- `frontend/src/fristenrechner.tsx:227-310` — Pathway B markup (the four-layer mess).
- `frontend/src/client/fristenrechner.ts:2405-2574``renderB1Cascade`.
- `frontend/src/client/fristenrechner.ts:2914-3081` — forum + perspective narrowing engine (`activeForumOnPage`, `inboxFilterAllowsForums`, `perspectiveAllowsParty`, `applyOurSidePredefine`).
- `frontend/src/styles/global.css:1636-1822``.fristen-pathway-shell`, `.fristen-mode-toggle`, `.fristen-b1-breadcrumb`, `.fristen-b1-question`, `.fristen-b1-buttons`, `.fristen-b1-button`, `.fristen-b1-step-back` (the visuals this design overhauls).
- `frontend/src/styles/global.css:1965-2065``.fristen-inbox-bar`, `.fristen-perspective-bar`, `.fristen-inbox-chip` (the chip strip rules).
- `frontend/src/client/views/verfahrensablauf-core.ts` (t-paliad-179) — pure-functional core, verified to carry **zero** Pathway B / cascade code. The lift is clean; this design is independent of it.
### 0.7 Adjacent design docs
- `docs/design-tools-cleanup-2026-05-12.md` (kelvin, t-paliad-178). Slice 1 of that shipped today; Slice 2 (Step 0 toggle + Akte auto-derivation on `/tools/fristenrechner`) is adjacent and will share the `litigation_code × jurisdiction → fristenrechner_code` mapping with this design.
- `docs/research-determinator-coverage-2026-05-08.md` (curie, t-paliad-167). Identified leaves missing from the cascade. Out of scope here — this design is the UX shell that any future coverage additions will land into.
If any of these conflict with what the task brief or memory asserts, **the live state wins** and the brief is the bug — flagged in §13 for m.
---
## 1. Vision + the three pillars
m's framing (2026-05-13 11:17):
> When I select a project, it should already narrow down the options (at least if it is a court proceeding). If it is a UPC proceeding, there is no need to show "non-UPC options"; this starts with the "how did you receive it?" which - for the UPC - will always be the UPC CMS.
>
> Not only is the different format for the levels of the questions weird (this needs an overhaul!), also there is no narrowing at all. I already described before that I want each decision on the tree to remain visible (one row per decision, it may be more compact than the active question was) and then go through things until there are only the least possible options left.
Three pillars, intertwined:
### Pillar 1 — Project-driven narrowing
Pre-fill or hide decision rows whose answer is implied by the project. UPC project → "Wo kam es an?" is implied (CMS). Project with `our_side` → perspective implied. Project with `proceeding_type_id` → cascade root narrows to the matching forum (and deeper, if mappable).
### Pillar 2 — Visual hierarchy overhaul
All decision layers are **the same primitive**: a row with a question label, an answer-area, and an inline "ändern" affordance. Whether the layer is mode-toggle, perspective, inbox, or a cascade level, the visual shape is identical. The active layer expands inside its row; inactive (answered) layers compact to a single line.
### Pillar 3 — Row-by-row persistent cascade
Replace breadcrumb drilldown with stacked rows. Each answered decision stays visible as a compact row. The active question is the only row that expands. The cascade builds top-to-bottom; the user sees every choice they made in one glance, and the answered rows act as their own affordances for "ändern".
The pillars interact:
- Pillar 3 (row layout) needs to know what to skip (Pillar 1 narrowing). A skipped row can render as a compact "(aus Akte) UPC CMS" pseudo-row, or be absent. We pick per row in §5.
- Pillar 2 (visual hierarchy) defines how *answered* vs *active* vs *skipped-but-shown* rows look. The four-different-treatments mess gets resolved by a single `.fristen-row` primitive.
- Pillar 1 (narrowing) also affects *initial state*: in Akte-mode, several rows may render as already-answered on page load. The cascade jumps to the first un-answered row.
---
## 2. The row primitive
The whole new layout is built from one element shape. Call it `.fristen-row` (the existing `.fristen-b1-*` class names get retired or rebased).
```text
┌─ .fristen-row ──────────────────────────────────────────────────────┐
│ .fristen-row-num .fristen-row-label .fristen-row-edit │
│ [1] Wie suchen? [ändern] │
│ .fristen-row-body │
│ ✓ Schritt-für-Schritt │
└──────────────────────────────────────────────────────────────────────┘
```
Three states:
### 2.1 `state="active"` — the user is answering this row
```text
┌─ .fristen-row.is-active ────────────────────────────────────────────┐
│ [3] Von wem ist das Schriftstück? │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ ⚖️ │ │ 🏛️ │ │ ✉️ │ │
│ │ Vom Gericht │ │ Von der │ │ Vom Patent- │ │
│ │ │ │ Gegenseite │ │ amt │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ← zurück │
└──────────────────────────────────────────────────────────────────────┘
```
Same chip-style buttons regardless of which row it is. Mode pick = two big chips. Perspective = three chips. Inbox = four chips. Cascade step = N chips, one per child node. Leaf cascade chips get a subtle modifier (`.fristen-row-chip--leaf`) so the user can see "this one ends the cascade".
### 2.2 `state="answered"` — the user has picked, but the answer is below
```text
┌─ .fristen-row.is-answered ──────────────────────────────────────────┐
│ [1] Wie suchen? ✓ Schritt-für-Schritt │
│ [ändern] │
└──────────────────────────────────────────────────────────────────────┘
```
Single line. The label, the picked answer, an "ändern" affordance. Click anywhere on the row (or the explicit ändern link) re-opens the row as active and drops every row below it. (This matches the existing breadcrumb-click semantic: jumping back to an ancestor invalidates descendants.)
### 2.3 `state="prefilled"` — derived from the project (or other auto-source)
```text
┌─ .fristen-row.is-answered.is-prefilled ─────────────────────────────┐
│ [2] Ich vertrete ✓ Klägerseite │
│ aus Akte: HL-2024-001 [ändern] │
└──────────────────────────────────────────────────────────────────────┘
```
Visually identical to `is-answered` but carries a small "aus Akte: <reference>" tag and a slightly muted background. Clicking ändern flips it to active (and drops the prefilled marker — the user has now made an explicit choice).
This generalises t-paliad-164's perspective predefine: same shape, same hint, same override-by-click semantics. The hint becomes a row-level token rather than a one-off `<span>` next to the chip strip.
### 2.4 `state="hidden"` — row is implied by an earlier pre-fill
A row that adds no information given upstream rows can be omitted entirely. e.g. UPC project → forum is `upc` → inbox row's only valid answer is "CMS" → the row simply doesn't render. We **do not** render a `is-hidden` placeholder; the absence is the affordance. (This is m's "no need to show non-UPC options".)
The first user-actionable row floats up under the prefilled stack.
### 2.5 Why one primitive
The current four-layer mess works against m because each layer looks like a different *kind* of question. The row primitive collapses that: every decision row carries the same "label + answer + ändern" anatomy. The user reads top-to-bottom; the answered rows stack as a paper trail; the active row is the only thing that demands interaction.
This also implicitly solves the row-count tax of m's "see your selections" ask: the rows compact to ~28px each when answered, so even a deep cascade keeps the active question in the upper third of the viewport.
---
## 3. Answered / active / prefilled / hidden — visual treatment
Concrete CSS sketch (Slice 1 will tune; this is the contract):
| Token | Active | Answered | Prefilled | Hidden |
|---|---|---|---|---|
| `min-height` | auto (chips wrap) | `28px` | `28px` | 0 (not rendered) |
| `background` | `var(--surface-card)` | `transparent` | `color-mix(var(--color-accent) 4%, transparent)` | n/a |
| `border-left` | `4px solid var(--color-accent)` | none | `4px solid var(--color-accent-faded)` | n/a |
| `font-weight` (label) | 600 | 500 | 500 | n/a |
| `font-weight` (answer) | n/a | 600 | 600 | n/a |
| `cursor` | default | pointer (whole row) | pointer (whole row) | n/a |
| `ändern` affordance | hidden | shown on hover + always on focus-within | always shown | n/a |
| Row number badge | accent-filled | outlined | outlined (faded) | n/a |
**No `::before { inset: 0 }` overlay tricks.** The whole-row click is wired via a JS handler that calls `reopenRow(idx)` and skips clicks on `<a>` / `<button>` inside the row body — same pattern as `.entity-table` and the project-detail Verlauf items (CLAUDE.md anchor under "Whole-card / whole-row click").
Active vs answered transition: when the user picks an answer in an active row, the row collapses to `is-answered` and the **next un-prefilled row materialises as active**. The DOM is preserved across the transition (row stack is one container with `data-state` attribute switched on each row); the chip set inside the answered row replaces with the single ✓-prefixed answer span.
For the prefilled state's "aus Akte: <reference>" tag — reference comes from `project.reference` (e.g. `HL-2024-001`), falling back to the first 8 chars of `project.id` if no reference. Click on the reference tag is a navigation shortcut to the project (open in new tab — keeps the Fristenrechner state intact).
---
## 4. Project-driven narrowing — data mapping
What can we derive from a selected project, and where does each derivation land?
### 4.1 Mapping table
| Derivation | Source column(s) | Maps to | Pre-fills row | Hides row? |
|---|---|---|---|---|
| **Forum** (upc / de / epa / dpma) | `proceeding_type_id``proceeding_types.code` prefix. Fallback: `court` free-text contains UPC/LG/OLG/BGH/BPatG/EPA/DPMA. | Cascade filter (existing `inboxFilterAllowsForums`). | "Wo kam es an?" if forum=UPC (→ CMS). DE: prefills nothing (beA vs Posteingang is a Postal Realität, not on the project). | UPC: yes. DE/EPA/DPMA: no. |
| **Perspective** | `project.our_side` ∈ {claimant, defendant} | Cascade filter (existing `perspectiveAllowsParty`). | "Ich vertrete" → Klägerseite / Beklagtenseite. `both` / `court` / NULL: no prefill. | No — even when prefilled, the row stays visible (the user needs to see "ah yes, I'm the Beklagte here"). |
| **Proceeding type** | `proceeding_type_id` + jurisdiction → fristenrechner code via `mapLitigationToFristenrechner()` (new helper, shared with t-paliad-178 Slice 2) | Cascade depth: prunes root buckets that don't apply, and prunes inner buckets to those matching the proceeding code. e.g. UPC + INF → only `cms-eingang.gegenseite.upc-inf.*`, `cms-eingang.gericht.urteil-upc-cfi`, etc. | Pre-collapses cascade sub-branches; surfaces deeper-leaf rows directly when only one path applies. | Hides intermediate cascade rows whose only child matches the derived code. |
| **Counterclaim** | `counterclaim_of IS NOT NULL` | Implies `with_ccr` / `with_cci` condition flag context. | Not a cascade row today — surfaces as a `condition_flag` chip on the wizard. **Out of scope for this design**; flagged in §13 Q6. | n/a |
| **Filing / grant dates** | `filing_date`, `grant_date` | Wizard anchor pre-fill. | Not a cascade row. Out of scope. | n/a |
### 4.2 Detail: the litigation → fristenrechner mapping
t-paliad-178 §0 and the task brief both call out: `project.proceeding_type_id` points at the **7 `litigation` codes** (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL). The cascade speaks **`fristenrechner` codes** (UPC_INF, DE_INF, ...). A small mapping is needed:
```text
INF + UPC → UPC_INF
INF + DE → DE_INF (first instance; OLG/BGH not derivable from project)
REV + UPC → UPC_REV
REV + DE → DE_NULL
CCR + UPC → UPC_INF + condition_flag=[with_ccr] (linked via parent's proceeding)
CCR + DE → DE_NULL (German Nichtigkeit IS the counterclaim equivalent)
APP + UPC → UPC_APP
APP + DE → DE_INF_OLG | DE_NULL_BGH (ambiguous — needs court or instance hint; degrade)
APM + UPC → UPC_PI
AMD + UPC → UPC_INF + condition_flag=[with_amend]
ZPO_CIVIL + DE → ZPO civil only; ignore for cascade (no fristenrechner code)
```
The mapping lives in **one** place — a new `internal/services/proceeding_mapping.go` (or the same shared helper t-paliad-178 Slice 2 introduces). The frontend gets the **resolved fristenrechner code** plus `condition_flag` array as part of the project payload (`ProjectOption.derived_fristenrechner_code` + `.derived_condition_flags`).
**Honest about degrade:** the mapping isn't always 1:1. APP+DE is ambiguous, ZPO_CIVIL has no analogue, and projects without `proceeding_type_id` (all 11 live ones today) get no derivation at all. The cascade falls back to forum-only narrowing in every ambiguous case. **Never silent FK promotion.**
### 4.3 Detail: court free-text fallback
When `proceeding_type_id` is NULL but `court` has a recognisable substring:
```text
court contains "UPC" → forum=upc
court contains "BPatG" → forum=de (Nichtigkeit / DPMA-Beschwerde)
court contains "BGH" → forum=de
court contains "OLG" → forum=de
court contains "LG" → forum=de
court contains "EPA" / "EPO" → forum=epa
court contains "DPMA" → forum=dpma
otherwise → no narrowing
```
This is a UX nicety, not a correctness mechanism. The fuzzy match always loses to a real `proceeding_type_id` if both are set. Surfaces as the prefilled-row reference tag: "Forum: UPC (aus Gericht: UPC CoA)".
### 4.4 What the cascade hides given a forum
`event_categories.forums` is the live signal:
- 91/103 leaves carry a forum tag.
- 12 are neutral (cross-cutting: `frist-verpasst`, `sonstiges`, some Mündl-Verhandlung leaves, court actions).
With `forum=upc` active, ~73 leaves drop from the cascade. The user sees the same root buckets (cms-eingang / muendl / beschluss / frist-verpasst / ich-moechte-einreichen / sonstiges) but each bucket's children list collapses to the upc-relevant subset. **This is already wired today; the design doesn't change the filter, only its visual presentation.**
The new contribution: when a non-leaf bucket reduces to a single descendant chain (e.g. UPC project → `cms-eingang``gegenseite``upc-inf` is the only chain), the cascade should optionally **auto-walk** the chain and surface the leaf parent's siblings directly. §5 below.
### 4.5 What the cascade hides given perspective
Currently only the 16 `ich-moechte-einreichen.*` leaves carry `party` tags. So perspective filters outgoing-filing nodes only. Incoming `cms-eingang.gegenseite.*` nodes don't have party tags — receiving from the opposing side is symmetric (you receive what they sent, regardless of who you are). This is correct and doesn't need fixing.
**Design implication:** the perspective row is *always* visible (rows can never be `is-hidden` based on perspective alone), even when prefilled, because its filter affects user-write decisions that the user might still want to override. Match t-paliad-164.
---
## 5. What gets pre-answered, hidden, or skipped-but-shown
A concrete matrix per row, given live data + the rules above:
| Row | Question | Pre-fill source | UPC project | DE project | EPA / DPMA project | No project (ad-hoc) | No project (zero ctx) |
|---|---|---|---|---|---|---|---|
| **R0 Mode** | Wie suchen? | none | active | active | active | active | active |
| **R1 Perspective** | Ich vertrete | `project.our_side` | prefilled iff `our_side` ∈ {claimant, defendant}; else active | same | same (rare for EPA/DPMA — usually only `court` or NULL) | active | active |
| **R2 Inbox** | Wo kam es an? | forum derivation | **hidden** (forum=upc ⇒ CMS implied) | active (beA vs Posteingang) | active | active | active |
| **R3 Bucket** | Was ist passiert? | none — user always picks the bucket | active | active | active | active | active |
| **R4..Rn Cascade** | per-node `step_question_de` | proceeding-code derivation can pre-walk a single-child chain | optionally auto-walks single-child chains | same | same | active | active |
Notes:
- **R0 Mode**: kept active in all cases. The user always picks Tree vs Filter (or skips R0 entirely if we ditch the mode toggle — see §6). The mode pick is meta and not derivable from the project.
- **R1 Perspective**: a project with `our_side='both'` is rare but legitimate; it lands as active. `'court'` is even rarer (m's project model includes a "we are the court" perspective for hypothetical training scenarios). For now: `court` → active row.
- **R2 Inbox**: m's literal ask. UPC → hidden. DE → active (because beA vs Posteingang is meaningful for downstream Phase-0 manual workflows even if the cascade filter doesn't care). EPA/DPMA → active (e.g. EPA online filing vs Post). The "Alle" chip stays for "I don't know yet".
- **R3 Bucket**: the 6 root buckets are always shown. Even with a derived proceeding code, the user still has to say "I'm here because I received something / mündl. Verhandlung / Urteil / etc." This is too coarse to derive.
- **R4..Rn Cascade auto-walk**: when a derived proceeding code reduces a bucket's children to a single chain, the cascade should pre-walk that chain. e.g. UPC + INF + `cms-eingang` bucket → only `gegenseite.upc-inf.*` chain survives → R4 `gegenseite` is pre-answered (with the "aus Akte" badge), R5 jumps directly to `upc-inf` (also pre-answered), and R6 is the active question "Welcher Schriftsatz?". The user sees four R-rows (R0, R1 prefilled, R3 picked, R4 prefilled, R5 prefilled, R6 active) — clean paper trail of inference + one active question.
**Important constraint:** auto-walk is **descendants-of-the-picked-bucket only**. R3 (bucket) is always active because the bucket is the user's intent. We never auto-pick the bucket. So a UPC project doesn't pre-pick "cms-eingang" for you; it just makes the sub-cascade efficient once you've said "cms-eingang".
### 5.1 Compact summary diagram — UPC INF project drilling into a cms-eingang opposing-side schriftsatz
```text
┌─ Step 1: Akte (Step 1 surface, above Pathway B) ────────────────────┐
│ Akte: HL-2024-001 — Acme v. Globex (UPC INF) [Andere Akte] │
└─────────────────────────────────────────────────────────────────────┘
┌─ [1] Wie suchen? ✓ Schritt-für-Schritt [ändern]┐
└─────────────────────────────────────────────────────────────────────┘
┌─ [2] Ich vertrete ✓ Klägerseite [ändern]┐
│ aus Akte: HL-2024-001│
└─────────────────────────────────────────────────────────────────────┘
Row R2 (Inbox) hidden — UPC implies CMS
┌─ [3] Was ist passiert? ✓ CMS-Eingang [ändern]┐
└─────────────────────────────────────────────────────────────────────┘
┌─ [4] Von wem ist das Schriftstück? ✓ Von der Gegenseite [ändern]┐
│ aus Akte (UPC INF impliziert)│
└─────────────────────────────────────────────────────────────────────┘
┌─ [5] Welches Verfahren? ✓ UPC Verletzungsverfahren │
│ aus Akte: HL-2024-001 │
└─────────────────────────────────────────────────────────────────────┘
┌─ [6] Welcher Schriftsatz wurde eingereicht? (active, awaiting pick)│
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Klageschrift │ │ Klageerwiderung │ │ Replik │ │
│ │ (R.13) │ │ + Widerklagen │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ ... (rest of UPC_INF Schriftsätze) │
│ │
│ ← zurück │
└─────────────────────────────────────────────────────────────────────┘
```
Six rows. Three user picks (mode, bucket, leaf). Three Akte-derived prefills. One R2 absent. The user sees their full decision path at a glance.
For comparison, today's UI: the user clicks four times into the cascade, the top of the page is two chip strips and a radio they didn't touch, the breadcrumb at the top of `.fristen-b1-cascade` shows three crumb buttons in 12pt text, and there's no inline indication that the cascade is narrower than the full taxonomy. m's "no narrowing at all" is the literal reading of what's visible.
### 5.2 Compact summary diagram — DE project drilling into the same
```text
┌─ [1] Wie suchen? ✓ Schritt-für-Schritt [ändern]┐
└─────────────────────────────────────────────────────────────────────┘
┌─ [2] Ich vertrete ✓ Klägerseite [ändern]┐
│ aus Akte: HL-2024-002│
└─────────────────────────────────────────────────────────────────────┘
┌─ [3] Wo kam es an? (active, awaiting pick)┐
│ │
│ ┌──────┐ ┌──────────────┐ ┌──────┐ │
│ │ beA │ │ Posteingang │ │ Alle │ │
│ └──────┘ └──────────────┘ └──────┘ │
└─────────────────────────────────────────────────────────────────────┘
... and the cascade continues below once R3 is answered.
```
R2 (Inbox) is active because beA vs Posteingang is a real distinction for German projects. The forum is already known (`de`), so the cascade below R3 will be DE-only — but the user still tells us *how* the document arrived.
### 5.3 Compact summary diagram — abstract / no-Akte mode
```text
┌─ [1] Wie suchen? (active, awaiting pick)┐
│ │
│ ┌────────────────────────┐ ┌─────────────────────┐ │
│ │ Schritt-für-Schritt │ │ Filter / Suche │ │
│ │ (Entscheidungsbaum) │ │ │ │
│ └────────────────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
No prefills, no hidden rows. Every row is asked. Full taxonomy.
---
## 6. Filter / Suche mode — coexistence with the cascade
Today's mode toggle (radio) is a UX wart: it's the only radio on the page, it looks unlike everything else, and it sits at the top of Pathway B as if it were a primary axis.
Two options to fold it into the row model:
### Option A — Mode is R0, a row like any other
The mode toggle becomes the first row in the stack. Two chips. Pick determines what populates below: tree picker → R3 + cascade. Filter picker → R3 collapses into a search input + result list. The row stays visible (you can switch mid-flow via ändern), but the chrome is consistent.
Pros: simple, every decision is a row, the page reads top-to-bottom.
Cons: adds one always-active row to every flow including the "I know what I'm doing, just give me search" use case.
### Option B — Mode is an escape hatch, not a row
Filter is positioned as "ich weiß schon, wonach ich suche" — a small link / icon at the top of Pathway B that toggles between cascade and search. No R0 row. Default = cascade. Click → search replaces the row stack.
Pros: fewer rows, less for the common case to scan past.
Cons: more discoverable than the current radio? unclear. "Where did the radio go?" is a question.
### Option C — Filter as a *bottom-of-stack* affordance
Cascade is the only top-down flow. Below the cascade results, a "Sie wissen schon den Namen? → direkt suchen" link / row appears. Search is a graceful fallback, not a peer mode.
Pros: gives cascade the primary surface, search becomes a tool for "wait, I know better".
Cons: discoverability of search is reduced for power users who DO know.
**Inventor's pick:** Option B. The radio is dead weight, and the search use case is "I know the name; let me skip the cascade" — that's an escape hatch, not a peer axis. Visually: a small `🔍` icon-button at the top-right of Pathway B titled "Direkt suchen". Click expands a search input that replaces the row stack; result list appears below; "← Zurück zum Entscheidungsbaum" returns to the row stack with prior state preserved.
But this is design-question territory — m's call. §13 Q1.
---
## 7. Mobile + responsive
The row primitive is naturally responsive: rows stack vertically by default. Width concerns only the chip set inside an active row.
### 7.1 Breakpoints
`paliad` already uses 640 / 768 / 1023 px breakpoints. The rows live inside `.fristen-pathway-shell` which is already a column-flex.
| Width | Row chrome | Chip layout (active row) |
|---|---|---|
| ≥ 1024px | full label + answer + ändern on one line, badge left | chips in a 3-column grid (or auto-fill min 220px) |
| 7681023px | same | chips in a 2-column grid |
| 640767px | label + answer on line 1, ändern on line 2 right-aligned | chips in a 1-column stack |
| < 640px | label on line 1, answer on line 2, ändern as `` icon right-aligned | chips full-width, single column |
### 7.2 Active-row collapse on tap (mobile-only)
On `< 768px`, the row stack scrolls; the active row's chip set can be long (e.g. 9 Schriftsatz children). When the user picks an answer, the page autoscrolls so the next active row is at the top of the viewport. This is the same pattern as the Akte picker (Step 1) and existing form flows.
### 7.3 What we don't do on mobile
- **No drawer / modal for the cascade.** The whole point of the row stack is being able to see history at a glance; collapsing into a separate surface defeats it.
- **No fly-out for ändern.** Tap on an answered row's ändern affordance simply re-activates the row in place.
- **No "next" button.** Picking a chip advances automatically; mobile doesn't need an extra tap to confirm.
---
## 8. "Neu starten" / Reset semantics
Three flavours of reset, all need a home:
### 8.1 Reset the whole cascade (every row to empty)
Today: clicking the breadcrumb's "Pfad zurücksetzen" root crumb. In the new layout: a small `↺ Pfad zurücksetzen` link at the top of the row stack, right of the heading. Clicking it:
- Drops every cascade row (R3+).
- Leaves R0 (Mode), R1 (Perspective prefilled), R2 (Inbox if visible) as they are those are "context", not "the user's investigation".
- Re-activates R3.
Optional behaviour (per Q9): a confirm-dialog if the user has drilled 3 cascade levels deep. Probably overkill; current breadcrumb root-click is destructive without confirm. Match existing semantic.
### 8.2 Drop just one decision (ändern semantic)
Built into every answered row's `[ändern]` affordance and clicking on the row body. Effect: that row reverts to active; every row below it drops; URL ?b1= shortens to that row's prefix.
This is the workhorse of the row stack m's "you can see your selections" UX implies "you can also rewind to any of them at any time". Built-in.
### 8.3 Drop the Akte-derived prefills
Trickier: if the user clicks ändern on a `is-prefilled` row, the prefill is overridden. But what about "I want to ignore my Akte entirely for this exercise"? The Akte itself is bound at the Step 1 surface, above Pathway B. Clicking "Andere Akte" at the Step 1 summary unbinds the Akte and drops all `is-prefilled` markers. The cascade rows that were `is-answered` because they were prefilled now revert to `is-active` (or, if the user had already explicitly overridden via ändern, stay answered with no `is-prefilled` flag).
This semantic already half-exists for t-paliad-164's perspective predefine; we generalise it to every prefilled row. Implementation: hold a `prefillSources: Map<rowID, "akte" | "user">` and re-derive on Akte unbind / change.
### 8.4 The "Neu starten" button at the bottom
A second affordance at the bottom of the results area, after the user has reached a leaf and is reading concept-cards. "Andere Frist nachschlagen?" reset to R3. Optional but discoverable; today's UI lacks an equivalent, so this is a small UX win.
---
## 9. Search affordance integration
Tied to §6's mode-toggle question. Two integration points:
### 9.1 Search panel placement (Option B from §6)
The `🔍 Direkt suchen` button lives at the top-right of `.fristen-pathway-shell`. Click animates the row stack out (or simply replaces it), shows a search input row with a single text field + result list below. ESC or "← Zurück zum Entscheidungsbaum" returns; row stack restores via URL state.
The search is the existing `?q=` + B2 chips flow we don't rebuild it, just relocate its entry point. Existing forum-filter chip row stays inside the search panel.
### 9.2 Inline search on each cascade row (rejected)
An alternative: each cascade row's chip list gets a tiny "filter chips" input at the top. Reject. Adds chrome to every active row for a feature most users don't need.
### 9.3 "I searched but want to see the path" round-trip
When the user lands on a leaf via search, optionally show "Im Entscheidungsbaum öffnen " clicking restores the row stack with all ancestor rows pre-answered (which is what the cascade's slug already encodes). This is a small extra: lets a search-first user verify "yes, this is the leaf I thought, here's the proceeding context I missed".
---
## 10. Slicing for the coder pass
Three slices, each independently shippable, mergeable in order:
### Slice 1 — Visual hierarchy + row-by-row layout (no narrowing change)
Replaces the four-layer mess with the row primitive. **No backend or DB changes.** The narrowing engine stays the same (existing forum + perspective filters fire); the visual presentation moves from breadcrumb + chip strips + radio row stack.
In scope:
- New `.fristen-row` CSS primitive (with `.is-active`, `.is-answered`, `.is-prefilled` modifiers).
- Refactor `renderB1Cascade` into a row-stack renderer (`renderRowStack(rows: RowSpec[])`).
- Migrate L1 (mode) / L2 (perspective) / L3 (inbox) / L4..n (cascade) all to row instances.
- "ändern" semantic = re-activate row, drop rows below, push history state.
- Reset link at top of stack.
- i18n keys for row labels.
Out of scope for Slice 1:
- Project-derived proceeding-code narrowing (the `mapLitigationToFristenrechner` helper).
- Auto-walk single-child cascade chains.
- Hide-R2-on-UPC behaviour (Slice 2 needs the proceeding mapping helper anyway).
- Search affordance relocation (Slice 3).
Outcome: same data, same narrowing, **vastly better visual narrative**. The user can finally see their decision path. m's pillar 2 + 3 are addressed.
### Slice 2 — Project-driven narrowing depth
Adds the `litigation_code × jurisdiction → fristenrechner_code` mapping and uses it to:
- Pre-fill the proceeding-type sub-cascade rows (R5 in the §5.1 diagram).
- Hide R2 (Inbox) when project is UPC.
- Auto-walk single-child chains.
- Add the "aus Akte: <reference>" tag on prefilled rows.
This is where Pillar 1 fully lands. Depends on Slice 1's row primitive.
Includes a small backend helper (shared with t-paliad-178 Slice 2 if both ship in parallel): `internal/services/proceeding_mapping.go` exposes `MapLitigationToFristenrechner(litCode string, jurisdiction string) (fristenCode string, conditionFlags []string, ok bool)`.
Outcome: an Akte-bound user starts the cascade with three rows already answered, and only one or two active questions remain to drill to the leaf.
### Slice 3 — Search affordance + mobile polish
Relocates the mode-toggle / search affordance per §6 Option B. Adds the responsive breakpoints from §7. Polishes the autoscroll-to-active behaviour on mobile.
Mobile-only fixes ride here so Slices 1+2 can be reviewed by m at desktop width first.
### Why this order
- Slice 1 is purely visual. m can see the row stack and validate the layout BEFORE we change any narrowing semantic. If m hates the row primitive, we revert one PR. (We won't — but the option matters.)
- Slice 2 is the heavy correctness lift. It depends on the mapping helper, on Akte payload extensions, and on careful Test_DATABASE_URL integration tests.
- Slice 3 is final polish. Independently mergeable, lowest risk.
Each slice is roughly:
- Slice 1: 1 frontend PR (~700 LoC TSX + CSS + client). No backend, no migrations.
- Slice 2: 1 mixed PR (~150 LoC Go + 300 LoC client). No migrations.
- Slice 3: 1 frontend PR (~150 LoC).
---
## 11. Tradeoffs flagged
### 11.1 Row stack is taller than the current shell
A deep cascade (4 levels) plus 3 prefilled rows + R0 = 8 rows. Each ~28px compact + the active row's chip body (200400px depending on chip count) + spacing → ~600800px tall. The current shell is ~400px tall in the same scenario. Mitigation: rows are compact (28px), active-row autoscrolling keeps the chip set in view on mobile, and the visual narrative wins. m's ask explicitly trades vertical space for visibility.
### 11.2 "Aus Akte" tags are slightly noisy
Three rows showing "aus Akte: HL-2024-001" reads a bit redundant. Mitigation: only the first prefilled row shows the reference; subsequent rows show "(aus Akte)" without the reference. Saves vertical noise, keeps the source visible once.
### 11.3 Auto-walk single-child chains can confuse
The user picks "cms-eingang" → suddenly two rows materialise pre-answered. Looks magical. Mitigation: the two rows are clearly `is-prefilled` with an "aus Akte (UPC INF impliziert)" tag, and ändern is available on each. After the user has done it twice, the inference becomes a feature; before, a tooltip on first-render ("Diese Schritte ergeben sich aus Ihrer Akte") could help (deferred for v2 — see Q11).
### 11.4 Removing the radio mode-toggle is a behavioural change
Existing power users may know the radio. Mitigation: the new `🔍 Direkt suchen` icon-button at the top of Pathway B is a visible affordance; URL ?mode=filter still works as deep-link. Soft transition.
### 11.5 11/11 live projects have NULL `proceeding_type_id`
Slice 2's narrowing literally doesn't fire in production today. We're building UX that requires data nobody has yet. Mitigation: graceful degrade (forum-only narrowing via court free-text fuzzy match — already a feature today). Backfill of `proceeding_type_id` is a separate follow-up (see Q13).
### 11.6 The mapping table in §4.2 has ambiguities
APP+DE → ambiguous; ZPO_CIVIL → no analogue; CCR ↔ counterclaim modeling is fragile. Mitigation: every ambiguous case degrades to "no narrowing" — the row stays active rather than incorrectly pre-filled. Better silent than wrong.
### 11.7 ändern-on-an-ancestor invalidates descendants
Same as today's breadcrumb-click semantic — clicking a non-current crumb drops cascade depth. **No data is lost** (you can re-walk the cascade), but if the user was reading concept-cards at a leaf, those cards disappear. Mitigation: when ändern is clicked on an answered row, before dropping descendants, brief inline confirmation? Or just match today's behaviour (drop immediately). Inventor recommends match-today; Q12.
### 11.8 The row primitive may be over-engineered
A single visual primitive for four functionally different layers is a strong opinion. If a future cascade layer (e.g. variant chips for `condition_flag`) doesn't fit the primitive shape, we have to either extend the primitive or break the consistency. Mitigation: the primitive is shape (label + answer-area + ändern), not behaviour — variant chips fit because they're also "pick one (or several)". The contract is loose enough.
---
## 12. Files the implementer will touch (Slice 1 only)
### 12.1 Frontend
- **`frontend/src/fristenrechner.tsx:227-310`** — Pathway B markup. Replace `.fristen-mode-toggle` + `.fristen-perspective-bar` + `.fristen-inbox-bar` + `.fristen-b1-cascade` with a single `.fristen-row-stack` container. Add minimal scaffolding rows for mode / perspective / inbox / cascade-host. Keep `.fristen-b1-results` below — unchanged.
- **`frontend/src/client/fristenrechner.ts:2405-2574`** — Refactor `renderB1Cascade` into `renderRowStack(rows)`. The row spec is a discriminated union: `{kind: "mode" | "perspective" | "inbox" | "cascade", state: "active" | "answered" | "prefilled", question, options[], picked?}`. Rendering is one function per state; one switch on `kind` for the options builder.
- **`frontend/src/client/fristenrechner.ts:2914-3081`** — `inboxFilterAllowsForums` + `perspectiveAllowsParty` unchanged (Slice 1 is visual-only).
- **`frontend/src/client/fristenrechner.ts:initInboxFilter`** + perspective init — same handlers, new DOM targets.
- **`frontend/src/client/i18n.ts`** — ~20 new keys under `deadlines.row.*` (row labels, ändern affordance, prefilled tag, reset link, "next active" autoscroll-target announce).
- **`frontend/src/styles/global.css:1636-1822` + `:1965-2065`** — Retire `.fristen-mode-toggle`, `.fristen-perspective-bar`, `.fristen-inbox-bar`, `.fristen-b1-breadcrumb`, `.fristen-b1-question`, `.fristen-b1-buttons`, `.fristen-b1-button*`. Add `.fristen-row-stack`, `.fristen-row`, `.fristen-row-num`, `.fristen-row-label`, `.fristen-row-answer`, `.fristen-row-edit`, `.fristen-row-body`, `.fristen-row-chip`, `.fristen-row-chip--leaf`, `.is-active`, `.is-answered`, `.is-prefilled`.
### 12.2 Backend
No backend changes for Slice 1. The existing `/api/tools/fristenrechner/event-categories` and `/api/tools/fristenrechner/search` endpoints are unchanged.
### 12.3 Tests
- Pure-TS unit tests for `buildRowStack(currentState)` if extracted (table-driven: given URL state + Akte payload, output the RowSpec[]).
- Playwright smoke (post-deploy): land on Pathway B with `?path=b&project=<uuid>`, verify R1 prefilled with "aus Akte", R2 hidden for UPC project, ändern on R1 reopens, ändern on bucket drops cascade depth.
### 12.4 Anchoring back
t-paliad-164 perspective predefine code is the precedent. Re-read it before implementing — same hint mechanism, same override semantics, generalised.
t-paliad-178 Slice 2 (Step 0 toggle + Akte auto-derivation) is parallel; coordinate on the shared `proceeding_mapping.go` helper file (Slice 2 of this task introduces it; t-paliad-178 Slice 2 can adopt or vice versa, depending on which lands first).
---
## 13. Open questions for m
These are inventor's calls flagged for m's gate. Picking is on m, not the coder.
**Q1 — Mode-toggle disposition.** Three options in §6: (A) R0 row, (B) escape-hatch icon-button [inventor's pick], (C) bottom-of-stack affordance. Pick one or specify another.
**Q2 — UPC project: hide R2 entirely or show as compact prefilled?**
- Hide entirely (inventor's pick — matches m's "no need to show non-UPC options").
- Show as compact `[2] Wo kam es an? ✓ UPC CMS [ändern] aus Akte` row — verbose but explicit.
**Q3 — Auto-walk single-child cascade chains?**
- Yes, materialise R4..Rn-1 as prefilled (inventor's pick — strong UX, but feels magical first time).
- No, the user always picks their way down even when only one child applies (slower, more predictable).
- Yes-but-only-when-≥-2-rows-collapse (tradeoff).
**Q4 — "ändern" affordance shape on an answered row.**
- Hover-revealed link "ändern" (inventor's pick — keeps row clean by default).
- Always-visible pencil icon (more discoverable but more chrome).
- Whole-row click is the only handle (cleanest, but no visible affordance — newcomers won't discover it).
**Q5 — Drop confirmation when ändern invalidates descendants?**
- No (match today's breadcrumb-click — inventor's pick).
- Yes, when ≥ 3 cascade levels would be dropped.
- Always — even a one-row drop confirms.
**Q6 — Counterclaim awareness in the cascade.**
`project.counterclaim_of IS NOT NULL` implies `[with_ccr]` or `[with_cci]` condition flag depending on the parent's proceeding code. Should this surface as a prefilled row (e.g. "Variante: with_ccr"), or only as a backend filter on the result concept cards (silent)?
- Surface as a prefilled row (transparency — user sees the variant is active).
- Silent backend filter (no row tax, but mystery narrowing).
- Out of scope for this design — handle in a separate variant-chip task.
**Q7 — R0 mode-pick deep link.**
If a user lands on `?path=b` without `?mode=`, do we default to tree or to "no R0 picked yet"?
- Default to tree, R0 prefilled (today's behaviour — silent).
- R0 active until the user picks (more explicit, but adds one extra click for the common case).
**Q8 — Prefilled-row override permanence.**
After the user clicks ändern on a prefilled R1 (perspective) and explicitly picks "Beklagter" instead of the Akte's "Kläger", does this override persist if they re-bind the same Akte?
- No, re-bind re-applies (today's behaviour — clean, but overrides feel ephemeral).
- Yes, store override per-Akte in localStorage (sticky overrides — UX-friendly, but new state).
**Q9 — Reset confirm.**
A "Pfad zurücksetzen" link at the top of the row stack — confirm dialog?
- No confirm — match today's breadcrumb root-click (inventor's pick).
- Confirm if cascade depth ≥ 3.
- Always confirm.
**Q10 — Search escape-hatch position.**
Per §6 / §9, the `🔍 Direkt suchen` button sits at the top-right of Pathway B.
- Top-right (inventor's pick — discoverable, doesn't push down the row stack).
- Below the row stack, after results.
- As a permanent row at the bottom of the stack.
**Q11 — First-visit tooltip on auto-walked rows.**
"Diese Schritte ergeben sich aus Ihrer Akte" tooltip on the first prefilled-from-mapping row, dismissed forever on first close?
- Yes (helps onboarding).
- No (extra chrome; the "aus Akte" tag is enough).
- Inline help-icon (?) link to a docs page (longer-form).
**Q12 — Concept cards live below the row stack today. Should they collapse / hide when the user reopens an ancestor row (ändern)?**
- Collapse/hide on ändern, repopulate when the cascade reaches a leaf again (inventor's pick — matches the "no orphan content" rule).
- Keep visible as last-known until cascade resolves to a new leaf.
**Q13 — Backfill `paliad.projects.proceeding_type_id`?**
11/11 live rows are NULL. Slice 2's narrowing depends on this. Should the Slice 2 PR also include a one-off Akte-edit nudge ("Projekt-Setup vervollständigen: Verfahrensart fehlt"), or do we wait until m manually fills them in over time?
- Inline "Verfahrensart ergänzen" link on Akten with NULL proceeding_type_id.
- Backfill script (inferring from `court` free-text where unambiguous).
- Defer entirely; live with degraded narrowing until users fill it organically.
**Q14 — Reorder rows so prefilled stack at top, user-picked at bottom?**
The §5.1 diagram orders rows R0..Rn in their natural cascade sequence (mode → perspective → inbox → bucket → cascade depth). The prefilled rows happen to be R1, R4, R5 (not contiguous). Alternative: visually float all prefilled rows to a single "aus Akte" group at the top, with user-picked rows below. Tradeoff: cleaner separation vs. losing the temporal narrative of the decision path.
- Keep natural order (inventor's pick — narrative wins).
- Group prefilled at top.
**Q15 — Should `Filter / Suche` mode also see Akte prefills?**
If the user enters search mode with a project bound, do we silently scope results to the project's forum, or show the full taxonomy?
- Scope (consistent with cascade narrowing — inventor's pick).
- Don't scope (search is a "I know what I'm looking for" mode; the project is incidental).
- Scope with a visible toggle "Auch andere Foren anzeigen".
---
## DESIGN READY FOR REVIEW
Awaiting m's go/no-go on the questions in §13 before the coder shift starts. Inventor (pauli) parks after this commit — no implementation kickoff, no other-skill autoload, head gates the transition.
Recommended implementer: pattern-fluent Sonnet coder. The row primitive is straightforward CSS + a small state machine refactor; the precedent code (t-paliad-164 + t-paliad-133 cascade engine) is well-understood. **NOT cronus per memory directive 2026-05-06.**

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,943 @@
# Inline Paliadin chat modal + agent-suggested-with-approval write path
**Inventor:** dirac · **Task:** t-paliad-161 · **Issue:** m/paliad#20
**Date:** 2026-05-08 · **Branch:** `mai/dirac/inventor-inline-paliadin`
**Status:** READY FOR REVIEW — awaiting m's go/no-go before any coder shift.
---
## 0 · TL;DR
Two intertwined upgrades, scoped together because the chat surface is where
the write path is triggered and the write path is what makes the chat
non-trivial:
1. **Inline modal**: a slide-out chat widget reachable from every
authenticated paliad page, replacing the standalone `/paliadin` route's
primacy (the page survives as the dedicated full-screen surface). The
widget is **context-aware** — it knows which route the user is on, the
primary entity in view, and any selected text — and uses that to
pre-populate page-specific starter prompts.
2. **Agent-suggested write path**: Paliadin gains a *suggestion verb* that
drafts a deadline / appointment / note / project edit straight into the
existing `pending_create` lifecycle from t-paliad-160. The user reviews
via the same eye-pill 👀 surface (`/inbox`, list/agenda views) and
approves or rejects. Approved-from-suggestion rows pick up a sparkle ✨
provenance glyph that lives **next to** 👀, not in place of it.
**Hard call**: the inline modal should **keep the existing tmux-relay
backend** for v1. Cutover to the Anthropic Messages API is a separate
substantial piece of work (auth, prompt-caching, tool framework, budget
management); coupling it to the inline-modal ship would extend the design
window past where m needs the modal to land. The design *recommends* the
API cutover as a prerequisite for opening Paliadin beyond owner-only — but
the inline modal at owner-only scope works fine on the existing relay.
**Key locked positions** (all reversible by m before coder shift):
| # | Decision | Position |
|---|---|---|
| 1 | Modal trigger | Floating button bottom-right + `Cmd/Ctrl-K` shortcut |
| 2 | Surface shape | Right slide-out drawer, 420px desktop, full-screen on mobile |
| 3 | Visibility | Every authenticated page **except** `/paliadin`, `/login`, `/onboarding` |
| 4 | Gate | Same `PaliadinOwnerEmail` gate as today (no scope expansion in this task) |
| 5 | Backend transport | Tmux relay (existing). Anthropic-API cutover deferred. |
| 6 | Multi-turn coherence | Tmux session reuse already handles it; no client-side history hydrate beyond what's there |
| 7 | Context payload | `route_name` + `primary_entity_type` + `primary_entity_id` + `user_selection_text` (optional) + page metadata |
| 8 | Starter-prompt library | Per-route `paliadinStarters` registry, ships with 8 routes + a generic fallback |
| 9 | Agent-suggested attribution | New columns on `paliad.approval_requests` (`requester_kind`, `agent_turn_id`); **not** on entity rows |
| 10 | Visual language | ✨ glyph alongside 👀 on pending rows; persistent ✨ on approved-from-agent rows in audit log |
| 11 | Persona separation | Single Paliadin SKILL.md unchanged. No pre-design for split personas. |
| 12 | Concurrency | One in-flight turn per user enforced server-side (existing `turnMu`); request-side cancel via context |
---
## 1 · Premises verified live
Read the live system before designing on top — every claim below was
checked against the running paliad.de + DB on 2026-05-08, not against
CLAUDE.md or memory.
- **paliad.de**: live; root 200, `/paliadin` 302 (login redirect for
anon). Production runs `RemotePaliadinService` against mRiver (CLAUDE.md
flags `tmux + claude` as missing in the Dokploy container — confirmed
the prod path actually goes through `paliadin-shim` over SSH).
- **Migration tracker**: `paliad.paliad_schema_migrations.version=69`. Next
free migration is **070**.
- **`paliad.approval_requests`** existing columns: `id, project_id,
entity_type, entity_id, lifecycle_event, pre_image, payload,
requested_by, requested_at, required_role, status, decided_by,
decided_at, decision_kind, decision_note, created_at, updated_at`. **No
`agent_*` columns yet** — migration 070 adds them.
- **`paliad.paliadin_turns`**: already has a `page_origin TEXT` column
populated from `req.PageOrigin` on every turn. Today the frontend only
ever sets `window.location.pathname` on the standalone page; the inline
widget will widen this from a single string into a structured payload.
- **`paliad.deadlines` + `paliad.appointments`**: already carry
`approval_status text NOT NULL DEFAULT 'approved'` + `pending_request_id
uuid` from migration 054. The 👀 eye-pill renders on pending rows in
`events.ts:521` and `agenda.ts:289` via `.approval-pill--icon`.
- **Sidebar** (`frontend/src/components/Sidebar.tsx:123`): already has a
`/paliadin` entry hidden by default, revealed by `client/sidebar.ts`
after `/api/me` confirms the caller is the Paliadin owner. The same
reveal hook drives the inline modal's visibility.
- **`PaliadinOwnerEmail`** (`internal/services/paliadin.go:51`):
`matthias.siebels@hoganlovells.com`. Hard-coded gate. **No scope
expansion in this task.**
- **youpc.org reference files** all readable at
`/home/m/dev/web/youpc.org/`: `frontend/templates/ai/sidebar-widget.html`,
`frontend/js/utils/ai-chat-client.js`, `frontend/js/components/ai/sidebar.js`,
`youpc-go/internal/services/youpc_ai_relay.go`, `scripts/youpc-ai-shim`.
Klaus's brief in #20 maps to these directly.
**One CLAUDE.md correction**: the project's `CLAUDE.md` currently calls
`ANTHROPIC_API_KEY` "reserved-but-unused for the eventual production-v1
Paliadin". That language stays correct — this design *recommends but does
not commit* the API cutover. No CLAUDE.md edit in the implementation PR.
---
## 2 · Why the inline modal matters
m's framing (#20 §1) is "Paliadin should be reachable from anywhere". The
real differentiation argument is sharper: the *value of the assistant
collapses to "open a chat tab" if you can't get to it without leaving the
page you're already working on.* For a patent-practice tool, the most
common questions are page-anchored:
- On `/projects/<id>` → "Was steht für diese Akte diese Woche an?"
- On `/deadlines/<id>` → "Erkläre mir die Klageerwiderungsfrist nach UPC RoP 23.1."
- On `/agenda` with selection → "Schreibe einen Nachtrag zu diesem
Termin: …"
The standalone `/paliadin` page solves none of these because asking the
question requires the user to (a) leave the page, (b) re-explain context
the page already had, (c) navigate back. The inline modal solves (a) by
construction; (b) is solved by the **context payload** (§4); (c) is moot.
The widget is therefore the **default surface** going forward; the
`/paliadin` standalone page survives as the dedicated full-screen mode
(useful for long sessions where the slide-out is too narrow). Both speak
the same backend.
---
## 3 · Modal — shape, trigger, injection
### 3.1 Visual shape (recommendation)
**Right-edge slide-out drawer** — same pattern as youpc.org's
`ai-sidebar-widget.html` because it solves the right problems:
- Doesn't crowd the page content (drawer slides in *over* a translucent
scrim, page underneath stays visible at ~70% opacity so the user can
reference what they were looking at).
- Mobile-responsive for free: at `<640px` the drawer goes full-screen and
the floating button hides while open.
- Doesn't fight with paliad's existing left sidebar (`Sidebar.tsx`) — the
drawer claims the right edge, the sidebar keeps the left.
**Considered and rejected:**
- *Always-visible secondary sidebar* (left or right rail). Wastes ~280px
of horizontal real-estate on every page; collides with the sidebar on
mobile.
- *Popover anchored to the floating button*. Too small for multi-turn
conversations; mobile would need a separate full-screen mode anyway.
- *Fullscreen takeover overlay*. Defeats the purpose — if it covers the
page you can't reference what you were looking at.
### 3.2 Trigger
Two entry points:
1. **Floating action button** at bottom-right (`position: fixed; bottom:
20px; right: 20px;`). Lime accent (`var(--color-accent)`), ✨ glyph.
Same auth-reveal hook as the sidebar `/paliadin` link — `display:none`
until `client/sidebar.ts` confirms `/api/me.email ===
PaliadinOwnerEmail`.
2. **Keyboard shortcut**: `Cmd-K` (macOS) / `Ctrl-K` (other). Standard
command-palette muscle memory. Doesn't collide with browser shortcuts.
Paliad has no other Cmd-K binding today (verified via grep on
`keydown` handlers).
The shortcut also dismisses the drawer when it's open. `Esc` dismisses
unconditionally.
### 3.3 Drawer content
Layout (top to bottom):
```
┌──────────────────────────────┬─┐
│ ✨ Paliadin ↻ ↗ ✕│ │ Header: name, reset-session, open-fullscreen, close
├──────────────────────────────┼─┤
│ [Auf dieser Seite] │
│ Akte: Acme v. Müller │ │ Context chip — collapsible, shows what Paliadin
│ 19 Fristen · 4 Termine │ │ knows about the current page (read from payload)
├──────────────────────────────┼─┤
│ [empty-state starter prompts] │
│ • "Was steht hier an?" │
│ • "Erkläre die offene…" │
│ • "Lege eine Frist an" │
├──────────────────────────────┼─┤
│ <messages> │ │ Scrollable, user-right / paliadin-left
│ > User bubble │
│ < Paliadin bubble + ✨ chip │ │ ✨ chip = "I drafted this — it's awaiting your approval"
├──────────────────────────────┼─┤
│ [textarea + send + abort] │
└──────────────────────────────┴─┘
```
The `` button is the escape hatch to the standalone `/paliadin` for
users who want a full-screen session with full message history visible.
### 3.4 Injection mechanism
**One file edits the universe**: `frontend/src/components/PaliadinWidget.tsx`
emits an inline `<div id="paliadin-widget" style="display:none"></div>`
that page-template files include alongside `<PWAHead />` and `<Sidebar />`.
The mechanical edit pass: every authenticated TSX page (~30 files) gets a
`<PaliadinWidget />` near `</body>`. This mirrors the existing
`<PWAHead />` mechanical pass from t-paliad-042 and is the cleanest way to
guarantee the widget reaches every page without HTMX or runtime injection.
**Alternative considered**: server-side template fragment injected by Go's
HTML response writer (cleaner: no per-page edit). Rejected because paliad
uses bun-built static HTML files, not templated server responses — there's
no place to inject server-side. The mechanical pass is fine; the
boilerplate it adds is one component.
**Visibility predicate** (in `client/paliadin-widget.ts`):
- **Hide** on `/paliadin` (the standalone page IS Paliadin, the widget
would be redundant).
- **Hide** on `/login`, `/onboarding` (no auth context).
- **Hide** until `/api/me` resolves to `email === PaliadinOwnerEmail`.
Same fail-closed pattern as the sidebar link.
- **Show** on every other authenticated page.
### 3.5 What about the BottomNav (mobile)?
`BottomNav.tsx` has 5 slots (Dashboard / Projects / Add / Agenda / Menu)
— full. Adding a Paliadin slot would require evicting one. **Don't.**
The floating button is fine on mobile (it sits in the bottom-right corner
*above* the bottom nav, with `z-index` arbitration). At full-screen-drawer
size on mobile, the floating button hides while the drawer is open.
---
## 4 · Context payload — what flows from frontend to backend
### 4.1 Schema
The current `TurnRequest.PageOrigin` is a single string (the URL path).
The inline modal needs more. Define a structured payload:
```ts
interface PaliadinContext {
// Stable route key — independent of URL params. e.g. "projects.detail"
// not "/projects/61e3.../tab=team". The frontend computes this from
// `window.location.pathname` via a route-table lookup.
route_name: string;
// Path including query string (cosmetic; for audit + display only).
page_origin: string;
// The "primary entity" of the current page, if any. Examples:
// /projects/<id> → ("project", "<id>")
// /deadlines/<id> → ("deadline", "<id>")
// /appointments/<id> → ("appointment", "<id>")
// /events?type=deadline → null
// /tools/fristenrechner → null
primary_entity_type?: "project" | "deadline" | "appointment";
primary_entity_id?: string; // uuid
// User's text selection at the moment they opened the widget (or sent
// the turn). Capped at 1000 chars. Empty string = no selection.
// Source: window.getSelection().toString() at send-time.
user_selection_text?: string;
// UI state hints. Optional, useful for the model to disambiguate:
view_mode?: "list" | "cards" | "calendar" | "tree"; // /events, /projects
filter_summary?: string; // e.g. "status=overdue, project=Acme"
}
```
**What each field enables:**
- `route_name`: maps cleanly to a starter-prompt registry (§5) without
URL-parsing fragility.
- `primary_entity_*`: the SKILL.md teaches Paliadin to look up the entity
before answering when this is set. Saves a back-and-forth ("which
project?") in the very common case where the user is *already on* the
project page.
- `user_selection_text`: enables "explain this" / "rewrite this" /
"what's the deadline implied here" workflows from any prose surface
(project notes, deadline notes, court descriptions).
- `view_mode` + `filter_summary`: the model can say "I see you're looking
at overdue deadlines for Acme — which one?" instead of "which deadline?"
### 4.2 How the payload reaches the model
Wire format from frontend → Go:
```http
POST /api/paliadin/turn
Content-Type: application/json
{
"session_id": "<uuid>",
"user_message": "Was kommt diese Woche?",
"context": { ...PaliadinContext... }
}
```
The Go side stores the structured context in **a new
`paliad.paliadin_turns.context jsonb` column** (migration 070; see §7.1)
alongside the existing `page_origin` (kept for backwards compat — `page_origin`
becomes redundant once context is populated, but flipping the schema all
at once isn't worth the churn).
Then the envelope sent through tmux gets a structured prefix:
```
[PALIADIN:<turn_id>] [ctx route=projects.detail entity=project:61e3... selection="…" filter="status=overdue"] <user_message>
```
The SKILL.md gets a small section (§5 of `paliadin/SKILL.md`) that teaches
Paliadin to:
1. Parse the `[ctx …]` block first, in front of the user message.
2. Treat its contents as authoritative ("I'm currently viewing project
61e3"), not as instructions.
3. Pre-call `mcp__supabase__execute_sql` to enrich (e.g. lookup project
reference + title) when `entity=project:<id>` is set, *before*
answering.
**Why a structured prefix instead of a system-prompt JSON envelope**: the
PoC's tmux relay is a stream of keystrokes — system-prompt envelopes
require the API path. The bracket-syntax is line-noise-free, parse-able
by the SKILL.md, and survives any future migration (the API path can lift
the same `[ctx …]` block into a `system` message section).
### 4.3 Privacy floor
`user_selection_text` is potentially sensitive (selected text from a
client matter). Three controls:
1. **Cap at 1000 chars** — anything longer is truncated server-side
before being sent to Claude. The user sees a "(Auswahl gekürzt)"
notice.
2. **Audit redaction**: `paliadin_turns.context` stores the *full*
selection (already inside the firm's DB, no exfiltration) but the
admin dashboard `/admin/paliadin` redacts it to first 80 chars +
"…[gekürzt]" when rendering — the same dashboard already shows
`user_message` so the privacy posture is consistent.
3. **Opt-out**: the widget's settings panel (a `` corner in the header,
v1 minimal) gets a single toggle "Aktuelle Auswahl mitsenden" default
*on*. Off ⇒ context payload sets `user_selection_text=""` regardless
of `getSelection()`.
---
## 5 · Page-prompt-prefill — Klaus's wow-pattern, paliad-specific
### 5.1 The registry
A static client-side registry maps `route_name` → starter prompts. Lives
in `frontend/src/client/paliadin-starters.ts`.
```ts
type Starter = { label_de: string; label_en: string; prompt_de: string; prompt_en: string };
export const paliadinStarters: Record<string, Starter[]> = {
"dashboard": [
{ label_de: "Heute", label_en: "Today",
prompt_de: "Was steht heute an?", prompt_en: "What's on my plate today?" },
{ label_de: "Diese Woche", label_en: "This week",
prompt_de: "Welche Fristen sind diese Woche?", prompt_en: "Which deadlines are this week?" },
{ label_de: "Nächste Schritte", label_en: "Next steps",
prompt_de: "Was sollte ich als nächstes erledigen?", prompt_en: "What should I tackle next?" },
],
"projects.detail": [
{ label_de: "Status der Akte", label_en: "Project status",
prompt_de: "Was ist der aktuelle Status dieser Akte?", prompt_en: "What's the status of this project?" },
{ label_de: "Diese Woche", label_en: "This week",
prompt_de: "Was steht für diese Akte diese Woche an?", prompt_en: "What's on for this project this week?" },
{ label_de: "Frist anlegen", label_en: "Add a deadline",
prompt_de: "Lege eine Frist für diese Akte an: ", prompt_en: "Add a deadline for this project: " },
],
"deadlines.detail": [
{ label_de: "Erkläre die Frist", label_en: "Explain this deadline",
prompt_de: "Erkläre mir die Frist auf dieser Seite.", prompt_en: "Explain this deadline." },
{ label_de: "Rechtsgrundlage", label_en: "Legal basis",
prompt_de: "Welche Norm ist hier einschlägig?", prompt_en: "What's the relevant rule?" },
],
"agenda": [ /* … */ ],
"events": [ /* … */ ],
"inbox": [ /* … */ ],
"tools.fristenrechner": [ /* … */ ],
"glossary": [ /* … */ ],
// Generic fallback for unmapped routes.
"_default": [
{ label_de: "Was kann ich für dich tun?", label_en: "What can I help with?",
prompt_de: "", prompt_en: "" },
],
};
```
The widget's empty state renders the matching starter list. Click → the
prompt populates the textarea (or sends immediately if `prompt_de` is
empty — letting the user type their own). Picking up "Lege eine Frist an: "
seeds the input *partially* so the user finishes the sentence — a
deliberate friction-reducer for the common "draft and approve" workflow.
### 5.2 Why per-route registry, not LLM-generated suggestions?
Considered: dynamically ask Paliadin to suggest 3 starters based on
context. Rejected because:
1. **Latency**: every drawer-open would burn a full turn before the user
even types. The PoC's tmux turn is ~2-5 seconds cold; that's an
unusable empty state.
2. **Determinism**: m's audience (PA team) needs predictable affordances.
"What does this thing know how to do?" answered the same way each
visit beats "what does this thing know how to do *today*?"
3. **Translatability**: hand-crafted bilingual starters live next to the
rest of the i18n. LLM-generated would be one language at a time.
The registry is small (~10 routes × 3 starters × 2 langs = ~60 strings)
and lives next to `i18n.ts` patterns m's team already understands.
---
## 6 · Backend transport — tmux relay vs Anthropic API
### 6.1 Recommendation: keep tmux relay for v1 of the inline modal
Two reasons:
1. **Scope discipline**: the inline modal's user-visible payoff is
independent of which backend serves it. Cutover to the API is a 4-6
commit piece of substantial work (auth headers, prompt-cache
management, tool-definition framework, streaming format conversion,
budget controls, audit reshape, plus the existing tmux path needs to
remain as fallback during rollout). Bundling it with the inline modal
doubles the design's blast radius for no inline-modal-side benefit.
2. **Owner-only scope**: paliad's user base today is `PaliadinOwnerEmail =
m`. One user. The tmux relay's serialised one-turn-at-a-time, ~2-5s
cold start, ~1-3s warm response holds up fine for one user clicking
through the day.
### 6.2 What the API cutover *would* fix (recommend as Phase 2)
When scope expands beyond owner-only — even just to "m + 2 PA colleagues
for piloting" — the tmux relay starts to bend:
- **Concurrency**: serialised turn lock means PA-A waits while PA-B
thinks. Per-user tmux sessions help but mRiver still has finite
resources.
- **Latency**: ~2s cold tmux start is ok for one user; bad for "I just
opened the widget, ask a quick question, close" rhythm at scale.
- **Cost vs subscription**: m's Claude Code subscription covers his
personal turns. Multi-user would either need m's account to absorb the
load (dubious) or the firm's enterprise key (the actual prod path).
- **Streaming**: tmux streaming today is the youpc.org-style "tail the
response file as it grows" stopgap. Real token streaming (TTFB <1s)
needs the API.
The API cutover should therefore be **a prerequisite for opening Paliadin
beyond owner-only**. The inline modal's design assumes API-cutover-ready
boundaries (the relay interface in §6.4) so when m flips the switch, the
inline-modal frontend doesn't change.
### 6.3 Why not cutover now anyway?
It's tempting because:
- The CLAUDE.md note about `ANTHROPIC_API_KEY` reserved-but-unused has
been there since 2026-04-16 and would benefit from being un-deferred.
- The inline modal is the natural moment to revisit infrastructure.
- Klaus's youpc.org has built a relay-interface abstraction
(`youpcAIRelay` interface in `youpc_ai_relay.go`) that paliad could
borrow for the swap point.
**Counter-arguments that win:**
- Today's tmux relay shipped only 2-3 days ago (`paliadin_remote.go`
reference t-paliad-151). It's not a legacy substrate to escape — it's
fresh code that hasn't earned a rewrite yet.
- The compliance question for the API path (HLC-key vs personal-key,
audit retention requirements, prompt-logging policy) hasn't been
resolved with HLC IT. m flagged this as the **biggest open question** in
the t-paliad-146 design and it's still open.
- Inline modal can ship entirely on the existing relay; if the API
cutover comes later, the modal doesn't have to re-ship.
**Therefore**: design a small interface seam (§6.4) so v1 doesn't paint us
into a tmux-only corner, but don't pay the cutover cost in this PR.
### 6.4 Relay-interface seam (small, optional, recommended)
Mirror youpc.org's pattern (`youpc_ai_relay.go`) but smaller — paliad
has one role, no streaming variant yet:
```go
// internal/services/paliadin_relay.go (new)
type PaliadinRelay interface {
RunTurn(ctx context.Context, session string, turnID uuid.UUID,
envelope string) ([]byte, error)
Reset(ctx context.Context, session string) error
HealthGate(ctx context.Context, session string) error
}
```
`LocalPaliadinService` and `RemotePaliadinService` keep their current
shapes; the audit-row writes (`paliadinDB`) stay shared. `RunTurn` becomes
a thin wrapper that builds the envelope (with the new `[ctx …]` block from
§4.2) and delegates to the relay. A future `httpAPIRelay` slots in beside
the SSH one without touching the audit/turn-row code.
**Don't extract the interface unless the inline modal's PR organically
needs it.** If the modal can ship without restructuring the existing
relay, the abstraction-cost is negative.
---
## 7 · Agent-suggested write path — schema + flow
### 7.1 Schema decision: extend `approval_requests`, not entity rows
The brief listed three candidate locations:
| Option | Where the marker lives | Verdict |
|---|---|---|
| A | `boolean agent_suggested` on `paliad.deadlines` / `paliad.appointments` | **Reject**: pollutes domain tables; survives past approval (the entity is no longer "agent-suggested" once it's been live for six months); doesn't carry which agent / which turn |
| B | `text suggested_by_agent` on entity rows (multi-agent provenance) | Same problems as A; "agent name" never used because we have one agent |
| C | New columns on `paliad.approval_requests` linking back to the suggesting turn | **Recommended** |
The `approval_request` row IS the audit-chain entry; the entity row is
just current state. Provenance information belongs on the audit-chain row
where it can persist forever without polluting the entity schema.
**Migration 070 (proposed):**
```sql
ALTER TABLE paliad.approval_requests
-- 'user' = direct user create; 'agent' = drafted by Paliadin from a chat turn.
ADD COLUMN requester_kind text NOT NULL DEFAULT 'user'
CHECK (requester_kind IN ('user', 'agent')),
-- When requester_kind='agent', the chat turn the suggestion came from.
-- NULL otherwise. ON DELETE SET NULL — the audit record survives even
-- if the turn row is purged (paliadin_turns has no retention policy
-- today, but design for it).
ADD COLUMN agent_turn_id uuid
REFERENCES paliad.paliadin_turns(turn_id) ON DELETE SET NULL,
ADD CONSTRAINT approval_requests_agent_xor
CHECK (
(requester_kind = 'agent' AND agent_turn_id IS NOT NULL)
OR (requester_kind = 'user' AND agent_turn_id IS NULL)
);
CREATE INDEX approval_requests_agent_turn_idx
ON paliad.approval_requests (agent_turn_id)
WHERE agent_turn_id IS NOT NULL;
-- paliadin_turns also gets the structured context column.
ALTER TABLE paliad.paliadin_turns
ADD COLUMN context jsonb;
```
`requested_by` continues to be the user uuid — even for agent suggestions
the user is the *initiator* (Paliadin acts on their behalf, never
autonomously). `requester_kind` distinguishes "the user typed Speichern"
from "the user typed `/lege eine Frist an: …` to Paliadin and Paliadin
drafted it; the user has not yet approved".
### 7.2 The flow
1. **User asks Paliadin**: "Lege eine Frist für diese Akte an: 16.05.
Klageerwiderung Acme".
2. **Paliadin's SKILL.md gets a new section**: "Agent-suggested writes"
that teaches it to call a new MCP tool `paliad__suggest_deadline` (and
siblings for appointment / project_note / project_attach). The tool's
server-side handler:
- Validates the user has visibility on the project (existing
`can_see_project`).
- Calls `DeadlineService.Create` *with the new
`IsAgentSuggestion=true` flag* and `agent_turn_id=<current turn>`.
- Inside the create-tx, after the entity insert, the existing approval
hookup runs: `ApprovalService.SubmitCreate(...)`. **Critical
change**: when `IsAgentSuggestion` is set, the submit unconditionally
creates an approval request *even if no policy applies* — the agent
path is approval-gated by construction, not by partner-unit policy.
3. **Eye-pill 👀 + sparkle ✨** render on the resulting row in `/inbox`,
`/deadlines`, `/agenda`. Click → standard approve/reject UI. Approve
flips status to `approved`, sets `decision_kind='peer'` (or
admin_override if global_admin), the entity becomes live.
4. **Audit chain on the project's Verlauf**:
- `deadline_approval_requested` event with
`metadata.requester_kind='agent'` + `metadata.agent_turn_id=<uuid>`.
Verlauf renderer picks this up and labels the event "Paliadin hat
eine Frist vorgeschlagen ✨".
- `deadline_approval_approved` with the user as `decided_by` + the
existing `decision_kind` ladder. Verlauf renders "Anna hat
Paliadin's Vorschlag genehmigt ✨".
### 7.3 Why agent-suggested unconditionally goes through approval
Two reasons:
1. **Trust gradient**: even if a partner has direct create authority on
their own projects (no policy = no approval needed today), an agent
suggesting on their behalf is qualitatively different. Visible review
keeps the user in the loop.
2. **Single audit shape**: today the partner-unit policy decides which
creates need approval; bypassing that for agent suggestions creates a
second code path. Forcing agent suggestions into the approval pipeline
means there's exactly one "agent created an entity" audit shape (the
approval_request row).
A user who finds the per-suggestion review tedious can request `/genehmige
einfach alles was Paliadin vorschlägt` — but that's a Phase 2 setting
("auto-approve agent suggestions on projects where I'm lead"), explicitly
out-of-scope for v1 (and m says so in #20: "Multi-turn agent loops …
Every creation gets the user's eye.").
### 7.4 What entities can Paliadin suggest in v1?
The brief mentions "deadlines, appointments, notes, project-tree edits".
Recommend ordering by reversibility + audit complexity:
| Entity | v1? | Why |
|---|---|---|
| Deadline create | **Yes** | Highest-value (Klaus would rate this top), well-supported by existing `pending_create` lifecycle |
| Appointment create | **Yes** | Same lifecycle substrate; symmetric tool |
| Project note (`project_events.note`) | **Yes** | Read-only audit event, no approval gate today — but for agent-authored notes route through approval anyway (consistency) |
| Project-tree edit (move, rename) | **No, defer** | Approval lifecycle for project moves doesn't exist; designing it is its own task. |
| Deadline / appointment **edit** | **No, defer** | Edits today only need approval when date-fields change (t-paliad-138 §Q4). Agent edits would need their own design pass for "what changes does the user see in the diff?" |
| Deadline **complete** | **No, defer** | Same reason — complete already has approval lifecycle, but the agent path is qualitatively different (a deadline being marked done is high-stakes; design it after a v1 lands and we see how often agent-creates need editing) |
**v1 = create only**. Edits/completes are a Phase 2 expansion.
---
## 8 · Visual language — ✨ alongside 👀, not in place of
### 8.1 Design
`.approval-pill--agent` is a new modifier that sits **next to** the
existing `.approval-pill--icon` (the 👀 glyph), not replacing it.
| Row state | Pill rendering |
|---|---|
| `approval_status='pending'` AND `requester_kind='user'` | 👀 |
| `approval_status='pending'` AND `requester_kind='agent'` | 👀 ✨ |
| `approval_status='approved'` AND `requester_kind='user'` | (no pill) |
| `approval_status='approved'` AND `requester_kind='agent'` | ✨ (subtle, in the row's *secondary* badge slot — not a pill) |
The 👀 + ✨ pairing communicates: "this is awaiting approval *and* came
from Paliadin". Hover (`title` attr) on ✨ reads:
"Paliadin hat das vorgeschlagen — angeklickt klärt".
**Why both glyphs, not a fused single glyph?** The two questions ("is
this awaiting approval?" / "did a human or Paliadin originate this?") are
orthogonal — a future autopilot mode might let some agent suggestions
auto-approve, in which case 👀 disappears but ✨ stays. Keeping them
separate keeps the visual taxonomy decomposable.
### 8.2 Where ✨ renders
Three surfaces:
1. **Eye-pill row** (`/inbox`, `/deadlines`, `/agenda`, project detail,
/events): 👀 ✨ side-by-side when applicable. Same `.approval-pill`
shape, separate elements.
2. **Audit log** (`/admin/audit-log` + project Verlauf): the row's
"approved by" line gets a trailing ✨ when the underlying request had
`requester_kind='agent'`. Reads "Anna ✨ Schmidt" → tooltip "Über
Paliadin vorgeschlagen, von Anna genehmigt".
3. **Approval request inbox card**: the requester's name in the inbox
card gets a subtle "✨ Paliadin (für Anna)" badge instead of just
"Anna" when `requester_kind='agent'`.
### 8.3 The "+p" annotation question
m's #20 said: "we say USER + p or with a star or something". The "+p"
text annotation reads in audit logs but doesn't scan in a pill row (✨ is
recognisable; "+p" is not without learning). **Recommend**: ✨ as the
universal glyph. Reserve a textual fallback for compliance-export
contexts where emojis don't render — there the audit string becomes
"Anna [agent: Paliadin]" rather than "Anna ✨".
---
## 9 · Persona separation
m's brief asked whether to lean on klaus's "scope-bouncer in SKILL.md"
pattern (Hugo refuses legal questions, points at Lexie; Lexie refuses
"how do I subscribe?", points at Hugo) for paliad — i.e. pre-design
multi-persona infrastructure.
**Recommendation: don't.** Paliad has one Paliadin (Patentpraxis assistant
at HLC's Patent team). The youpc.org split exists because *youpc.org has
fundamentally different audiences* — public visitors (Hugo handles "how
does this site work?") and premium-beta lawyers (Lexie does case-law
research). Their refusal scopes are different because their users are
different.
Paliad's audience is one cohesive group: HLC PA team. They want one
assistant that does "everything PA-relevant" — Aktenmanagement, Fristen,
Begriffe, Gerichte, UPC-Recht. There's no audience pair that requires
distinct refusal scopes.
**If Phase 2 wants to add a case-law research persona** (e.g. cross-link
to youpc.org's Lexie) — *that's a separate skill alongside Paliadin*, not
a persona-split inside Paliadin. The infrastructure for that already
exists in Claude Code's skill router (multiple skills, each its own
description/persona).
**No SKILL.md changes for persona separation in this design**. The skill
gets §4.2's `[ctx …]` parser added, plus §7.2's `paliad__suggest_*` tool
guidance, but the persona stays "der Paliad-Patentpraxis-Assistent".
---
## 10 · Phasing & implementation surface
### 10.1 Suggested phasing (single PR is feasible; split optional)
**Slice A — schema + relay seam** (~1 commit)
- Migration 070: `approval_requests.requester_kind` +
`agent_turn_id` + xor-check + index; `paliadin_turns.context jsonb`.
- Optional `PaliadinRelay` interface extraction (skip if it makes the PR
bigger without removing duplication).
**Slice B — context payload + SKILL.md update** (~1 commit)
- Wire structured `PaliadinContext` from frontend → Go → tmux envelope.
- SKILL.md `[ctx …]` parsing + behaviour.
- `client/paliadin-context.ts` route-table + entity extraction (one file).
- `/api/paliadin/turn` accepts the new body shape (backwards-compatible:
old `page_origin` still honoured if `context` is absent).
**Slice C — inline widget** (~1 commit, biggest)
- `frontend/src/components/PaliadinWidget.tsx`.
- `client/paliadin-widget.ts` (drawer state, sending, history, hide-on-route).
- `client/paliadin-starters.ts` registry (8 routes + default).
- Mechanical pass: every authenticated TSX adds `<PaliadinWidget />`.
- CSS: `.paliadin-widget`, `.paliadin-drawer`, `.paliadin-trigger`,
`.paliadin-context-chip`, ~150 lines of `global.css`.
- ~30 i18n keys.
**Slice D — agent-suggested write path** (~1 commit)
- `paliad__suggest_deadline` + `paliad__suggest_appointment` MCP tools
(or HTTP tool, depending on how the MCP scope already wires —
`internal/handlers/paliadin_tools.go` if new file warranted).
- `DeadlineService.Create` / `AppointmentService.Create` accept a
`IsAgentSuggestion bool` + `AgentTurnID *uuid.UUID` plumbed into
`ApprovalService.SubmitCreate` (which gets a sibling
`SubmitAgentCreate` that always creates a request even without policy).
- SKILL.md adds the §7.2 "Agent-suggested writes" instruction block.
**Slice E — visual language** (~1 commit)
- `.approval-pill--agent` CSS.
- `events.ts`, `agenda.ts`, `inbox.ts` render ✨ when
`requester_kind='agent'`.
- Audit-log + Verlauf renderer extends to surface ✨ on approved-from-agent
events.
- ~10 i18n keys for the badges + tooltips.
**Recommended PR shape**: single PR with five commits in this order. Slice
A's migration is independent (can deploy without the rest); Slice D needs
B + C; Slice E builds on D. If sliced into multiple PRs, A and B-C can
ship independently of D-E (modal works as read-only chat without the
write path; that's already an upgrade).
### 10.2 Files of note for the implementer
**New files:**
- `internal/db/migrations/070_paliadin_inline.{up,down}.sql`
- `internal/handlers/paliadin_tools.go` (suggest verbs)
- `internal/services/paliadin_relay.go` (optional interface)
- `frontend/src/components/PaliadinWidget.tsx`
- `frontend/src/client/paliadin-widget.ts`
- `frontend/src/client/paliadin-starters.ts`
- `frontend/src/client/paliadin-context.ts`
**Edits:**
- `internal/services/paliadin.go` (TurnRequest gains structured Context;
insertTurnRow stores it)
- `internal/services/approval_service.go` (SubmitCreate accepts
agent-flag; SubmitAgentCreate variant)
- `internal/services/deadline_service.go`,
`internal/services/appointment_service.go` (Create accepts
IsAgentSuggestion + AgentTurnID; threads to ApprovalService)
- `internal/handlers/paliadin.go` (turnRequest body schema)
- `frontend/src/client/events.ts`, `agenda.ts`, `inbox.ts` (✨ render)
- `frontend/src/styles/global.css` (drawer + ✨ pill CSS)
- `frontend/src/client/i18n.ts` (~40 new keys × 2 langs)
- `frontend/src/components/Sidebar.tsx` — no edit (the existing sidebar
link logic already gates on owner; no new entries)
- ~30 page TSX files: mechanical `<PaliadinWidget />` add (~1 line each)
- `~/.claude/skills/paliadin/SKILL.md` (via `scripts/install-paliadin-skill`):
add §4.2 ctx-parser block + §7.2 suggest-tools block
**Total estimated surface**: comparable to t-paliad-146 (the original
Paliadin design — ~3500-4500 LoC) plus the agent-suggest write path
(~1000 LoC). Single PR is feasible if the implementer is pattern-fluent;
split is fine.
---
## 11 · Open questions for m
These are the calls m has to make before any coder shift starts.
### Q1 — Scope gate: still owner-only?
The inline modal's design assumes `PaliadinOwnerEmail` stays as the only
gate (m only). When does scope expand?
- (a) **Stays owner-only for v1** of inline modal — recommended; matches
brief. ← **inventor's pick**
- (b) Extend to a beta-features whitelist (firm-wide email domain + flag).
- (c) Expand to all of `hoganlovells.com` immediately. Requires API
cutover (Phase 2 prerequisite).
### Q2 — Backend: tmux relay or Anthropic API for the inline modal?
- (a) **Keep tmux relay** for v1 — recommended; ships fastest. ← **inventor's pick**
- (b) Cutover to Anthropic API now — slower ship; better long-term.
- (c) Both: ship tmux v1, design the API path as a parallel deferred PR.
### Q3 — Agent-suggested entities in v1: where to draw the line?
- (a) **Create-only**: deadline, appointment, note. Defer edits/completes/project-tree. ← **inventor's pick**
- (b) Create + edit (deadline + appointment).
- (c) Create + edit + complete + project-tree.
### Q4 — Visual language for agent provenance?
- (a) **✨ glyph alongside 👀** — recommended; orthogonal to lifecycle. ← **inventor's pick**
- (b) "+p" text annotation in audit lines only; no glyph in pills.
- (c) Replace 👀 with ✨ for agent-pending rows (single glyph, more compact).
### Q5 — Selection text in context payload — default on or off?
- (a) **Default on**, opt-out via widget settings — recommended. ← **inventor's pick**
- (b) Default off, opt-in via widget settings.
- (c) Always on, no toggle.
### Q6 — Widget visibility scope: everywhere except `/paliadin`, or finer?
- (a) **Everywhere except `/paliadin`, `/login`, `/onboarding`** —
recommended; lowest cognitive load. ← **inventor's pick**
- (b) Only on data-bearing pages (dashboard, projects, deadlines, agenda,
events, inbox); hide on tool pages (fristenrechner etc.).
- (c) User-configurable per page.
### Q7 — Modal vs dialog: drawer + scrim, or non-modal floating panel?
- (a) **Modal slide-out drawer with scrim** (focus-traps) — recommended. ← **inventor's pick**
- (b) Non-modal floating panel (page stays interactive while widget is open).
### Q8 — Keyboard shortcut for opening: Cmd-K?
- (a) **Cmd-K / Ctrl-K** — recommended. ← **inventor's pick**
- (b) Different shortcut (m to specify).
- (c) No shortcut, button-only.
### Q9 — Context payload truncation cap (selection text)?
- (a) **1000 chars** — recommended; balances usefulness vs prompt-bloat. ← **inventor's pick**
- (b) Higher cap (5000 chars).
- (c) Lower cap (300 chars).
### Q10 — Persona separation pre-design?
- (a) **Single Paliadin, no scope-bouncer pattern** — recommended; YAGNI. ← **inventor's pick**
- (b) Add scope-bouncer pattern now (Paliadin refuses non-paliad questions, points at... where?).
- (c) Pre-design split with a second skill (Phase 2 case-law researcher).
### Q11 — Auto-approve some agent suggestions?
- (a) **No, every agent suggestion needs the user's eye** — recommended; matches m's #20 verbatim. ← **inventor's pick**
- (b) Auto-approve agent suggestions on projects where the user is lead.
- (c) Auto-approve when the suggestion was a direct response to "Lege … an" (user opted in by phrasing).
### Q12 — Recommended implementer?
Same substrate as t-paliad-146 + t-paliad-160 + t-paliad-138 (paliadin,
approval pipeline, eye-pill UI). Pattern-fluent Sonnet work.
- (a) **Any pattern-fluent Sonnet coder** — recommended. ← **inventor's pick**
- (b) The same coder who shipped t-paliad-160 (deepest context on the
approval pipeline).
- (c) Two coders: one on Slices A-C (modal + context), one on Slices D-E
(agent-suggest + visual language).
---
## 12 · Out of scope (for now) — preserved
Per m's brief:
- Direct Paliadin write permission (no RLS bypass, no agent service-role
identity). The approval gate stays the only path agents take into prod
data.
- Multi-turn agent loops — no chained writes without per-step user
approval.
- Production-v1 Anthropic API cutover for the existing standalone
`/paliadin` route (recommended in §6 as a *prerequisite* for opening
beyond owner-only, but not committed in this task).
- Edits / completes / project-tree as agent-suggestible entities (§7.4
defers to Phase 2).
- Persona separation infrastructure (§9 defers indefinitely).
---
## 13 · Trade-offs flagged
| Trade-off | What we accept | Mitigation |
|---|---|---|
| Tmux-relay v1 caps concurrency at one turn per user | Owner-only v1 makes this fine | Spec the relay-interface seam (§6.4) so API cutover is non-disruptive |
| Mechanical `<PaliadinWidget />` pass touches ~30 files | Same pattern as t-paliad-042 PWAHead, low risk | One commit per slice keeps blame surface tight |
| Agent suggestions unconditionally route through approval | Some users may find it tedious | Phase 2 auto-approve setting (m wants Q11 = no, so this isn't urgent) |
| Two glyphs (👀 + ✨) might confuse first-time approvers | Slight onboarding cost | Tooltip on hover; admin/onboarding doc one-liner |
| Selection-text in context payload risks accidental info leakage | Low (data already in DB) | Cap + redaction in admin dashboard (§4.3) |
| Per-route starter registry needs maintenance as routes evolve | Yes; cost is real | Default fallback ensures no route is silent; route renames are caught by build (registry imports route names as a const map) |
---
## 14 · Implementation hygiene
- **No bare CSS tokens.** New `.paliadin-widget*` + `.approval-pill--agent`
CSS uses existing `--color-*` / `--accent-*` / `--bg-soft` tokens. The
reminder from t-paliad-150 (third occurrence of bare-token leaks) holds.
- **No RAISE EXCEPTION in migration 070** — Maria's build constraint.
- **No `2>&1` on diagnostic** — global rule.
- **i18n must compile** — every new label gets a key in `client/i18n.ts`
+ DE/EN values; `bun run build` regenerates `i18n-keys.ts`.
- **Build + vet + test gate** — `go build ./...` + `go vet ./...` +
`go test ./...` + `cd frontend && bun run build` all clean before push.
- **Don't self-merge** — push branch, comment on Gitea #20, await m's
merge gate.
- **Don't close issue #20** — m closes issues. Set `done` label on
approval.
---
## 15 · End-of-shift checklist (this design)
- [x] Read m/paliad#20 + Klaus's reply (msg #1563 / comment).
- [x] Read existing `paliadin.go` + `paliadin_remote.go` + `approval_service.go` + `paliadin-shim` + `install-paliadin-skill` + `~/.claude/skills/paliadin/SKILL.md`.
- [x] Read youpc.org reference: `sidebar-widget.html` + `sidebar.js` + `ai-chat-client.js` + `youpc_ai_relay.go`.
- [x] Verify live state: paliad.de up, migration tracker at 69, schema columns matched expectations, eye-pill 👀 already wired.
- [x] Take a position on every decision in the brief (see §0 table; §11 for the open questions).
- [x] No hour estimates anywhere in the doc.
- [x] Recommend implementer + phasing.
- [ ] Commit this doc on `mai/dirac/inventor-inline-paliadin`.
- [ ] Push branch.
- [ ] Comment on Gitea #20 with summary + doc link.
- [ ] File mBrian synthesis node under `topic-paliadin` (or equivalent).
- [ ] `mai report completed "DESIGN READY FOR REVIEW: …"` and **stop**. Do not auto-flip to coder.
---
*Inventor parked after this commit. The head will surface to m for the
go/no-go gate before any coder shift begins. Skipping that gate has
burned commits before (m/mAi#142); the gate is non-negotiable.*

View File

@@ -0,0 +1,677 @@
# Paliadin: route prod via Tailscale SSH to mRiver
**Issue:** m/paliad#12 — t-paliad-151
**Date:** 2026-05-07
**Author:** noether (inventor)
**Supersedes nothing.** Extends `docs/design-paliadin-2026-05-07.md` (the Phase 0 PoC) with a third deployment path between "laptop-only PoC" and "Anthropic API direct".
**Related:** t-paliad-146 (PoC ship), t-paliad-150 (`friendlyErrorMessage` pattern).
---
## 1. Goal
Make Paliadin reachable from `paliad.de` (Dokploy on mLake) without losing m's Claude Code subscription, by routing each turn over Tailscale + SSH from the paliad container to mRiver, where the existing long-lived `tmux` + `claude` PoC keeps running.
**Non-goals (v1):**
- Multi-host failover.
- Encryption beyond SSH-over-tailnet (already E2E-encrypted by Tailscale's WireGuard layer).
- Anthropic API fallback when mRiver is offline — show a friendly error instead.
- Wake-on-LAN of mRiver.
- Multi-tenant or multi-firm variants.
---
## 2. Live state — what was verified before designing
A design built on stale facts rots fast. These were probed on 2026-05-07, not assumed from CLAUDE.md or memory:
| Fact | How verified | Result |
|---|---|---|
| mRiver = `100.99.98.203`, has tmux + claude | this worker runs on mRiver; `tmux -V``tmux 3.6a`; `which claude``/home/m/.local/bin/claude` | confirmed |
| mLake (`100.99.98.201`) has Tailscale running | `ssh m@mlake tailscale status` | confirmed; mRiver visible as `active; direct [2a02:4780:41:3fbc::1]:41641` |
| paliad container Dockerfile is alpine:3.21 minimal, no SSH, no tailscaled | `Dockerfile` | confirmed (only `ca-certificates`) |
| paliad compose runs default Docker bridge (no `network_mode`) | `docker-compose.yml` | confirmed |
| mRiver has no `~/.ssh/authorized_keys` yet | `ls ~/.ssh/` | confirmed — file must be created in Phase A |
| `/tmp/paliadin/` does not exist on mRiver yet | `ls /tmp/paliadin` | confirmed — created on first turn (paliadin.go:185 `os.MkdirAll`) |
| `paliad-paliadin` tmux session is not currently running on mRiver | `tmux ls` | not present; the existing PoC creates it on demand |
**Implication for design:** the paliad container needs new infrastructure on three axes — network reachability of the tailnet, an SSH client + identity, and a service-layer code path that talks to a remote tmux instead of a local one. Each axis is its own sub-design below.
---
## 3. Locked decisions (m, 2026-05-07 22:35)
m made four design-shaping calls via the inventor's `AskUserQuestion` pass. They are recorded here verbatim because every downstream choice in §4§6 follows from them.
| # | Question | m's choice |
|---|---|---|
| 1 | Container Tailscale shape | **`network_mode: host` on paliad** |
| 2 | SSH-to-mRiver protocol granularity | **Server-side `paliadin-shim` (one RPC per turn)** |
| 3 | Routing trigger | **Env var `PALIADIN_REMOTE_HOST` + interface split** |
| 4 | SSH private key storage | **Dokploy secret env var `PALIADIN_SSH_PRIVATE_KEY`** |
| 5 | SSH port to bypass Tailscale SSH | **Port 22022 via `ssh.socket` drop-in (Phase A finding, 23:30)** |
Decision (1) was *not* the inventor's recommendation — host mode has known interaction risk with traefik (§4.2). m is overriding the recommendation; this design accepts the call and codifies a Phase A test step that gates the rollout on traefik still working under host mode. If Phase A blows up, the fallback is to revisit (1) in a follow-up issue, not to silently swap to a sidecar.
Decision (5) emerged during Phase A: Tailscale SSH on mRiver was found to intercept `:22` from tailnet peers and bypass OpenSSH's `authorized_keys` entirely (banner says "Tailscale", auth method "none"). The `command=` shim restriction therefore never fires on the standard port. Adding port 22022 via a `systemd ssh.socket` drop-in routes paliad's connections to real OpenSSH where the restriction works. m's interactive `tailscale ssh m@mriver` on `:22` stays untouched. See §4.4 for the implementation.
---
## 4. Sub-design A — Container Tailscale shape
### 4.1 Shape: `network_mode: host`
paliad's container shares mLake's network namespace. `tailscale0` (mLake's tailnet interface) is directly visible from inside the container. Outbound `ssh m@100.99.98.203` reaches mRiver over the tailnet without any sidecar, userspace tailscaled, SOCKS proxy, or auth-key flow inside the container.
```yaml
# docker-compose.yml diff
services:
web:
build: .
network_mode: host # NEW
# remove: expose: ["8080"] # host mode means port is on the host directly
environment:
- PORT=8080
...
# NEW Paliadin remote-routing knobs
- PALIADIN_REMOTE_HOST=${PALIADIN_REMOTE_HOST} # 100.99.98.203
- PALIADIN_REMOTE_PORT=${PALIADIN_REMOTE_PORT} # 22022 (bypasses Tailscale SSH, see §4.5)
- PALIADIN_REMOTE_USER=${PALIADIN_REMOTE_USER} # m
- PALIADIN_SSH_PRIVATE_KEY=${PALIADIN_SSH_PRIVATE_KEY}
- PALIADIN_KNOWN_HOSTS=${PALIADIN_KNOWN_HOSTS} # one-line ssh-keyscan -p 22022 output
restart: unless-stopped
```
### 4.2 Trade-off accepted: traefik routing under host mode
paliad.de's TLS is provided by Dokploy's traefik on the `dokploy-network` overlay. With `network_mode: host`, paliad is no longer attached to that overlay. Two failure modes are possible:
- **(M1)** traefik can't discover the service via Docker DNS → 502 at the edge.
- **(M2)** traefik routes via host loopback (`http://127.0.0.1:8080` or `host.docker.internal`) and works fine.
Recent Dokploy versions configure traefik with both `loadbalancer.server.url` and Docker labels; (M2) is the documented host-mode path. **Phase A explicitly tests this** (§7) before any code is written; if (M1) materialises, the design rolls back to the sidecar variant of decision 1 in a follow-up issue.
Other host-mode side-effects to flag in operations:
- paliad listens on host port 8080 directly. Any other compose service binding 8080 conflicts.
- paliad's outbound DNS uses host resolver (no Docker-internal `web` etc.). Currently fine: paliad's only network deps are external (Supabase, SMTP, GitHub raw). No service on `dokploy-network` is referenced by name.
- The container can reach **every** Tailscale node, not just mRiver. Mitigations live in §5 (key restriction) and §5.2 (`from=` clause on mRiver authorized_keys).
### 4.3 Dockerfile diff
```dockerfile
# Final stage adds the SSH client only. Tailscale is provided by the host.
FROM alpine:3.21
RUN apk add --no-cache ca-certificates openssh-client # +openssh-client (~1MB)
WORKDIR /app
COPY --from=backend /paliad /app/paliad
COPY --from=frontend /app/frontend/dist /app/dist
EXPOSE 8080
CMD ["/app/paliad"]
```
Image-size delta: alpine `openssh-client` is ~1.1 MB compressed — negligible. No tailscaled, no entrypoint script, no extra processes inside the container.
### 4.4 What does NOT change
- No Tailscale auth-key inside paliad. The container inherits the host's tailnet binding, so there is no per-container Tailscale identity to rotate. mLake's existing Tailscale auth is the only one in scope.
- No tailscaled process inside the container.
- No new sidecar container.
### 4.5 Bypassing Tailscale SSH via port 22022 (Phase A discovery)
**Phase A revealed** that Tailscale SSH on mRiver intercepts `:22` from tailnet peers before OpenSSH sees the connection. The SSH banner reads `SSH-2.0-Tailscale`, the verbose log shows `Authenticated using "none"`, and the `authorized_keys command=` directive is therefore inert. mRiver's `tailscale status --json` confirms the `https://tailscale.com/cap/ssh` capability is enabled.
The fix: a separate listening port for the paliad route, where Tailscale SSH does not intercept and real OpenSSH handles auth.
mRiver uses systemd socket activation for sshd (`/usr/lib/systemd/system/ssh.socket` binds `:22`). Setting `Port 22022` in `sshd_config` is **ignored** under socket activation — listen ports come from the socket unit, not sshd's own config. The correct change is a drop-in:
```ini
# /etc/systemd/system/ssh.socket.d/paliad.conf
[Socket]
ListenStream=0.0.0.0:22022
ListenStream=[::]:22022
```
Followed by `systemctl daemon-reload && systemctl restart ssh.socket`. Both `:22` (still routed through Tailscale SSH for m's interactive use) and `:22022` (real OpenSSH) end up listening. The same sshd binary handles both — same host key, same `authorized_keys`, same sshd_config. The only difference is *which port* a peer dials.
A failed first attempt (2026-05-07 23:07) added the drop-in while a stale `Port 22022` directive in `sshd_config.d/99-paliad-test.conf` was still bound — the resulting `Address already in use` took `ssh.socket` down for ~30 s until reverted. Lesson: clean any prior `Port` directives out of `sshd_config.d/*.conf` before retrying the socket drop-in.
Phase A end-to-end test (2026-05-07 23:31) succeeded with port 22022:
- `ssh -p 22022 -i paliad-prod-key m@100.99.98.203 health``ok`
- `run-turn <uuid> <base64-msg>` → 3.4 s round-trip including a Claude-Code response
- `from="100.99.98.201"` correctly rejected a connection sourced from mRiver itself (`Permission denied (publickey,password)`)
---
## 5. Sub-design B — SSH identity, restricted shim, host-key pinning
### 5.1 Identity: dedicated ed25519 keypair `paliad-prod`
One keypair, generated once on mRiver during Phase A, used by every paliad-prod deploy:
```bash
# On mRiver (Phase A bootstrap):
ssh-keygen -t ed25519 -N "" -C "paliad-prod $(date +%Y-%m-%d)" -f /tmp/paliad-prod-key
# Public key → mRiver authorized_keys (see 5.2)
# Private key → Dokploy secret store as PALIADIN_SSH_PRIVATE_KEY
shred -u /tmp/paliad-prod-key # only the encrypted/secret-stored copies survive
```
Rotation: regenerate, push public key to mRiver authorized_keys, update Dokploy secret, redeploy. No code change needed — paliad's startup re-reads the env var on every boot.
The private key is delivered to the container as a multi-line env var. At process start, paliad writes it to a tmpfile so OpenSSH can use it:
```go
// cmd/server/main.go (sketch)
func loadPaliadinSSHKey() (string, error) {
blob := os.Getenv("PALIADIN_SSH_PRIVATE_KEY")
if blob == "" { return "", nil } // remote mode disabled
f, err := os.CreateTemp("", "paliadin-id_ed25519-")
if err != nil { return "", err }
if err := os.Chmod(f.Name(), 0o600); err != nil { return "", err }
if _, err := f.WriteString(blob); err != nil { return "", err }
if err := f.Close(); err != nil { return "", err }
return f.Name(), nil // path passed to RemotePaliadinService
}
```
The tmpfile lives at `/tmp/paliadin-id_ed25519-<rand>` for the container's lifetime. On container restart, a fresh tmpfile is written. We never persist the key to a volume.
### 5.2 mRiver `authorized_keys` entry
```
command="/home/m/.local/bin/paliadin-shim",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-user-rc,from="100.99.98.201" ssh-ed25519 AAAA...PUBKEY... paliad-prod
```
Each restriction matters:
- `command=` — every `ssh m@mriver …` invocation runs the shim regardless of what the client asked for. The client's requested command is exposed as `$SSH_ORIGINAL_COMMAND` for the shim to dispatch on.
- `no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-user-rc` — defence-in-depth: even if someone steals the key and bypasses the shim's argument validation, they can't get an interactive shell, can't tunnel ports, can't pivot via agent forwarding.
- `from="100.99.98.201"` — only accept connections from mLake's tailnet IP. Defends against the "container has full tailnet visibility" host-mode side-effect from §4.2: if the key leaks off mLake, it can't be replayed from another tailnet host.
### 5.3 Host-key pinning
`StrictHostKeyChecking=accept-new` is too loose for a long-lived production identity (one-time MITM during first connect substitutes a different key forever). Instead:
- During Phase A, run `ssh-keyscan -p 22022 -t ed25519 100.99.98.203` on mLake.
- Capture the single output line. The host-key portion is identical to the `:22` entry — same sshd, same keys — but the `[100.99.98.203]:22022` prefix matters because OpenSSH's `known_hosts` is `host:port`-keyed for non-22 ports.
- Store as Dokploy secret `PALIADIN_KNOWN_HOSTS`.
- At container startup, write to `/tmp/paliadin-known_hosts` chmod 644.
- Pass to OpenSSH via `-o UserKnownHostsFile=/tmp/paliadin-known_hosts -o StrictHostKeyChecking=yes`.
If mRiver's host key ever rotates (rare; only on disk wipe / fresh OS), Phase A runs again and the secret is updated. SSH refuses to connect with a clear "host key changed" error, which surfaces as `mriver_unreachable` to the user — exactly the right blast-radius (loud failure, no silent connect to a substitute host).
### 5.4 The shim — `paliadin-shim`
A bash script on mRiver at `/home/m/.local/bin/paliadin-shim`. It is the **only** thing the paliad-prod key is allowed to invoke, and it dispatches on `$SSH_ORIGINAL_COMMAND`. Three RPCs:
```bash
#!/bin/bash
# paliadin-shim — server-side RPC for paliad's remote-tmux turns.
# Invoked via authorized_keys command= with $SSH_ORIGINAL_COMMAND set.
set -euo pipefail
umask 077
readonly TMUX_SESSION="${PALIADIN_TMUX_SESSION:-paliad-paliadin}"
readonly RESPONSE_DIR="${PALIADIN_RESPONSE_DIR:-/tmp/paliadin}"
readonly TIMEOUT_S=60
readonly TURN_ID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
mkdir -p "$RESPONSE_DIR"
# Parse $SSH_ORIGINAL_COMMAND. Format: "<verb> <arg1> <arg2> …"
read -r -a argv <<< "${SSH_ORIGINAL_COMMAND:-}"
verb="${argv[0]:-}"
ensure_pane() {
if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
tmux new-session -d -s "$TMUX_SESSION"
fi
# Find or create the @paliadin-scope=chat window.
local target=""
while read -r idx; do
scope=$(tmux show-window-option -t "$TMUX_SESSION:$idx" -v @paliadin-scope 2>/dev/null || true)
if [[ "$scope" == "chat" ]]; then target="$TMUX_SESSION:$idx"; break; fi
done < <(tmux list-windows -t "$TMUX_SESSION" -F '#{window_index}')
if [[ -z "$target" ]]; then
idx=$(tmux new-window -t "$TMUX_SESSION" -n claude-paliadin -P -F '#{window_index}' claude)
target="$TMUX_SESSION:$idx"
# Wait for claude to settle (60s bound; matches Go waitForPaneReady).
for _ in $(seq 1 120); do
pane=$(tmux capture-pane -t "$target" -p 2>/dev/null || true)
if [[ "$pane" == *""* || "$pane" == *"│"* ]]; then break; fi
sleep 0.5
done
tmux set-window-option -t "$target" @paliadin-scope chat
tmux set-window-option -t "$target" @fix-name claude-paliadin
# Bootstrap system prompt — reuses the Go service's prompt text.
# The Go side sends this via the `bootstrap` RPC on first turn instead
# of duplicating the prompt here. See §6.4.
fi
echo "$target"
}
case "$verb" in
health)
# Liveness check — used by paliad to short-circuit when mRiver is offline.
# Returns "ok" iff tmux + claude are reachable.
tmux has-session -t "$TMUX_SESSION" 2>/dev/null \
|| tmux new-session -d -s "$TMUX_SESSION"
command -v claude >/dev/null && echo ok || { echo no-claude; exit 1; }
;;
bootstrap)
# First-turn-only: ensure pane exists and inject the system prompt.
# $1 = base64-encoded prompt body (avoids quoting hell).
target=$(ensure_pane)
prompt=$(printf '%s' "${argv[1]:?missing prompt}" | base64 -d)
tmux send-keys -t "$target" -l -- "$prompt"
tmux send-keys -t "$target" Enter
sleep 2 # give claude a moment to absorb
echo ok
;;
run-turn)
# $1 = turn_id (UUID); $2 = base64-encoded user message.
turn_id="${argv[1]:?missing turn_id}"
[[ "$turn_id" =~ $TURN_ID_RE ]] || { echo >&2 "bad turn_id"; exit 2; }
msg=$(printf '%s' "${argv[2]:?missing message}" | base64 -d)
target=$(ensure_pane)
out="$RESPONSE_DIR/$turn_id.txt"
rm -f "$out"
# Envelope matches what paliadin_prompt.go expects.
tmux send-keys -t "$target" -l -- "[PALIADIN:$turn_id] $msg"
tmux send-keys -t "$target" Enter
# Poll for the response file. Same shape as Go pollForResponse.
for _ in $(seq 1 $((TIMEOUT_S * 5))); do
if [[ -s "$out" ]]; then
sleep 0.05 # settle
cat "$out"
rm -f "$out"
exit 0
fi
sleep 0.2
done
echo >&2 "paliadin: response timeout after ${TIMEOUT_S}s"
exit 124
;;
reset)
# /clear the conversation; next turn starts fresh.
target=$(ensure_pane)
tmux send-keys -t "$target" -l -- "/clear"
tmux send-keys -t "$target" Enter
echo ok
;;
*)
echo >&2 "paliadin-shim: unknown verb '$verb'"
exit 2
;;
esac
```
Why a shim instead of raw tmux-over-SSH:
- One SSH round-trip per turn (~50 ms over tailnet) vs ~1020 round-trips for the granular pattern.
- Argument validation lives in one place (UUID regex on turn_id, base64 for messages, fixed verb list) — easier to audit than a regex over `$SSH_ORIGINAL_COMMAND` matching `tmux send-keys …`.
- mRiver-side concerns (response polling, settle delays, pane-readiness) stay on mRiver, which is where the tmux state lives. The Go service stops caring about local file polling at all.
---
## 6. Sub-design C — Service-layer integration, routing, reliability
### 6.1 Interface split
The current `*PaliadinService` becomes an interface with two implementations: `LocalPaliadinService` (the existing tmux code, renamed) and `RemotePaliadinService` (the new SSH code). Construction picks one at startup based on `PALIADIN_REMOTE_HOST`.
```go
// internal/services/paliadin.go (after refactor)
type Paliadin interface {
RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error)
ResetSession(ctx context.Context) error
ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error)
Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error)
IsOwner(ctx context.Context, userID uuid.UUID) (bool, error)
}
// LocalPaliadinService wraps the current tmux PoC (laptop / dev path).
type LocalPaliadinService struct { /* identical to today's PaliadinService */ }
// RemotePaliadinService talks to a paliadin-shim over SSH on mRiver.
type RemotePaliadinService struct {
db *sqlx.DB
users *UserService
sshHost string // 100.99.98.203
sshPort int // 22022 — bypasses Tailscale SSH on :22 (see §4.5)
sshUser string // m
sshKeyPath string // /tmp/paliadin-id_ed25519-<rand>
knownHosts string // /tmp/paliadin-known_hosts
turnMu sync.Mutex
// Health-check cache.
healthMu sync.Mutex
healthOK bool
healthCheckedAt time.Time
}
```
DB access (`ListRecentTurns`, `Stats`, `IsOwner`) is identical for both — they only read `paliad.paliadin_turns`. They live in a shared `paliadinDB` helper struct embedded in both implementations.
### 6.2 Wiring at startup
```go
// cmd/server/main.go (excerpt)
var paliadin services.Paliadin
remoteHost := os.Getenv("PALIADIN_REMOTE_HOST")
switch {
case remoteHost != "":
keyPath, err := loadPaliadinSSHKey()
if err != nil { log.Fatalf("paliadin: load ssh key: %v", err) }
if keyPath == "" { log.Fatalf("paliadin: PALIADIN_REMOTE_HOST set but no PALIADIN_SSH_PRIVATE_KEY") }
knownHosts, err := loadPaliadinKnownHosts()
if err != nil { log.Fatalf("paliadin: load known_hosts: %v", err) }
port, _ := strconv.Atoi(cmpOr(os.Getenv("PALIADIN_REMOTE_PORT"), "22022"))
paliadin = services.NewRemotePaliadinService(db, userSvc, services.RemotePaliadinConfig{
SSHHost: remoteHost,
SSHPort: port,
SSHUser: cmpOr(os.Getenv("PALIADIN_REMOTE_USER"), "m"),
SSHKeyPath: keyPath,
KnownHostsPath: knownHosts,
})
log.Printf("paliadin: remote mode → ssh %s@%s:%d", "m", remoteHost, port)
case localTmuxAvailable():
paliadin = services.NewLocalPaliadinService(db, userSvc, "", "")
log.Printf("paliadin: local tmux mode")
default:
paliadin = services.NewDisabledPaliadinService(db, userSvc)
log.Printf("paliadin: disabled (no remote host, no local tmux)")
}
```
`NewDisabledPaliadinService` exists today implicitly via the `ErrTmuxUnavailable` path; making it explicit gives the constructor a clear name and the handler doesn't have to special-case `nil`.
### 6.3 SSH invocation pattern
`RemotePaliadinService` runs every RPC through the same helper:
```go
func (s *RemotePaliadinService) callShim(ctx context.Context, args ...string) ([]byte, error) {
sshArgs := []string{
"-F", "/dev/null", // ignore /etc/ssh/ssh_config + ~/.ssh/config
"-i", s.sshKeyPath,
"-p", strconv.Itoa(s.sshPort), // 22022 — bypasses Tailscale SSH on :22
"-o", "IdentitiesOnly=yes", // don't fall back to other keys
"-o", "UserKnownHostsFile=" + s.knownHostsPath,
"-o", "StrictHostKeyChecking=yes",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=3",
"-o", "ServerAliveInterval=10",
"-o", "ServerAliveCountMax=3",
s.sshUser + "@" + s.sshHost,
"--",
}
sshArgs = append(sshArgs, args...)
c, cancel := context.WithTimeout(ctx, 70*time.Second) // shim has its own 60s; +10s for SSH overhead
defer cancel()
cmd := exec.CommandContext(c, "ssh", sshArgs...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout; cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("paliadin: ssh shim %v: %w (stderr: %s)", args, err, stderr.String())
}
return stdout.Bytes(), nil
}
```
`RunTurn` becomes:
```go
func (s *RemotePaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error) {
s.turnMu.Lock()
defer s.turnMu.Unlock()
if err := s.healthGate(ctx); err != nil {
return nil, err // ErrMRiverUnreachable, picked up by handler
}
turnID := uuid.New()
started := time.Now().UTC()
if err := s.insertTurnRow(ctx, ); err != nil { return nil, err }
// First-turn-only: bootstrap the system prompt on mRiver. Detected by
// checking whether any prior turn for this user has succeeded.
if err := s.ensureBootstrapped(ctx); err != nil {
_ = s.markTurnError(ctx, turnID, "bootstrap_failed")
return nil, err
}
msg := sanitiseForTmux(req.UserMessage)
msgB64 := base64.StdEncoding.EncodeToString([]byte(msg))
body, err := s.callShim(ctx, "run-turn", turnID.String(), msgB64)
if err != nil {
_ = s.markTurnError(ctx, turnID, classifySSHError(err))
return nil, err
}
// Same trailer-parse + audit-row writes as Local, factored into shared helper.
return s.completeTurnFromBody(ctx, turnID, started, string(body))
}
```
### 6.4 System prompt bootstrap
The local PoC calls `paliadinSystemPrompt(s.responseDir)` once when it creates the pane. The remote path needs the same hook. Two options that don't require duplicating the German prompt body to mRiver:
- **Lazy bootstrap (chosen):** the first `RunTurn` after a paliad-prod restart sends the system prompt via `bootstrap` RPC, then runs the actual turn. Subsequent turns skip the bootstrap. State is per-process: `RemotePaliadinService.bootstrapped` boolean guarded by mutex.
- Eager bootstrap at startup is rejected — it forces every container start to wait for mRiver to be online, which couples paliad's boot to mRiver's availability.
Lazy bootstrap means the very first turn after a paliad redeploy pays a ~3 s extra cost (claude pane spin-up + system prompt absorb). Acceptable for a single-user PoC.
### 6.5 Health-check gating (`mriver_unreachable`)
Every `RunTurn` first calls `healthGate(ctx)`:
- Cached for 10 s. If last check was <10 s ago and was OK, skip the probe.
- Otherwise: `s.callShim(ctx, "health")` with a 3 s timeout. On success, set cache OK; on failure, return `ErrMRiverUnreachable`.
Why 10 s: short enough that "I just woke my laptop" propagates inside one user retry; long enough that a busy chat doesn't probe on every turn.
```go
var ErrMRiverUnreachable = errors.New("paliadin: mriver unreachable")
func (s *RemotePaliadinService) healthGate(ctx context.Context) error {
s.healthMu.Lock()
defer s.healthMu.Unlock()
if s.healthOK && time.Since(s.healthCheckedAt) < 10*time.Second {
return nil
}
c, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
out, err := s.callShim(c, "health")
s.healthCheckedAt = time.Now()
if err != nil || strings.TrimSpace(string(out)) != "ok" {
s.healthOK = false
return fmt.Errorf("%w: %v", ErrMRiverUnreachable, err)
}
s.healthOK = true
return nil
}
```
### 6.6 Friendly error code (extends t-paliad-150)
`friendlyErrorMessage` already maps `tmux_unavailable` to a localised message. We add one new code:
- `mriver_unreachable` DE: *"mRiver ist offline — Paliadin nicht erreichbar. Mach mRiver an, oder nutze Paliadin lokal mit `./paliad`."* / EN: *"mRiver is offline — Paliadin can't reach it. Wake mRiver, or run Paliadin locally with `./paliad`."*
Implementation: one new `case` in the SSE-error switch in `frontend/src/client/paliadin.ts`'s `friendlyErrorMessage`, plus matching i18n keys (`paliadin.error.mriver_unreachable.de` / `.en`). Server-side: `paliadin` HTTP handler maps `errors.Is(err, services.ErrMRiverUnreachable)` to `event: error\ndata: {"code":"mriver_unreachable","message":"..."}\n\n`.
### 6.7 Rate limit
A runaway loop on the paliad side could DOS the SSH connection. Cheapest cap: enforce one in-flight turn at a time via `turnMu` (already exists in the local PoC). On top of that, a rolling cap of N=20 turns/min in `RemotePaliadinService` rejects with `ErrRateLimited` (mapped to a friendly `paliadin.error.rate_limited`). PoC has one user (m); the cap is a paranoid safety, not a real throttle.
### 6.8 What about ControlMaster?
Decision-2's chosen path (server-side shim with one RPC per turn) makes ControlMaster optional. The shim collapses ~10 raw-tmux ops into a single SSH connect that's already the latency win ControlMaster would buy.
Adding it on top would save ~3050 ms per turn but adds:
- A persistent `~/.ssh/cm-*` socket inside the container.
- Cleanup logic on shutdown.
- A subtle interaction with the SSH BatchMode + ConnectTimeout settings.
Verdict: skip ControlMaster in v1. If turn latency over Tailscale is measured >300 ms in practice and hot enough to matter, add it in a follow-up; the call site is one helper.
---
## 7. Phasing
### Phase A — manual proof-of-concept (no Dockerfile change yet)
Goal: validate the round-trip end-to-end on a deployed paliad, before touching the image.
**Phase A.0 (DONE 2026-05-07 23:31):** SSH+shim end-to-end on the tailnet.
1.**Generate keypair** on mRiver: `ssh-keygen -t ed25519 -N "" -C "paliad-prod" -f ~/.paliad-staging/paliad-prod-key`. Fingerprint `SHA256:5uV8v872F/IhJycjjq0crFue/emAYfw71N9bxTvkl9c`.
2.**Commit shim** to `scripts/paliadin-shim` and **install** at `/home/m/.local/bin/paliadin-shim`, `chmod 755`.
3.**Write authorized_keys** with public key + `command=`/`from="100.99.98.201"`/no-pty/no-port-forwarding/no-agent-forwarding/no-X11-forwarding/no-user-rc restrictions (§5.2).
4.**Add port 22022 socket drop-in** at `/etc/systemd/system/ssh.socket.d/paliad.conf`, `systemctl daemon-reload && systemctl restart ssh.socket`. Both `:22` (Tailscale SSH for m) and `:22022` (real OpenSSH for paliad) listening (§4.5).
5.**Capture mRiver:22022 host key**: `ssh-keyscan -p 22022 -t ed25519 100.99.98.203 > ~/.paliad-staging/known_hosts` from mLake. Fingerprint `SHA256:HPoUzy60Cb8yLERIBQcB2mHihNST3NaTODx5Ypd1XpA`.
6.**Smoke-test from mLake** (without paliad container, just raw ssh from mLake's host shell):
```
ssh -F /dev/null -i /tmp/paliad-prod-key -o UserKnownHostsFile=/tmp/paliad-known_hosts \
-o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes \
-p 22022 m@100.99.98.203 health
→ ok
ssh … run-turn $(uuidgen) "$(printf 'Sag …' | base64 -w0)"
→ "test ok" (3.4 s round-trip including a real Claude response)
```
7. ✅ **from= rejection verified**: the same key from mRiver itself (`100.99.98.203`) → `Permission denied (publickey,password)` as expected.
**Phase A.5 (PENDING m's hands):** validate `network_mode: host` + traefik routing on prod paliad.de.
- Branch the live `docker-compose.yml` on a temp branch.
- Add `network_mode: host` to the `web` service; remove `expose: ["8080"]`.
- Push to trigger a Dokploy redeploy.
- `curl --connect-timeout 5 -sSI https://paliad.de/` — expect 200 (or login redirect), NOT 502.
- If 502: revert the temp branch (`git revert HEAD && git push`); revisit decision 1 in a follow-up issue.
- If 200: keep the host-mode change; ready for Phase B.
This is **m's call to execute** — it briefly touches prod paliad.de. Inventor/coder should not flip prod compose without explicit go-ahead. Rollback is one revert + redeploy.
**Phase A.6 (after A.5 passes):** smoke-test SSH from inside the paliad-prod container itself (the real container, not just the mLake host shell):
```
docker exec -it <paliad-container> sh
apk add --no-cache openssh-client # one-shot, before Dockerfile change
ssh -F /dev/null -i /tmp/paliad-prod-key -o UserKnownHostsFile=/tmp/paliad-known_hosts \
-o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes \
-p 22022 m@100.99.98.203 health
# expected: "ok"
```
This proves the container's host-mode networking actually delivers a tailnet connect.
**Phase A.7:** wire env vars manually via Dokploy UI for one deploy; confirm `/paliadin` chat works against mRiver from paliad.de.
If A.5 fails: the design rolls back to a sidecar in a new issue (decision 1 follow-up). The SSH path (A.0) and traefik path (A.5) are independent — A.0 is already proven; only A.5+ is at risk.
### Phase B — bake into Dockerfile + Dokploy secrets
1. Dockerfile: add `openssh-client` to the final stage (§4.3).
2. compose: add `network_mode: host` and the four new env vars (§4.1).
3. Dokploy secrets: register `PALIADIN_REMOTE_HOST=100.99.98.203`, `PALIADIN_REMOTE_USER=m`, `PALIADIN_SSH_PRIVATE_KEY=...`, `PALIADIN_KNOWN_HOSTS=...`.
4. Code: refactor `PaliadinService` to the interface split (§6.1§6.2). New file `internal/services/paliadin_remote.go`. Tests: `paliadin_remote_test.go` mocks `callShim` to verify `RunTurn` audit-row writes, error mapping, and `healthGate` caching.
5. Ship under one PR; tag t-paliad-151 done.
### Phase C — friendly errors + monitoring
1. `paliadin.error.mriver_unreachable` i18n keys + `friendlyErrorMessage` case (§6.6).
2. `/admin/paliadin` shows last health-probe result + last successful turn timestamp.
3. Optional: `mai-mesh` integration to surface mRiver-offline events to m on Telegram (out-of-band; not gating).
---
## 8. Security review summary
| Risk | Mitigation |
|---|---|
| Stolen private key → arbitrary SSH on mRiver | `command=` shim restriction + `from="100.99.98.201"` + ed25519 key + private key only in Dokploy secret store (encrypted at rest); paliad route uses port 22022 where real OpenSSH enforces all of the above |
| Stolen private key → tailnet-wide SSH from non-mLake host | `from="100.99.98.201"` clause (verified: rejected from mRiver itself in Phase A.0) |
| Tailscale SSH on `:22` bypasses `authorized_keys` | The paliad-prod key's `command=` restriction is not enforced on `:22`. Mitigation: paliad always dials `:22022`, which is real OpenSSH. m's interactive `tailscale ssh m@mriver` on `:22` continues to be governed by Tailscale ACLs, separate from paliad's identity. |
| Container compromise → key extraction | Key written to tmpfile chmod 600, only root inside container can read; alpine container has no shell-on-error trampolines |
| Host-key MITM during connect | Pinned `known_hosts`; `StrictHostKeyChecking=yes` |
| Shim argument injection (e.g. via `run-turn $(rm -rf /)`) | Shim parses positional args from `$SSH_ORIGINAL_COMMAND` via `read -r -a`; never passes args to a subshell `eval`; turn_id validated by UUID regex; message body always base64-decoded into a single shell variable, never re-evaluated |
| Runaway loop → SSH flood | Single-flight `turnMu` + 20/min rolling cap |
| `network_mode: host` widens blast radius | The `command=` + `from=` restrictions on mRiver mean container compromise = "can run shim verbs against mRiver only", not "shell on mRiver" |
| PaliadinOwnerEmail bypass | Unchanged from PoC: gate is in Go (`/paliadin` 404s for any other user). Even if mRiver SSH key leaks, attacker still needs paliad session as `m@hoganlovells.com`. |
---
## 9. Out-of-scope clarifications (for review)
These were called out in the issue but the design intentionally does not solve them, to keep v1 tight. Each is acknowledged so review knows it wasn't an oversight:
- **Wake-on-LAN of mRiver:** out of scope. v1's UX when mRiver is asleep is the friendly error from §6.6. Future work: integrate with `mai-mesh` capability fallback.
- **Multi-host failover:** out of scope. Only mRiver is targeted.
- **Anthropic API fallback when mRiver offline:** out of scope per CLAUDE.md (`ANTHROPIC_API_KEY` reserved for production-v1, unused in PoC).
- **ControlMaster:** v1 ships without; revisit if turn latency >300 ms in practice (§6.8).
---
## 10. File-level deliverables (for the coder shift)
When this design is approved and the coder shift starts, the work splits roughly into:
- `Dockerfile` — `+openssh-client`.
- `docker-compose.yml` — `network_mode: host`, five new env entries (`PALIADIN_REMOTE_HOST`, `PALIADIN_REMOTE_PORT`, `PALIADIN_REMOTE_USER`, `PALIADIN_SSH_PRIVATE_KEY`, `PALIADIN_KNOWN_HOSTS`).
- `internal/services/paliadin.go` — extract `Paliadin` interface; rename existing to `LocalPaliadinService`; pull DB-only methods (`ListRecentTurns`, `Stats`, `IsOwner`) into a shared embedded `paliadinDB` so both implementations get them for free.
- `internal/services/paliadin_remote.go` — new file: `RemotePaliadinService`, `RemotePaliadinConfig` (with `SSHPort`), `callShim`, `healthGate`, `ensureBootstrapped`, `classifySSHError`, `ErrMRiverUnreachable`.
- `internal/services/paliadin_remote_test.go` — unit tests with a mocked `callShim`.
- `cmd/server/main.go` — env-var-based wiring (§6.2), `loadPaliadinSSHKey`, `loadPaliadinKnownHosts`, `PALIADIN_REMOTE_PORT` parse with default `22022`.
- `frontend/src/client/paliadin.ts` — one `case` in `friendlyErrorMessage` for `mriver_unreachable`.
- `frontend/src/i18n.ts` — two new keys (`paliadin.error.mriver_unreachable.de` / `.en`).
- `scripts/paliadin-shim` — server-side script (§5.4); already shipped + installed on mRiver during Phase A.0, not part of any container. Repo location chosen so the security-relevant script is version-controlled.
- `docs/project-status.md` — note Phase 0.5 (PoC) → Phase 0.6 (Tailscale-SSH prod route).
- **mRiver host setup (one-time, already done in Phase A.0):** `/etc/systemd/system/ssh.socket.d/paliad.conf` (port 22022 listen drop-in); `~/.ssh/authorized_keys` (paliad-prod public key with restrictions); `/home/m/.local/bin/paliadin-shim` (executable). These are NOT in the repo because they live on m's laptop; `docs/project-status.md` should reference them.
No DB migrations needed — `paliad.paliadin_turns` schema already covers everything (`error_code` field already accepts free-form codes including `mriver_unreachable`).
---
## 11. Open questions for review
- **Q (m), still open:** Phase A.5 (traefik+host-mode on prod paliad.de) is not yet executed. m drives this; rollback is one revert. Dokploy doc check before flipping is recommended but not blocking.
- **Q (m), resolved 2026-05-07 23:50:** shim location → repo (`scripts/paliadin-shim`, committed in `0248411`). Version-controlled and auditable.
- **Q (m), still open:** `ANTHROPIC_API_KEY` env var reservation in compose comments — keep for production-v1, or strip now? Not blocking either phase; defer.
---
## 12. Phase A.0 completion summary (2026-05-07 23:50)
**Coder shift (noether) executed Phase A.0 in full:**
1. ✅ shim committed at `scripts/paliadin-shim` (commit `0248411`, repo-version-controlled)
2. ✅ shim installed at `/home/m/.local/bin/paliadin-shim` on mRiver
3. ✅ ed25519 keypair `paliad-prod` generated, public-key fingerprint `SHA256:5uV8v872F/IhJycjjq0crFue/emAYfw71N9bxTvkl9c`, private key staged at `~/.paliad-staging/paliad-prod-key` on mRiver (mode 600)
4. ✅ `~/.ssh/authorized_keys` written with `command=`/`from=`/no-pty/no-port-forwarding/no-agent-forwarding/no-X11-forwarding/no-user-rc restrictions
5. ✅ `ssh.socket` drop-in installed at `/etc/systemd/system/ssh.socket.d/paliad.conf`; both `:22` and `:22022` listening
6. ✅ host key for `:22022` captured at `~/.paliad-staging/known_hosts` (fingerprint `SHA256:HPoUzy60Cb8yLERIBQcB2mHihNST3NaTODx5Ypd1XpA`)
7. ✅ end-to-end SSH+shim+Claude run-turn validated from mLake → mRiver:22022 (3.4 s round-trip)
8. ✅ `from="100.99.98.201"` rejection verified
**Three secrets ready for Dokploy registration** (m to copy from `~/.paliad-staging/` on mRiver):
- `PALIADIN_SSH_PRIVATE_KEY` ← `cat ~/.paliad-staging/paliad-prod-key`
- `PALIADIN_KNOWN_HOSTS` ← `cat ~/.paliad-staging/known_hosts`
- `PALIADIN_REMOTE_HOST=100.99.98.203`, `PALIADIN_REMOTE_PORT=22022`, `PALIADIN_REMOTE_USER=m`
**Phase A.5 (traefik+host-mode test) and Phase A.6/A.7 (in-container SSH smoke + paliad/paliadin end-to-end) await m's hands** — they touch prod paliad.de.
**Phase B (Dockerfile + Go interface split + Dokploy secrets) is unblocked from a code perspective** — but should not merge until Phase A.5 confirms the host-mode networking trade-off is acceptable.
---
**Inventor design + coder Phase A.0 complete.** Awaiting m for Phase A.5 traefik validation before the coder writes the Go interface split.

View File

@@ -0,0 +1,607 @@
# Design — Project Timeline / Chart (visualisation layer above SmartTimeline)
**Author:** faraday (inventor)
**Date:** 2026-05-09
**Task:** t-paliad-177
**Issue:** m/paliad#35
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
---
## 0. Premises verified live (before designing)
Before anchoring the design, I checked the live state — CLAUDE.md / memory / issue body can drift, the live system can't.
- **SmartTimeline data substrate is shipped through Slice 4.** `internal/services/projection_service.go:287 (For)` returns `([]TimelineEvent, ProjectionMeta, error)`. The wire envelope (`ResponseEnvelope`) is `{events: TimelineEvent[], lanes: LaneInfo[]}``Lanes` is the load-bearing primitive for parent-node aggregation (one column per direct child case / patent / litigation). `LevelPolicy` already differentiates `self_plus_ccr` (Case) / `child_case` (Patent) / `child_patent` (Litigation) / `child_litigation` (Client). Recent commits 7da8802, 7e57507, 7930ee0 confirm — design merge is on `main` (b4f4b3 baseline as of this branch).
- **Frontend renderer for the SmartTimeline is `frontend/src/client/views/shape-timeline.ts` (960 LoC, hand-rolled DOM via `document.createElement`).** It already implements: vertical flow, parallel-track CSS-grid for CCR (`renderParallelTracks`), lane-strip CSS-grid for parent-node aggregation (`renderLaneStrip`), click-to-anchor inline editor, `[Track ▼]` chip, lane-filter chip multiselect, lookahead toggle. The "horizontal Gantt" mode m's brief asks about does **not** exist.
- **No chart library is in the repo.** `package.json` has only `@types/bun`. No D3, no Chart.js, no Apache ECharts, no plotly, no chartjs-node-canvas. Frontend is hand-rolled DOM/SVG via the custom TSX renderer described in `.claude/CLAUDE.md`. Adding a runtime dep would need m's explicit approval (per global rules).
- **No PDF / image-export pipeline exists either.** `internal/services/caldav_ical.go` generates VCALENDAR strings (BEGIN:VCALENDAR / BEGIN:VEVENT) for CalDAV PUT bodies, but there is no public iCal-feed download endpoint, no headless-browser dep (`chromedp` not in `go.sum`), no Go PDF lib. The only existing `Content-Disposition: attachment` header is in `internal/handlers/files.go` for the Gitea Downloads proxy.
- **Custom Views render shapes are list / cards / calendar.** `internal/services/render_spec.go` declares `RenderShape` = `ShapeList | ShapeCards | ShapeCalendar`. **There is no `ShapeTimeline` registered yet** — t-paliad-169 §8.6 reserved the slot but didn't claim it. A new chart shape would extend this enum and grow `frontend/src/views.tsx` host accordingly.
- **Mobile breakpoints in use today are 640px / 720px / 768px / 1023px** (`frontend/src/styles/global.css`). Lime green primary token is `--color-accent: var(--hlc-lime)` with light/dark variants and a `--color-accent-fg` foreground token. There is `@media print` already in the stylesheet — printing is on the table.
- **Project hierarchy depth in prod = 4 levels, 11 projects total.** A loaded Patent at the upper end has 5 child cases; a hypothetical Client could have 100+ matters. Any chart layout must answer "how does this look on a page with 5 cases × 30 events" and "with 100+ matters" — see §10.
If the live state above contradicts a memory or issue note, the live state wins.
---
## 1. Vision + scope
m's brief (verbatim 2026-05-09 18:32):
> One could chose to show the timeline in one or in separate columns and with different colors even... bigger feature development but ... a project timeline / chart would be nice in general. So we need to make some considerations on how to design one. Another aspect to this is vertical or horizontal... and an export functionality would also be great.
The **Project Timeline / Chart** is the *visualisation layer* above the SmartTimeline data substrate. Where SmartTimeline answers "what is the data", the Chart answers "how does the lawyer want to see it today, on what surface, in what shape, exported to whom".
### What this design covers
| Axis | Choices |
|---|---|
| **Layout direction** | Vertical (today) / Horizontal Gantt-strip / Hybrid |
| **Column model** | Single-column flow / Multi-column (lanes — already in substrate) |
| **Visual customisation** | Color schemes per track / kind / status / party; density modes (compact/standard/spacious); status pill / kind chip / shape variants |
| **Export** | SVG (vector) / PNG (raster) / PDF (browser-print or rasterised) / CSV (data) / JSON (data) / iCal (deadlines+appointments feed) |
| **Surfaces** | Verlauf-tab embed (existing) / `/projects/{id}/chart` standalone full-page / `RenderShape="timeline"` Custom Views |
### What stays
- **`projection_service.go` is the only data source.** No new query path. The chart is a presentation-level concern; data composition is solved.
- **`shape-timeline.ts` (vertical DOM renderer) stays** as the embed default for the Verlauf tab. We add modes alongside it; we don't tear it out.
- **`paliad.deadlines`, `paliad.appointments`, `paliad.project_events`, `paliad.deadline_rules`** schemas — unchanged. Zero migrations in this design.
- **Color tokens (`--color-accent`, `--color-bg-lime-tint`, …)** — anchor every chart palette, light/dark mode + WCAG follow for free.
### Out of scope (v1 of this feature)
- **Cross-matter chart on `/projects` list page** — bundled under the Custom-Views path (§8.3) once `RenderShape="timeline"` lands. Not v1.
- **Live collaborative cursors / annotation pins** — presentation features for a later phase, not for shipping the chart itself.
- **Rich-text editing of chart entries from inside the chart canvas** — clicks deep-link to existing detail pages. Edit-in-place is the SmartTimeline's anchor affordance and stays there.
- **Server-side PDF rendering via headless browser** — adding `chromedp` introduces a Chromium runtime dependency on the Dokploy compose host. Recommend client-side `window.print()` for v1; revisit only if user feedback says "PDFs differ across employees' browsers". See §7.3 for the trade-off in full.
- **Theming UI for end users to pick palettes** — v1 gives a small fixed palette set; a colour-picker is v2 nice-to-have only if real users ask for it.
---
## 2. Renderer choice — SVG for the Gantt mode, DOM for the flow mode
This is the load-bearing call. Five candidates surveyed:
| Renderer | Pros | Cons | Fit |
|---|---|---|---|
| **DOM/CSS grid** (existing) | Accessible by default; themable via CSS vars; free dark-mode + i18n; exportable via `window.print()` | Hard to do continuous date-axis math (Gantt scaling); heavy reflow on resize; html-to-PNG via foreignObject is browser-quirky | Best for vertical flow ✓ |
| **SVG hand-rolled** | Vector by construction → free SVG / PNG export via canvas drawImage; precise positioning math; one paint call; printable | Manual ARIA scaffolding; no automatic text-wrapping; need a layout pass | Best for horizontal Gantt ✓ |
| **`<canvas>`** | Top performance for 1000+ nodes | Zero accessibility; manual hit-testing for clicks; export needs separate path | Overkill for our scale (≤150 nodes typical) ✗ |
| **D3.js** | Battle-tested abstractions for axes / scales | ~250 KB minified, runtime data-driven DOM mutation conflicts with our IIFE-bundle pattern, would need m's package approval | Overkill, runtime cost ✗ |
| **SVG + foreignObject for text** | Vector with native HTML text wrapping | Spotty PDF and Safari support; defeats the export-for-free pitch | Avoid ✗ |
### 2.1 Recommendation
**Two renderers coexist.** Same data, different DOM:
- **`shape-timeline.ts`** (existing DOM/CSS grid, vertical) keeps powering the Verlauf-tab embed — it's small, accessible, themed.
- **`shape-timeline-chart.ts`** (new SVG) powers the standalone `/projects/{id}/chart` page in horizontal Gantt mode. Hand-rolled, no library, ~500 LoC for v1.
The horizontal Gantt page is also where the export buttons live (§7) — exporting a vertical DOM list is "open browser print and cmd-P" already, no new code needed; the Gantt is the genuinely new surface and brings PDF/SVG/PNG with it.
### 2.2 Why hand-rolled SVG over D3
We have ≤150 nodes per project, two axes (date + lane), three primitives (bar, dot, label) and one expanding need (zoom + pan, eventually). D3 ships ~250 KB to give us scales + axis generators + zoom. Our scale is `(date - earliestDate) / dayWidthPx`, a one-liner; our axis is a year/quarter tick generator, ~30 LoC; pan + zoom is `addEventListener("wheel"|"pointermove")`, ~50 LoC. The lift to write it ourselves is real but small, the runtime cost saving is real, and we keep the single-file IIFE bundle pattern intact.
If we ever hit "the layout math is too painful to maintain", D3-only-the-axis-helper or an `axes.ts` module is a refactor we can do then. v1 ships without.
### 2.3 What hand-rolled SVG looks like
One root SVG element, three layered groups:
```
<svg viewBox="0 0 W H">
<defs>
<pattern id="weekend"…/> # weekend background stripe
<linearGradient id="proj"…/> # projected-row gradient
</defs>
<g class="chart-grid"> # lane separators + date-axis ticks + today rule
<g class="chart-bars"> # one rect/g per event
<g class="chart-labels"> # text labels (kind chip, title)
<g class="chart-overlay"> # tooltip + selection scrim
</svg>
```
Coordinates are computed by a `layout(events, lanes, viewport)` pure function — testable, deterministic, the same on screen and on export.
---
## 3. Layout — vertical (existing) + horizontal (new)
### 3.1 Vertical (DOM, existing — no changes)
Embedded on `/projects/{id}` Verlauf tab. Today's `shape-timeline.ts` flow with date column / event card right column, "Heute →" rule, parallel tracks for CCR, lane-strip for parent-node aggregation. Nothing changes in this design — I'm explicit about that so the implementer doesn't accidentally rewrite working code.
### 3.2 Horizontal Gantt-strip (SVG, new)
The `/projects/{id}/chart` page. Time on the X axis, lanes on the Y axis. Each lane is a horizontal row; events plot as either a dot (point-in-time: deadline due-date, milestone, appointment) or a bar (range: future-projected sequence between two anchors, or appointment with end_at). Today's rule = vertical line.
```
←──────── 2026 ────────→ 2027 ─────→
┌────────────────────────────────────────┐
Self │ ✓ ●─────●────────────● ░──░──░──░ │
Hauptverf. │ Klage Antw. HV R29a R29c │
│ ↑Heute │
├────────────────────────────────────────┤
Widerklage │ ⊕──────░───░──░ │
(CCR) │ Filed R29d R32 │
│ │
└────────────────────────────────────────┘
Date axis: Q1 Q2 Q3 Q4 Q1 Q2 Q3
│ │
└ year border └ Today rule (lime)
```
### 3.3 Layout invariants (both modes)
These rules must hold across both renderers — they're the contract that lets us swap modes without surprising the user:
1. **Past = left/below; Future = right/above; Today = lime separator.** Vertical: future at top per existing convention. Horizontal: future on right per Gantt convention. The convention flip is fine because the "today" lime separator orients the user instantly.
2. **One row = one event** in vertical; **one bar/dot = one event** in horizontal. We never group two events into one mark. Lane (column in horizontal, parallel-track-column in vertical) is the only grouping primitive.
3. **`Kind` drives shape / glyph; `Status` drives color saturation; `Track` drives column placement.** This composes orthogonally — see §5.
### 3.4 Hybrid not in v1
A "compact horizontal-strip-on-top + vertical-detail-below" hybrid (think Gmail conversation view but for matters) is a tempting third mode. **Not in v1** — adds a third renderer with no clear user request behind it. Revisit if a partner asks "I want both at once".
### 3.5 Single-column vs multi-column on horizontal
Multi-column = lanes, identical to the substrate's `LaneInfo` already. The horizontal Gantt **always multi-lanes** when there's more than one lane; collapsing all events into one row just to give a "single-column" version produces visual chaos with overlapping bars on the same date. The `[Track ▼]` filter (existing) lets the user collapse to a single track if they want a single-row view. So:
- **Substrate has 1 lane** (Case-level, no CCR): single horizontal row.
- **Substrate has 2+ lanes** (Case + CCR sub-project, OR Patent / Litigation / Client level): horizontal multi-lane Gantt with one row per lane.
This mirrors the lane-mode the vertical renderer already uses (`renderLaneStrip`) — same data shape, different rendering.
---
## 4. Column model — extend `LaneInfo`, no new substrate concept
The substrate already discriminates lanes via `levelPolicy(projectType)` returning `LaneAxis`. The chart inherits that vocabulary for free.
### 4.1 What the chart adds
Two read-only filters at chart mount time, both client-side (no backend changes):
```ts
interface ChartViewState {
layout: "vertical" | "horizontal"; // default "horizontal" on /chart, "vertical" on Verlauf
columns: "auto" | "single" | "lanes"; // "auto" reads lanes.length from substrate
density: "compact" | "standard" | "spacious";
palette: "default" | "high-contrast" | "print" | "kind-coded" | "track-coded";
zoom: number; // px-per-day; default 4
range?: { from: string; to: string }; // ISO; defaults to substrate's earliest..latest+30d
}
```
`columns="auto"` is the default — the substrate decides. `columns="single"` collapses everything into one row (useful when comparing dates across CCR + parent on horizontal). `columns="lanes"` forces lane mode even when only one lane exists (useful for screenshot consistency).
### 4.2 What the chart does not add to the substrate
**No new lane axis.** If the brief later wants "lanes per party" (claimant vs defendant) or "lanes per court country", that becomes a new `LaneAxis` value in `levelPolicy` — substrate work, not chart work. The chart is a render of whatever lanes the substrate produced.
This boundary is important: the chart can be improved / re-skinned / re-renderered without touching the data layer, and substrate improvements (new lane axes, new event kinds) automatically reach both renderers.
---
## 5. Color schemes
The brief asks for *"different colors even"*. Three palette dimensions are useful — and they're orthogonal, so a user picks one at a time.
### 5.1 Palette presets (built-in, fixed)
| Preset | What's color-coded by | Use case |
|---|---|---|
| **`default`** | Lane (`--color-accent` for parent, neutral grey for CCR/parent_context) | Embed in Verlauf, partner glance |
| **`kind-coded`** | Event kind (deadline = blue, appointment = amber, milestone = lime, projected = soft-grey) | "Show me what's a hearing vs a deadline at a glance" |
| **`track-coded`** | Track tag (parent / counterclaim / parent_context — three distinct hues) | CCR-heavy projects where the track is the most important axis |
| **`high-contrast`** | Status only (done = green ✓; open = amber; overdue = red; predicted = light-grey) | Print-friendly, accessibility-first, screenshot for client |
| **`print`** | Black / white / one-stripe-pattern (no color at all) | Faxable, b&w-printable, redactable |
All five palettes are CSS custom-property *swaps* on the chart root — the renderer reads `var(--chart-bar-deadline)`, the palette CSS file defines what each is. No JS branching in the renderer.
### 5.2 Token surface (CSS vars)
```css
.smart-timeline-chart {
--chart-bar-deadline: var(--color-accent);
--chart-bar-appointment: #f5a623;
--chart-bar-milestone: var(--hlc-midnight);
--chart-bar-projected: var(--color-text-subtle);
--chart-bar-overdue: #d62828;
--chart-track-parent: var(--color-accent);
--chart-track-counterclaim: #6e8a8c; /* desaturated teal */
--chart-track-parent-context: var(--color-text-subtle);
--chart-today-rule: var(--color-accent);
--chart-grid-line: var(--color-border);
--chart-bg: var(--color-bg);
--chart-bg-weekend: var(--color-bg-subtle);
}
.smart-timeline-chart[data-palette="kind-coded"] {
/* override --chart-bar-* — track tokens stay neutral so kind dominates */
--chart-track-parent: var(--color-text-subtle);
--chart-track-counterclaim: var(--color-text-subtle);
}
.smart-timeline-chart[data-palette="print"] {
--chart-bar-deadline: #000;
--chart-bar-appointment: #555;
--chart-bar-milestone: #000;
--chart-bar-projected: #aaa;
/* …and so on; the palette is a pure CSS swap */
}
```
### 5.3 Why no per-user color picker in v1
A per-user palette picker is a feature with a long tail (storage in user prefs, defaults vs overrides, migration when palette tokens change names, theme conflicts with light/dark). The fixed-preset surface answers 90 % of "I want different colors" with 10 % of the cost. If real users say "I want my-firm-blue", we add a v2 admin-level palette override (`paliad.firm_palette` row keyed by `FIRM_NAME`).
### 5.4 Light / dark / print
Existing dark-mode flip works automatically — the chart palette tokens *reference* `--color-*` family which is already dark-mode-aware. No extra surface. `@media print` overrides force the `print` palette regardless of the user-selected one — a print-out is always b&w-friendly.
---
## 6. Density + visual variants
### 6.1 Density modes
```ts
type Density = "compact" | "standard" | "spacious";
```
- `compact`: lane height 24px, bar height 12px, label inline-only (no description). Use for "1000-row birds-eye" lane mode.
- `standard` (default): lane height 40px, bar height 20px, label + status pill.
- `spacious`: lane height 64px, bar height 28px, label + pill + description below.
CSS-driven via `[data-density="…"]` on the chart root. The bar & dot SVG geometry is computed from a single `--lane-height` var; switching density is a re-layout pass, not a re-render.
### 6.2 Status / kind / shape variants
The visual encoding stays consistent with `shape-timeline.ts`:
| Kind | Vertical glyph | Horizontal mark |
|---|---|---|
| `deadline` | `…` / `!` (open / overdue) | Filled circle on due date; ring around it for "open" |
| `appointment` | `▢` | Bar from `start_at` to `end_at` (or fixed-width if same-day) |
| `milestone` | `⊕` | Diamond at the date |
| `projected` | `░` | Hatched circle (predicted), dashed-circle (court_set), amber-outlined (predicted_overdue) |
Colour saturation drives `Status` independently: done = full color; open = lighter; predicted = 50% opacity; overdue = red overlay.
The CSS for the vertical mode already has these variants — the SVG mode replicates them via `<circle>` / `<rect>` + `fill` / `stroke-dasharray` attributes. Same visual language across modes is a non-negotiable.
---
## 7. Export pipeline
This is the most-requested part of the brief. Five formats; client-side only (no Go PDF dep, no headless browser).
### 7.1 The five formats
| Format | Content | Path | Why this path |
|---|---|---|---|
| **SVG** | Vector chart as-rendered | Browser: `new XMLSerializer().serializeToString(svgEl)` → Blob → download | Free — SVG IS our render. |
| **PNG** | Raster chart at 2× device pixel ratio | Browser: SVG → `<img>``<canvas>.drawImage``canvas.toBlob()` | One stdlib API call chain. |
| **PDF** | Print-formatted page | `window.print()` with `@media print` stylesheet; user picks "Save as PDF" | Reuses browser's hardened PDF engine — no Go PDF dep, no Chromium pinned to Dokploy. |
| **CSV** | Tabular data, flat | Server: `GET /api/projects/{id}/timeline.csv` → text/csv | Cleanest for "Excel this" use case. |
| **JSON** | Data-as-stored | Server: `GET /api/projects/{id}/timeline?format=json` (existing endpoint, alt content type) | Zero new code beyond a `Content-Disposition: attachment`. |
| **iCal** | Deadlines + appointments as VEVENT | Server: `GET /api/projects/{id}/timeline.ics` reusing `caldav_ical.go` formatter | Lawyers can subscribe in Outlook / Apple Calendar. |
### 7.2 Why client-side for SVG/PNG/PDF, server-side for CSV/JSON/iCal
- **SVG/PNG/PDF need the rendered pixel layout.** Client has it, server doesn't (without a headless browser). Doing it on the client is a 30 LoC flow per format using stdlib browser APIs.
- **CSV/JSON/iCal are pure data.** Server-side they hit the existing `ProjectionService` and stream straight to the client. CSV is `encoding/csv`; JSON is `json.Marshal`; iCal reuses the existing string-builder. Three new handlers, ~120 LoC total.
### 7.3 Why NOT server-side PDF
The clean alternative is "spin up `chromedp` on the Dokploy compose host, render the chart page, return PDF". Trade-off:
- Pro: one canonical PDF render, works the same regardless of user's browser.
- Con: adds a Chromium runtime dep to the paliad Docker image (~150 MB), spins up a child process per export, opens an attack surface (someone exports a hostile SVG → Chromium handles it → CVE), and needs a queue (PDF render is 1-3s; a clicky user can DoS the box).
Browser print, by contrast, is in-process, free, sandboxed, and produces fine-looking PDFs. It loses pixel-perfect cross-browser parity, but lawyers care about content, not subpixel kerning.
**Recommend client-side print for v1.** Revisit if lawyers complain about cross-browser PDF differences. Adding `chromedp` later is a one-PR move; designing it into v1 risks shipping infra weight we may never need.
### 7.4 Print-mode CSS
The PDF path needs a robust `@media print`:
- Fix the chart to fit on landscape A4 (1100 × 760 px viewport).
- Force `palette="print"`.
- Hide chrome (sidebar, footer, header → `.print-hide` class on existing layout).
- Show project metadata (title, parties, court, proceeding type) as a printed header.
- Page-break logic: each lane group fits on one page; if a lane has too many events, split horizontally by year.
This print stylesheet can be extracted as `frontend/src/styles/chart-print.css` so it's auditable separately from the screen styles.
### 7.5 Export menu UI
Single button on the chart page header opens a menu:
```
[ ⤓ Export ▼ ]
├─ SVG (Vektorgrafik)
├─ PNG (Bild, 2× HiDPI)
├─ PDF (Drucken)
├─ ───
├─ CSV (Excel-Tabelle)
├─ JSON (Rohdaten)
└─ iCal (.ics — Outlook / Apple)
```
Translated via existing i18n (`projects.detail.chart.export.*`). One menu, one keyboard shortcut (`Cmd+E` / `Ctrl+E`) opens it.
### 7.6 What's exported in CSV
Flat schema, one row per `TimelineEvent`:
```
project_id,project_title,kind,status,track,lane_id,lane_label,date,
title,description,rule_code,depends_on_rule_code,depends_on_date,
sub_project_id,sub_project_title,bubble_up,deadline_id,appointment_id,
project_event_id
```
Columns mirror the wire `TimelineEvent` struct. UTF-8 with BOM (Excel-DE compat). Date format ISO-8601.
### 7.7 What's exported in JSON
The wire `ResponseEnvelope` directly: `{events: TimelineEvent[], lanes: LaneInfo[], meta: ProjectionMeta, exported_at, exported_by, project_id}`. Stable JSON schema; `meta` lets a future re-importer reconstruct the projection state exactly.
### 7.8 What's exported in iCal
Only `kind IN ("deadline", "appointment")` (projected rows are not stable enough to commit to a calendar). VEVENT block per row reuses `caldav_ical.go` formatter; UID is `paliad-deadline-<id>@paliad.de` so re-export overwrites prior subscription. Future projected rows omitted by design — they would clutter every lawyer's Outlook with rule_code-derived events that may or may not fire on the predicted date.
---
## 8. Surfaces — three places the chart shows up
### 8.1 Verlauf tab embed (`/projects/{id}` — existing)
Vertical DOM mode only (existing `shape-timeline.ts`). Density `standard`. Palette `default`. Lane count obeys substrate. **No changes** in this design — the embed stays exactly as it is. The chart-mode opt-in lives below the tab.
A new "**Als Chart anzeigen ↗**" link in the SmartTimeline header opens `/projects/{id}/chart` in a new tab. Optionally (Q3 below) we could host a chart inline with a `[Layout: ▽ Vertikal | ▷ Horizontal]` toggle.
### 8.2 Standalone `/projects/{id}/chart` (new)
Full-page surface optimized for the horizontal SVG renderer. Layout:
```
┌───────────────────────────────────────────────────────────────────────┐
│ Siemens AG ./. Huawei — EP3456789 — UPC-CFI München │
│ Verfahrenstyp: UPC-Verletzung Anker: Klageschrift @ 2026-04-29 │
│ │
│ [Layout ▷] [Spalten Auto] [Dichte Standard] [Palette Default] [Export ⤓]│
├───────────────────────────────────────────────────────────────────────┤
│ ━━━━ FilterBar (existing primitive) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
├───────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──── Horizontal SVG chart (full bleed) ───┐ │
│ │ │ │
│ │ ←─── 2026 ────→ 2027 ────→ │ │
│ │ Self ●─●───●──── ░──░──░ │ │
│ │ CCR ⊕────░───░──░ │ │
│ │ │ │
│ └──────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────────┘
```
URL convention: `/projects/{id}/chart?layout=horizontal&palette=default&density=standard&zoom=4`. State persists in URL so the link is shareable and copy-pasteable. localStorage caches the last chosen state per user as the default.
### 8.3 Custom Views shape (`shape="timeline"`)
Registers `ShapeTimeline RenderShape = "timeline"` in `internal/services/render_spec.go` and adds a corresponding `frontend/src/client/views/shape-timeline-chart.ts` view-host wrapper that adapts a `ViewRow[]``TimelineEvent[]` array. This unlocks **cross-project timelines** as a Custom View — "all my UPC matters" or "everything where I'm in the team" rendered as one chart.
ViewRow → TimelineEvent is a lossy shim: `kind` and `track` map directly; `date` reuses `event_date`; cross-project lanes are auto-derived from `project_id`. Projected rows are not surfaced from `ViewService` (it doesn't run the calculator) — Custom Views show actuals only. We document that limitation, ship the shape, and revisit later if needed.
This is **§8.3's gating**: the standalone page (§8.2) and embed (§8.1) ship before the Custom Views shape. The shape is Slice 4 — last, optional, lower-priority.
---
## 9. Mobile behaviour
Three breakpoints, one rule:
| Width | Vertical embed | Standalone chart |
|---|---|---|
| ≥1024 px (desktop) | Existing | Horizontal SVG, full-bleed |
| 6401023 px (tablet) | Existing | Horizontal SVG, narrower viewport, density auto-switches to compact |
| <640 px (phone) | Existing | **Force vertical** horizontal Gantt on phone is unreadable |
The "force vertical on phone" rule is enforced server-side via the Accept-CH `Sec-CH-UA-Mobile` header (defensive) and client-side via `window.matchMedia("(max-width: 640px)")`. The user can override but the default flips.
A horizontal-on-phone variant with `overflow-x: scroll` is technically possible but UX-poor date axis disappears off-screen, lawyer can't see context. Force vertical, force collapsing of lanes into stacked sections, keep the export menu reachable.
---
## 10. Performance
### 10.1 Current numbers
- Patent (5 child cases × 30 events) = 150 nodes typical
- Client (100+ matters) = 100s of lane rows; aggregation already sub-filters to milestones-only at Client level <500 nodes
- Backend projection cost: ~285 ms cold cache for one project (per t-paliad-169 §13). Backend is not the bottleneck.
### 10.2 Where each renderer caps
| Renderer | Comfortable | Stressed | Breaks |
|---|---|---|---|
| DOM grid (vertical) | 300 nodes | 300-1000 (sluggish reflow) | 1000+ (frame drops on scroll) |
| Hand-rolled SVG | 1000 nodes | 1000-3000 (slow zoom / pan) | 3000+ (paint cost) |
| Canvas (not chosen) | 10 000 nodes | | |
We're sitting in the **comfortable band for both** for any plausible Paliad project. Numbers above 1000 happen only in pathological "show all my Client's matters" scenarios and those are bound by levelPolicy aggregation already (Client-level Custom Views).
### 10.3 Mitigations if a real project exceeds the comfort zone
- **Lookahead cap** (existing): `?lookahead=N` keeps projected nodes capped at 7 by default (50 max). Future-only, doesn't help if there are 1000 actuals.
- **Date-range filter**: chart shows only events in a date window (defaults `earliest..latest+30d` no implicit cap). For pathological cases, user can narrow the range.
- **Lane filter** (existing): hide / dim selected lanes on multi-lane render.
If a single matter genuinely has 1000+ actuals, the user has a deeper data-discipline problem and the right answer is to escalate, not to optimize a chart for it.
### 10.4 SVG paint budget
A 200-event chart in horizontal mode is ~600 SVG primitives (200 bars/dots × 3 elements: shape + label + tooltip-trigger). One initial paint = <50 ms on a low-end laptop. Subsequent zoom / pan re-runs the layout fn (10 ms) and re-attributes existing nodes (no re-create) fast. We do not need virtualization in v1.
---
## 11. Phasing — 4 sequential slices
Each slice independently shippable. m's go/no-go gate after each.
### Slice 1 — Standalone `/projects/{id}/chart` page + horizontal SVG renderer (no exports yet)
What lands:
- New page route `GET /projects/{id}/chart` (handler `internal/handlers/chart_pages.go`, ~50 LoC). Reuses existing project gate.
- New `frontend/src/projects-chart.tsx` page TSX (renders shell + mount target). ~100 LoC.
- New `frontend/src/client/views/shape-timeline-chart.ts` SVG renderer (~500 LoC). Pure-function `layout(events, lanes, viewport)` + `paint(layout, palette, root)`.
- Reuses the existing `GET /api/projects/{id}/timeline` endpoint no backend change.
- Mode toggle on Verlauf tab: `[Als Chart anzeigen ↗]` link opens `/chart`.
- Default palette + standard density + auto columns. **No** export, **no** palette picker, **no** density picker yet controls render as inert chips.
What it gives m: the horizontal Gantt rendering, end-to-end. Lawyer can open `/chart`, see the matter in horizontal layout, share the URL.
### Slice 2 — Export pipeline (SVG / PNG / PDF / CSV / JSON / iCal)
What lands:
- Client-side: `frontend/src/client/views/chart-export.ts` (~150 LoC) handling SVG PNG conversion, PDF print invocation, blob downloads. Three new i18n keys per format.
- Server-side: `internal/handlers/projection.go` gains 3 new handlers `handleProjectTimelineCSV`, `handleProjectTimelineJSON` (alt `?format=json` on existing), `handleProjectTimelineICS`. Each ~30 LoC.
- New `frontend/src/styles/chart-print.css` for `@media print` and palette swap.
- Export menu UI on chart page header.
What it gives m: every export format the brief asked for, no infra additions, lawyer-shareable PDFs.
### Slice 3 — Density + palette + zoom controls
What lands:
- Density toggle (`compact / standard / spacious`) pure CSS-var + `[data-density]` attr swap, no re-fetch.
- Palette picker (`default / kind-coded / track-coded / high-contrast / print`) same pattern.
- Zoom in / out controls + pan (mousewheel + drag).
- Date-range narrower (FilterBar `time` axis already exists wire it to chart viewport).
- localStorage persistence per-user-per-project.
What it gives m: full visual customisation per the brief.
### Slice 4 — Custom Views integration (`shape="timeline"`)
What lands:
- Register `ShapeTimeline RenderShape = "timeline"` in `internal/services/render_spec.go` + validator.
- New `frontend/src/client/views/shape-timeline-cv.ts` view-host adapter. Reuses Slice 1's renderer; adapts `ViewRow[]` to `TimelineEvent[]`.
- `frontend/src/views.tsx` shape-switcher gets the 4th button.
- Documented limitation: projected rows not surfaced in Custom Views.
What it gives m: "all my UPC matters as one chart" via Custom Views cross-project chart on the existing CV substrate.
### What's NOT in any slice (v2 nice-to-haves)
- Per-user palette picker beyond fixed presets.
- Server-side PDF render via `chromedp`.
- Live collaborative cursors / annotation pins.
- Animation / transitions when zoom changes.
- Hybrid layouts (compact-strip + detail-list).
- Color-coding with custom user-defined rules.
---
## 12. Files implementer will touch (Slice 1 only)
**Backend (Go):**
- `internal/handlers/chart_pages.go` new, ~50 LoC. `handleProjectChartPage(w, r)` returns the rendered TSX shell. Auth + project visibility gates as on `/projects/{id}`.
- `internal/handlers/handlers.go` register `GET /projects/{id}/chart`.
**Frontend (TS / TSX):**
- `frontend/src/projects-chart.tsx` new, ~100 LoC. Page shell with mount target + page-level controls scaffold (chips inert in Slice 1).
- `frontend/src/client/views/shape-timeline-chart.ts` new, ~500 LoC. SVG renderer:
- `layout(events: TimelineEvent[], lanes: LaneInfo[], viewport: Viewport): ChartLayout` pure function returning bar/dot positions + axis ticks + today-rule x.
- `paint(layout: ChartLayout, palette: Palette, root: SVGSVGElement): void` DOM-mutates the root.
- `mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle` composes layout + paint + interaction (click deep-link, hover tooltip).
- `frontend/src/client/projects-chart.ts` new, ~150 LoC. Page boot: fetch `/api/projects/{id}/timeline`, mount renderer, wire URL state control chips (inert), wire SmartTimeline embed's `[Als Chart anzeigen ↗]` link from `frontend/src/client/projects-detail.ts`.
- `frontend/src/styles/global.css` `.smart-timeline-chart-*` CSS additions, ~120 LoC. Including the palette token swap CSS but not yet wired to a picker.
- `frontend/src/client/i18n.ts` ~25 keys under `projects.detail.chart.*` (page title, control labels, default-palette-name, etc.) DE+EN.
- `frontend/build.ts` register the new page bundle.
**Tests:**
- `frontend/src/client/views/shape-timeline-chart.test.ts` new, pure-function tests for `layout()` (ranges, tick generation, lane stacking, today-rule positioning, undated-row handling).
Slices 2-4 are scoped in §11; coder picks them up after m's gate.
---
## 13. Trade-offs flagged
- **SVG accessibility.** Hand-rolled SVG needs explicit ARIA scaffolding (`role="img"` + `<title>` + `<desc>` per group, `aria-label` per event mark) to be screen-reader-readable. This is real implementation work DOM mode gets it for free. Mitigation: lockdown `role` and label conventions in the renderer and test with VoiceOver / NVDA before Slice 1 merges.
- **Print-CSS quirks.** `window.print()` PDFs will look slightly different across Chrome / Safari / Firefox. Lawyers comparing two exports may notice. Mitigation: documentation states "use Chrome for archival exports". Pursue chromedp only if real complaints surface.
- **No virtualization in v1.** A 1000-event chart is not virtualized every node is in the DOM/SVG tree. Mitigation: existing levelPolicy aggregation + lookahead caps keep node counts bounded for plausible projects. Add virtualization only if a real project exceeds the comfort band.
- **Two renderers means two paths to maintain.** A bug in vertical-mode rendering doesn't auto-fix the horizontal mode. Mitigation: both render the **same** `TimelineEvent` / `LaneInfo` data; the discriminator is just the layout fn. Rendering bugs tend to be in shared event-mark visual tokens (color, status pill) which CSS-token-swap centralizes anyway.
- **Custom Views adapter is lossy.** Cross-project chart in CV doesn't show projected rows. Some users might expect them. Mitigation: in-page tooltip on first CV-chart open: "Custom Views show actual events only. Open the project's `/chart` for projected rules." A future v2 could push the projection through ViewService but the substrate redesign is non-trivial.
- **Date-range default.** Defaulting to `earliest_event..latest_event+30d` means a matter with one ancient deadline forces the whole span on every render. Mitigation: clamp default range to `today-1y..today+1y`, with a chip for "Alles anzeigen" to expand. Keeps the typical render compact.
- **`/chart` URL collision.** `/projects/{id}/chart` doesn't conflict with any existing route, but adding `/chart` at the project level forces the route table to stay tidy. Defensive: implementer greps `internal/handlers/handlers.go` before adding to confirm no collision.
- **Browser-print PDF on Safari shows the menu bar.** Cosmetic; print stylesheet's `@page` directive helps, but Safari ignores some rules. Mitigation: documentation; lawyer-facing exports recommend Chrome.
---
## 14. Open questions for m
Listed with my (inventor) pick where I have one m decides.
**Q1 — Default landing on `/projects/{id}/chart`: horizontal Gantt or vertical (with a toggle)?**
My pick: horizontal Gantt as the default. The whole reason `/chart` exists is the horizontal mode; defaulting to vertical would make it a duplicate of Verlauf. Add a `[Layout ▷|▽]` toggle for users who want vertical-on-bigscreen.
**Q2 — Should the chart page replace Verlauf when accessed at desktop width, or stay a separate URL?**
My pick: separate URL. Verlauf is the "scan & action" tab (click rows to mark deadlines done, add notes). Chart is the "share & overview" surface. Conflating them risks losing the inline-action affordance Verlauf was built for.
**Q3 — Should the chart be embeddable inside the Verlauf tab (with a layout toggle), or only standalone?**
My pick: standalone in Slice 1; if user feedback says "I want to see horizontal on the project page directly", add the embed in a follow-up slice. Embedding doubles render cost on every project page open and creates layout pressure on the existing tab UI.
**Q4 — Chromedp / server-side PDF: rule out for v1, or design in?**
My pick: rule out. Browser-print PDFs are good enough; Chromium-on-Dokploy is a heavy dep. Keep the door open by abstracting the export-button handler so a future server-side path is a one-route addition.
**Q5 — Color palette presets: ship the full 5 in Slice 3, or just `default` + `print` for safety?**
My pick: ship all 5. The palette mechanism is just CSS-var swaps; adding the other three is hours of design polish, not weeks of work. More options give more lawyers their preferred read.
**Q6 — iCal export: only deadlines + appointments (recommendation), or include projected too?**
My pick: only deadlines + appointments. Subscribing to a calendar that fills with rule_code-derived predicted dates that never fire would erode trust. Future projected = visualisation only, never calendar artifacts.
**Q7 — Custom Views integration (`shape="timeline"`): Slice 4 priority, or descope?**
My pick: keep as Slice 4 but explicit go/no-go after Slice 3 ships. The cross-project chart is a *cool* demo but not in the original brief descoping if real users haven't asked is fine.
**Q8 — Date-range default on `/chart`: data-driven (`earliest..latest+30d`) or fixed (`today-1y..today+1y`)?**
My pick: fixed `today-1y..today+1y`, with a chip "Alles anzeigen" expanding. Old matters with one historical deadline shouldn't force a 5-year span on first render.
**Q9 — Should the chart support project comparison (chart 2-3 projects side-by-side)?**
My pick: no out of scope for this feature. That's a Custom Views job (multi-project query chart shape), not a per-project surface concern.
**Q10 — Should we expose a permalink that captures *zoom + range + palette + density + lane-filter*?**
My pick: yes, via URL query params (already designed in §8.2). Sharing a chart-URL via WhatsApp / email then renders the same view for the recipient.
**Q11 — Mobile: vertical-only fallback, or horizontal-with-scroll?**
My pick: vertical-only on phones (<640px). Horizontal-with-scroll loses the date axis off-screen. Tablet (640-1023px) keeps horizontal in compact density.
**Q12 — On the SmartTimeline (Verlauf embed), do we also add an inline horizontal mode (Q3 follow-up)?**
My pick: NO in v1. The standalone `/chart` is the new surface; Verlauf stays vertical. Adding both modes inline-Verlauf doubles the test matrix without clear user demand yet.
---
## 15. Recommendation for implementer
Pattern-fluent Sonnet coder. Slice 1 is the heaviest (new SVG renderer, new page, new TSX shell). Slice 2 needs careful CSS print-mode tuning best paired with browser-screenshot iteration. Slice 3 is mostly CSS-token plumbing + UI controls. Slice 4 is the lightest if Slice 1 left the renderer well-decomposed.
Before Slice 1, the coder should sketch the `layout(events, lanes, viewport)` function on paper / a tests file that's where the math lives, and getting it right deterministically is the difference between "works" and "subtle render glitches in obscure date ranges". Pure-function with table-driven tests for `layout()` is the correct approach.
Faraday (this worktree) parks. Not pre-emptively flipping to coder m gates.
---
**DESIGN READY FOR REVIEW**

View File

@@ -0,0 +1,739 @@
# Design — SmartTimeline (Verlauf-tab redesign)
**Author:** lagrange (inventor)
**Date:** 2026-05-08
**Task:** t-paliad-169
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
---
## 0. Premises verified live (before designing)
Before anchoring the design, I checked the live state — CLAUDE.md / memory / issue body can drift, the live system can't.
- **Verlauf today** is `frontend/src/projects-detail.tsx:74-101``<ul className="entity-events" id="project-events-list">` rendered from `paliad.project_events` via `loadEvents(id)` at `client/projects-detail.ts:305`. Pure audit log: `event_type` distribution in prod is 100 % administrative — `deadline_completed/updated/created/...`, `note_created`, `appointment_*`, `checklist_*`, `project_type_changed`, `our_side_changed`, `deadlines_imported`. No "future-tense" or "off-script" events surface anywhere on the project page today.
- **Projection logic** lives in `internal/services/fristenrechner.go:Calculate(ctx, proceedingCode, triggerDateStr, opts CalcOptions)` returning a `UIResponse{Deadlines []UIDeadline}` keyed by `rule_code`. `CalcOptions.AnchorOverrides map[string]string` lets callers replace any rule's date and downstream rules re-anchor — already the load-bearing primitive for "actual dates anchor downstream projections" (t-paliad-131 Phase A).
- **`paliad.deadline_rules`** carries 172 active rules across 19 fristenrechner proceeding types (UPC×8, DE×5, EPA×2, EP×1, DPMA×3). `condition_flag text[]` already drives counterclaim cross-flows: `with_ccr` enables 7 UPC_INF cross-flow rules (Defence-to-CCR R.29.a, Application to amend R.30.1, Defence to App-to-amend R.32.1, Reply to Defence-to-CCR R.29.d, Rejoinder R.29.e, +2). `with_amend` / `with_cci` work on UPC_REV.
- **`paliad.projects.our_side`** column exists (added in t-paliad-164) but is **null on every live row today**. The CCR perspective-flip the cascade implements via Determinator B1 (t-paliad-167) is not yet exercised by real data.
- **CCR is not a separate project today.** It's a flag (`with_ccr=true`) on a parent UPC_INF project. m's vision asks us to revisit that.
- **FilterBar** (`frontend/src/client/filter-bar/`, riemann's t-paliad-163 Phase 1) ships with axis stubs `deadline_event_type` + `project_event_kind` already wired into `BarState` and `AxisKey` — Phase 2 is supposed to fill them in. The SmartTimeline's facet set is exactly the kind of thing those stubs were left pending for.
- **Project hierarchy in prod** is the canonical 4-level shape: Client (`Siemens AG`) → Litigation (`Siemens ./. Huawei`) → Patent (`EP3456789`) → Case (`UPC-CFI München — Klage Siemens ./. Huawei`). 11 projects total.
- **t-paliad-168 deliverable 3 is dropped** per task brief — there will be no separate Verfahrensablauf-as-its-own-tab on the project page. The wizard's projection logic is the SmartTimeline's future-skeleton feeder.
---
## 1. Vision + scope
m's vision (verbatim 2026-05-08 23:02):
> The Verlauf tab inside the case should hold past + future events. If we know the proceeding type, there is a timeline. We adapt the Verfahrensablauf logic and fix dates for things when they happened. A smart timeline. If a counterclaim is filed, that is also included. Hold it flexible — add events regardless of whether they fit the normal course.
The **SmartTimeline** is one composed view that answers *"what has happened in this matter, what is happening now, and what is on the standard road from here"*. Three time-zones in one widget:
| Zone | What it shows | Data source |
|---|---|---|
| **Past** | Filings, decisions, appointments, audit milestones — all dated, anchored to reality | `paliad.deadlines` (status=`done`) `paliad.appointments` (start_at < today) `paliad.project_events` (selected `timeline_kind`) |
| **Now** | Open deadlines + appointments today | same tables, today-bracket |
| **Future (predicted)** | Standard-course rules from `deadline_rules` projected forward, faded only those without an actual `paliad.deadlines` row yet | `fristenrechner.Calculate` against project's proceeding type + trigger anchor |
| **Future (off-script)** | User-added events that don't fit the standard tree (counterclaim filed, ad-hoc Anhörung, party amendment) | `paliad.deadlines` with `source='off_script'` child counterclaim sub-project's actuals `project_events` with `timeline_kind` |
### What changes
- The `tab=history` panel on `/projects/{id}` becomes a SmartTimeline component that renders all four zones in one column.
- The audit-only Verlauf view does not disappear it survives as a "Audit-Log" sub-toggle inside the SmartTimeline ("Alle Audit-Events anzeigen") and on the existing `/admin/audit-log` page (t-paliad-071).
- The existing FilterBar primitive grows two facets (`timeline_track`, `timeline_status`) and re-uses three (`time`, `personal_only`, `deadline_event_type`).
### What stays
- Step 2 third-card + sidebar entry from t-paliad-168 are unaffected the standalone Verfahrensablauf wizard at `/tools/fristenrechner` remains a knowledge-platform tool.
- `paliad.project_events` keeps its full audit-log role for `/admin/audit-log`.
- `paliad.deadlines` + `paliad.appointments` schemas don't migrate (only one optional column added; details in §2).
- The existing "Inkl. Unterprojekte" toggle on the project page stays the SmartTimeline reads child events through it.
### Out of scope (v1)
- Horizontal-Gantt rendering. We pick a vertical timeline; Gantt is a future shape (t-paliad-144 substrate already supports `shape` switching, so adding a Gantt shape is later, not now).
- Outlook/Exchange sync. CalDAV stays the only sync path.
- Cross-matter timelines (e.g. "everything happening on EP3456789 across Siemens ./. Huawei AND any related opposition"). The patent-level aggregation in §5 is a step in that direction but cross-matter view is a separate task.
- Rendering documents (Schriftsätze) on the timeline. That's the t-paliad-17 Incoming-Submission workflow, separate.
---
## 2. Data model
**Recommendation: virtual view, ONE optional column.** No new top-level table for v1. The four zones above are computed at read time from the existing tables. The single schema change is a nullable `timeline_kind text` column on `paliad.project_events` so a subset of audit rows can opt into surfacing as timeline content.
### 2.1 Why no new `timeline_events` table
A first-instinct design would materialise a new `paliad.timeline_events` table with columns `(project_id, kind, date, title, status, source_track, rule_code?, actual_deadline_id?, …)`. I recommend against it for v1:
1. **Three of the four zones already have authoritative tables.** `paliad.deadlines` is the source-of-truth for legal deadlines (with completion + approval state); `paliad.appointments` for hearings + court dates; `paliad.project_events` for audit. Forcing a copy into `timeline_events` creates a sync problem on every mutation.
2. **The future-projected zone is a function of proceeding-type + trigger date + actual anchors** not stored data. Materialising it would require invalidation on every `paliad.deadlines` change. Cheaper to recompute per request: 19 proceeding types × at most ~15 rules = ~285 ms with cold pg cache, well under the page-render budget. Re-uses the cached `FristenrechnerService` (already memoised per request via service instantiation).
3. **t-paliad-144 set the precedent** that ViewService composes per request without materialising. The SmartTimeline is a project-scoped instance of the same pattern.
If load testing later shows the projection cost matters, we materialise into a `paliad.projected_timeline_cache` table indexed by (project_id, rule_code) but design that when load shows it, not now.
### 2.2 The one column added
```sql
-- migration NNN_project_events_timeline_kind.up.sql
ALTER TABLE paliad.project_events
ADD COLUMN timeline_kind text NULL;
-- nullable + no CHECK — enum lives in code (services/projection_service.go).
-- Value space (v1):
-- 'milestone' — a structural event worth pinning to the timeline
-- (counterclaim_filed, third_party_intervened,
-- party_amendment, our_side_changed, scope_change)
-- 'custom_milestone' — free-text user-added event
-- NULL — audit only (default, all existing rows)
CREATE INDEX project_events_timeline_kind_idx
ON paliad.project_events (project_id, timeline_kind)
WHERE timeline_kind IS NOT NULL;
```
Existing event types stay `NULL` they remain audit-only and don't clutter the timeline. New write paths (counterclaim-link, off-script milestone) set the column on insert.
### 2.3 The discriminated `TimelineEvent` shape
Composed in `internal/services/projection_service.go` (new). One Go struct, one TS mirror. Frontend renders without knowing where each row came from:
```go
type TimelineEvent struct {
Kind string // "deadline" | "appointment" | "milestone" | "projected"
Status string // "done" | "open" | "overdue" | "court_set" | "predicted" | "off_script"
Track string // "parent" | "counterclaim" | "child:<project_id>" | "off_script"
Date *time.Time // nil = undated (court-set + counterclaim-pending)
Title string
Description string
RuleCode string // empty when not deadline-rule-derived
// Provenance — exactly one is non-nil for actual rows; both nil for projected.
DeadlineID *uuid.UUID
AppointmentID *uuid.UUID
ProjectEventID *uuid.UUID
// For projected rows (Kind=="projected") — the rule it came from, for
// the click-to-anchor affordance (§6).
DeadlineRuleID *uuid.UUID
DeadlineRuleParty string // 'claimant' | 'defendant' | 'court' | 'both'
// For child-track rows — the sub-project this event belongs to.
SubProjectID *uuid.UUID
SubProjectTitle string
}
```
### 2.4 Read path
```
GET /api/projects/{id}/timeline?
from=...&to=...&direct_only=true|false&
tracks=parent,counterclaim,...&kinds=deadline,appointment,projected,...
```
The handler:
1. Calls `ProjectionService.For(ctx, projectID, opts)` which:
- Loads the project (proceeding_type_id, our_side, parent chain).
- Loads child counterclaim sub-projects (if any see §4).
- Loads `paliad.deadlines` (project_id IN [self, child counterclaims]) emits Kind=deadline rows.
- Loads `paliad.appointments` (same) emits Kind=appointment rows.
- Loads `paliad.project_events WHERE timeline_kind IS NOT NULL` emits Kind=milestone rows.
- For each (project, child) with a proceeding_type_id, calls `FristenrechnerService.Calculate` with `AnchorOverrides` derived from completed actuals emits Kind=projected rows for any rule that does **not** have a matching `paliad.deadlines.rule_id` row.
- Sorts by Date ASC, undated rows last (with secondary sort on rule sequence_order so undated court-set rows preserve the standard course's order).
Visibility is inherited via existing `visibilityPredicate` on each underlying service no new RLS surface to design.
### 2.5 What does NOT need to change
- `paliad.deadlines` schema unchanged. (The existing `original_due_date`, `source`, and the AnchorOverrides plumbing already cover "actual date anchors downstream", §6.)
- `paliad.appointments` unchanged.
- `paliad.deadline_rules` unchanged. The existing `condition_flag text[]` keeps doing its job.
- `paliad.projects` unchanged. (See §4 for the counterclaim sub-project shape: it uses existing columns.)
---
## 3. UI mockup — three states
The SmartTimeline replaces the current `<ul className="entity-events">` block (~30 lines of TSX) with a vertically-flowing two-column timeline:
- Left column: date (or "Datum offen" placeholder).
- Right column: stacked card per event with a status icon, title, kind chip, and (for actuals) a deep-link to `/deadlines/{id}` etc. Same `.entity-event` row contract as today (cf. CLAUDE.md whole-card click rule), no `::before` overlay.
A horizontal "**Heute →**" rule separates past from future. Past goes below (most-recent first), future above (chronological). Today's events sit on the rule.
### 3.1 State A — empty / no proceeding type set
```
┌──────────────────────────────────────────────────────────────┐
│ SmartTimeline [Filter ▼] [+ Eintrag] │
├──────────────────────────────────────────────────────────────┤
│ │
│ Noch keine Ereignisse erfasst. │
│ │
│ Setze einen Verfahrenstyp im Projekt-Header, um den │
│ Standardverlauf als Vorhersage zu sehen, oder lege │
│ einen Eintrag manuell an. │
│ │
│ [+ Frist anlegen] [+ Termin anlegen] [+ Meilenstein] │
│ │
└──────────────────────────────────────────────────────────────┘
```
The empty state actively guides toward the two unlocks: setting a proceeding type (enables future-projection) or adding manual events (works without one).
### 3.2 State B — UPC_INF, infringement-only
```
┌──────────────────────────────────────────────────────────────┐
│ SmartTimeline Verfahrenstyp: UPC-Verletzung [Filter ▼] │
├──────────────────────────────────────────────────────────────┤
│ Zukunft (vorhergesagt) │
│ ───────────────────────────── │
│ 2027-02-20 ░ Hauptverhandlung │
│ ░ wird vom Gericht bestimmt [Datum setzen] │
│ ─ │
│ 2026-12-02 ░ Duplik (RoP.029.c) [voraussichtlich]│
│ 2026-11-02 ░ Replik (RoP.029.b) [voraussichtlich]│
│ 2026-08-31 ░ Klageerwiderung (RoP.023) [voraussichtlich]│
│ │
│ ━━━━━━━━━━━━━━━━━━━━ Heute (2026-05-08) ━━━━━━━━━━━━━━━━━━━━ │
│ │
│ Vergangenheit │
│ ───────────────────────────── │
│ 2026-04-29 ✓ Klageschrift zugestellt (Anker) │
│ 2026-04-25 ✓ Akte angelegt (Audit) │
└──────────────────────────────────────────────────────────────┘
```
- `░` (faded) = projected, `✓` = done, `!` = overdue (red), `…` = open (amber), `▢` = court-set (dashed border).
- "Datum setzen" on the Hauptverhandlung row is the click-to-anchor affordance 6).
- "voraussichtlich" pill is the projected-status visual; tooltip explains "Anhand des Standardverlaufs aus dem Fristenrechner berechnet".
- Filter chip selector reveals the FilterBar primitive directly above the list (collapsed by default to reduce noise on first load same affordance riemann shipped on /inbox).
### 3.3 State C — UPC_INF + Counterclaim (CCR-Subprojekt)
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ SmartTimeline Verfahrenstyp: UPC-Verletzung [Track ▼ Beide] [Filter ▼] │
├──────────────────────────────────────────────────────────────────────────────┤
│ Verletzung (Klägerseite) ┊ Widerklage (Beklagtenseite, CCR) │
│ ──────────────────────────────────────┊──────────────────────────────────────│
│ Zukunft (vorhergesagt) │
│ 2027-02-20 ░ Hauptverhandlung ┊ │
│ [Datum setzen] ┊ │
│ 2027-01-29 ░ Rejoinder R.29.e ┊ 2026-12-29 ░ Rejoinder R.32.3 │
│ 2026-12-29 ░ Reply to Defence-CCR ┊ │
│ 2026-11-29 ░ Defence to App-amend ┊ 2026-11-29 ░ Reply to Defence-amend│
│ 2026-10-31 ░ Defence to CCR (R.29a)┊ 2026-09-30 ░ Defence to amend │
│ 2026-08-31 ░ Klageerwiderung mit CCR┊ │
│ ┊ │
│ ━━━━━━━━━━━━━━━━━━━━ Heute (2026-05-08) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ ┊ │
│ Vergangenheit ┊ │
│ 2026-04-29 ✓ Klageschrift zugestellt┊ ⊕ Widerklage angekündigt │
│ ┊ (off-script, 2026-05-02) │
│ 2026-04-25 ✓ Akte angelegt ┊ │
└──────────────────────────────────────────────────────────────────────────────┘
```
- Two parallel tracks left is the parent infringement, right is the linked counterclaim sub-project (see §4).
- `[Track ▼]` chip toggles between "Beide" (default when a CCR sub-project exists), "Nur Verletzung", "Nur Widerklage".
- "⊕" marks an off-script milestone (the counterclaim was *announced* before being formally filed a `project_events` row with `timeline_kind='custom_milestone'`).
- Mobile: stacks vertically with collapsible per-track headers.
---
## 4. Counterclaim shape — sub-project, defended
m's framing offered two shapes. Inventor recommendation: **sub-project**. Trade-off explicit.
### 4.1 The choice
| | **Sub-project (recommended)** | **Same-project, parallel proceeding-overlay** |
|---|---|---|
| Project rows | One per proceeding (parent INF + child CCR) | One project, two proceeding-types attached |
| `our_side` flip | Independent on the child (parent: claimant; child: defendant in CCR-on-validity, claimant on CCR-of-infringement) | Needs a "perspective per proceeding" sub-table |
| Determinator routing (t-paliad-167) | Existing child gets its own cascade | Needs proceeding-aware routing inside one project |
| Project tree (t-paliad-149) | Naturally appears as a nested node | Same-row, no tree change |
| Dashboard per-project counts | Each gets its own count | Mixing needs new "by-proceeding" aggregator |
| Visibility / RLS | Inherits `can_see_project` cascade | Same |
| CCR Number from CMS | Stored on child's `case_number` | Stored on parent in a new `case_numbers jsonb` |
| New schema | None (uses existing project + parent_id) | New `project_proceedings` join table |
### 4.2 Why sub-project
- **Cheap.** Zero schema migration. The hierarchy already supports arbitrary nesting (4 types: client / litigation / patent / case but `parent_id` is type-agnostic).
- **Consistent with the data we just built.** t-paliad-164 our_side, t-paliad-149 project tree, t-paliad-167 Determinator cascade, t-paliad-168 deadline-rule jurisdiction defaults all assume "one project = one proceeding perspective". Counterclaim being a sub-project just means we keep that assumption.
- **CCR Number.** The counterclaim has its own CCR number in the UPC CMS which means it is in fact a separate proceeding artifact, not just a phase of the parent. Modeling it as a separate project row with its own `case_number` reflects reality. The "case-complex-wise" closeness m asks about is the parent_id link, not collapsing them into one row.
- **Independent timeline math.** UPC R.49(2) puts CCI / app-to-amend "as part of" Defence to revocation but that just means zero-duration filed-with-parent. The downstream re-anchoring is independent in each tree.
### 4.3 The link
A new optional FK on `paliad.projects`:
```sql
-- migration NNN_projects_counterclaim_of.up.sql
ALTER TABLE paliad.projects
ADD COLUMN counterclaim_of uuid NULL
REFERENCES paliad.projects(id) ON DELETE SET NULL;
CREATE INDEX projects_counterclaim_of_idx
ON paliad.projects (counterclaim_of)
WHERE counterclaim_of IS NOT NULL;
-- A project can be EITHER a parent (counterclaim_of IS NULL) OR a
-- counterclaim against another project (counterclaim_of points at it),
-- but not both. Enforced by a CHECK on the union of FKs (see §10).
```
`parent_id` keeps the standard hierarchy (the counterclaim child still lives under the same patent / litigation tree). `counterclaim_of` is an *additional* relation expressing "this project is the CCR against project X". The two are both set on a counterclaim sub-project.
### 4.4 Creating a counterclaim from the timeline
The "+ Eintrag" button on the parent's SmartTimeline opens a typed-add modal 7). Picking type=`Counterclaim` (UPC) creates a child project with:
- `parent_id` = parent's parent (so CCR appears as a sibling under the patent, not a grandchild debatable; see §11 Q4).
- `counterclaim_of` = parent project id.
- `proceeding_type_id` = `UPC_REV` (CCR-on-validity is the standard case; UPC_CCI is the rarer R.49.2.b path).
- `our_side` = inverted from parent (parent claimant child defendant, parent defendant child claimant).
- `title` = `<patent> — Widerklage (CCR)` auto-suggested.
The same flow applies to `case_amend` (UPC R.30 application to amend) a separate child sub-project. *Whether to model R.30 as a child project or as a flag on the parent is open: amendments are usually just a flag in our existing model. Default v1 = stay as flag, do **not** create a sub-project for application-to-amend; only formal counterclaims (CCR / CCI) get sub-projects.*
### 4.5 What the parent's SmartTimeline shows for the child
When `counterclaim_of` exists pointing at this project, the SmartTimeline renders a parallel right-track with the child's events (limited to `kind IN ('deadline','appointment','milestone')` child's projected rows are also included). User can collapse/hide the child track via the `[Track ▼]` chip.
The child's own SmartTimeline shows its own events as the primary track plus the parent as a left-side faded-context track (so the lawyer working on the CCR can see what's happening on the main proceeding without leaving the page).
---
## 5. Parent-node aggregation rule
What does the SmartTimeline render at higher levels of the project hierarchy? The four levels we have today:
### 5.1 Per-level rendering
| Level | Default render | Why |
|---|---|---|
| **Case** (UPC-CFI X) | Full SmartTimeline of self + parallel-track for any linked CCR sub-project. All zones, all kinds. | The lawyer working a single proceeding sees everything in one view. |
| **Patent** (EP3456789) | Lanes one per child case. Each lane shows only `kind IN ('deadline','milestone')` + status `IN ('done','open','overdue')`. Projected rows hidden by default (unfold-per-lane on click). | A patent typically has 1-3 active cases (CFI + CoA + opposition). Showing all projected rows from every case = overwhelming. Showing actuals + structural milestones gives the matter-level view. |
| **Litigation** (Siemens ./. Huawei) | Lanes one per child patent's primary case (most-recently-active case). Show only `kind='milestone'` + status=`done` + per-case "next due" pill. | Litigation level is portfolio-of-patents-against-this-defendant. Useful to see when each patent's current proceeding is, not the granular deadlines. |
| **Client** (Siemens AG) | Default = matter list (existing project tree). Behind a "Timeline-Ansicht" toggle, lanes = one per litigation. Shows only `kind='milestone'` + status=`done`. | Client level can have 100+ matters. A timeline across all is meaningless. The toggle makes it discoverable for the partner who wants the bird's-eye view. |
### 5.2 The single rule
> Each level removes one tier of detail and adds one tier of grouping. Going up: fewer kinds rendered, fewer statuses surfaced, more lanes.
| Level | Kinds | Statuses | Lanes |
|---|---|---|---|
| Case | all | all | self + CCR child |
| Patent | deadline + milestone | done + open + overdue | one per child case |
| Litigation | milestone | done | one per child patent |
| Client | milestone (toggle) | done | one per child litigation |
This rule is implementable as a single `levelPolicy(projectType)` function in `ProjectionService` returning a `(kinds, statuses, lane_grouping)` triple. All four cases share the same render component; only the input filter varies.
### 5.3 Off-script events at higher levels
Off-script milestones (counterclaim filed, party amendment, scope change) are first-class at every level they're the events m most cares about seeing at the litigation/patent overview. The "milestone" kind survives the level filter at all levels.
### 5.4 Not in v1
Cross-matter aggregation (e.g. "all my UPC matters, one timeline") is a Custom-View concern (t-paliad-144 substrate). The SmartTimeline is project-scoped; cross-project goes through `/views/{slug}` with a sources=`timeline` ViewSpec. Phase 5+, after t-paliad-163 Phase B lands.
---
## 6. Date-anchoring + reflow semantics
### 6.1 The rule (explicit)
> An actual date — recorded as a `paliad.deadlines.due_date` (status `done`) or `paliad.appointments.start_at` (in the past) or a milestone date — anchors every downstream projected event whose parent rule is the corresponding deadline_rule. The reflow propagates one parent-step at a time, until the next actual takes over or the chain bottoms out.
In other words: the existing `AnchorOverrides` mechanism in `FristenrechnerService.Calculate` is exactly the load-bearing primitive. The SmartTimeline's `ProjectionService` builds the override map at request time:
```go
overrides := map[string]string{}
for _, d := range completedDeadlines {
if d.RuleCode == "" || d.CompletedAt == nil { continue }
overrides[d.RuleCode] = d.CompletedAt.Format("2006-01-02")
}
// Court-set rules pick up the actual date too — set when the user enters
// "Hauptverhandlung fand statt am ..." via the inline anchor affordance.
opts := CalcOptions{AnchorOverrides: overrides, Flags: flagsForProject(p)}
result := frist.Calculate(ctx, p.ProceedingCode, p.TriggerDate, opts)
```
### 6.2 The UI affordance
Each projected row carries a `[Datum setzen]` link (or full-row click on tap-targets). Click inline date input expands inline. On submit:
- If the row corresponds to a `deadline_rules` entry that has a *real* deadline (not court-set), the action creates a `paliad.deadlines` row with `rule_id` set, `due_date=entered`, `original_due_date=projected`, `source='anchor'`, `status='done'`, `completed_at=entered`. (The "anchor" source is new; existing values are `manual`, `rule`, `import`. v1 adds `'anchor'` to the existing CHECK list.) This is the "we just learned the parent fact" path.
- If the row is court-set (decision / hearing / order), the action creates a `paliad.appointments` row with `start_at=entered`, `appointment_type='hearing'|'decision'|'order'` derived from the rule's `event_type`. The appointment links back to `rule_code` via a new optional FK column `paliad.appointments.deadline_rule_id` (nullable; existing rows stay null).
- Either way, the next read recomputes the projection with the new override and downstream rows reflow.
### 6.3 Editing an actual date later
If the user clicks an existing actual row's date, the inline editor PATCHes the underlying record (`/api/deadlines/{id}` or `/api/appointments/{id}`), and the next read re-projects.
### 6.4 What happens to overdue projected rows
A projected row whose date is in the past but no actual exists yet renders as "vorhergesagt überfällig" (faded amber). Clicking it lets the user either (a) anchor it as actual on a different date, or (b) explicitly mark "ist nicht eingetreten / wurde verschoben" which writes a `project_events` row with `event_type='rule_skipped'` + `timeline_kind='milestone'` so the audit trail records the decision.
---
## 7. Off-script event UX
The cardinal constraint: "We must hold it flexible add events regardless of whether they fit the normal course." Off-script events are first-class.
### 7.1 The "+ Eintrag" CTA
Persistent button in the SmartTimeline header. Click typed-add modal:
```
┌──────────────────────────────────────────────────┐
│ Neuer Eintrag im SmartTimeline │
├──────────────────────────────────────────────────┤
│ │
│ Was ist passiert? (oder wird passieren?) │
│ │
│ ◯ Frist → /deadlines/new │
│ ◯ Termin → /appointments/new │
│ ◯ Widerklage (CCR) → Anlegen Sub-Akte │
│ ◯ Anwendung auf Änderung (R.30) → Flag setzen │
│ ◯ Schriftsatz / Order → Off-script │
│ ◯ Eigener Meilenstein → Off-script (frei) │
│ │
│ [ Abbrechen ] [ Weiter ▶ ] │
└──────────────────────────────────────────────────┘
```
The visible options depend on the project's `proceeding_type_id`. UPC_INF gets the CCR + R.30 routes; UPC_REV gets CCI; DE_INF gets none of these. The "Schriftsatz / Order" + "Eigener Meilenstein" routes are universal.
### 7.2 The off-script branch
For "Schriftsatz / Order" and "Eigener Meilenstein" a small form:
```
Off-script Meilenstein
Titel: [Widerklage angekündigt durch Beklagten ]
Datum: [2026-05-02]
Beschreibung: [Schreiben des Beklagtenanwalts vom 02.05., … ]
Verknüpfung: ☐ Frist daraus erzeugen ☐ Termin daraus erzeugen
Sichtbar in: ◉ Diese Akte ◯ Diese Akte + Eltern
↑ Will it bubble up to higher levels?
[ Abbrechen ] [ Speichern ]
```
On submit, writes a `paliad.project_events` row with:
- `event_type='off_script_milestone'` (new value in the event_type enum-ish CHECK; today's CHECK is open-ended text confirm during impl).
- `timeline_kind='custom_milestone'`.
- `event_date=entered`.
- `description=...`.
- `metadata={"track": "parent" | "off_script", "links": [...]}`.
The optional checkboxes "Frist daraus erzeugen / Termin daraus erzeugen" open the standard deadline/appointment-create flow with the milestone's data prefilled and the milestone's id linked via metadata for audit trail.
### 7.3 Curated catalogue per proceeding type (NICE TO HAVE)
A small lookup table `paliad.timeline_event_catalogue (proceeding_type_id, kind, slug, name_de, name_en, primary_party)` could surface in the modal as a "Häufige Ereignisse" section above the universal "Eigener Meilenstein" route. Examples:
- UPC_INF: Counterclaim Filed, Third Party Intervention, Hearing Postponement, Cost Decision Issued
- UPC_REV: Application to Amend Filed, Substantive Decision, Costs Order
- DE_INF: Hinweisbeschluss Issued, Verteidigungsanzeige, Termin Hauptverhandlung, Versäumnisurteil
The catalogue is a v2 nice-to-have. v1 ships with "Eigener Meilenstein" as the universal escape hatch and the few proceeding-specific routes named above (CCR, CCI, R.30) hardcoded on the modal.
---
## 8. Filter facets — first-pass refinement
Refining the task brief's first-pass list against the FilterBar API (riemann's `BarState` / `AxisKey`). Each axis maps to either a universal axis (already shipped), an existing per-source stub (riemann left ready), or a new one.
### 8.1 Reused universal axes (already in BarState)
- **`time`** (universal, chip cluster + custom range) past 30/90d, next 30/90/any/custom. Default = `any`. Re-used verbatim; no work.
- **`personal_only`** (universal, chip) re-used. "Nur meine Einträge" `created_by=me`. Behavior same as on `/events` (t-paliad-128).
### 8.2 New per-source axes (extend `AxisKey`)
```ts
// frontend/src/client/filter-bar/types.ts — additions
export type AxisKey =
| existing
| "timeline_kind" // multi-select chip cluster
| "timeline_status" // multi-select chip cluster
| "timeline_track" // multi-select chip cluster
;
export interface BarState {
existing
timeline_kind?: ("deadline" | "appointment" | "milestone" | "projected")[];
timeline_status?: ("done" | "open" | "overdue" | "court_set" | "predicted" | "off_script")[];
timeline_track?: ("parent" | "counterclaim" | string /* child:<projectid> */)[];
}
```
### 8.3 The facet set on the SmartTimeline surface
The surface declares this `axes` array when it mounts the bar:
```ts
mountFilterBar(host, {
axes: [
"time", // universal — past/future filter
"timeline_kind", // deadline | appointment | milestone | projected
"timeline_status", // done | open | overdue | court_set | predicted | off_script
"timeline_track", // parent | counterclaim | child:<id>
"personal_only", // optional — toggle "nur meine Einträge"
"deadline_event_type", // existing stub — wired in t-paliad-117 multi-select
"shape", // timeline (default) | list | cards
"sort", // chronological asc/desc
"density", // comfortable | compact
],
surfaceKey: "project-smart-timeline",
systemViewSlug: "project-timeline",
});
```
### 8.4 Defaults
- `time = any`
- `timeline_kind = [deadline, appointment, milestone]` (projected hidden by default the user opts in via chip; reduces noise on first load when most projects don't have a proceeding type set)
- `timeline_status = [done, open, overdue, off_script]` (predicted + court_set hidden by default if `projected` kind is hidden the chip group is one logical "show future" toggle)
- `timeline_track = all available`
- `shape = timeline`
- `sort = date_desc` (most recent first; matches today's Verlauf default)
- `density = comfortable`
### 8.5 The "show future" macro
Most users will only want one toggle: "Zukunft anzeigen". We render that as a primary chip pair next to `time`:
```
[ Vergangenheit | Heute | Zukunft ] ← primary toggle
```
Internally this maps to `time + timeline_kind` (Vergangenheit hides projected, Zukunft shows projected, Heute is just today). Power users can drill into the granular axes via the bar.
### 8.6 What riemann's port (t-paliad-170) needs to know
Riemann is porting FilterBar onto the Verlauf surface in parallel. Three things they need:
1. **Three new axis keys** (`timeline_kind`, `timeline_status`, `timeline_track`). They render as chip clusters the same primitive `chipRow + chipBtn` riemann already factored.
2. **`shape: "timeline"`** is a new render shape. Existing shapes are `list | cards | calendar` (t-paliad-144). We pick "timeline" as a 4th shape so the FilterBar's shape switcher lets the user collapse to `list` (compact audit log) or `cards` (chronological card grid) without losing the data. Implementation = new `frontend/src/client/views/shape-timeline.ts` mirroring the other shape files. Out of scope for t-paliad-170 (riemann ports the bar, not the new shape).
3. **The `timeline_track` axis options are dynamic** they depend on whether the project has a counterclaim child. The bar already supports lazy axes (the `project` axis pattern in `axes.ts:30` `"populated lazily"`). `timeline_track` follows the same shape: surface fetches available tracks at mount, passes them to the bar.
---
## 9. Verfahrensablauf-logic sharing — extract, don't import
**Recommendation: extract into a shared module first.**
### 9.1 The decision
The wizard's projection logic is currently in two places:
1. `internal/services/fristenrechner.go:Calculate(...)` the canonical Go implementation. Already returns a `UIResponse{Deadlines []UIDeadline}` keyed by rule_code, supports `AnchorOverrides`. ~1000 lines, tested.
2. `frontend/src/client/fristenrechner.ts:calculate()` the frontend wrapper that POSTs `/api/tools/fristenrechner` and handles flags + overrides. ~3500 lines including the wizard UI, but the projection-relevant slice is small (call + render).
The SmartTimeline's `ProjectionService.For(projectID)` needs the *Go calculator*, not the frontend code path. So the question is really: *do we add a new Go service that wraps `FristenrechnerService.Calculate` for projects?*
Yes a thin adapter, not a parallel implementation.
### 9.2 The adapter
```go
// internal/services/projection_service.go (new, ~200 LoC)
type ProjectionService struct {
db *sqlx.DB
fristen *FristenrechnerService
deadlines *DeadlineService
appointments *AppointmentService
projects *ProjectService
courts *CourtService
}
// For builds a SmartTimeline for one project (and its CCR child if any).
// Composes the four zones described in §1; returns sorted TimelineEvent[].
func (s *ProjectionService) For(ctx context.Context, projectID uuid.UUID, opts ProjectionOpts) ([]TimelineEvent, error) {
p, err := s.projects.GetVisible(ctx, projectID, opts.ViewerID)
// ...
children := s.projects.LoadCounterclaimChildrenVisible(ctx, projectID, opts.ViewerID)
actuals := s.collectActuals(ctx, []uuid.UUID{p.ID, children...}) // dl + appt + milestones
overrides := buildAnchorOverrides(actuals)
var projected []TimelineEvent
if p.ProceedingTypeCode != "" && p.TriggerDate != nil {
proj := s.fristen.Calculate(ctx, p.ProceedingTypeCode, p.TriggerDate.Format("2006-01-02"),
CalcOptions{AnchorOverrides: overrides, Flags: flagsFor(p), CourtID: p.CourtID})
projected = projectionToTimeline(proj, p, actuals)
}
// (same for each child counterclaim)
return mergeAndSort(actuals, projected, opts.LevelPolicy), nil
}
```
The adapter does not duplicate the calculator it calls `FristenrechnerService.Calculate` exactly once per (project, child). Same code path as `/api/tools/fristenrechner` uses today; same tests cover both.
### 9.3 What the standalone wizard keeps
`/tools/fristenrechner` continues to use `FristenrechnerService.Calculate` directly it's a knowledge-platform tool, not a project-scoped view. It does not gain anchoring affordances or off-script events. The projection there is hypothetical ("if you start a UPC_INF on date X, here's the timeline"), not project-actual.
`ProjectionService` is a project-scoped composition layer; it lives one level above `FristenrechnerService` in the dependency graph.
### 9.4 The test split
- `fristenrechner_test.go` keeps testing the calculator (duration math, AnchorOverrides, CourtID resolution).
- `projection_service_test.go` (new) tests the composition: mixing actuals + projected, level policy, counterclaim child merging, sort order.
---
## 10. Phasing — 4 sequential slices
Each slice is independently shippable and reviewable. m's go/no-go gate after each.
### Slice 1 — SmartTimeline skeleton (no projection yet)
What lands:
- New `internal/services/projection_service.go` with `For()` returning only actuals (deadlines + appointments + opted-in `project_events`). No `fristenrechner` call yet.
- Migration `NNN_project_events_timeline_kind.up.sql` adds the optional column + partial index 2.2).
- New endpoint `GET /api/projects/{id}/timeline?…` returning `[]TimelineEvent`.
- `frontend/src/client/projects-detail.ts:loadEvents` rewritten to call `/timeline` instead of `/events`. The current Verlauf list is replaced by the new vertical timeline component (`client/views/shape-timeline.ts` new file, ~300 LoC).
- "+ Eintrag" CTA in the timeline header (modal partially implemented only "Eigener Meilenstein" route lit; CCR / R.30 / Frist / Termin routes are link buttons to existing flows).
- "Audit-Log anzeigen" toggle that switches to the legacy chronological list rendering (`paliad.project_events` ALL not just `timeline_kind IS NOT NULL`).
What it gives m: a working SmartTimeline showing past actuals + open/upcoming deadlines + appointments + off-script milestones, with the audit log surviving as a toggle. No future-projection yet.
### Slice 2 — Future-projection + click-to-anchor
What lands:
- `ProjectionService.For` calls `FristenrechnerService.Calculate` and emits projected rows.
- Click-to-anchor inline date editor 6.2). New endpoint `POST /api/projects/{id}/timeline/anchor` taking `{rule_code, actual_date, kind?}` and writing the appropriate `paliad.deadlines` (`source='anchor'`) or `paliad.appointments` (`deadline_rule_id` FK new) row.
- Migration `NNN_appointments_deadline_rule_id.up.sql` adds the optional FK on appointments + extends `paliad.deadlines.source` CHECK to include `'anchor'`.
- "voraussichtlich" / "Datum vom Gericht" status pills + projected-row CSS (faded + dashed border for court-set).
- New "Zukunft anzeigen" macro chip pair 8.5).
- `event_type='rule_skipped'` write path for the "ist nicht eingetreten" decision 6.4).
What it gives m: predicted future course based on standard timeline; click to fix any date when something happens; downstream reflows automatically.
### Slice 3 — Counterclaim sub-project
What lands:
- Migration `NNN_projects_counterclaim_of.up.sql` the new `counterclaim_of` FK + index + the CHECK (a project either has counterclaim_of OR is parent not both to keep the invariant clean).
- "+ Eintrag Widerklage (CCR)" route in the modal 7.1) creates child project with auto-suggested `our_side` flip, `proceeding_type_id`, and title, then navigates to it for the user to fill in `case_number`.
- `ProjectionService` loads CCR children + emits parallel-track rows.
- `[Track ▼]` chip in the header reads `available_tracks` from the timeline response.
- The two-column rendering on State C 3.3).
- `paliad.project_events` audit row written on counterclaim creation (`event_type='counterclaim_created'`, `timeline_kind='milestone'`).
What it gives m: counterclaims as proper sub-projects, parallel timelines, CCR perspective-flip works end-to-end.
### Slice 4 — Parent-node aggregation
What lands:
- `levelPolicy(projectType)` in `ProjectionService` kinds/statuses/lane filter per level 5.1).
- Lane-grouped rendering at Patent / Litigation / Client levels.
- "Timeline-Ansicht" toggle on Client-level project page (default off; lanes-of-litigations when on).
- Off-script milestones bubble up to higher levels via the `metadata.bubble_up: true` flag 7.2 form's "Sichtbar in: Diese Akte + Eltern" checkbox).
What it gives m: portfolio-level timelines without overload the bird's-eye view he asked about.
### What's NOT in any slice
- Curated per-proceeding event catalogue 7.3) v2 nice-to-have.
- Gantt rendering separate `shape: "gantt"` follow-up.
- Cross-matter timeline Custom Views path.
- Outlook integration out of scope.
---
## 11. Open questions for m
Listed with my (inventor) pick where I have one m decides.
**Q1 — Counterclaim sub-project vs proceeding-overlay (§4).** I recommend sub-project. Confirm before Slice 3 design lock.
**Q2 — Should `our_side` flip automatically on counterclaim sub-project creation?** My pick: yes, default-flip with a "Stimmt nicht?" toggle on the create modal. The R.49.2.b CCI is the edge case (parent claimant child claimant in CCI of the *separate* infringement claim), but the standard CCR-on-validity always inverts. Default-flip + toggle handles both.
**Q3 — Should `paliad.deadlines.source` gain `'anchor'` or should we re-use `'manual'`?** My pick: new `'anchor'` value separates "user-typed-it-in" from "user-recorded-an-actual-after-projection-fired" for analytics + future automated import (Outlook event anchor).
**Q4 — Counterclaim sub-project's `parent_id` — under the patent (sibling to parent case) or under the parent case (grandchild)?** My pick: under the patent (sibling). The CCR is its own proceeding with its own case_number; modeling it as a sibling to the parent infringement, both under the patent, mirrors how UPC CMS sees them. Grandchild placement would imply CCR is "part of" the parent case which it structurally isn't.
**Q5 — Off-script milestone bubble-up default.** My pick: default-on for `event_type IN ('counterclaim_created', 'third_party_intervention', 'scope_change')`; default-off for `event_type='custom_milestone'`. Form has the override checkbox in either case.
**Q6 — Should `/tools/fristenrechner` keep its standalone existence?** Brief says yes knowledge tool, separate from project context. My pick: yes, agree. It stays.
**Q7 — Application-to-amend (UPC R.30) as sub-project or flag?** My pick: stay as flag (`with_amend`). Amendments are not a separate proceeding artifact in the CMS they ride on the parent's record. The cross-flow rules already activate via `condition_flag`.
**Q8 — On the parent's SmartTimeline, do CCR rows mix into one column or stay in a parallel right-track?** My pick: parallel right-track when both are populated; collapses into one column on mobile (vertical stacking with sub-headers per track). The `[Track ▼]` chip lets desktop users opt into single-column mode.
**Q9 — Court-set anchor (Hauptverhandlung) creates a `paliad.appointments` row or a `paliad.deadlines` row?** My pick: `paliad.appointments` it's an appointment, not a deadline. The new `appointments.deadline_rule_id` FK preserves the link back to the rule for downstream re-anchoring.
**Q10 — Is `timeline_kind` the right column name?** Alternatives: `is_timeline_milestone bool`, `surface_on_timeline bool`. My pick: keep `timeline_kind text NULL` because it lets us distinguish `milestone` (structural) from `custom_milestone` (free-form) without a second column.
**Q11 — Should the SmartTimeline be the only view of the project's events?** Or do we keep a "klassisch (chronologisch)" sidebar tab? My pick: SmartTimeline as the only Verlauf tab; "Audit-Log anzeigen" toggle inside the timeline reveals the chronological rendering. m uses `/admin/audit-log` (t-paliad-071) for the cross-project audit query.
**Q12 — Patent-level "matter list vs lane timeline" default.** My pick: lanes by default at Patent + Litigation; matter list by default at Client. The Litigation level has 1-3 child patents typically 1-3 lanes is fine. Client can have 100+ lanes are a toggle.
---
## 12. Files implementer will touch (Slice 1 only)
Aggregated for the coder shift kickoff:
**Backend (Go):**
- `internal/services/projection_service.go` new, ~250 LoC.
- `internal/handlers/projection.go` new, GET /api/projects/{id}/timeline, ~80 LoC.
- `internal/handlers/handlers.go` register the new route.
- `internal/db/migrations/NNN_project_events_timeline_kind.{up,down}.sql` new.
**Frontend (TS / TSX):**
- `frontend/src/client/views/shape-timeline.ts` new render shape, ~300 LoC.
- `frontend/src/client/projects-detail.ts:loadEvents` replace with timeline fetch.
- `frontend/src/projects-detail.tsx:74-101` replace Verlauf markup with `<div id="project-smart-timeline">`.
- `frontend/src/styles/global.css` `.smart-timeline-*` styles, ~150 LoC.
- `frontend/src/client/i18n.ts` ~30 keys under `projects.detail.smarttimeline.*`.
**Tests:**
- `internal/services/projection_service_test.go` new (live-DB integration test, skipped without `TEST_DATABASE_URL`).
- `internal/services/projection_service_unit_test.go` pure-function tests (sort, level policy, override-build).
Slices 2-4 are scoped in §10; coder picks them up after m's gate.
---
## 13. Trade-offs flagged
- **Per-request projection cost.** Recomputing on every Verlauf load is fine for a single project. If m navigates to a Client-level lane view with 50 child litigations × 3 cases each, that's 150 calculator invocations. Mitigation: lane-rendering at Litigation+Client levels excludes `kind='projected'` by default 5), so the calculator is only called on the leaf rendering. Watch in production; add per-(project, hash(overrides)) cache if needed.
- **Migration order across active workers.** riemann is on t-paliad-170 (FilterBar Verlauf port) in parallel. Slice 1 must merge **after** their port because Slice 1 mounts the bar with new axis keys. Coordinate via head before Slice 1 PR opens.
- **Sub-project counterclaim adds a tier.** The project tree gets deeper (Patent Case + Patent CCR-Sub-Case as siblings). Existing tree visualisation in t-paliad-149 handles arbitrary depth, but the per-card "in 3 children" badge needs to count the CCR child correctly verify in Slice 3.
- **`appointments.deadline_rule_id`** is a backward-pointing FK that doesn't exist yet. Adding it in Slice 2 is clean (nullable, no backfill needed). Just flagging that this ties appointments to deadline_rules where they previously had no link.
- **Anchor write path can race.** Two users clicking "Datum setzen" on the same row simultaneously could both write `paliad.deadlines` rows. Mitigation: server-side check `WHERE NOT EXISTS (SELECT 1 FROM paliad.deadlines WHERE project_id=... AND rule_id=...)` before insert, otherwise PATCH the existing row. Standard pattern.
- **What if the proceeding type changes mid-flight?** The user changes `paliad.projects.proceeding_type_id` after deadlines have been calculated. Existing actuals stay (they have `rule_id` FK pointing to the OLD rule tree). Projected rows recompute against the NEW rule tree; rule_codes that don't exist in the new tree drop out. This is the same behaviour today flagging because the SmartTimeline makes it more visible.
---
## 14. Recommendation for implementer
Pattern-fluent Sonnet coder. Slice 1 is largely boilerplate (new service + handler + render shape). Slice 2 needs the calculator integration which is well-trodden (t-paliad-131 Phase A shipped overrides). Slice 3 needs the sub-project FK design (one careful migration) and the parallel-track CSS. Slice 4 is render-policy logic, low-risk.
Lagrange (this worktree) parks. NOT pre-emptively flipping to coder m gates.
---
**DESIGN READY FOR REVIEW**

View File

@@ -0,0 +1,569 @@
# Design — Tools surface cleanup (Fristenrechner vs Verfahrensablauf split)
**Author:** kelvin (inventor)
**Date:** 2026-05-12
**Task:** t-paliad-178
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
---
## 0. Premises verified live (before designing)
CLAUDE.md / memory / the task brief can all drift. Each anchor below is verified against the live codebase or DB on `mai/kelvin/inventor-tools-surface` (baseline commit `54b227c`).
- **One route + one TSX serve both nav entries today.** `/tools/fristenrechner` is the only registered page route (`internal/handlers/handlers.go:162`). Both sidebar entries (Fristenrechner + Verfahrensablauf) target the same Bun-built `dist/fristenrechner.html` and disambiguate purely through `?path=a` and a client-side active-class fix-up (`frontend/src/client/sidebar.ts:447 fixVerfahrensablaufActive`). Confirmed: the live HTML pulled from paliad.de (auth-gated → 302 to login, served-bytes match) is the shell rendered by `frontend/src/fristenrechner.tsx:87 renderFristenrechner`.
- **The client runtime is 3 559 lines, not the 2 700+ quoted in the task brief.** `frontend/src/client/fristenrechner.ts` carries Step 1 / Step 2 / Step 3a / Pathway A wizard / Pathway B cascade + filter / search + cascade engines / column + timeline result-card renderers in one IIFE bundle (`Pathway` type at line 2315, `showPathway()` at line 2370, `showBMode()` at line 2406). Any "separate route" path must either lift code out of this bundle into a shared module or accept a larger duplicated bundle on the new page.
- **Sidebar deep-link `?path=a` lands on Pathway A directly, NOT on the Akte picker.** I traced `initPathwayFork → readPathwayFromURL → showPathway("a")`: it sets `step1.style.display = "none"`, `step2.hidden = true`, `step3a.hidden = true`, `pathway-a.hidden = false`. The user sees the wizard's "Verfahrensart wählen" tile picker first. The task brief's phrasing — "still drops users at Step 1 (Akte-Picker)" — is the perceived UX from the wizard's own internal "wizard-step-1" labelled "Verfahrensart wählen". Mental model: two surfaces with the same nav label "Step 1" muddy intent; the fix m wants is structural (a dedicated route), not a JS bug fix.
- **`paliad.projects.court` is a free-text column, NOT an FK to `paliad.courts`.** Confirmed in `information_schema.columns`. Live values: `LG München I` (1 row), `UPC` (2), `UPC CoA` (1). The task brief's "project has a court FK" is **wrong**; only `proceeding_type_id` is a real FK. The design must NOT silently auto-pick a `paliad.courts.id` from `projects.court` — fuzzy mapping is best-effort + always overridable, never silent.
- **`paliad.projects.proceeding_type_id` points at `category='litigation'` rows (7 codes: INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL).** The Fristenrechner wizard accepts `category='fristenrechner'` codes (20 codes: UPC_INF, DE_INF, EPA_OPP, …). These overlap conceptually (`INF` is the abstract noun behind both `UPC_INF` and `DE_INF`) but are different rows. Auto-derivation needs a small mapping: `litigation_code × jurisdiction → fristenrechner_code`. Example: `INF + UPC → UPC_INF`. `INF + DE → DE_INF` (first instance). The instance dimension (LG / OLG / BGH) is **not** on `paliad.projects` today, so DE_INF_OLG / DE_INF_BGH cannot be inferred — only the first-instance code can be.
- **`paliad.projects` carries no `priority_date` or `trigger_date` column.** It does have `filing_date` and `grant_date`. Only EP_GRANT.ep_grant.publish (Art. 93 EPÜ) is anchored on `priority_date` today (via `anchor_alt`). For Akte-driven prefill, `priority_date` stays blank by default and the user fills it.
- **`paliad.projects.our_side` and `paliad.projects.counterclaim_of` exist** (already exploited by t-paliad-164 perspective-chip predefine and the parent-counterclaim link respectively). These two columns are the actual hooks for "consolidated timeline" vs "side-by-side lanes" — see §6.
- **`deadline_rules.condition_flag` is a real text[] column with exactly 4 distinct value-sets in production:** `[with_amend]` (4 rows), `[with_cci]` (4), `[with_ccr]` (5), `[with_ccr, with_amend]` (4). Only `UPC_INF` (proceeding_type_id=8) and `UPC_REV` (proceeding_type_id=9) carry variant-flagged rules. Every other proceeding type renders a single canonical timeline today. **This is the hard data bound on the variant-chip design** — chips beyond these three flags would have no rules to flip and must be marked "future".
- **Court-specific rule overrides do not exist as a mechanism.** `CourtID` in `CalcOptions` (`internal/services/fristenrechner.go:107`) only switches the holiday calendar (via `courts.CountryRegime`). There is no per-court rule branch. "UPC LD Mü vs LD Düsseldorf" overrides are NOT a thing — they'd need a new column on `deadline_rules`.
- **Expedited-vs-standard distinctions do not exist** either. No `condition_flag` row matches an expedited concept. Adding one is a schema-and-seed change, out of scope here.
- **Result rendering today** lives in `renderTimelineBody` and `renderColumnsBody` (`frontend/src/client/fristenrechner.ts:637 / :664`). The user toggles between the two with a radio (`#fristen-view-toggle`). Both renderers take a single `DeadlineResponse` and emit DOM strings; neither knows about "two timelines side by side". A consolidated-vs-lane view (§5§6) is a renderer-level change, not a backend one.
- **The Step 1/Step 2/Step 3a/Pathway A/B layout shipped under t-paliad-133 + t-paliad-168.** The "Verfahrensablauf einsehen" card (Step 2 third option, lines 215-223 of fristenrechner.tsx) was added in t-paliad-168 specifically to give the abstract-browse case a discoverable entry. If Verfahrensablauf moves to its own route, the third card becomes redundant (§9).
If any of these conflict with what the task brief asserts, **the live state wins** and the brief is the bug — flagged in §13 for m.
---
## 1. Vision + scope
m's framing (verbatim from the task brief):
> Users want to **either** (1) determine a deadline — possibly Akte-scoped, possibly abstract — **or** (2) browse a typical Verfahrensablauf abstractly with variant options.
The two intents are **fundamentally different**:
- **Determine a deadline** ends with a save (or a print, or a manual transcription) of a *specific* date attached to *something* — a project, or a sticky-note in the user's head.
- **Browse a Verfahrensablauf** ends with the user understanding the *shape* of a proceeding — no date binding required.
Today both intents collapse onto one URL because the wizard infrastructure is shared. The cost: two sidebar entries pointing at the same shell, an active-class fix-up script (`fixVerfahrensablaufActive`), and a Step 1 ("Welche Akte?") frame that doesn't match the abstract-browse intent.
### Scope of this design
1. **Page surface split** — separate routes per intent. `/tools/fristenrechner` keeps the deadline-determination intent (Akte-scoped *or* abstract). `/tools/verfahrensablauf` becomes the dedicated abstract-browse surface with variant chips + side-by-side compare.
2. **Step 0 "Abstrakt oder Akte?"** as the FIRST affordance on `/tools/fristenrechner`. Pick → narrows downstream inputs.
3. **Akte-driven auto-derivation** — map project columns to wizard inputs and flag the gaps.
4. **Variant chips + consolidated-vs-lane view** for `/tools/verfahrensablauf`.
5. **Side-by-side compare** on `/tools/verfahrensablauf` (max 2 timelines for v1).
6. **Sidebar labels + URL conventions** post-split.
7. **Mobile responsive** plan.
8. **What gets dropped** (Step 2 browse card, sidebar fix-up script).
### Explicitly out of scope (per task brief)
- Deadline-rule data-model changes (court-specific overrides, expedited-flag, new condition_flag values). Audited in §0, propose nothing here.
- t-paliad-166 Determinator B1 cascade redesign — separate ticket, on-hold. Pathway B continues to exist inside `/tools/fristenrechner`; we note interplay in §11 but do not pre-empt.
- t-paliad-157 Fristenrechner interactive-UX pair session — on-hold. The cleanup here may inform it, but we don't dictate it.
- Project Verlauf tab (`/projects/{id}` → Verlauf). Stays as-is. SmartTimeline renders concrete-per-case via `internal/services/projection_service.go`; no Tool-side mirror.
- New backend services. The split runs on the existing `POST /api/tools/fristenrechner` + `POST /api/tools/event-deadlines` endpoints; we add at most one helper for Akte → fristenrechner-code mapping.
- Backend rule changes — touch the substrate only enough to verify what the design needs is already there.
---
## 2. Page surfaces + route split
m has already chosen **Option A** in the task brief: split by intent, separate URLs. The design here implements that choice. For honesty I also note the alternatives I considered and why A still wins after audit.
### 2.1 Three options weighed
| Option | URL shape | Trade-off | Verdict |
|---|---|---|---|
| **A — Two routes** | `/tools/fristenrechner` + `/tools/verfahrensablauf` | Clean mental model. Sidebar entries map 1:1 to URLs. `fixVerfahrensablaufActive` dies. Two HTML files; shared client code lifted into a module. | **Picked.** Aligns with intent split. |
| **B — One route, `?mode=` fork** | `/tools/fristenrechner?mode=calc` vs `?mode=browse` | Single HTML bundle, no shared-module lift. But: sidebar entries still alias the same page; muddled intent stays in the user's head; we'd still need a Step 0 inside the calc mode. | Rejected by m. Verifies on second look: it just moves `?path=a` to `?mode=browse`, doesn't fix the problem. |
| **C — Move into Patentglossar** | Verfahrensablauf renders inline on glossary pages | Discoverability shrinks. Glossary entries are concept-bounded; Verfahrensablauf is procedure-bounded. The two indexes don't map. | Rejected by m. |
### 2.2 Code-reuse strategy under Option A
The honest cost of splitting routes is shared-client-code duplication. Today `client/fristenrechner.ts` (3 559 LoC) bundles everything. The Verfahrensablauf-only surface needs:
- The proceeding-type tile picker (`UPC_TYPES`, `DE_TYPES`, `EPA_TYPES`, `DPMA_TYPES` arrays in `fristenrechner.tsx`).
- The timeline + columns result renderers (`renderTimelineBody`, `renderColumnsBody`).
- The `POST /api/tools/fristenrechner` calc invocation.
- Court picker + holiday-calendar pickup (read-only).
- DE/EN i18n for the timeline rows.
It does NOT need:
- Step 1 Akte picker / ad-hoc chip / Step 1 summary.
- Step 2 file/happened/browse cards.
- Step 3a outgoing-intent chooser.
- Pathway B cascade + filter + perspective + inbox chips (~1 200 LoC).
- Save-to-Akte modal.
- Trigger-event mode (`mode-event-panel`).
**Plan:** lift the deadline-timeline core (proceeding picker + calc + render) into `frontend/src/client/views/verfahrensablauf-core.ts`. Both pages import it. Pathway B + Save modal + Step machinery stay in `client/fristenrechner.ts`. Estimated lifted surface: ~700900 LoC. New code on `verfahrensablauf.ts` (variant chips + lane mode + compare): ~400600 LoC.
This keeps the IIFE per-page bundle pattern intact (one entry per route in `frontend/build.ts:228`). No runtime npm dep added.
### 2.3 The two pages in one sentence each
- **`/tools/fristenrechner`** — Deadline determination. Optional Akte scope. Ends in "save / print / done".
- **`/tools/verfahrensablauf`** — Procedural shape browser. No Akte. Ends in "now I understand the shape".
### 2.4 Sidebar
```text
Werkzeuge
Fristenrechner → /tools/fristenrechner
Verfahrensablauf → /tools/verfahrensablauf
Kostenrechner → /tools/kostenrechner
```
`fixVerfahrensablaufActive` deletes; the SSR-time `navItem` helper handles both active classes natively because the hrefs differ on pathname.
---
## 3. Step 0 "Abstrakt oder Akte?" on `/tools/fristenrechner`
m's lock-in: Step 0 comes FIRST. Today's Step 1 (Akte picker) forces the user to either commit to an Akte or escape via ad-hoc chips before anything else moves. Step 0 makes the binary choice explicit.
### 3.1 Affordance — three sketches considered
**Sketch A — Radio toggle (Recommended).**
A pair-of-toggle at the top of the page, wide on desktop, stacked on mobile. The currently-active half expands into its full picker; the inactive half collapses to a slim header that the user can click to flip.
```
┌──────────────────────────────────────────────────────────────┐
│ Schritt 0 — Wie wollen Sie die Frist bestimmen? │
│ │
│ ◉ Mit Akte verknüpfen ○ Abstrakt — ohne Akte │
│ ────────────────────────────────────────────────────────────│
│ │
│ 🔍 Akte suchen… │
│ [Akte 1 · CLI-2024 — Foo GmbH vs Bar Ltd. — UPC LD Mü] │
│ [Akte 2 · …] │
│ ──── │
│ + Neue Akte anlegen │
│ │
└──────────────────────────────────────────────────────────────┘
```
When the user picks "Abstrakt":
```
┌──────────────────────────────────────────────────────────────┐
│ Schritt 0 — Wie wollen Sie die Frist bestimmen? │
│ │
│ ○ Mit Akte verknüpfen ◉ Abstrakt — ohne Akte │
│ ────────────────────────────────────────────────────────────│
│ │
│ Verfahrensart wählen: │
│ [UPC] [DE] [EPA] [DPMA] ← jurisdiction picker (4 tabs) │
│ (then proceeding-type tiles within the chosen tab) │
│ │
└──────────────────────────────────────────────────────────────┘
```
**Why I'd recommend this:** the toggle is a single decision, declared up-front, with the consequence visible inline. No modal dismissal cost. Keyboard navigation natural. On mobile it stacks to two stacked rows where the active row expands and the inactive row stays a touch-target.
**Sketch B — Two big cards.** Like today's Step 2 cards but at the very top. Pro: pretty + tappable. Con: click-and-commit feels heavier than a toggle; "going back" reads as undoing a choice instead of flipping it.
**Sketch C — Modal-before-render.** Most decisive, also most annoying — the user can't even see the page before the dialog clears. Reject. (Modals interrupt; we want the user oriented before they're asked.)
### 3.2 URL state
Step 0 binds to `?mode=akte|abstract` in the URL.
- `?mode=akte&project=<uuid>` — Akte selected. Court / proceeding-type / our_side auto-derived (§4).
- `?mode=abstract&forum=upc|de|epa|dpma` — abstract. Jurisdiction tab selected; proceeding-type tiles below.
- `?mode=` absent — render Step 0 with no preselection.
Deep-link from `/projects/{id}` → "Frist berechnen" button passes `?mode=akte&project=<id>` and lands on Step 0 with Akte branch already filled.
`localStorage["paliad.fristen.mode"]` remembers the user's last choice for soft re-entry (the `PATHWAY_STORAGE_KEY` pattern already exists).
### 3.3 Removal of today's Step 2 fork (file / happened / browse)
With Step 0 making the intent binary, the file-vs-happened branching collapses into one wizard with two anchor sources:
- **Akte mode** — wizard pre-filled. After calc, the save CTA is "An Akte hängen". `?path=` machinery shrinks because Pathway A vs Pathway B becomes a wizard *step* (incoming-event vs outgoing-event), not a top-level path.
- **Abstract mode** — wizard takes proceeding-type + date as today. After calc, save CTA disabled (no Akte to save against); `Drucken` remains.
The "Verfahrensablauf einsehen" card is gone from `/tools/fristenrechner` (its purpose lives on `/tools/verfahrensablauf` now — §9).
Pathway B (the cascade) is **kept** as a separate entry-flow inside Akte-mode for "Etwas ist passiert" — the t-paliad-166 redesign is on-hold and we don't pre-empt it. In abstract mode Pathway B is reachable via a "Frist aufgrund Ereignis (Determinator)" link in the result panel; the cascade itself unchanged.
---
## 4. Akte-driven auto-derivation
When `mode=akte&project=<uuid>`, the wizard prefills as much as it honestly can from `paliad.projects`. The rest stays empty + visible.
### 4.1 Mapping table
| Wizard input | Project source | Confidence | Behaviour |
|---|---|---|---|
| **proceeding_type_code** (UPC_INF, DE_INF, …) | `proceeding_types.code` via `projects.proceeding_type_id` + jurisdiction disambiguation | medium-high | Best-effort pick + the proceeding-tile picker stays visible with the picked tile pre-selected. User can flip. |
| **trigger_date** | None today | low | Always empty. User fills. |
| **priority_date** (EP_GRANT only) | `projects.grant_date` or `projects.filing_date` (parent patent project's filing) | low-medium | Pre-fill only when the chosen proceeding is `EP_GRANT`. Field stays visible + editable. |
| **court_id** | `projects.court` (free text) — fuzzy match against `paliad.courts.code` | low | Pre-select if string-match is exact-or-trivial-canon (e.g. `"UPC"``upc-cd-...`? **No** — too ambiguous; leave blank); else leave blank. Picker visible + required for UPC where holiday calendar differs. |
| **our_side** (perspective chip) | `projects.our_side` | high | Already wired (t-paliad-164). Predefine + show "vorgegeben durch Akte" hint. |
| **condition_flag** (with_ccr, with_cci, with_amend) | None today | low | Stays user-driven. Flag checkboxes appear conditionally on UPC_INF/UPC_REV. |
| **counterclaim sibling info** | `projects.counterclaim_of` | medium | If set, the result panel shows a small "Verbundenes Verfahren: <parent>" line with a deep-link to the parent's Verlauf tab. Informational only — doesn't change calc. |
### 4.2 Litigation → fristenrechner code mapping
`projects.proceeding_type_id` points to `category='litigation'` rows. The wizard wants `category='fristenrechner'`. The mapping is multi-key:
| litigation code | jurisdiction | resolved fristenrechner code |
|---|---|---|
| `INF` | UPC | `UPC_INF` (id 8) |
| `INF` | DE | `DE_INF` (id 12) — first instance only; OLG/BGH not derivable |
| `REV` | UPC | `UPC_REV` (id 9) |
| `REV` | DE | `DE_NULL` (id 13) |
| `CCR` | UPC | `UPC_REV` (id 9) + `with_cci` flag suggested |
| `APM` | UPC | `UPC_PI` (id 10) |
| `APP` | UPC | `UPC_APP` (id 11) |
| `AMD` | UPC | (no direct fristenrechner code; suggest UPC_INF with `with_amend`) |
| `ZPO_CIVIL` | DE | `DE_INF` (id 12) — fallback |
The jurisdiction comes from `proceeding_types.jurisdiction` (UPC / DE / EPA / DPMA) on the project's own proceeding_type row, not from `projects.country` directly (which is a different axis — country of patent, not of forum).
Implementation: a helper `services.ResolveFristenrechnerCodeForProject(projectID)` returning `(code, confidence, reason)` so the UI can render "Vorgeschlagen: UPC_INF (aus Akte abgeleitet — Sie können umstellen)". Where confidence is `low`, no preselect — user picks.
### 4.3 Court free-text — no silent FK promotion
`projects.court` is a free-text field. Live values include `"UPC"` (ambiguous: which division?), `"UPC CoA"` (matches `upc-coa-luxembourg`), `"LG München I"` (matches `de-lg-muenchen1`). I deliberately do NOT auto-pick a `paliad.courts.id` from this string in v1: the cost of a wrong silent pick (a holiday-calendar mismatch invalidating a calculated date) is high; the benefit of saving one click is low. The Court picker stays visible and **required** for UPC proceedings (already today's behaviour via the `isCourtDeterminedRule` check in `internal/services/fristenrechner.go:779`).
If the free-text value matches a canonical `paliad.courts.code` exactly (case-insensitive), we *highlight* the matching option but do not auto-select. The user clicks to confirm.
Follow-up ticket worth filing (out of scope here): migrate `projects.court` from text to `court_id` FK. That'd land a real auto-derivation. Until then, this design treats it as a hint.
### 4.4 Edge case — Akte without a proceeding_type_id
11 of 11 live projects today have no `proceeding_type_id` set yet. Behaviour: the wizard renders with all proceeding-type tiles selectable, no preselect, no hint. Functionally identical to abstract mode but with the Akte locked for save-CTA. No error state — silent graceful degradation.
---
## 5. Variant chips on `/tools/verfahrensablauf`
The new dedicated route renders proceeding-shape with the user toggling "what variant am I looking at?". Variants are the live `condition_flag` mechanism.
### 5.1 Variants that exist today (audited live)
Only **UPC_INF** (id 8) and **UPC_REV** (id 9) carry `condition_flag` rules. The flags themselves:
- `with_ccr` — Klägerseite, infringement claim met with revocation counterclaim. Adds `inf.def_to_ccr`, `inf.reply`, `inf.reply_def_ccr`, `inf.rejoin`, `inf.rejoin_reply_ccr` (5 rules) to UPC_INF.
- `with_cci` — Beklagtenseite on revocation answered with infringement counterclaim. Adds `rev.cc_inf`, `rev.def_cci`, `rev.reply_def_cci`, `rev.rejoin_cci` (4 rules) to UPC_REV.
- `with_amend` — Patent amendment proposed. Adds `inf.app_to_amend`, `inf.def_to_amend`, `inf.reply_def_amd`, `inf.rejoin_amd` to UPC_INF; `rev.app_to_amend`, `rev.def_to_amend`, `rev.reply_def_amd`, `rev.rejoin_amd` to UPC_REV. Composes with `with_ccr` / `with_cci`.
Every other proceeding type (DE_INF, DE_NULL, EPA_OPP, EPA_APP, EP_GRANT, DPMA_*, UPC_APP, UPC_PI, UPC_DAMAGES, UPC_DISCOVERY, UPC_COST_APPEAL, UPC_APP_ORDERS) has zero `condition_flag` rules — only one canonical timeline.
### 5.2 Chip set per proceeding
Chips are conditionally rendered based on which flags exist on the selected proceeding's `condition_flag` rule rows.
```
UPC_INF: [Standard] [+ Widerklage Nichtigkeit (with_ccr)] [+ Patentänderung (with_amend)]
UPC_REV: [Standard] [+ Verletzungs-Widerklage (with_cci)] [+ Patentänderung (with_amend)]
DE_INF, DE_NULL, EPA_OPP, …: (no chips, single timeline)
```
Chips are **toggleable** (multi-select), not radio. Each chip toggles its flag on/off; the timeline reflows. Composite combinations (`with_ccr + with_amend`) render the union of rules. Toggling all chips off renders the base proceeding (no `condition_flag` rules).
Future flags (court-specific, expedited) — chips are **disabled and dimmed** with a tooltip "wird noch nicht unterstützt" when the proceeding has nothing to offer. We do NOT pre-render dead chips for proceedings without variants.
### 5.3 Consolidated vs lane view — the toggle m asked for
m's example: an infringement action triggers a counterclaim for revocation. Two ways to render:
**Consolidated** — One timeline. CCR-related events (the `with_ccr` flag) interleave with base UPC_INF events along the same vertical timeline. Colour-coded by `primary_party` (claimant / defendant / court). This is the current behaviour when `?flags=with_ccr` is set.
**Lane** — Two parallel columns. Column 1 = UPC_INF base timeline. Column 2 = UPC_REV timeline (the counterclaim's own proceeding). Rules anchored on shared trigger dates align horizontally.
Toggle UI sits beside the variant chips:
```
[Standard] [+ Widerklage] | View: ◉ Konsolidiert ○ Spalten
```
In v1, the lane view is only available when the user has selected a variant that implies a *second proceeding* — i.e., `UPC_INF + with_ccr` shows UPC_INF || UPC_REV side-by-side, `UPC_REV + with_cci` shows UPC_REV || UPC_INF. Same backend data, different paint.
For variants that DON'T imply a second proceeding (`with_amend` alone), the lane toggle is hidden — there's only one timeline.
### 5.4 URL state
`/tools/verfahrensablauf?proceeding=UPC_INF&flags=with_ccr,with_amend&view=lane&trigger_date=2026-05-12`
Trigger date is optional — without it, the timeline renders with relative offsets ("+3 Monate", "+6 Wochen") instead of absolute dates. This is the "browse shape" mode. With a trigger date the timeline becomes concrete.
`view=consolidated` (default) or `view=lane` toggles paint.
---
## 6. Side-by-side compare
The second variant axis. m wants to compare *two different proceeding types* OR *two variants of the same proceeding* side-by-side.
### 6.1 Affordance
A "Vergleichen" button next to the variant chips. Click → second proceeding picker slides in, second variant-chip row appears, two timelines render side-by-side.
```
┌──────────────────────────────────────────────────────────────┐
│ Verfahren A: [UPC_INF ▾] Flags: [✓ with_ccr] [ with_amend]│
│ Verfahren B: [UPC_REV ▾] Flags: [✓ with_cci] [ with_amend]│
│ Trigger A: [2026-05-12] Trigger B: [synced ✓] │
│ ────────────────────────────────────────────────────────────│
│ │
│ Timeline A ║ Timeline B │
│ ┌─ Klageerhebung ║ ┌─ Nichtigkeitsklage │
│ │ 2026-05-12 ║ │ 2026-05-12 │
│ ├─ Klageerwiderung ║ ├─ Klageerwiderung │
│ │ 2026-08-12 (3M) ║ │ 2026-08-12 (3M) │
│ … │
└──────────────────────────────────────────────────────────────┘
```
### 6.2 Decisions
- **Max 2 timelines for v1.** Three+ would push the layout below mobile readability and add picker friction. The `counterclaim_of` example always pairs two proceedings; that's the common case.
- **Synchronised date axis** by default (Trigger A = Trigger B). Toggle "Unabhängige Trigger-Daten" reveals a second date input. Synced is the right default because the most common compare is "what happens in both proceedings starting from the same Klageerhebung date".
- **Independent variant chips per timeline.** Variant A's flags don't affect Variant B. The chips render per-column.
- **Wide-screen primary.** Lane and compare views require ≥720px to be readable. Below that, stack vertically (Timeline A above Timeline B, full-width each). The synced-trigger constraint stays; users on small screens still get the compare, just stacked.
- **Permalink-shareable.** `?compare=1&a_proceeding=UPC_INF&a_flags=with_ccr&b_proceeding=UPC_REV&b_flags=with_cci&trigger=2026-05-12&synced=true` — every chip + variant + trigger captured in URL. Copy-paste produces an identical render.
### 6.3 Lane view vs Compare view — are they the same thing?
Conceptually similar (two columns), but UX-distinct:
- **Lane view** is "one variant that implies two proceedings rendered together". The two columns are *logically linked* (e.g., `UPC_INF + with_ccr` always shows the same UPC_REV alongside).
- **Compare view** is "the user picked two arbitrary proceedings + variants to look at together". The two columns are *independently chosen*.
In renderer terms they share the same DOM layout (CSS grid with 2 columns). The state differs: lane view's second proceeding is computed from the variant flag; compare view's second proceeding is user-picked. We implement them as one renderer with two state-entry points.
---
## 7. Sidebar nav labels + URL conventions
### 7.1 Labels (post-cleanup)
Today: **Fristenrechner** + **Verfahrensablauf**.
Recommendation: keep the labels as-is. m's brief suggested alternatives ("Frist berechnen" / "Verfahrensabläufe") — I think the current labels are tighter:
- "Fristenrechner" is a known brand-term in the firm vocabulary (per the German-tool-names-as-brands convention in CLAUDE.md).
- "Verfahrensablauf" reads as a noun "the procedural flow", which matches the abstract-browse intent better than the plural "Verfahrensabläufe" (which reads as "the catalogue of all flows").
But I flag this for m in §13 — the call is brand-strategic, not technical.
### 7.2 URL conventions
| Route | Key params | Purpose |
|---|---|---|
| `/tools/fristenrechner` | `mode=akte\|abstract` | Pick branch |
| `/tools/fristenrechner?mode=akte&project=<uuid>` | + `path=outgoing\|happened` | Akte deadline determination |
| `/tools/fristenrechner?mode=abstract&forum=upc&proceeding=UPC_INF&trigger_date=…` | + `flags=…` | Abstract deadline determination |
| `/tools/verfahrensablauf` | `proceeding=…&flags=…&view=…&trigger_date=…` | Browse one proceeding-shape |
| `/tools/verfahrensablauf?compare=1&a_proceeding=…&b_proceeding=…&…` | (per §6.2) | Compare two |
The `?path=a` query param dies entirely. The `fixVerfahrensablaufActive` function deletes. The localStorage key `paliad.fristen.pathway` is preserved (still used by Akte-mode Pathway A/B inside `/tools/fristenrechner`); it gets a sibling `paliad.fristen.mode`.
### 7.3 Bookmarkability + share
Both pages produce permalinks. Copy URL → paste in another browser → identical view (with same auth gate). The compare-view URL is particularly load-bearing for the "send your colleague a precomputed timeline" use case — it's how a PA quickly shows a counterpart "this is the shape we're looking at".
---
## 8. Mobile + responsive
Existing breakpoints in the codebase: 640px / 720px / 768px / 1023px (`frontend/src/styles/global.css`).
### 8.1 `/tools/fristenrechner`
- **≥720px:** Step 0 toggle horizontal. Akte search results in a list.
- **<720px:** Step 0 toggle stacks (radio rows top-to-bottom). Akte list full-width.
- **<480px:** Proceeding-tile picker (UPC / DE / EPA / DPMA tabs + tiles) wraps tiles to one column.
### 8.2 `/tools/verfahrensablauf`
- **≥1023px:** Lane view + compare view render side-by-side (CSS grid 2-col).
- **7201022px:** Lane view side-by-side; compare view stacks (Timeline A above Timeline B, full-width).
- **<720px:** Both lane and compare stack vertically. Variant chips wrap to 2-3 rows.
- **<480px:** Single-column always. Compare-view "Vergleichen" button still works but stacks the result rows.
### 8.3 Variant chips on mobile
Chips wrap with `flex-wrap`. Maximum 3 chips per row on a 360px viewport (each chip 110px wide); composite proceedings (UPC_INF, UPC_REV) fit 3 chips so this works.
### 8.4 What does NOT collapse on mobile
- The trigger-date input. Stays a single date picker (browser-native; iOS / Android already render their own UI).
- The proceeding picker. Stays tiled (large tap targets).
- The result rows (column + timeline views). Render unchanged from today; mobile already handles them.
---
## 9. What gets dropped
| Today | Post-cleanup |
|---|---|
| **Step 2 "Verfahrensablauf einsehen" card** | Deleted. The abstract-browse case has its own route. |
| **Sidebar `?path=a` deep-link** | Deleted. `/tools/verfahrensablauf` replaces it. |
| **`fixVerfahrensablaufActive()` function** | Deleted. Both sidebar entries map 1:1 to URLs; native SSR active-class works. |
| **`localStorage["paliad.fristen.pathway"]`** | Preserved as-is. Still used inside Akte-mode Pathway A/B. |
| **The Step 1/Step 2 fork on `/tools/fristenrechner`** | Replaced by Step 0 (Akte vs Abstract). Step 2's "file vs happened vs browse" becomes a wizard-internal branch, not a top-level page state. |
| **Step 3a "outgoing-intent chooser" (File / Draft / Enter)** | Kept inside Akte-mode. The Draft option (`fristen-step3a-draft`) stays disabled as today (placeholder). |
The deletions sum to maybe 200300 LoC out of `client/fristenrechner.ts`. The lift of `verfahrensablauf-core.ts` is the bigger reshape; net LoC churn around +500 / -300.
---
## 10. Slicing for the coder pass
Four slices, each independently mergeable. Slice 1 ships the structural split; Slices 24 layer features.
### Slice 1 — Route + shell split (foundation)
- New route `/tools/verfahrensablauf` registered in `internal/handlers/handlers.go`.
- New handler `handleVerfahrensablaufPage` serves `dist/verfahrensablauf.html`.
- New TSX `frontend/src/verfahrensablauf.tsx` renders the proceeding-tile picker + result panel. No variant chips yet; no compare yet. Just the abstract-browse case factored out.
- New client `frontend/src/client/verfahrensablauf.ts` minimal: picker calc render. Imports from a new shared module `client/views/verfahrensablauf-core.ts`.
- Sidebar `Sidebar.tsx:163-164` updated: second nav entry's href flips from `/tools/fristenrechner?path=a` to `/tools/verfahrensablauf`.
- `client/sidebar.ts:447 fixVerfahrensablaufActive` deleted (and its call site at the bottom of `initSidebar`).
- Step 2 "Verfahrensablauf einsehen" card markup in `frontend/src/fristenrechner.tsx` + its handler in `client/fristenrechner.ts` deleted.
- Step 2's "browse" event handler at `fristen-step2-browse` removed; the path="a" branch in `showPathway` still exists for Akte-mode wizard re-use.
- DE/EN i18n keys: `tools.verfahrensablauf.title`, `tools.verfahrensablauf.subtitle`, plus all the proceeding-tile labels (already exist reused).
- Build: add `renderVerfahrensablauf` import and `bun:write` step in `frontend/build.ts`.
- Tests: Playwright smoke `/tools/verfahrensablauf` renders, sidebar nav links work, no 404s, the old `?path=a` URL 302s to `/tools/verfahrensablauf` (back-compat for any bookmarked links).
**What does NOT change in Slice 1:** the existing `/tools/fristenrechner` page works exactly as today (Step 1 / Step 2 / Step 3a / Pathway A / Pathway B). Step 0 is Slice 2.
### Slice 2 — Step 0 on `/tools/fristenrechner`
- New Step 0 toggle component in `fristenrechner.tsx` (above today's Step 1).
- `?mode=akte|abstract` URL param + `paliad.fristen.mode` localStorage hook.
- "Abstract" branch reveals a new compact proceeding-tile picker inside the Step 0 frame (or scrolls to today's wizard-step-1).
- "Akte" branch renders today's Step 1 (Akte search + ad-hoc chips).
- Akte-driven auto-derivation 4): a new service `ResolveFristenrechnerCodeForProject(projectID)` and frontend hook that preselects the proceeding tile + `our_side` chip + Court hint (highlight only, not pre-select).
- Tests: Playwright smoke for the four state transitions (akte abstract, abstract akte, akte+project akte-no-project, deep-link `?mode=abstract&forum=upc`).
### Slice 3 — Variant chips + consolidated/lane view
- Variant-chip strip on `/tools/verfahrensablauf` (`with_ccr`, `with_cci`, `with_amend` conditional on proceeding).
- `?flags=` URL param.
- Lane-vs-consolidated toggle. Lane view auto-enables when the variant implies a second proceeding (UPC_INF+with_ccr UPC_REV; UPC_REV+with_cci UPC_INF).
- Lane renderer in `views/verfahrensablauf-core.ts` (CSS grid 2-col, shared trigger-date axis).
- Tests: Playwright smoke for variant toggles + lane render + lane on mobile (stack).
### Slice 4 — Side-by-side compare
- "Vergleichen" button + second-proceeding picker.
- `?compare=1&a_proceeding=…&b_proceeding=…&…` URL state.
- Synced-trigger toggle; independent-trigger fallback.
- Permalink test (copy URL fresh tab same render).
- Mobile fallback (stacked).
- Tests: Playwright smoke for compare entry, both timelines render, permalink roundtrip.
Each slice merges to main independently. Slice 1 is the bottleneck; once it's in, Slices 24 can ship in any order (Slice 2 only touches `/tools/fristenrechner`, Slices 3+4 only touch `/tools/verfahrensablauf`).
---
## 11. Tradeoffs flagged
### 11.1 Code duplication vs route clarity
The split forces ~700900 LoC of client code into a shared module (`views/verfahrensablauf-core.ts`). That's lift work without user-visible benefit. The alternative (one big page with `?mode=`) saves the lift but keeps the muddled mental model that triggered this redesign in the first place. **Decision: pay the lift cost.** It's a one-time refactor; the navigation clarity is durable.
### 11.2 Step 0 vs Step 1 — perceived "extra step"
Today's flow: Akte picker (Step 1) choose-intent cards (Step 2) wizard. Tomorrow's flow: mode toggle (Step 0) Akte picker OR abstract picker wizard. Same number of clicks for the Akte case. One *fewer* click for the abstract case (you go straight to proceeding tiles instead of clicking "Verfahrensablauf einsehen" first). Net win.
### 11.3 Court free-text means imperfect auto-derivation
We can't reliably auto-pick `court_id` from `projects.court` until that column becomes an FK. The design leans on "highlight matching options" rather than silent preselect. The cost is one extra click. **File a follow-up ticket** to migrate `projects.court` `court_id` FK; until then, no silent FK promotion.
### 11.4 Pathway B (Determinator cascade) stays inside Akte-mode
t-paliad-166 will redesign Pathway B as a row-by-row cascade. We don't pre-empt that. Pathway B remains reachable from Akte-mode's "Etwas ist passiert" card. In Abstract mode it's reachable through a "Frist aufgrund Ereignis" link in the result panel. Both paths stay; only the entry surface changes.
### 11.5 Variant chips disabled for non-UPC proceedings
Only UPC_INF and UPC_REV have `condition_flag` rules today. DE_INF, DE_NULL, EPA_OPP, etc. show no chips. This is honest the data isn't there. If users ask for German "with/without counterclaim" variants, that's a `condition_flag` seed-data ticket, not a UX redesign.
### 11.6 Lane view assumes the second proceeding exists
`UPC_INF + with_ccr` lanes to `UPC_REV`. But `UPC_REV` itself is a full proceeding with its own deadlines anchored on a *separate* trigger date (the CCR filing date, not the SoC date). For v1 we render the second lane with the *same trigger date* as the primary which is wrong-but-useful: the user sees the *shape* of the counterclaim's flow but the dates are nominal. A future iteration adds a "second trigger date" input for the lane. **Document this in the UI** with a small caveat: "Annahme: Widerklage zur gleichen Zeit eingelegt".
### 11.7 No state preserved across the route boundary
If a user is mid-calc on `/tools/fristenrechner` and clicks the sidebar's `/tools/verfahrensablauf`, their wizard state is lost. We don't try to bridge the two they're different intents. The URL captures everything important; the user can pop back via the browser back button.
### 11.8 Print mode is the only export
No PDF, no SVG, no CSV export in this design. The existing `#fristen-print-btn` + `@media print` stylesheet handles it. m's broader chart-export design (`docs/design-project-chart-2026-05-09.md`) covers the export ambition for the project-level chart; this Tool-level surface keeps it simple.
---
## 12. Files implementer will touch (Slice 1 only)
This is the bottleneck slice. Slices 24 each add their own scope but Slice 1 defines the structural change.
**Backend (Go):**
- `internal/handlers/handlers.go:162` add `protected.HandleFunc("GET /tools/verfahrensablauf", handleVerfahrensablaufPage)`.
- `internal/handlers/fristenrechner.go` add `handleVerfahrensablaufPage` (1-liner, serves `dist/verfahrensablauf.html`). Or split into its own file `internal/handlers/verfahrensablauf.go` for tidiness.
- `internal/handlers/handlers.go` add back-compat 302: `/tools/fristenrechner?path=a` `/tools/verfahrensablauf` (preserves bookmarked links). A small middleware or an `init` redirect handler suffices.
**Frontend (TSX + TS):**
- `frontend/src/verfahrensablauf.tsx` new file. ~250 LoC. Renders header + jurisdiction-tab picker + proceeding-tile picker + result panel container. No variant chips, no compare yet (those are Slices 3+4). Reuses `<PWAHead>`, `<Sidebar>`, `<Footer>`.
- `frontend/src/client/verfahrensablauf.ts` new file. ~150 LoC for Slice 1. Wires the picker POST `/api/tools/fristenrechner` render via shared module.
- `frontend/src/client/views/verfahrensablauf-core.ts` new file. The lifted code: `renderTimelineBody`, `renderColumnsBody`, the `calculateDeadlines` fetch wrapper, court picker, view-toggle. Imported by both `client/fristenrechner.ts` and `client/verfahrensablauf.ts`.
- `frontend/src/client/fristenrechner.ts` delete the Step 2 "browse" card handler (lines 2715-2717 today). Remove the `?path=a` interpretation as a top-level entry (still keep `path="a"` as an Akte-mode wizard pathway). Import calc + render from `views/verfahrensablauf-core.ts`.
- `frontend/src/fristenrechner.tsx` delete the `fristen-step2-browse` card markup (lines 215-223 today).
- `frontend/src/components/Sidebar.tsx:163-164` change href from `/tools/fristenrechner?path=a` to `/tools/verfahrensablauf`. Adjust the `currentPath` comparison to match the new pathname.
- `frontend/src/client/sidebar.ts:447 fixVerfahrensablaufActive` delete the function + its call site.
**Build:**
- `frontend/build.ts` add `renderVerfahrensablauf` import (line 5-6 area), add `client/verfahrensablauf.ts` to `entrypoints` array (line 228 area), add the `Bun.write(join(DIST, "verfahrensablauf.html"), renderVerfahrensablauf())` step (line 355 area).
**i18n:**
- `frontend/src/client/i18n.ts` + `i18n-keys.ts` add `tools.verfahrensablauf.title`, `tools.verfahrensablauf.subtitle`, `nav.verfahrensablauf` (already exists; re-verify the key still points at the right label).
**Tests:**
- Playwright smoke covering: `/tools/verfahrensablauf` renders, sidebar nav link active class lights up correctly without `fixVerfahrensablaufActive`, `/tools/fristenrechner?path=a` 302s, the calc roundtrip works on both routes, build artefacts emit both `fristenrechner.html` and `verfahrensablauf.html`.
**Out of Slice 1 (deferred to Slices 2-4):**
- Step 0 toggle on `/tools/fristenrechner` (Slice 2).
- Akte-driven auto-derivation helper service (Slice 2).
- Variant chips, lane view (Slice 3).
- Compare view (Slice 4).
---
## 13. Open questions for m
1. **Sidebar label.** Keep "Verfahrensablauf" (current) or switch to "Verfahrensabläufe" (plural reads as catalogue) or something else? Current label is unambiguous; plural risks reading as a list page.
2. **Akte-mode mapping with no `proceeding_type_id`.** 11/11 live projects have NULL proceeding_type_id. Akte-mode silently degrades to "pick proceeding manually". OK? Or should Akte-mode require a proceeding_type_id and force the user to set it on the project first?
3. **Court free-text → FK migration.** I'm flagging this as a follow-up but not designing it here. Want me to file a separate ticket so it's tracked, or fold it into Slice 2's scope?
4. **Lane view caveat for v1.** The second lane uses the same trigger date as the primary (so dates are nominal-but-wrong for a real-world CCR filed weeks later). UI caveat "Annahme: Widerklage zur gleichen Zeit eingelegt" is honest but adds clutter. Acceptable or do we hold lane view back until trigger-2 input lands?
5. **Compare view max columns.** v1 caps at 2. Three+ would be a richer compare ("UPC_INF vs DE_INF vs EPA_OPP for the same patent") but layout-hostile on anything <1280px. Confirm 2 for v1?
6. **Back-compat for `?path=a`.** I propose a 302 redirect so old bookmarked URLs work. Alternative: 410 Gone (harsh) or 200-with-deprecation-banner (chatty). 302 is the conventional move; confirm?
7. **Drop the "Verfahrensablauf einsehen" card from Step 2 entirely** vs keep it as a deep-link shortcut to `/tools/verfahrensablauf` from inside the Fristenrechner flow? I'm proposing drop; m signals?
8. **DE_INF / EPA_OPP / DPMA variants.** Today no `condition_flag` rules. Future seed-data tickets (out of scope here): with/without expedited, with/without amendment for EPA opposition, etc. Want a follow-up ticket filed for the seed-data work or wait for user feedback?
9. **Pathway B (Determinator) entry point in Abstract mode.** I propose a small "Frist aufgrund Ereignis" link in the result panel. Or hide it entirely from abstract mode? Today Pathway B is reachable from anywhere via `?path=b`.
10. **Implementer choice.** I'd recommend a coder familiar with `frontend/src/client/fristenrechner.ts` for Slice 1 since the bundle split is the load-bearing risk. Curie (t-paliad-086), cronus (t-paliad-088, t-paliad-110), noether (t-paliad-165) have all touched the file. Head decides.
---
**DESIGN READY FOR REVIEW**
Slice 1 is the structural foundation (route split, sidebar cleanup, code lift). Slices 2-4 layer Step 0 / variant chips / compare on top. Awaiting m's go/no-go before coder shift.

View File

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

View File

@@ -0,0 +1,394 @@
# Research — Determinator coverage audit (gaps + smart-navigation framing)
**Author:** curie (researcher)
**Date:** 2026-05-08
**Task:** t-paliad-167 (Gitea m/paliad#26)
**Mode:** read-only research; produces a gap matrix + design framing, not migrations.
Builds on `docs/audit-upc-rop-deadlines-2026-05-08.md` (t-paliad-159) which drove from the UPC Rules of Procedure outward. This one drives from **paliad's own corpus** outward: every active rule, every firm-wide event_type, every cascade leaf — and asks "can a Determinator user actually reach this row?"
m's prompt (verbatim, 2026-05-08 22:24 Determinator dogfooding):
> We are still missing all kinds of orders in our decision tree. What do we need to do to cover everything? Can we maybe check what "options" we have covered in our tree and which we don't? I want to have a smart way to navigate people through the tree to determine what's next.
---
## 1. Scope and method
**Five surfaces, three pathways.**
paliad currently has three independent ways to land on a deadline:
- **Pathway A — Fristenrechner (proceeding tree).** User picks a proceeding type (`UPC_INF`, `DE_NULL`, `EPA_OPP`, …) and a trigger date; the engine emits the entire timeline. Source: `paliad.deadline_rules` rows where the parent proceeding has `category='fristenrechner'` (19 active proceeding types).
- **Pathway B — Determinator cascade.** User answers "what just happened?" by drilling 1-3 levels through `paliad.event_categories` (6 roots → 27 → 49 → 43 leaves; 103 leaves total). Each leaf maps to one or more `paliad.deadline_concepts` via `paliad.event_category_concepts`. Concepts then resolve to rules (`deadline_rules.concept_id`) and event_types (`deadline_concept_event_types`, mig 072).
- **Pathway C — Trigger-event search.** Free-text `paliad.trigger_events` lookup (102 youpc-imported rows). Used by the t-paliad-086 "Was kommt nach…" mode and by autocomplete. Out of audit scope here — no Determinator surface uses it.
**Reachability rule.** For this audit, "reachable from the Determinator cascade" means: there exists some leaf `L` in `event_categories` such that `event_category_concepts(L → C)` and either:
- (rule-side) `deadline_rules.concept_id = C` for the rule under test, or
- (event_type-side) `deadline_concept_event_types(C, E)` for the event_type under test.
Concepts that exist but never appear in `event_category_concepts` are **dead-end concepts** — Pathway A may use them, Pathway B can't.
**Inventory snapshot (live youpc Supabase, 2026-05-08 22:30):**
| Surface | Rows | Notes |
|---|---|---|
| `proceeding_types` (`category='fristenrechner'`) | 19 | UPC×8, DE×5, EPA×2, EP×1, DPMA×3 |
| `proceeding_types` (`category='litigation'`, legacy/dormant) | 7 | INF, REV, CCR, AMD, APM, APP, ZPO_CIVIL — see §2.1 |
| `deadline_rules` active | 172 | 95 true deadlines (`duration_value > 0`), rest are anchors / court-set |
| `deadline_rules` true deadlines, `category='fristenrechner'` only | **76** | The audit denominator |
| `event_categories` active | 125 | 6 roots, 103 leaves |
| `event_category_concepts` mappings | 153 | 45 distinct concepts in cascade |
| `deadline_concepts` active | 57 | 45 in cascade, 12 dead-end |
| `event_types` firm-wide active | 44 | 26 reachable, 18 unreachable |
| `deadline_concept_event_types` (mig 072) | 32 rows / 25 concepts / 30 event_types | The Regel↔Typ junction |
**Cascade root inventory (Pathway B entry chips):**
| Root | Children | Leaves | Purpose |
|---|---|---|---|
| `cms-eingang` | gericht / gegenseite | 50 | Inbound — paper just landed |
| `muendl-verhandlung` | geladen / gehalten / verlegt / zwischenverfahren | 4 | Hearing-pivot |
| `beschluss-entscheidung` | (11 leaf decisions per forum) | 11 | Decision-pivot — duplicate of `cms-eingang.gericht.endentscheidung.*` |
| `frist-verpasst` | upc / de-patg / de-zpo / epa / dpma | 5 | Wiedereinsetzung family |
| `ich-moechte-einreichen` | klage / berufung / widerklage / spätere-schriftsätze / einspruch | 32 | Outbound — file something |
| `sonstiges` | — | 1 (dangling, no concept) | Escape hatch |
**Per-forum cascade depth:** UPC has 38 reachable leaves, DE 35, EPA 11, DPMA 7. The DE corpus is now within 8% of UPC's — the imbalance flagged in earlier audits is largely closed. EPA/DPMA remain underbuilt.
---
## 2. Inventory by jurisdiction
Each section answers the same three questions: (a) which rules exist, (b) are they reachable from the cascade, (c) what's missing relative to a real practitioner's everyday surface area.
### 2.1 Legacy / dormant proceedings (out of scope but worth flagging)
The 7 `category='litigation'` proceedings (INF, REV, CCR, APM, AMD, APP, ZPO_CIVIL) carry **40 active rules** between them but:
- 0 cascade references (`event_category_concepts.proceeding_type_code` never names them),
- 0 concept_id linkage on any of their 18 true deadlines,
- not surfaced in the Fristenrechner UI (filtered by `category='fristenrechner'` in `deadline_rule_service.go:740`).
These rows are zombie taxonomy from migration 008/009 — superseded by the `UPC_*` / `DE_*` / `EPA_*` / `DPMA_*` family in mig 012/042/043/044. **Recommendation:** flag them `is_active=false` in a follow-up cleanup migration; they only confuse audits.
The audit denominator is therefore **76 true Fristenrechner deadlines across 19 active proceedings**.
### 2.2 UPC
Most-mature jurisdiction. 8 proceedings, 40 true deadlines, 39 reachable from cascade.
| Proceeding | True deadlines | Reachable | Notes |
|---|---|---|---|
| UPC_INF | 11 | 10 | `inf.app_to_amend` (RoP.030.1, 2mo) has no concept_id — Pathway A only |
| UPC_REV | 9 | 9 | Plus 2 duration bugs flagged in t-paliad-159 (R.49.1 3→2mo, R.52 2→1mo) |
| UPC_PI | 0 | n/a | All 4 rules are anchors / court-set (no calendar arithmetic) |
| UPC_APP | 5 | 5 | 3 rule_code-drift bugs flagged in t-paliad-159 (R.224.1.a, R.224.2.a, R.235.2) |
| UPC_DAMAGES | 3 | 3 | |
| UPC_DISCOVERY | 3 | 3 | |
| UPC_COST_APPEAL | 1 | 1 | Tree-end leaf still missing R.155 chain |
| UPC_APP_ORDERS | 4 | 4 | R.224.2.b grounds-on-orders missing entirely (RoP audit gap 6) |
**Cascade-side gaps that t-paliad-159 surfaced and remain open:**
- R.19 Preliminary Objection (no leaf, no rule, no event_type — but `upc_preliminary_objection` event_type exists, archived from cascade)
- R.197.3 Saisie review request, R.198/R.213 31d-or-20wd start-of-merits
- R.262.2 Confidentiality response (14d) — daily occurrence in HLC infringement, completely absent from both pathways
- R.333.2 Review of CMO (15d) — trigger event #16 exists, no rule, no leaf
- R.353 Rectification (1mo) — trigger event #41 exists, no rule, no leaf
- R.207.6.a / R.229.2 / R.71 Mängelbeseitigung — registry-correction family entirely missing
- R.109.1 / R.109.4 / R.109.5 oral-hearing translation prep (only `before`-mode rules in the corpus)
### 2.3 DE (Zivilgericht + Bundesinstanzen)
5 proceedings, 22 true deadlines, all 22 reachable from cascade.
| Proceeding | True deadlines | Reachable | Cascade entry |
|---|---|---|---|
| DE_INF | 6 | 6 | `cms-eingang.gegenseite.de-inf.*` + `urteil-de-inf-lg` |
| DE_NULL | 5 | 5 | `cms-eingang.gegenseite.de-null.*` + `urteil-de-null-bpatg` |
| DE_INF_OLG | 3 | 3 | `urteil-de-inf-lg` (Berufung-Begründung) |
| DE_INF_BGH | 5 | 5 | `urteil-de-inf-olg` (NZB / NZB-Begründung / Revisionsfrist / Revisionsbegründung) |
| DE_NULL_BGH | 3 | 3 | `urteil-de-null-bpatg` (Berufung BGH) |
**Headline DE gaps (entirely uncovered by both pathways):**
- **Hinweisbeschluss** — `cms-eingang.gericht.hinweisbeschluss` leaf exists and links to `response-to-preliminary-opinion` concept, but **no rule row computes a deadline from it**. The concept has 1 rule (`r79-further-stellungnahme`, 2mo) wired to EPA_OPP only. The DE Hinweisbeschluss deadline (4 weeks under §139 ZPO is judge-set; under § 522 ZPO Berufung-Hinweis is judge-set with min 2 weeks) is not in the rule corpus.
- **Beweisbeschluss / Beweissicherungsanordnung (DE)** — `cms-eingang.gericht.anordnung` leaf exists but only links to `request-for-discretionary-review` (UPC R.220.3). No DE-side reaction (e.g. Stellungnahme nach Beweisaufnahme, § 411 ZPO 2-week comment on Sachverständigengutachten).
- **Streitwertbeschluss** — neither cascade leaf nor rule. Streitwertbeschwerde is § 68 GKG, 6 months → frequent and unrepresented.
- **Versäumnisurteil** — leaf `versaeumnisurteil` exists with concept `versaeumnisurteil-einspruch`, but the concept has 0 rules. The 2-week Einspruch deadline (§ 339 Abs. 1 ZPO) is documented in the concept text but doesn't compute. A user lands on the leaf and gets a hint card, no calendar entry.
- **ZPO Klage as starting point** — Pathway A has a legacy `ZPO_CIVIL` proceeding (dormant per §2.1) but no live equivalent; Pathway B's `cms-eingang.gegenseite.de-inf.klageschrift` covers the *defendant*'s perspective only. A claimant entering "I just filed a Klageschrift" has no path.
- **Schriftsatznachfristsetzung (§ 283 ZPO)** — concept `schriftsatznachreichung` exists in cascade with 0 rules; "court grants me a 3-week response window" produces no calendar entry.
### 2.4 EPO
2 active proceedings (EPA_OPP, EPA_APP) plus 1 grant-side outlier (EP_GRANT). 12 true deadlines, 8 reachable from cascade.
| Proceeding | True deadlines | Reachable | Notes |
|---|---|---|---|
| EPA_OPP | 4 | 4 | Cascade entry via `cms-eingang.gegenseite.epa-opp.einspruchsschrift` + `entscheidung-epa-opp` |
| EPA_APP | 4 | 4 | Cascade entry via `cms-eingang.gegenseite.epa-app` + `entscheidung-epa-boa` |
| **EP_GRANT** | **4** | **0** | All 4 unreachable — concepts (`search-report`, `publication`, `request-for-examination`, `approval-and-translation`) have no `event_category_concepts` row |
**EP_GRANT is the single biggest blanket-gap in the audit.** The 4 most fundamental EPO grant-side deadlines (R.70(1) examination request 6mo, Art. 93 publication, R.71(3) approval+translation 4mo, search-report 6mo) are computable in Pathway A but the cascade has zero entry points for them. A user landing on the Determinator says "EP-Anmeldung erteilt, was nun?" and finds nothing.
**Headline EPO gaps (both pathways):**
- **R.71(3) communication received** — `cms-eingang.gericht.rechtsverlust-epa` covers the *negative* outcome (Rechtsverlust → Weiterbehandlung/Wiedereinsetzung) but the *positive* outcome (Mitteilung nach R.71(3) → 4-month approval+translation) has no leaf. The concept exists (`approval-and-translation`) but no leaf binds it.
- **R.94(3) examination-stage Bescheid** — entirely absent. Most-frequent EPO deadline in prosecution practice ("4-month period to respond to examination report"); no rule, no leaf, no event_type.
- **EPO opposition reply** — event_type `epo_opposition_reply` exists, archived from cascade (no concept link). Pathway A's EPA_OPP has the rule but no Pathway B path.
- **R.116 EPO oral-proceedings final-submissions** — covered (`r116-final-submissions` concept, 2 rules, leaf `muendl-verhandlung.geladen` + `ich-moechte-einreichen.spaetere-schriftsaetze.r116-eingaben`).
- **Annual renewal fees (Art. 86 EPC)** — `epo_renewal_fee` event_type exists, archived from cascade. No concept, no rule.
### 2.5 DPMA
3 active proceedings (DPMA_OPP, DPMA_BPATG_BESCHWERDE, DPMA_BGH_RB). 6 true deadlines, all 6 reachable from cascade.
| Proceeding | True deadlines | Reachable | Cascade entry |
|---|---|---|---|
| DPMA_OPP | 2 | 2 | `cms-eingang.gegenseite.dpma-opp` + `entscheidung-dpma` |
| DPMA_BPATG_BESCHWERDE | 2 | 2 | `entscheidung-dpma` (Beschwerde) + `beschluss-bpatg-beschwerde` |
| DPMA_BGH_RB | 2 | 2 | `beschluss-bpatg-beschwerde` (Rechtsbeschwerde) |
**Headline DPMA gaps (both pathways):**
- **Beanstandungsbescheid (Prüfungsverfahren)** — DPMA examination-stage objection notice with 4-month default response window (§ 45 PatG). No rule, no leaf, no event_type. Most-frequent DPMA deadline in real practice and entirely unrepresented.
- **Aktenversendungsbescheid / Anhörungsbescheid (Einspruchsverfahren)** — § 59 PatG opposition oral-hearing summons; no leaf.
- **Anmeldetag-Mitteilung / Recherchenbericht (DPMA)** — `dpma_examination_request` event_type exists with concept link to `request-for-examination`, but the concept is a Pathway-A-only dead-end (not in cascade).
- **Patenterteilungsbeschluss** — no leaf for the positive grant decision (the negative-outcome Beschluss-BPatG path covers appeals, not the grant-stage event).
### 2.6 Cross-cutting (procedural orders that span jurisdictions)
The categories m specifically called out — "court orders that aren't entry events but procedural orders." Status:
| Order type | UPC | DE | EPA | DPMA | Notes |
|---|---|---|---|---|---|
| Hinweisbeschluss / vorläufige Würdigung | concept-only | concept-only (no rule) | n/a | n/a | Leaf `cms-eingang.gericht.hinweisbeschluss` exists; the only rule wired to `response-to-preliminary-opinion` is EPA-side R.79. Judge-set period in DE/UPC; the leaf produces no calendar entry. |
| Beweisbeschluss / Beweissicherungsanordnung | partial (R.196/R.197) | absent | n/a | n/a | Trigger events #26 / #44 / #65 / #66 exist; only R.197.3 (saisie review 30d) is missing as a rule. § 411 ZPO 2-week Stellungnahme-Frist nowhere. |
| Streitwertbeschluss | n/a | absent | n/a | n/a | § 68 GKG 6-month Streitwertbeschwerde — common, unrepresented. |
| Versäumnisurteil | n/a | leaf-only (no rule) | n/a | n/a | § 339 ZPO 2-week Einspruch — concept `versaeumnisurteil-einspruch` carries 0 rules. |
| Case-Management-Order (R.220.1.c / § 273 ZPO) | partial | absent | n/a | n/a | UPC R.333.2 review-of-CMO 15d missing; trigger event #16 exists. |
| Berichtigungsbeschluss / Tatbestandsberichtigung | absent | absent | n/a | n/a | UPC R.353 1mo / § 320 ZPO 2-week — both unrepresented. |
| Konfidentialitätsantrag der Gegenseite | absent | n/a | n/a | n/a | UPC R.262.2 14d — high-frequency in HLC infringement work. |
| R.71(3) communication | n/a | n/a | absent | n/a | The most-common EPO prosecution deadline. |
| Examination-stage Bescheid | n/a | n/a | absent (R.94(3)) | absent (§ 45 PatG) | 4-month response. Single biggest *prosecution* gap. |
| Mängelbeseitigung notification | absent (R.71/R.207.6.a/R.229.2) | absent | absent | absent | Cross-jurisdictional gap. Trigger event #71 exists for UPC. |
| Translation lodging order | absent (R.109.5) | n/a | n/a | n/a | `before`-mode rules — schema supports, no data. |
| Rechtsverlust-Mitteilung | n/a | n/a | leaf-only (covered) | n/a | Only EPA branch wired (`weiterbehandlung` + `wiedereinsetzung`). |
---
## 3. Cascade reachability tables
### 3.1 Rule reachability per proceeding
| Proceeding | True deadlines | No concept | Reachable | Unreachable (concept exists, not in cascade) |
|---|---|---|---|---|
| UPC_INF | 11 | 1 (`inf.app_to_amend`) | 10 | 0 |
| UPC_REV | 9 | 0 | 9 | 0 |
| UPC_APP | 5 | 0 | 5 | 0 |
| UPC_DAMAGES | 3 | 0 | 3 | 0 |
| UPC_DISCOVERY | 3 | 0 | 3 | 0 |
| UPC_COST_APPEAL | 1 | 0 | 1 | 0 |
| UPC_APP_ORDERS | 4 | 0 | 4 | 0 |
| EP_GRANT | 4 | 0 | 0 | **4** |
| DE_INF | 6 | 0 | 6 | 0 |
| DE_NULL | 5 | 0 | 5 | 0 |
| DE_INF_OLG | 3 | 0 | 3 | 0 |
| DE_INF_BGH | 5 | 0 | 5 | 0 |
| DE_NULL_BGH | 3 | 0 | 3 | 0 |
| EPA_OPP | 4 | 0 | 4 | 0 |
| EPA_APP | 4 | 0 | 4 | 0 |
| DPMA_OPP | 2 | 0 | 2 | 0 |
| DPMA_BPATG_BESCHWERDE | 2 | 0 | 2 | 0 |
| DPMA_BGH_RB | 2 | 0 | 2 | 0 |
| **Total** | **76** | **1** | **71** | **4** |
**Reachability rate: 71/76 = 93.4 %.** The 5 unreachable rules concentrate in two clusters:
- `UPC_INF.inf.app_to_amend` (RoP.030.1, 2mo) — no concept_id assigned. Recommended fix: link to `defence-to-application-to-amend` or create a new `application-to-amend` concept.
- All 4 `EP_GRANT` rules — concepts exist (`search-report`, `publication`, `request-for-examination`, `approval-and-translation`) but none has an `event_category_concepts` row. Recommended fix: add an EP-Grant subtree under either `cms-eingang.gericht` or a new `ich-moechte-einreichen.ep-grant` branch.
### 3.2 Event_type reachability (firm-wide active types only, n=44)
**Reachable via cascade (26 of 44):**
| Slug | Category | Jurisdiction |
|---|---|---|
| de_klageerwiderung | submission | DE |
| dpma_appeal | submission | DPMA |
| dpma_opposition | submission | DPMA |
| epo_appeal_grounds, epo_appeal_notice, epo_opposition_filing | submission | EPO |
| upc_application_for_cost_decision, upc_application_for_damages | submission | UPC |
| upc_counterclaim_for_infringement, upc_counterclaim_for_revocation | submission | UPC |
| upc_cross_appeal_2242a (×2 concepts) | submission | UPC |
| upc_defence_to_amend_patent, upc_defence_to_revocation | submission | UPC |
| upc_grounds_of_appeal_2242a (×2 concepts) | submission | UPC |
| upc_protective_letter, upc_rejoinder_to_reply, upc_reply_to_defence | submission | UPC |
| upc_reply_to_defence_to_amend_patent, upc_reply_to_defence_to_revocation | submission | UPC |
| upc_request_to_lay_open_books | submission | UPC |
| upc_statement_for_revocation, upc_statement_of_appeal_2201 | submission | UPC |
| upc_statement_of_claim, upc_statement_of_defence | submission | UPC |
| upc_statement_of_defence_no_ccr, upc_statement_of_defence_with_ccr | submission | UPC |
**Unreachable (18 of 44):**
| Slug | Category | Why unreachable |
|---|---|---|
| upc_decision_of_epo | decision | Concept missing, no junction row |
| upc_decision_on_costs | decision | Junction → `cost-decision` concept; that concept is dead-end (not in cascade) |
| upc_decision_on_merits | decision | No junction row |
| upc_final_decision | decision | No junction row |
| upc_oral_hearing | hearing | Junction → `oral-hearing` concept; dead-end |
| upc_case_management_order | order | Junction → `order` concept; dead-end |
| upc_order_lodge_translations | order | No junction row |
| upc_summons_oral_hearing | service | No junction row |
| upc_application_to_amend_patent | submission | No junction row (parallel to UPC_INF gap above) |
| upc_defence_to_statement_dni, upc_statement_dni | submission | DNI family (RoP audit gap 23) — no rule, no concept, no leaf |
| upc_grounds_of_appeal_2242b | submission | RoP audit gap 6 — R.224.2.b orders-track grounds entirely missing |
| upc_preliminary_objection | submission | RoP audit gap 5 — R.19 entirely missing |
| dpma_examination_request | submission | Junction → `request-for-examination`; dead-end |
| epo_renewal_fee, contract_renewal | fee | No junction row, no concept |
| epo_opposition_reply | submission | No junction row |
| stellungnahme | submission | No junction row, no concept (generic catch-all) |
**Pattern.** The 18 unreachable types split into three groups:
- **Court-side trigger types (8/18)**: decisions, orders, hearings, summons. The cascade is *reaction*-oriented (clicking a leaf yields "what's next") and cannot represent these as endpoints because they are themselves the entry points of reaction trees. Adding them via the `ich-moechte-einreichen` root is structurally wrong; they're not user filings. Adding them via `cms-eingang.gericht` would require an explicit "tag this incoming court event" sub-mode that the Determinator currently doesn't have.
- **Genuinely missing UPC content (5/18)**: DNI family, R.19 PO, R.224.2.b orders-track grounds, EP-grant `application_to_amend_patent`. These are real gaps the RoP audit already named.
- **Prosecution-side gaps (5/18)**: EPO renewal fees, R.94(3) reply, DPMA examination request, generic Stellungnahme, contract renewal. Both pathways skip prosecution; the platform is litigation-first today.
### 3.3 Cascade-side dangling (leaves with no concept attached)
3 leaves carry no concept link:
- `cms-eingang.gericht.bescheid-mit-frist` ("Bescheid mit explizit gesetzter Frist") — intentional escape hatch but produces no calendar entry. A user lands here when no specific Bescheid type matches; without a concept, no autofill, no "I'll do the math for you."
- `muendl-verhandlung.verlegt` — when an oral hearing is rescheduled, no follow-on deadline (correct: judge re-issues with new date).
- `sonstiges` — top-level "Anderes" escape hatch.
These three leaves are the existing "not in the tree" UX — a user already CAN bottom out, but only with zero downstream support. §4 below proposes how to make those moments useful.
### 3.4 Concept-side dead-ends (concepts with rules but no cascade entry)
12 concepts have `is_active=true` and ≥1 rule attached but never appear in `event_category_concepts`:
| Concept | Rules | Comment |
|---|---|---|
| `decision` | 14 | Generic decision-anchor — used by every proceeding's `*.decision` row. Not a reaction target. |
| `oral-hearing` | 11 | Same as decision — anchor not reaction. |
| `publication` | 3 | EP grant publication, A1/B1 dates. |
| `order` | 2 | Generic order-anchor. |
| `cost-decision` | 1 | R.157 fixation-of-costs. Should arguably be reachable since post-cost-decision reactions exist (`application-for-leave-to-appeal`); the leaf `kostenfestsetzung` already maps to `notice-of-appeal` and `application-for-leave-to-appeal`, so the *reaction* path is covered — `cost-decision` itself just doesn't need to be in the cascade. |
| `preliminary-opinion` | 1 | EPA preliminary opinion — used by EPA_OPP. |
| `grant` | 1 | EP grant decision. |
| `filing` | 1 | EP filing date. |
| `search-report` | 1 | EPO search-report 6mo period. |
| `request-for-examination` | 1 | EPO R.70(1) 6mo. |
| `approval-and-translation` | 1 | EPO R.71(3) 4mo. |
| `communication-r71-3` | 1 | Same family as approval-and-translation; intermediate. |
**Reading.** 8 of these are court-side anchors (decision, order, hearing, publication, grant, filing, search-report, preliminary-opinion) — by design not reactions, so their absence from the cascade is structurally correct. The remaining 4 are all the EP-grant family (request-for-examination, approval-and-translation, communication-r71-3, plus the implicit `publication` for EP_GRANT) — these *should* be reachable and currently aren't. Confirms §3.1's EP_GRANT cluster as the single biggest fixable cluster.
---
## 4. Smart-navigation framing — which pattern fits the gap distribution?
Issue §3 names three candidate patterns:
- **(P1) Free-text search at every cascade depth.** "Beweisbeschluss" → suggests closest leaves with a "that's not it" fallback.
- **(P2) Persistent "Mein Ereignis ist nicht dabei" escape button.** Visible at every level → opens a manual entry form with rule-only / no-rule paths.
- **(P3) Breadcrumb-aware "weiter unten suchen".** Flattens deeper levels into the current row's chip set when the user can't pick at the current depth.
The gap distribution we just enumerated tells us which pattern earns its keep. There are four kinds of "I don't see my event" moments:
**Type α — Real gap, content missing.** The user wants a real event paliad genuinely doesn't model (Streitwertbeschluss, R.19 PO, DPMA Beanstandungsbescheid, R.71(3), R.94(3), § 411 ZPO Stellungnahme nach Beweisaufnahme). Count: ~18-22 events from §2.6 plus the RoP audit's 25 missing. **What helps:** an escape that captures *what* the user wanted, so we can prioritise the right migration rather than guess. P2 + telemetry.
**Type β — Reachable but mis-modelled cascade path.** The leaf exists, the user can't find it (different mental label, deeper than expected, wrong root). E.g. R.116 final submissions live under `muendl-verhandlung.geladen` AND `ich-moechte-einreichen.spaetere-schriftsaetze.r116-eingaben`; if the user starts at `cms-eingang` they hit a dead end. Or: Wiedereinsetzung is under `frist-verpasst.*` but a user might look under `ich-moechte-einreichen.spaetere-schriftsaetze`. **What helps:** P1 (search collapses the labelling problem) and P3 (flat-search within current branch when nothing matches).
**Type γ — Court-side trigger event needs to be tagged, not reacted-to.** The user has a `upc_decision_on_merits` and wants to *file it as an event in their project*, not get a reaction list. The cascade doesn't model this — it always assumes "reaction wanted." Count: ~8 of the 18 unreachable event_types. **What helps:** none of P1/P2/P3 directly — this is a separate "tag, don't react" mode. Out of scope here but worth flagging.
**Type δ — Dead-end leaf with no concept (the 3 dangling leaves).** User selected `bescheid-mit-frist` and lands on a content-free card. **What helps:** P2's "manual entry with rule-only path" is exactly the escape these leaves need — turn the dangle into a deliberate fall-through.
### 4.1 Recommendation: **P2 + P1, in that order, with P3 as a stretch.**
**Why P2 first.** Of the four types, only Type α (real content gaps) is genuinely closed by P2, but Type α is also the *only* type that produces actionable feedback for paliad's roadmap. A persistent "Ich finde mein Ereignis nicht" button at every cascade depth, opening a `<dialog>` with:
- a free-text "What event are you trying to file/respond to?" input,
- a date input,
- "kein Regelwerk verfügbar" rule-only path that creates a deadline with `event_type=null, rule_id=null, manual_due_date=...`,
- an opt-in checkbox "Mein Hinweis hilft, paliad zu verbessern" that posts the captured text to a (future) `paliad.coverage_gaps` table,
…does three things at once: (a) unblocks the user immediately, (b) gives m a backlog that's *exactly* the prioritisation signal this audit can't provide alone (which gaps are real demand vs. theoretical RoP completeness), (c) repurposes the 3 dangling leaves and `sonstiges` from "looks broken" to "deliberate fall-through."
Implementation cost: one `<dialog>` modal reused at every depth + one new `coverage_gap` event sink + one feedback-style admin view. The button itself can hang off the existing FilterBar primitive (t-paliad-163) or attach to the bottom of every cascade list.
**Why P1 second.** Type β (mis-modelled paths) is the *quietest* failure mode — the user gives up before clicking anywhere relevant. Search would catch it but the gap data alone doesn't tell us how many such users exist. Layering P1 on top of P2 turns the captured "Mein Ereignis nicht dabei" texts into the very query corpus that powers fuzzy-search ranking. A search input at the top of every cascade level (`<input type="search">` filtering the current set of children + drilling into matching deeper leaves via FTS over `label_de` / `label_en` / `aliases` / linked `concept.aliases`) closes Type β cheaply once the corpus is decent.
**Why P3 is a stretch.** "Flatten deeper levels into current chip-set" reads cleanly but trades depth for breadth: the cascade currently has 38 reachable UPC leaves under 2-3 levels — flattening to 38 chips at depth 1 produces analysis paralysis. The cascade's depth is a feature, not a bug. P3 is only worth building if telemetry from P2 shows a cluster of users bottoming out at level 2 with the *right* root selected. Defer.
### 4.2 What this means for current scope
- **m/paliad#25 (minkowski's row-by-row)** is orthogonal — that fixes individual rule rows. Keep that going.
- **Type α gap fill** is a separate workstream driven by the Wave 1-5 RoP-audit sequencing in `audit-upc-rop-deadlines-2026-05-08.md` §6. The smart-navigation work doesn't replace it; it gives the work a feedback loop.
- **Type γ (tag-don't-react)** is its own design problem — file as a separate ticket if/when it shows up in P2 telemetry.
- **The 5 unreachable rules from §3.1** (4 EP_GRANT + 1 UPC_INF) should be fixed with a 5-row migration regardless of the navigation work. Independent. EP-grant in particular is the single change that lifts cascade reachability from 93.4 % to 100 % of the audited rule corpus.
### 4.3 Suggested next steps (not implementation, just ordering)
1. **5-row reachability migration** (no design needed): link `inf.app_to_amend` to `defence-to-application-to-amend` concept; add cascade leaves for the 4 EP_GRANT concepts under a new `ich-moechte-einreichen.ep-erteilung` subtree. Wave-0 alongside the t-paliad-159 duration bug fixes.
2. **Inventor pass on P2 + P1** as one design ticket: persistent escape button + free-text search at each level + capture-table schema + admin view. This is where m's "smart navigation" intuition lives — keep P1 and P2 as a pair so the captured texts feed search ranking.
3. **Type α gap fill** continues independently per RoP audit waves — capture-table data in (2) refines priorities after a few weeks of real use.
4. **Defer P3 + Type γ** until telemetry justifies them.
---
## 5. Summary
**Coverage today (n=76 true Fristenrechner deadlines across 19 active proceedings):**
| Status | Count | Share |
|---|---|---|
| Reachable from cascade | 71 | 93 % |
| No concept_id | 1 | 1 % |
| Concept exists, dead-end | 4 | 5 % |
**Event_type reachability (n=44 firm-wide active types):**
| Status | Count | Share |
|---|---|---|
| Reachable | 26 | 59 % |
| Unreachable | 18 | 41 % |
**Headline gap categories** (entirely uncovered by both pathways, ordered by daily-practice frequency):
1. EPO R.94(3) examination-stage Bescheid (4mo) — most-frequent EPO prosecution deadline, **completely absent**.
2. EPO R.71(3) communication → approval+translation (4mo) — concept exists but no cascade entry.
3. DPMA § 45 PatG Beanstandungsbescheid (4mo) — most-frequent DPMA prosecution deadline, completely absent.
4. UPC R.262.2 confidentiality response (14d) — high-frequency in HLC infringement.
5. DE Hinweisbeschluss reaction — leaf exists, no rule.
6. DE Versäumnisurteil-Einspruch (§ 339 ZPO 2 weeks) — leaf exists, no rule.
7. DE Streitwertbeschwerde (§ 68 GKG 6mo) — neither leaf nor rule.
8. UPC R.19 Preliminary Objection (1mo) — neither pathway.
9. UPC R.224.2.b grounds-on-orders-track (15d) — neither pathway.
10. UPC R.353 Rectification (1mo) — neither pathway.
11. UPC EP-grant family (R.70(1), Art. 93, R.71(3), search-report) — Pathway A only, no cascade entry.
12. UPC R.109 oral-hearing translation prep (1mo / 2w / 2w `before`-mode) — schema-supported, no data.
**Recommended smart-navigation pattern:** P2 (persistent "Ich finde mein Ereignis nicht" escape with capture) + P1 (free-text search per cascade level), in that order. P2 alone unblocks users and produces the feedback loop the rest of the gap-fill roadmap needs; P1 layered on top closes mis-labelling. P3 is over-scoped for current data.
---
## Appendix A — files consulted
- `internal/services/deadline_rule_service.go` (proceeding-type filtering, `category='fristenrechner'` gate)
- `internal/services/event_category_service.go` (cascade traversal)
- `internal/services/fristenrechner.go` (Pathway A composer)
- `internal/db/migrations/008_seed_proceeding_types.up.sql` (legacy 7 codes)
- `internal/db/migrations/012_fristenrechner_rules.up.sql` (UPC/DE/EPA seed)
- `internal/db/migrations/042_de_expansion_b3.up.sql` (DE_INF_OLG / DE_INF_BGH / DE_NULL_BGH)
- `internal/db/migrations/043_de_instance_split_proceedings.up.sql`
- `internal/db/migrations/044_dpma_proceedings.up.sql`
- `internal/db/migrations/045_epa_gap_fill.up.sql`
- `internal/db/migrations/048_event_categories.up.sql` (cascade seed)
- `internal/db/migrations/049_event_categories_seed.up.sql`
- `internal/db/migrations/051_proceeding_display_order.up.sql`
- `internal/db/migrations/052_event_categories_rop_audit.up.sql` (cascade-side RoP fixes)
- `internal/db/migrations/063_frist_verpasst_upc.up.sql` (R.320 leaf)
- `internal/db/migrations/072_deadline_concept_event_types.up.sql` (Regel↔Typ junction)
## Appendix B — companion audits
- `docs/audit-upc-rop-deadlines-2026-05-08.md` — RoP-driven UPC audit (t-paliad-159, curie). Half the data for §2.2.
- `docs/audit-fristenrechner-completeness-2026-04-30.md` — youpc-vs-paliad (t-paliad-084, curie).
- `docs/design-deadline-data-model-2026-05-08.md` — current data-model design.

View File

@@ -4,6 +4,7 @@ import { renderIndex } from "./src/index";
import { renderLogin } from "./src/login";
import { renderKostenrechner } from "./src/kostenrechner";
import { renderFristenrechner } from "./src/fristenrechner";
import { renderVerfahrensablauf } from "./src/verfahrensablauf";
import { renderDownloads } from "./src/downloads";
import { renderLinks } from "./src/links";
import { renderGlossary } from "./src/glossary";
@@ -15,6 +16,7 @@ import { renderCourts } from "./src/courts";
import { renderProjects } from "./src/projects";
import { renderProjectsNew } from "./src/projects-new";
import { renderProjectsDetail } from "./src/projects-detail";
import { renderProjectsChart } from "./src/projects-chart";
import { renderEvents } from "./src/events";
import { renderDeadlinesNew } from "./src/deadlines-new";
import { renderDeadlinesDetail } from "./src/deadlines-detail";
@@ -38,6 +40,7 @@ import { renderAdminPartnerUnits } from "./src/admin-partner-units";
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
import { renderAdminEventTypes } from "./src/admin-event-types";
import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
import { renderPaliadin } from "./src/paliadin";
import { renderAdminPaliadin } from "./src/admin-paliadin";
@@ -233,6 +236,7 @@ async function build() {
join(import.meta.dir, "src/client/login.ts"),
join(import.meta.dir, "src/client/kostenrechner.ts"),
join(import.meta.dir, "src/client/fristenrechner.ts"),
join(import.meta.dir, "src/client/verfahrensablauf.ts"),
join(import.meta.dir, "src/client/downloads.ts"),
join(import.meta.dir, "src/client/links.ts"),
join(import.meta.dir, "src/client/glossary.ts"),
@@ -244,6 +248,7 @@ async function build() {
join(import.meta.dir, "src/client/projects.ts"),
join(import.meta.dir, "src/client/projects-new.ts"),
join(import.meta.dir, "src/client/projects-detail.ts"),
join(import.meta.dir, "src/client/projects-chart.ts"),
join(import.meta.dir, "src/client/events.ts"),
join(import.meta.dir, "src/client/deadlines-new.ts"),
join(import.meta.dir, "src/client/deadlines-detail.ts"),
@@ -267,8 +272,14 @@ async function build() {
join(import.meta.dir, "src/client/admin-email-templates.ts"),
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
join(import.meta.dir, "src/client/admin-event-types.ts"),
join(import.meta.dir, "src/client/admin-approval-policies.ts"),
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
join(import.meta.dir, "src/client/paliadin.ts"),
// t-paliad-161 — inline Paliadin widget. Loaded via the
// PaliadinWidget component on every authenticated page, so the
// bundle ships once per deploy and clients with a hot SW cache
// skip the re-fetch.
join(import.meta.dir, "src/client/paliadin-widget.ts"),
join(import.meta.dir, "src/client/admin-paliadin.ts"),
join(import.meta.dir, "src/client/notfound.ts"),
],
@@ -347,6 +358,7 @@ async function build() {
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
await Bun.write(join(DIST, "kostenrechner.html"), renderKostenrechner());
await Bun.write(join(DIST, "fristenrechner.html"), renderFristenrechner());
await Bun.write(join(DIST, "verfahrensablauf.html"), renderVerfahrensablauf());
await Bun.write(join(DIST, "downloads.html"), renderDownloads());
await Bun.write(join(DIST, "links.html"), renderLinks());
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
@@ -358,6 +370,7 @@ async function build() {
await Bun.write(join(DIST, "projects.html"), renderProjects());
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
// t-paliad-115 — shared EventsPage at the canonical /events URL.
// One HTML output; defaultType="all" baked in. Sidebar Fristen /
// Termine entries point at /events?type=… and events.ts re-highlights
@@ -385,6 +398,7 @@ async function build() {
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
await Bun.write(join(DIST, "admin-approval-policies.html"), renderAdminApprovalPolicies());
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());

View File

@@ -0,0 +1,137 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// t-paliad-154 — admin approval-policy authoring page. Single page with
// two sections:
//
// 1. Partner-Unit-Standards: list of partner_units, each expandable into
// its 8-cell matrix (deadline + appointment × create / update /
// complete / delete). Edits hit /api/admin/partner-units/{id}/...
//
// 2. Projekt-spezifisch: project-tree picker → 8-cell matrix for the
// selected project, showing the EFFECTIVE policy per cell with an
// attribution chip (Projekt / Geerbt / Standard). Edits hit
// /api/projects/{id}/approval-policies/{entity}/{lifecycle}.
//
// Mobile shape: the matrix grid collapses to two stacked sections (Fristen,
// Termine) below 700px — driven by CSS, not by JS.
export function renderAdminApprovalPolicies(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<PWAHead />
<title data-i18n="admin.approval_policies.title">Genehmigungspflichten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/approval-policies" />
<BottomNav currentPath="/admin/approval-policies" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="admin.approval_policies.heading">Genehmigungspflichten</h1>
<p className="tool-subtitle" data-i18n="admin.approval_policies.subtitle">
4-Augen-Pr&uuml;fung pro Projekt und Partner Unit konfigurieren.
</p>
</div>
<div id="ap-feedback" className="form-msg" style="display:none" />
{/* ============================================================
Section 1: Partner-Unit-Standards.
============================================================ */}
<h2 className="section-heading" data-i18n="admin.approval_policies.section.units">
Partner-Unit-Standards
</h2>
<p className="form-hint" data-i18n="admin.approval_policies.section.units.hint">
Standardregeln, die jedes Projekt erbt, das einer Partner Unit zugeordnet ist.
Bei mehreren Partner Units gewinnt die strengste Regel.
</p>
<div className="ap-units-list" id="ap-units-list">
<div className="ap-loading" data-i18n="admin.approval_policies.loading">L&auml;dt &hellip;</div>
</div>
{/* ============================================================
Section 2: Projekt-spezifisch.
============================================================ */}
<h2 className="section-heading" data-i18n="admin.approval_policies.section.projects">
Projekt-spezifisch
</h2>
<p className="form-hint" data-i18n="admin.approval_policies.section.projects.hint">
Eigene Regeln f&uuml;r ein Projekt. &Uuml;berschreiben Standards aus Partner Units und
geerbten Projektregeln.
</p>
<div className="ap-project-picker">
<label htmlFor="ap-project-search" data-i18n="admin.approval_policies.picker.label">
Projekt w&auml;hlen
</label>
<input
type="text"
id="ap-project-search"
className="ap-project-search"
data-i18n-placeholder="admin.approval_policies.picker.placeholder"
placeholder="Suchen..."
autocomplete="off"
/>
<div className="ap-project-results" id="ap-project-results" />
</div>
<div className="ap-project-matrix" id="ap-project-matrix" style="display:none">
<div className="ap-project-header">
<h3 id="ap-project-title" />
<button type="button" className="btn-secondary btn-small" id="ap-bulk-apply-btn"
data-i18n="admin.approval_policies.bulk.cta">
Auf Unterprojekte anwenden
</button>
</div>
<div className="ap-matrix-host" id="ap-matrix-host" />
</div>
</div>
</section>
<Footer />
<PaliadinWidget />
</main>
{/* Bulk-apply confirm modal — populated client-side. */}
<div className="modal-overlay" id="ap-bulk-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="admin.approval_policies.bulk.modal.title">
Auf Unterprojekte anwenden
</h2>
<button className="modal-close" id="ap-bulk-close" type="button" aria-label="Close">&times;</button>
</div>
<div className="ap-bulk-body">
<p data-i18n="admin.approval_policies.bulk.modal.body">
Die folgenden Unterprojekte erhalten die effektive Matrix dieses Projekts als
projektspezifische Regeln. Bestehende projektspezifische Regeln werden
&uuml;berschrieben. Standards aus Partner Units bleiben unber&uuml;hrt.
</p>
<ul className="ap-bulk-target-list" id="ap-bulk-target-list" />
<p className="form-msg" id="ap-bulk-msg" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="ap-bulk-cancel"
data-i18n="admin.approval_policies.bulk.modal.cancel">Abbrechen</button>
<button type="button" className="btn-primary btn-cta-lime" id="ap-bulk-confirm"
data-i18n="admin.approval_policies.bulk.modal.confirm">&Uuml;bernehmen</button>
</div>
</div>
</div>
</div>
<script src="/assets/admin-approval-policies.js" defer />
</body>
</html>
);
}

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -116,6 +117,7 @@ export function renderAdminAuditLog(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-audit-log.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -59,6 +60,7 @@ export function renderAdminBroadcasts(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-broadcasts.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -110,6 +111,7 @@ export function renderAdminEmailTemplatesEdit(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-email-templates-edit.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -49,6 +50,7 @@ export function renderAdminEmailTemplates(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-email-templates.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -149,6 +150,7 @@ export function renderAdminEventTypes(): string {
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-event-types.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -86,14 +87,17 @@ export function renderAdminPaliadin(): string {
<thead>
<tr>
<th data-i18n="admin.paliadin.col.started">Zeit</th>
<th data-i18n="admin.paliadin.col.user">Nutzer</th>
<th data-i18n="admin.paliadin.col.classifier">Art</th>
<th data-i18n="admin.paliadin.col.prompt">Anfrage</th>
<th data-i18n="admin.paliadin.col.response">Antwort</th>
<th data-i18n="admin.paliadin.col.tools">Tools</th>
<th data-i18n="admin.paliadin.col.origin">Seite</th>
<th data-i18n="admin.paliadin.col.duration">Dauer</th>
</tr>
</thead>
<tbody id="recent-turns-tbody">
<tr><td colspan={5} data-i18n="admin.paliadin.loading">Lade &hellip;</td></tr>
<tr><td colspan={8} data-i18n="admin.paliadin.loading">Lade &hellip;</td></tr>
</tbody>
</table>
</div>
@@ -102,6 +106,7 @@ export function renderAdminPaliadin(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-paliadin.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -124,6 +125,7 @@ export function renderAdminPartnerUnits(): string {
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-partner-units.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -132,6 +133,7 @@ export function renderAdminTeam(): string {
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-team.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -10,6 +11,7 @@ const ICON_LOG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" str
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
const ICON_FLAG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>';
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
const ICON_SHIELD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>';
interface PlannedCard {
icon: string;
@@ -88,6 +90,11 @@ export function renderAdmin(): string {
<h2 data-i18n="admin.card.broadcasts.title">Broadcasts</h2>
<p data-i18n="admin.card.broadcasts.desc">Versendete Massen-E-Mails an Teamauswahlen einsehen.</p>
</a>
<a href="/admin/approval-policies" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_SHIELD }} />
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Pr&uuml;fung pro Projekt und Partner Unit konfigurieren.</p>
</a>
</div>
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>
@@ -106,6 +113,7 @@ export function renderAdmin(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/admin.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -91,6 +92,7 @@ export function renderAgenda(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/agenda.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -94,6 +95,7 @@ export function renderAppointmentsCalendar(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/appointments-calendar.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -35,6 +36,9 @@ export function renderAppointmentsDetail(): string {
<div id="appointment-body" style="display:none">
<div className="tool-header">
<span className="termin-type-badge" id="appointment-type-badge" />
<span id="appointment-pending-approval-badge" className="approval-pending-badge" style="display:none" data-i18n="approvals.pending.badge" title="">
Wartet auf Genehmigung
</span>
<h1 id="appointment-title-display" />
<p className="tool-subtitle" id="appointment-time-display" />
</div>
@@ -94,6 +98,7 @@ export function renderAppointmentsDetail(): string {
<p className="form-msg" id="appointment-edit-msg" />
<div className="form-actions">
<button type="button" id="appointment-withdraw-btn" className="btn-secondary" style="display:none" data-i18n="approvals.withdraw.cta">Genehmigungsanfrage zur&uuml;ckziehen</button>
<button type="button" id="appointment-delete-btn" className="btn-danger" data-i18n="appointments.detail.delete">Termin l&ouml;schen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="appointments.detail.save">&Auml;nderungen speichern</button>
</div>
@@ -104,6 +109,7 @@ export function renderAppointmentsDetail(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/appointments-detail.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -86,6 +87,14 @@ export function renderAppointmentsNew(): string {
<p className="form-msg" id="appointment-new-msg" />
{/* t-paliad-154 — form-time 4-eye hint. */}
<div className="approval-hint" id="appointment-approval-hint" style="display:none">
<span className="approval-hint-icon" dangerouslySetInnerHTML={{
__html: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>'
}} />
<span id="appointment-approval-hint-text" />
</div>
<div className="form-actions">
<a href="/events?type=appointment" id="appointment-new-cancel" className="btn-cancel" data-i18n="appointments.neu.cancel">Abbrechen</a>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="appointments.neu.submit">Termin anlegen</button>
@@ -96,6 +105,7 @@ export function renderAppointmentsNew(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/appointments-new.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -41,6 +42,7 @@ export function renderChangelog(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/changelog.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -152,6 +153,7 @@ export function renderChecklistsDetail(): string {
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/checklists-detail.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -118,6 +119,7 @@ export function renderChecklistsInstance(): string {
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/checklists-instance.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -74,6 +75,7 @@ export function renderChecklists(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/checklists.js"></script>
</body>
</html>

View File

@@ -0,0 +1,661 @@
import { initI18n, onLangChange, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
// t-paliad-154 — admin approval-policy authoring page orchestration.
//
// Two sections: Partner-Unit-Standards (accordion list) and Projekt-spezifisch
// (project picker → 8-cell matrix). Edits hit the per-scope CRUD endpoints
// from the same page; re-renders refresh from server state to surface
// inheritance changes.
interface PartnerUnit {
id: string;
name: string;
office: string;
}
interface UnitPolicy {
id: string;
partner_unit_id: string | null;
project_id: string | null;
entity_type: string;
lifecycle_event: string;
// t-paliad-160 split-grammar.
requires_approval: boolean;
min_role?: string | null;
}
interface EffectivePolicy {
entity_type: string;
lifecycle_event: string;
requires_approval: boolean;
min_role?: string | null;
source?: string | null;
source_id?: string | null;
source_name?: string | null;
}
interface ProjectNode {
id: string;
title: string;
reference?: string | null;
type?: string;
parent_id?: string | null;
children?: ProjectNode[];
}
const ENTITY_TYPES = ["deadline", "appointment"] as const;
const LIFECYCLES = ["create", "update", "complete", "delete"] as const;
// Strict-ladder roles only. The legacy "none" sentinel is gone — its job
// (suppress the gate) is now done by the requires_approval=false checkbox
// (t-paliad-160 §A).
const ROLE_OPTIONS = [
"partner",
"of_counsel",
"associate",
"senior_pa",
"pa",
];
let partnerUnits: PartnerUnit[] = [];
let unitPolicies: Record<string, UnitPolicy[]> = {};
let allProjects: ProjectNode[] = [];
let selectedProjectID: string | null = null;
let selectedProjectTitle: string = "";
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function showFeedback(msg: string, isError: boolean): void {
const el = document.getElementById("ap-feedback");
if (!el) return;
el.textContent = msg;
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
el.style.display = "block";
if (!isError) setTimeout(() => { el.style.display = "none"; }, 3500);
}
// ============================================================================
// Loaders.
// ============================================================================
async function loadPartnerUnits(): Promise<void> {
const resp = await fetch("/api/partner-units");
if (!resp.ok) {
partnerUnits = [];
return;
}
partnerUnits = (await resp.json()) as PartnerUnit[];
}
async function loadUnitPolicies(unitID: string): Promise<UnitPolicy[]> {
const resp = await fetch(`/api/admin/partner-units/${encodeURIComponent(unitID)}/approval-policies`);
if (!resp.ok) return [];
return (await resp.json()) as UnitPolicy[];
}
async function loadAllUnitPolicies(): Promise<void> {
const out: Record<string, UnitPolicy[]> = {};
for (const u of partnerUnits) {
out[u.id] = await loadUnitPolicies(u.id);
}
unitPolicies = out;
}
async function loadProjects(): Promise<void> {
const resp = await fetch("/api/projects/tree");
if (!resp.ok) {
allProjects = [];
return;
}
const tree = (await resp.json()) as ProjectNode[];
allProjects = flattenTree(tree);
}
function flattenTree(nodes: ProjectNode[]): ProjectNode[] {
const out: ProjectNode[] = [];
const walk = (n: ProjectNode): void => {
out.push(n);
if (n.children) n.children.forEach(walk);
};
nodes.forEach(walk);
return out;
}
async function loadMatrix(projectID: string): Promise<EffectivePolicy[]> {
const resp = await fetch(`/api/admin/approval-policies/matrix?project_id=${encodeURIComponent(projectID)}`);
if (!resp.ok) return [];
return (await resp.json()) as EffectivePolicy[];
}
// ============================================================================
// Rendering — partner unit accordion.
// ============================================================================
function lifecycleLabel(l: string): string {
return tDyn("admin.approval_policies.lifecycle." + l) || l;
}
function entityLabel(e: string): string {
return tDyn("admin.approval_policies.entity." + e) || e;
}
function roleLabel(r: string): string {
return tDyn("admin.approval_policies.role." + r) || r;
}
function policyForCell(rows: UnitPolicy[], entity: string, lifecycle: string): UnitPolicy | undefined {
return rows.find((p) => p.entity_type === entity && p.lifecycle_event === lifecycle);
}
// Cell control state, t-paliad-160 §A.
// none → no project-specific rule authored (the cell inherits).
// off → requires_approval=false explicitly authored.
// on(role) → requires_approval=true with the given min_role.
//
// rendered as: [✓] requires approval [role select]
// - checkbox unchecked → role select disabled (greyed).
// - checkbox checked → role select enabled, min_role required.
// - "no rule" — surfaced as a third button next to the controls so the
// admin can explicitly clear an authored cell back to inheritance.
type CellAuthored =
| { kind: "none" }
| { kind: "off" }
| { kind: "on"; role: string };
function authoredFromUnitPolicy(p: UnitPolicy | undefined): CellAuthored {
if (!p) return { kind: "none" };
if (!p.requires_approval) return { kind: "off" };
return { kind: "on", role: p.min_role || "associate" };
}
function authoredFromEffective(r: EffectivePolicy): CellAuthored {
if (r.source !== "project") return { kind: "none" };
if (!r.requires_approval) return { kind: "off" };
return { kind: "on", role: r.min_role || "associate" };
}
function renderCellControls(authored: CellAuthored, dataAttrs: string): string {
const checked = authored.kind === "on";
const disabled = authored.kind !== "on";
const role = authored.kind === "on" ? authored.role : "associate";
const opts = ROLE_OPTIONS.map((r) =>
`<option value="${esc(r)}"${role === r ? " selected" : ""}>${esc(roleLabel(r))}</option>`
).join("");
const reqLabel = esc(t("admin.approval_policies.cell.requires") || "Genehmigung");
const clearLabel = esc(t("admin.approval_policies.cell.clear") || "—");
const clearTitle = esc(t("admin.approval_policies.cell.clear.title") || "Regel zurücksetzen (erben)");
const cleared = authored.kind === "none";
return `
<label class="ap-cell-toggle">
<input type="checkbox" class="ap-cell-requires" ${dataAttrs}${checked ? " checked" : ""} aria-label="${reqLabel}" />
<span class="ap-cell-toggle-label">${reqLabel}</span>
</label>
<select class="ap-cell-role" ${dataAttrs}${disabled ? " disabled" : ""}>${opts}</select>
<button type="button" class="ap-cell-clear" ${dataAttrs} title="${clearTitle}"${cleared ? " disabled" : ""}>${clearLabel}</button>
`;
}
function renderUnitMatrix(unit: PartnerUnit): string {
const rows = unitPolicies[unit.id] || [];
const buildCell = (e: string, l: string): string => {
const p = policyForCell(rows, e, l);
const authored = authoredFromUnitPolicy(p);
const attrs = `data-scope="unit" data-unit-id="${escAttr(unit.id)}" data-entity="${esc(e)}" data-lifecycle="${esc(l)}"`;
return `<div class="ap-cell-controls">${renderCellControls(authored, attrs)}</div>`;
};
let cells = "";
for (const e of ENTITY_TYPES) {
cells += `<tr><th class="ap-matrix-rowhead">${esc(entityLabel(e))}</th>`;
for (const l of LIFECYCLES) {
cells += `<td class="ap-matrix-cell">${buildCell(e, l)}</td>`;
}
cells += `</tr>`;
}
// Stacked sections for mobile (CSS toggles the table vs the list).
let stacked = "";
for (const e of ENTITY_TYPES) {
stacked += `<div class="ap-matrix-section"><h4>${esc(entityLabel(e))}</h4>`;
for (const l of LIFECYCLES) {
stacked += `<div class="ap-matrix-row">
<span class="ap-matrix-row-label">${esc(lifecycleLabel(l))}</span>
${buildCell(e, l)}
</div>`;
}
stacked += `</div>`;
}
return `
<table class="ap-matrix">
<thead><tr><th></th>
<th>${esc(lifecycleLabel("create"))}</th>
<th>${esc(lifecycleLabel("update"))}</th>
<th>${esc(lifecycleLabel("complete"))}</th>
<th>${esc(lifecycleLabel("delete"))}</th>
</tr></thead>
<tbody>${cells}</tbody>
</table>
<div class="ap-matrix-stacked">${stacked}</div>
`;
}
function renderUnits(): void {
const host = document.getElementById("ap-units-list");
if (!host) return;
// Preserve which unit blocks were expanded across re-renders. Without this,
// changing any cell's required_role saves and re-renders, collapsing the
// accordion the admin was working in (m, 2026-05-08).
const openUnitIDs = new Set<string>();
host.querySelectorAll<HTMLDetailsElement>("details.ap-unit-block").forEach((d) => {
if (d.open && d.dataset.unitId) openUnitIDs.add(d.dataset.unitId);
});
if (partnerUnits.length === 0) {
host.innerHTML = `<p class="form-hint">${esc(t("admin.approval_policies.units.empty") || "Keine Partner Units vorhanden.")}</p>`;
return;
}
host.innerHTML = partnerUnits.map((u) => `
<details class="ap-unit-block" data-unit-id="${esc(u.id)}"${openUnitIDs.has(u.id) ? " open" : ""}>
<summary class="ap-unit-summary">
<span class="ap-unit-name">${esc(u.name)}</span>
<span class="office-chip office-${esc(u.office)}">${esc(u.office)}</span>
</summary>
<div class="ap-unit-body">
${renderUnitMatrix(u)}
</div>
</details>
`).join("");
bindCellChangeHandlers(host);
}
// ============================================================================
// Rendering — project matrix with attribution chips.
// ============================================================================
function renderProjectMatrix(rows: EffectivePolicy[]): string {
const cell = (r: EffectivePolicy): string => {
const own = r.source === "project";
const attrs = `data-scope="project" data-project-id="${escAttr(selectedProjectID || "")}" data-entity="${esc(r.entity_type)}" data-lifecycle="${esc(r.lifecycle_event)}"`;
// The controls show the AUTHORED state — the project row's own values
// when there is one, else the inherited state is rendered via the
// attribution chip and the controls sit unset (kind="none"). Most-
// strict-wins inheritance from ancestors / unit defaults is purely
// informational on this row; flipping the controls writes a new
// project-specific row.
const authored = authoredFromEffective(r);
let chip = "";
if (r.source && !own && r.requires_approval) {
// Inherited from ancestor or unit default. Surface attribution +
// the inherited min_role so the admin sees what the cell is
// resolving to before they author an override.
const sourceKey = r.source === "ancestor" ? "admin.approval_policies.source.ancestor" :
r.source === "unit_default" ? "admin.approval_policies.source.unit_default" :
"admin.approval_policies.source.project";
const label = tDyn(sourceKey) || r.source;
const name = r.source_name ? ` · ${esc(r.source_name)}` : "";
const role = r.min_role ? ` · ${esc(roleLabel(r.min_role))}+` : "";
chip = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name}${role}</span>`;
} else if (r.source && !own && !r.requires_approval) {
// Inherited "no approval needed" — distinct from "no rule at all".
const sourceKey = r.source === "ancestor" ? "admin.approval_policies.source.ancestor" :
"admin.approval_policies.source.unit_default";
const label = tDyn(sourceKey) || r.source;
const name = r.source_name ? ` · ${esc(r.source_name)}` : "";
const offLabel = esc(t("admin.approval_policies.source.no_approval") || "keine Genehmigung");
chip = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name} · ${offLabel}</span>`;
} else if (own) {
chip = `<span class="ap-source-chip ap-source-project">${esc(t("admin.approval_policies.source.project") || "Projekt")}</span>`;
}
return `<div class="ap-cell-wrap"><div class="ap-cell-controls">${renderCellControls(authored, attrs)}</div>${chip}</div>`;
};
const byCell = new Map<string, EffectivePolicy>();
for (const r of rows) byCell.set(`${r.entity_type}:${r.lifecycle_event}`, r);
const cellFor = (e: string, l: string): EffectivePolicy =>
byCell.get(`${e}:${l}`) || { entity_type: e, lifecycle_event: l };
let table = "";
for (const e of ENTITY_TYPES) {
table += `<tr><th class="ap-matrix-rowhead">${esc(entityLabel(e))}</th>`;
for (const l of LIFECYCLES) {
table += `<td class="ap-matrix-cell">${cell(cellFor(e, l))}</td>`;
}
table += `</tr>`;
}
let stacked = "";
for (const e of ENTITY_TYPES) {
stacked += `<div class="ap-matrix-section"><h4>${esc(entityLabel(e))}</h4>`;
for (const l of LIFECYCLES) {
stacked += `<div class="ap-matrix-row">
<span class="ap-matrix-row-label">${esc(lifecycleLabel(l))}</span>
${cell(cellFor(e, l))}
</div>`;
}
stacked += `</div>`;
}
return `
<table class="ap-matrix">
<thead><tr><th></th>
<th>${esc(lifecycleLabel("create"))}</th>
<th>${esc(lifecycleLabel("update"))}</th>
<th>${esc(lifecycleLabel("complete"))}</th>
<th>${esc(lifecycleLabel("delete"))}</th>
</tr></thead>
<tbody>${table}</tbody>
</table>
<div class="ap-matrix-stacked">${stacked}</div>
`;
}
async function selectProject(p: ProjectNode): Promise<void> {
selectedProjectID = p.id;
selectedProjectTitle = p.title;
const matrix = await loadMatrix(p.id);
const wrap = document.getElementById("ap-project-matrix");
const host = document.getElementById("ap-matrix-host");
const titleEl = document.getElementById("ap-project-title");
if (!wrap || !host || !titleEl) return;
wrap.style.display = "block";
titleEl.textContent = p.title + (p.reference ? ` · ${p.reference}` : "");
host.innerHTML = renderProjectMatrix(matrix);
bindCellChangeHandlers(host);
}
function renderProjectResults(filter: string): void {
const host = document.getElementById("ap-project-results");
if (!host) return;
const q = filter.trim().toLowerCase();
let matches = allProjects;
if (q.length > 0) {
matches = allProjects.filter((p) => {
const t = (p.title || "").toLowerCase();
const r = (p.reference || "").toLowerCase();
return t.includes(q) || r.includes(q);
});
}
matches = matches.slice(0, 30);
if (matches.length === 0) {
host.innerHTML = `<p class="form-hint">${esc(t("admin.approval_policies.picker.no_results") || "Keine Treffer.")}</p>`;
return;
}
host.innerHTML = matches.map((p) => `
<button type="button" class="ap-project-result" data-id="${escAttr(p.id)}">
<span class="ap-project-result-title">${esc(p.title)}</span>
${p.reference ? `<span class="ap-project-result-ref">${esc(p.reference)}</span>` : ""}
</button>
`).join("");
host.querySelectorAll<HTMLButtonElement>(".ap-project-result").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.dataset.id || "";
const p = allProjects.find((x) => x.id === id);
if (p) void selectProject(p);
});
});
}
// ============================================================================
// Cell change → server.
// ============================================================================
function bindCellChangeHandlers(scope: HTMLElement): void {
// Each cell now has THREE controls — the requires-approval checkbox,
// the role select, and the explicit "clear / inherit" button. They all
// share data-* attrs so onCellChange can derive the URL + intended
// post-state from any of them.
scope.querySelectorAll<HTMLInputElement>(".ap-cell-requires").forEach((cb) => {
cb.addEventListener("change", () => {
// Toggle the sibling role select disabled state immediately for
// visual feedback — the server PUT might lag.
const wrap = cb.closest(".ap-cell-controls") as HTMLElement | null;
const sel = wrap?.querySelector<HTMLSelectElement>(".ap-cell-role");
if (sel) sel.disabled = !cb.checked;
void onCellChangeFromCheckbox(cb);
});
});
scope.querySelectorAll<HTMLSelectElement>(".ap-cell-role").forEach((sel) => {
sel.addEventListener("change", () => void onCellChangeFromRole(sel));
});
scope.querySelectorAll<HTMLButtonElement>(".ap-cell-clear").forEach((btn) => {
btn.addEventListener("click", () => void onCellClear(btn));
});
}
function cellEndpointURL(el: HTMLElement): string | null {
const scope = el.dataset.scope;
const entity = el.dataset.entity || "";
const lifecycle = el.dataset.lifecycle || "";
if (scope === "unit") {
const unitID = el.dataset.unitId || "";
return `/api/admin/partner-units/${encodeURIComponent(unitID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
}
if (scope === "project") {
const projectID = el.dataset.projectId || "";
return `/api/projects/${encodeURIComponent(projectID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
}
return null;
}
async function onCellChangeFromCheckbox(cb: HTMLInputElement): Promise<void> {
// Checkbox flipped — write the cell as (requires_approval, min_role).
const wrap = cb.closest(".ap-cell-controls") as HTMLElement | null;
const sel = wrap?.querySelector<HTMLSelectElement>(".ap-cell-role");
const requires = cb.checked;
const minRole = requires ? (sel?.value || "associate") : null;
await putCellSplit(cb, requires, minRole);
}
async function onCellChangeFromRole(sel: HTMLSelectElement): Promise<void> {
// Role select changed — only meaningful when the checkbox is on (the
// disabled state would block this on a real interaction, but pin it
// for safety).
const wrap = sel.closest(".ap-cell-controls") as HTMLElement | null;
const cb = wrap?.querySelector<HTMLInputElement>(".ap-cell-requires");
if (!cb || !cb.checked) return;
await putCellSplit(sel, true, sel.value);
}
async function onCellClear(btn: HTMLButtonElement): Promise<void> {
// Explicit "back to inheritance" — DELETE the project / unit row.
const url = cellEndpointURL(btn);
if (!url) return;
try {
const resp = await fetch(url, { method: "DELETE" });
if (!resp.ok) {
const errBody = await resp.text();
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${errBody}`, true);
return;
}
showFeedback(t("admin.approval_policies.cell.saved_msg") || "Gespeichert.", false);
await refreshAfterCellMutation(btn);
} catch (err) {
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`, true);
}
}
async function putCellSplit(el: HTMLElement, requires: boolean, minRole: string | null): Promise<void> {
const url = cellEndpointURL(el);
if (!url) return;
try {
const resp = await fetch(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requires_approval: requires, min_role: minRole }),
});
if (!resp.ok) {
const errBody = await resp.text();
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${errBody}`, true);
return;
}
showFeedback(t("admin.approval_policies.cell.saved_msg") || "Gespeichert.", false);
await refreshAfterCellMutation(el);
} catch (err) {
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`, true);
}
}
async function refreshAfterCellMutation(el: HTMLElement): Promise<void> {
const scope = el.dataset.scope;
if (scope === "unit") {
const unitID = el.dataset.unitId || "";
unitPolicies[unitID] = await loadUnitPolicies(unitID);
renderUnits();
} else if (selectedProjectID) {
const matrix = await loadMatrix(selectedProjectID);
const host = document.getElementById("ap-matrix-host");
if (host) {
host.innerHTML = renderProjectMatrix(matrix);
bindCellChangeHandlers(host);
}
}
}
// ============================================================================
// Bulk-apply to descendants.
// ============================================================================
function descendantsOf(rootID: string): ProjectNode[] {
// Build parent-child map from the flat list.
const byParent = new Map<string, ProjectNode[]>();
for (const p of allProjects) {
const parent = p.parent_id || "";
if (!byParent.has(parent)) byParent.set(parent, []);
byParent.get(parent)!.push(p);
}
const out: ProjectNode[] = [];
const walk = (id: string): void => {
const kids = byParent.get(id) || [];
for (const k of kids) {
out.push(k);
walk(k.id);
}
};
walk(rootID);
return out;
}
function openBulkModal(): void {
if (!selectedProjectID) return;
const targets = descendantsOf(selectedProjectID);
const list = document.getElementById("ap-bulk-target-list");
const modal = document.getElementById("ap-bulk-modal");
if (!list || !modal) return;
if (targets.length === 0) {
showFeedback(t("admin.approval_policies.bulk.no_descendants") || "Keine Unterprojekte vorhanden.", true);
return;
}
list.innerHTML = targets.map((p) => `
<li><span class="ap-bulk-target-title">${esc(p.title)}</span>${p.reference ? ` <span class="ap-bulk-target-ref">${esc(p.reference)}</span>` : ""}</li>
`).join("");
modal.style.display = "flex";
modal.dataset.targets = JSON.stringify(targets.map((p) => p.id));
}
function closeBulkModal(): void {
const modal = document.getElementById("ap-bulk-modal");
if (modal) modal.style.display = "none";
}
async function confirmBulk(): Promise<void> {
if (!selectedProjectID) return;
const modal = document.getElementById("ap-bulk-modal");
const msg = document.getElementById("ap-bulk-msg");
if (!modal || !msg) return;
const targets = JSON.parse(modal.dataset.targets || "[]") as string[];
msg.textContent = t("admin.approval_policies.bulk.modal.applying") || "Übernehme …";
try {
const resp = await fetch("/api/admin/approval-policies/apply-to-descendants", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source_project_id: selectedProjectID,
target_project_ids: targets,
}),
});
if (!resp.ok) {
const body = await resp.text();
msg.textContent = `${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${body}`;
return;
}
const out = await resp.json() as { writes: number; targets: number };
closeBulkModal();
showFeedback(
(t("admin.approval_policies.bulk.modal.done") || "Übernommen") +
`${out.writes} ${t("admin.approval_policies.bulk.modal.writes_label") || "Schreibvorgänge"} auf ${out.targets} ${t("admin.approval_policies.bulk.modal.targets_label") || "Projekte"}.`,
false,
);
// Re-fetch the source matrix so any cells the bulk-apply touched on
// descendants are reflected via inheritance attribution if applicable.
if (selectedProjectID) {
const matrix = await loadMatrix(selectedProjectID);
const host = document.getElementById("ap-matrix-host");
if (host) {
host.innerHTML = renderProjectMatrix(matrix);
bindCellChangeHandlers(host);
}
}
} catch (err) {
msg.textContent = `${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`;
}
}
// ============================================================================
// Wire-up.
// ============================================================================
function wirePicker(): void {
const input = document.getElementById("ap-project-search") as HTMLInputElement | null;
if (!input) return;
input.addEventListener("input", () => renderProjectResults(input.value));
// Initial empty-search renders top-of-list.
renderProjectResults("");
}
function wireBulk(): void {
const btn = document.getElementById("ap-bulk-apply-btn");
const close = document.getElementById("ap-bulk-close");
const cancel = document.getElementById("ap-bulk-cancel");
const confirm = document.getElementById("ap-bulk-confirm");
if (btn) btn.addEventListener("click", openBulkModal);
if (close) close.addEventListener("click", closeBulkModal);
if (cancel) cancel.addEventListener("click", closeBulkModal);
if (confirm) confirm.addEventListener("click", () => void confirmBulk());
}
async function init(): Promise<void> {
initI18n();
initSidebar();
await Promise.all([loadPartnerUnits(), loadProjects()]);
await loadAllUnitPolicies();
renderUnits();
wirePicker();
wireBulk();
onLangChange(() => {
renderUnits();
if (selectedProjectID) {
void loadMatrix(selectedProjectID).then((matrix) => {
const host = document.getElementById("ap-matrix-host");
if (host) {
host.innerHTML = renderProjectMatrix(matrix);
bindCellChangeHandlers(host);
}
});
}
renderProjectResults((document.getElementById("ap-project-search") as HTMLInputElement | null)?.value || "");
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => void init());
} else {
void init();
}

View File

@@ -23,14 +23,21 @@ interface Stats {
interface Turn {
turn_id: string;
user_id: string;
user_email: string | null;
user_display_name: string | null;
session_id: string;
started_at: string;
finished_at: string | null;
duration_ms: number | null;
user_message: string;
response: string | null;
used_tools: string[] | null;
rows_seen: number[] | null;
classifier_tag: string | null;
abandoned: boolean;
error_code: string | null;
page_origin: string | null;
chip_count: number;
}
document.addEventListener("DOMContentLoaded", async () => {
@@ -113,28 +120,51 @@ function renderTurns(turns: Turn[]): void {
const tbody = document.getElementById("recent-turns-tbody");
if (!tbody) return;
if (turns.length === 0) {
tbody.innerHTML = `<tr><td colspan="5">Noch keine Anfragen.</td></tr>`;
tbody.innerHTML = `<tr><td colspan="8">Noch keine Anfragen.</td></tr>`;
return;
}
tbody.innerHTML = turns
.map((t) => {
const tag = t.classifier_tag || "—";
// Tools cell pairs each tool name with its rows_seen count when
// available — "list_my_projects (11), search_my_deadlines (18)" —
// so the meta is legible at a glance instead of hidden in a side
// table. Falls back to "—" for casual chats with no tool calls.
const tools = t.used_tools && t.used_tools.length > 0
? t.used_tools.join(", ")
? t.used_tools
.map((name, i) => {
const r = t.rows_seen?.[i];
return r != null ? `${name} (${r})` : name;
})
.join(", ")
: "—";
const dur = t.duration_ms != null ? formatMs(t.duration_ms) : "—";
const errMark = t.error_code ? `${t.error_code}` : "";
const userLabel = t.user_display_name || t.user_email || t.user_id.slice(0, 8);
const userTitle = [t.user_email, t.user_display_name].filter(Boolean).join(" · ") || t.user_id;
// Response preview — first 200 chars of cleanBody. Full response
// available on hover via the title attribute.
const respPreview = t.response ? truncate(t.response, 80) : "—";
const respTitle = t.response || "";
const origin = t.page_origin || "—";
return `<tr>
<td>${formatTime(t.started_at)}</td>
<td title="${escapeAttr(userTitle)}">${escapeHTML(userLabel)}</td>
<td>${escapeHTML(tag)}</td>
<td>${escapeHTML(truncate(t.user_message, 120))}${errMark}</td>
<td title="${escapeAttr(t.user_message)}">${escapeHTML(truncate(t.user_message, 80))}${errMark}</td>
<td title="${escapeAttr(respTitle)}">${escapeHTML(respPreview)}</td>
<td>${escapeHTML(tools)}</td>
<td>${escapeHTML(origin)}</td>
<td>${dur}</td>
</tr>`;
})
.join("");
}
function escapeAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/\n/g, " ");
}
function setText(id: string, val: string): void {
const el = document.getElementById(id);
if (el) el.textContent = val;

View File

@@ -0,0 +1,236 @@
// Shared agenda timeline rendering primitives. The standalone /agenda page
// (client/agenda.ts) and the inline Agenda section on /dashboard
// (client/dashboard.ts) both render the same item shape; this module is
// the single source of truth for how an AgendaItem turns into HTML.
//
// Stateless. The caller fetches /api/agenda, hands the items to
// renderAgendaTimeline(), and drops the resulting HTML into a container.
// i18n labels are resolved at render time via t/tDyn from ./i18n, so the
// onLangChange hook on the calling page re-renders correctly.
import { t, tDyn, getLang } from "./i18n";
// Two-eyes glyph 👀 inside .approval-pill--icon. Kept in sync with the
// matching constants in events.ts / inbox.ts / dashboard.ts.
const APPROVAL_PILL_GLYPH = "👀";
// Sparkle glyph ✨ for Paliadin-drafted pending rows (t-paliad-161).
// Renders alongside (not in place of) 👀 — orthogonal axes.
const AGENT_PILL_GLYPH = "✨";
export type Urgency = "overdue" | "today" | "tomorrow" | "this_week" | "later";
export type AgendaType = "deadline" | "appointment";
export interface AgendaItem {
id: string;
type: AgendaType;
title: string;
date: string; // ISO 8601
end_at?: string | null;
due_date?: string | null; // YYYY-MM-DD (deadlines only)
status?: string | null;
location?: string | null;
appointment_type?: string | null;
urgency: Urgency;
project_id?: string | null;
project_title?: string | null;
project_type?: string | null;
project_reference?: string | null;
approval_status?: "approved" | "pending" | "legacy" | null;
requester_kind?: "user" | "agent" | null;
}
interface DayBucket {
dayKey: string;
day: Date;
items: AgendaItem[];
}
// Render a full timeline (day buckets with items) for an array of agenda
// items. Returns a single HTML string ready to assign via innerHTML.
// The empty case returns an empty string — callers that want an empty-
// state UI handle it themselves (different copy on /agenda vs the
// dashboard inline slot).
export function renderAgendaTimeline(items: AgendaItem[]): string {
if (!items.length) return "";
const buckets = groupByDay(items);
return buckets.map((b) => renderDay(b)).join("");
}
function groupByDay(items: AgendaItem[]): DayBucket[] {
const map = new Map<string, DayBucket>();
for (const it of items) {
const d = new Date(it.date);
if (isNaN(d.getTime())) continue;
const key = toLocalDayKey(d);
let b = map.get(key);
if (!b) {
b = { dayKey: key, day: new Date(d.getFullYear(), d.getMonth(), d.getDate()), items: [] };
map.set(key, b);
}
b.items.push(it);
}
return Array.from(map.values()).sort((a, b) => a.day.getTime() - b.day.getTime());
}
function renderDay(bucket: DayBucket): string {
const expected = expectedUrgency(bucket.day);
return `<section class="agenda-day">
<h2 class="agenda-day-heading">
<span class="agenda-day-relative">${esc(relativeDayLabel(bucket.day))}</span>
<span class="agenda-day-full">${esc(fullDateLabel(bucket.day))}</span>
</h2>
<ul class="agenda-items">
${bucket.items.map((it) => renderItem(it, expected)).join("")}
</ul>
</section>`;
}
// F-32: an item's urgency tag duplicates the day-bucket heading in the
// common case (a "Heute" item under HEUTE, a "Diese Woche" item under "in 3
// Tagen"). The tag stays only when it disagrees with the bucket — e.g. an
// "Überfällig" deadline that lands in today's bucket because of a filter
// quirk. expectedUrgency mirrors the server's bucketing rule against the
// bucket's day.
function expectedUrgency(day: Date): Urgency {
const today = startOfToday();
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
if (diff < 0) return "overdue";
if (diff === 0) return "today";
if (diff === 1) return "tomorrow";
if (diff <= 6) return "this_week";
return "later";
}
function renderItem(it: AgendaItem, bucketUrgency: Urgency): string {
const urgencyClass = `agenda-item-${it.urgency}`;
const typeClass = `agenda-item-type-${it.type}`;
const pendingClass = it.approval_status === "pending" ? " entity-row--pending-update" : "";
const iconHTML = it.type === "deadline" ? deadlineIcon() : appointmentIcon();
const detailHref = itemDetailHref(it);
const project = it.project_id
? `<a class="agenda-item-project" href="/projects/${esc(it.project_id)}">${esc(formatProjectLabel(it))}</a>`
: "";
const pendingLabel = it.approval_status === "pending" ? tDyn("approvals.pending_update.label") : "";
const pendingPill = it.approval_status === "pending"
? `<span class="approval-pill approval-pill--icon" title="${esc(pendingLabel)}" aria-label="${esc(pendingLabel)}">${APPROVAL_PILL_GLYPH}</span>`
: "";
const agentLabel = tDyn("approvals.agent.label");
const agentPill = it.approval_status === "pending" && it.requester_kind === "agent"
? `<span class="approval-pill approval-pill--agent" title="${esc(agentLabel)}" aria-label="${esc(agentLabel)}">${AGENT_PILL_GLYPH}</span>`
: "";
const timePart = it.type === "appointment"
? `<span class="agenda-item-time">${esc(formatAppointmentTime(it))}</span>`
: "";
const urgencyTag = it.urgency !== bucketUrgency
? `<span class="agenda-item-urgency">${esc(tDyn(`agenda.urgency.${it.urgency}`))}</span>`
: "";
const locationPart = it.type === "appointment" && it.location
? `<span class="agenda-item-location">${esc(it.location)}</span>`
: "";
const typeLabelKey = it.type === "deadline"
? "agenda.label.deadline"
: (it.appointment_type ? `agenda.appointment_type.${it.appointment_type}` : "agenda.label.appointment");
const typeLabel = tDyn(typeLabelKey);
return `<li class="agenda-item ${typeClass} ${urgencyClass}${pendingClass}">
<a class="agenda-item-link" href="${esc(detailHref)}">
<span class="agenda-item-icon" aria-hidden="true">${iconHTML}</span>
<span class="agenda-item-main">
<span class="agenda-item-headline">
<span class="agenda-item-type-label">${esc(typeLabel)}:</span>
<span class="agenda-item-title">${esc(it.title)}</span>
${pendingPill}
${agentPill}
</span>
<span class="agenda-item-sub">
${project}
${timePart}
${locationPart}
</span>
</span>
<span class="agenda-item-meta">
${urgencyTag}
</span>
</a>
</li>`;
}
function itemDetailHref(it: AgendaItem): string {
return it.type === "deadline"
? `/deadlines/${encodeURIComponent(it.id)}`
: `/appointments/${encodeURIComponent(it.id)}`;
}
function formatProjectLabel(it: AgendaItem): string {
const ref = it.project_reference ? `${it.project_reference} · ` : "";
const title = it.project_title || "";
return `${ref}${title}`.trim();
}
function formatAppointmentTime(it: AgendaItem): string {
const start = new Date(it.date);
if (isNaN(start.getTime())) return "";
const locale = getLang() === "de" ? "de-DE" : "en-GB";
const startStr = start.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
if (!it.end_at) return startStr;
const end = new Date(it.end_at);
if (isNaN(end.getTime())) return startStr;
const endStr = end.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
return `${startStr}${endStr}`;
}
function relativeDayLabel(day: Date): string {
const today = startOfToday();
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
if (diff < 0) {
const n = Math.abs(diff);
return getLang() === "de"
? (n === 1 ? "Gestern" : `vor ${n} Tagen`)
: (n === 1 ? "Yesterday" : `${n} days ago`);
}
if (diff === 0) return t("agenda.day.today");
if (diff === 1) return t("agenda.day.tomorrow");
return getLang() === "de" ? `in ${diff} Tagen` : `in ${diff} days`;
}
function fullDateLabel(day: Date): string {
const locale = getLang() === "de" ? "de-DE" : "en-GB";
return day.toLocaleDateString(locale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}
function startOfToday(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
function toISODate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function toLocalDayKey(d: Date): string {
return toISODate(d);
}
function esc(s: string): string {
const div = document.createElement("div");
div.textContent = s ?? "";
return div.innerHTML;
}
function deadlineIcon(): string {
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>';
}
function appointmentIcon(): string {
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
}

View File

@@ -1,32 +1,12 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initI18n, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import { attachEventTypeMultiSelectFilter, type FilterHandle } from "./event-types";
import { renderAgendaTimeline, type AgendaItem } from "./agenda-render";
let eventTypeFilter: FilterHandle | null = null;
type Urgency = "overdue" | "today" | "tomorrow" | "this_week" | "later";
type AgendaType = "deadline" | "appointment";
type TypeFilter = "both" | "deadlines" | "appointments";
interface AgendaItem {
id: string;
type: AgendaType;
title: string;
date: string; // ISO 8601
end_at?: string | null;
due_date?: string | null; // YYYY-MM-DD (deadlines only)
status?: string | null; // deadlines: pending/completed/...
location?: string | null;
appointment_type?: string | null;
urgency: Urgency;
project_id?: string | null;
project_title?: string | null;
project_type?: string | null; // client | litigation | patent | case | project
project_reference?: string | null;
// Approval workflow (t-paliad-138). "pending" → render the warning pill.
approval_status?: "approved" | "pending" | "legacy" | null;
}
interface AgendaPayload {
items: AgendaItem[];
from: string;
@@ -214,157 +194,7 @@ function render(): void {
}
empty.style.display = "none";
timeline.style.display = "";
const buckets = groupByDay(state.items);
timeline.innerHTML = buckets.map((b) => renderDay(b)).join("");
}
interface DayBucket {
dayKey: string; // YYYY-MM-DD local
day: Date;
items: AgendaItem[];
}
function groupByDay(items: AgendaItem[]): DayBucket[] {
const map = new Map<string, DayBucket>();
for (const it of items) {
const d = new Date(it.date);
if (isNaN(d.getTime())) continue;
const key = toLocalDayKey(d);
let b = map.get(key);
if (!b) {
b = { dayKey: key, day: new Date(d.getFullYear(), d.getMonth(), d.getDate()), items: [] };
map.set(key, b);
}
b.items.push(it);
}
return Array.from(map.values()).sort((a, b) => a.day.getTime() - b.day.getTime());
}
function renderDay(bucket: DayBucket): string {
const expected = expectedUrgency(bucket.day);
return `<section class="agenda-day">
<h2 class="agenda-day-heading">
<span class="agenda-day-relative">${esc(relativeDayLabel(bucket.day))}</span>
<span class="agenda-day-full">${esc(fullDateLabel(bucket.day))}</span>
</h2>
<ul class="agenda-items">
${bucket.items.map((it) => renderItem(it, expected)).join("")}
</ul>
</section>`;
}
// F-32: an item's urgency tag duplicates the day-bucket heading in the
// common case (a "Heute" item under HEUTE, a "Diese Woche" item under "in 3
// Tagen"). The tag stays only when it disagrees with the bucket — e.g. an
// "Überfällig" deadline that lands in today's bucket because of a filter
// quirk. expectedUrgency mirrors the server's bucketing rule against the
// bucket's day.
function expectedUrgency(day: Date): Urgency {
const today = startOfToday();
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
if (diff < 0) return "overdue";
if (diff === 0) return "today";
if (diff === 1) return "tomorrow";
if (diff <= 6) return "this_week";
return "later";
}
function renderItem(it: AgendaItem, bucketUrgency: Urgency): string {
const urgencyClass = `agenda-item-${it.urgency}`;
const typeClass = `agenda-item-type-${it.type}`;
const pendingClass = it.approval_status === "pending" ? " entity-row--pending-update" : "";
const iconHTML = it.type === "deadline" ? deadlineIcon() : appointmentIcon();
const detailHref = itemDetailHref(it);
const project = it.project_id
? `<a class="agenda-item-project" href="/projects/${esc(it.project_id)}">${esc(formatProjectLabel(it))}</a>`
: "";
const pendingPill = it.approval_status === "pending"
? `<span class="approval-pill" title="${esc(tDyn("approvals.pending_update.label"))}">${esc(tDyn("approvals.pending_update.label"))}</span>`
: "";
const timePart = it.type === "appointment"
? `<span class="agenda-item-time">${esc(formatAppointmentTime(it))}</span>`
: "";
const urgencyTag = it.urgency !== bucketUrgency
? `<span class="agenda-item-urgency">${esc(tDyn(`agenda.urgency.${it.urgency}`))}</span>`
: "";
const locationPart = it.type === "appointment" && it.location
? `<span class="agenda-item-location">${esc(it.location)}</span>`
: "";
const typeLabelKey = it.type === "deadline"
? "agenda.label.deadline"
: (it.appointment_type ? `agenda.appointment_type.${it.appointment_type}` : "agenda.label.appointment");
const typeLabel = tDyn(typeLabelKey);
return `<li class="agenda-item ${typeClass} ${urgencyClass}${pendingClass}">
<a class="agenda-item-link" href="${esc(detailHref)}">
<span class="agenda-item-icon" aria-hidden="true">${iconHTML}</span>
<span class="agenda-item-main">
<span class="agenda-item-headline">
<span class="agenda-item-type-label">${esc(typeLabel)}:</span>
<span class="agenda-item-title">${esc(it.title)}</span>
${pendingPill}
</span>
<span class="agenda-item-sub">
${project}
${timePart}
${locationPart}
</span>
</span>
<span class="agenda-item-meta">
${urgencyTag}
</span>
</a>
</li>`;
}
function itemDetailHref(it: AgendaItem): string {
return it.type === "deadline"
? `/deadlines/${encodeURIComponent(it.id)}`
: `/appointments/${encodeURIComponent(it.id)}`;
}
function formatProjectLabel(it: AgendaItem): string {
const ref = it.project_reference ? `${it.project_reference} · ` : "";
const title = it.project_title || "";
return `${ref}${title}`.trim();
}
function formatAppointmentTime(it: AgendaItem): string {
const start = new Date(it.date);
if (isNaN(start.getTime())) return "";
const locale = getLang() === "de" ? "de-DE" : "en-GB";
const startStr = start.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
if (!it.end_at) return startStr;
const end = new Date(it.end_at);
if (isNaN(end.getTime())) return startStr;
const endStr = end.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
return `${startStr}${endStr}`;
}
function relativeDayLabel(day: Date): string {
const today = startOfToday();
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
if (diff < 0) {
const n = Math.abs(diff);
return getLang() === "de"
? (n === 1 ? "Gestern" : `vor ${n} Tagen`)
: (n === 1 ? "Yesterday" : `${n} days ago`);
}
if (diff === 0) return t("agenda.day.today");
if (diff === 1) return t("agenda.day.tomorrow");
return getLang() === "de" ? `in ${diff} Tagen` : `in ${diff} days`;
}
function fullDateLabel(day: Date): string {
const locale = getLang() === "de" ? "de-DE" : "en-GB";
return day.toLocaleDateString(locale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
timeline.innerHTML = renderAgendaTimeline(state.items);
}
function syncChips(): void {
@@ -394,21 +224,3 @@ function toISODate(d: Date): string {
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function toLocalDayKey(d: Date): string {
return toISODate(d);
}
function esc(s: string): string {
const div = document.createElement("div");
div.textContent = s ?? "";
return div.innerHTML;
}
function deadlineIcon(): string {
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>';
}
function appointmentIcon(): string {
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
}

View File

@@ -13,6 +13,22 @@ interface Appointment {
location?: string;
appointment_type?: string;
created_by?: string;
// t-paliad-138 + t-paliad-160 — pending-approval surface.
approval_status?: "approved" | "pending" | "legacy";
pending_request_id?: string | null;
}
interface PendingApprovalRequest {
id: string;
status: string;
requested_by: string;
requested_at: string;
required_role: string;
requester_name?: string;
}
interface Me {
id: string;
}
interface Project {
@@ -25,6 +41,8 @@ interface Project {
let appointment: Appointment | null = null;
let project: Project | null = null;
let allProjects: Project[] = [];
let pendingRequest: PendingApprovalRequest | null = null;
let me: Me | null = null;
function parseAppointmentID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
@@ -89,6 +107,31 @@ async function loadAllProjects() {
}
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (resp.ok) me = await resp.json();
} catch {
/* non-fatal */
}
}
// loadPendingRequest mirrors deadlines-detail.ts (t-paliad-160 §C+E):
// pull the in-flight approval_request when the entity is pending so the
// badge tooltip + the Withdraw button can be wired correctly.
async function loadPendingRequest(): Promise<void> {
pendingRequest = null;
if (!appointment || appointment.approval_status !== "pending" || !appointment.pending_request_id) {
return;
}
try {
const resp = await fetch(`/api/approval-requests/${appointment.pending_request_id}`);
if (resp.ok) pendingRequest = await resp.json();
} catch {
/* non-fatal */
}
}
function populateProjectPicker() {
const sel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
if (!sel) return;
@@ -133,6 +176,44 @@ function renderHeader() {
} else {
projectRow.style.display = "none";
}
// t-paliad-160 §C+E — pending-approval badge + withdraw + freeze controls.
const isPending = appointment.approval_status === "pending";
const isRequester = !!(me && pendingRequest && me.id === pendingRequest.requested_by);
const apBadge = document.getElementById("appointment-pending-approval-badge") as HTMLElement | null;
if (apBadge) {
if (isPending) {
apBadge.style.display = "";
const labelDe = t("approvals.pending.badge") || "Wartet auf Genehmigung";
apBadge.textContent = labelDe;
if (pendingRequest) {
const role = tDyn(`approvals.required_role.${pendingRequest.required_role}`) || pendingRequest.required_role;
const requester = pendingRequest.requester_name || pendingRequest.requested_by;
const when = fmtDateTime(pendingRequest.requested_at);
apBadge.title = `${labelDe} · ${role}+ · ${requester} · ${when}`;
} else {
apBadge.title = labelDe;
}
} else {
apBadge.style.display = "none";
apBadge.title = "";
}
}
const withdrawBtn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
if (withdrawBtn) {
withdrawBtn.style.display = (isPending && isRequester) ? "" : "none";
withdrawBtn.disabled = false;
}
// Freeze the edit form + delete button while a request is in flight.
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
if (form) {
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
.forEach((el) => { el.disabled = isPending; });
}
const deleteBtn = document.getElementById("appointment-delete-btn") as HTMLButtonElement | null;
if (deleteBtn) deleteBtn.disabled = isPending;
}
function fillEditForm() {
@@ -219,9 +300,9 @@ async function deleteAppointment() {
if (resp.ok || resp.status === 204) {
window.location.href = "/events?type=appointment";
} else {
const data = await resp.json().catch(() => ({}) as { error?: string });
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
const msg = document.getElementById("appointment-edit-msg")!;
msg.textContent = data.error || t("appointments.error.generic");
msg.textContent = data.message || data.error || t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
}
} catch {
@@ -231,6 +312,40 @@ async function deleteAppointment() {
}
}
async function withdrawAppointmentRequest() {
if (!appointment || !pendingRequest) return;
if (!confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
if (btn) btn.disabled = true;
try {
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (resp.ok) {
const fresh = await fetch(`/api/appointments/${appointment.id}`);
if (fresh.ok) {
appointment = await fresh.json();
await loadPendingRequest();
}
renderHeader();
fillEditForm();
} else {
const data = await resp.json().catch(() => ({}) as { message?: string; error?: string });
const msg = document.getElementById("appointment-edit-msg")!;
msg.textContent = data.message || data.error || (t("approvals.withdraw.error") || "Fehler beim Zurückziehen");
msg.className = "form-msg form-msg-error";
if (btn) btn.disabled = false;
}
} catch (e) {
const msg = document.getElementById("appointment-edit-msg")!;
msg.textContent = (t("approvals.withdraw.error") || "Fehler beim Zurückziehen") + ": " + e;
msg.className = "form-msg form-msg-error";
if (btn) btn.disabled = false;
}
}
async function main() {
const id = parseAppointmentID();
const loading = document.getElementById("appointment-loading")!;
@@ -250,6 +365,8 @@ async function main() {
await Promise.all([
appointment.project_id ? loadProject(appointment.project_id) : Promise.resolve(),
loadAllProjects(),
loadMe(),
loadPendingRequest(),
]);
loading.style.display = "none";
body.style.display = "";
@@ -259,6 +376,8 @@ async function main() {
document.getElementById("appointment-edit-form")!.addEventListener("submit", saveEdit);
document.getElementById("appointment-delete-btn")!.addEventListener("click", deleteAppointment);
const withdrawBtn = document.getElementById("appointment-withdraw-btn");
if (withdrawBtn) withdrawBtn.addEventListener("click", () => void withdrawAppointmentRequest());
const notes = document.getElementById("notes-container");
if (notes) {

View File

@@ -1,4 +1,4 @@
import { initI18n, t } from "./i18n";
import { initI18n, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
import { projectIndent } from "./project-indent";
@@ -107,6 +107,53 @@ async function submitForm(ev: Event) {
}
}
// t-paliad-154 — form-time 4-eye hint, mirroring deadlines-new.ts.
async function refreshApprovalHint(): Promise<void> {
const hint = document.getElementById("appointment-approval-hint");
const text = document.getElementById("appointment-approval-hint-text");
if (!hint || !text) return;
const projectID = (document.getElementById("appointment-project") as HTMLSelectElement | null)?.value || "";
if (!projectID) {
hint.style.display = "none";
return;
}
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectID)}/approval-policies/effective?entity_type=appointment&lifecycle=create`,
{ credentials: "include" },
);
if (!resp.ok) {
hint.style.display = "none";
return;
}
// t-paliad-160 split-grammar — read requires_approval + min_role.
// Fall back to the legacy required_role mirror (M1 dual-read window
// only — drops in M2).
const eff = await resp.json() as {
requires_approval?: boolean;
min_role?: string | null;
required_role?: string | null;
source?: string | null;
source_name?: string | null;
};
const role = eff.min_role || eff.required_role || null;
const required = (eff.requires_approval === true) || (role !== null && role !== "none");
if (!required || !role) {
hint.style.display = "none";
return;
}
const roleLabel = tDyn("admin.approval_policies.role." + role) || role;
const sourceLabel = eff.source_name
? ` · ${tDyn("admin.approval_policies.source." + (eff.source || "")) || ""}: ${eff.source_name}`
: "";
text.textContent = (t("appointments.form.approval_hint") || "4-Augen-Prüfung erforderlich")
+ ` · ${roleLabel}${sourceLabel}`;
hint.style.display = "";
} catch {
hint.style.display = "none";
}
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
@@ -114,4 +161,8 @@ document.addEventListener("DOMContentLoaded", async () => {
populateProjects();
preFillStart();
document.getElementById("appointment-new-form")!.addEventListener("submit", submitForm);
void refreshApprovalHint();
document.getElementById("appointment-project")?.addEventListener("change", () => {
void refreshApprovalHint();
});
});

View File

@@ -49,6 +49,20 @@ export function firstName(displayName: string): string {
return displayName.trim().split(/\s+/)[0] ?? "";
}
// buildMailtoHref produces a `mailto:` URL with every recipient queued
// in the To: field, comma-separated per RFC 6068. The `?` form is
// preserved as a future hook for default subject/body — kept empty here
// so users compose their own message in their mail client. Empty input
// returns "mailto:" so the button still renders without a JS error.
export function buildMailtoHref(recipients: BroadcastRecipient[]): string {
const addrs = recipients
.map((r) => r.email.trim())
.filter((e) => e.length > 0)
.map((e) => encodeURIComponent(e));
if (!addrs.length) return "mailto:";
return `mailto:${addrs.join(",")}`;
}
export function openBroadcastModal(args: OpenBroadcastModalArgs): void {
if (!args.recipients.length) {
alert(t("team.broadcast.error.no_recipients") || "Keine Empfänger ausgewählt.");
@@ -147,6 +161,9 @@ function renderShell(args: OpenBroadcastModalArgs): string {
<div class="broadcast-recipient-summary">
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
<a class="link-button broadcast-mailto" href="${buildMailtoHref(args.recipients)}" data-broadcast-mailto title="${esc(t("team.broadcast.mailto.tooltip") || "Im lokalen Mail-Client öffnen")}">
${esc(t("team.broadcast.mailto.label") || "Im Mail-Client öffnen")}
</a>
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
<ul>${fullList}</ul>

View File

@@ -1,5 +1,6 @@
import { initI18n, onLangChange, t, tDyn, getLang, translateEvent } from "./i18n";
import { initSidebar } from "./sidebar";
import { renderAgendaTimeline, type AgendaItem } from "./agenda-render";
interface DashboardUser {
id: string;
@@ -73,7 +74,13 @@ declare global {
}
const POLL_INTERVAL_MS = 60_000;
// 30-day look-ahead matches the agenda.tsx default chip and the server's
// default `to=today+30d` window — keeps the inline agenda visually
// consistent with /agenda when users follow the "full agenda" link.
const AGENDA_LOOKAHEAD_DAYS = 30;
const COLLAPSE_KEY_PREFIX = "paliad:dashboard:collapse:";
let data: DashboardData | null = null;
let agendaItems: AgendaItem[] | null = null;
async function loadDashboard(): Promise<void> {
const unavailable = document.getElementById("dashboard-unavailable")!;
@@ -101,6 +108,7 @@ function render(): void {
renderMatters(data.matter_summary);
renderDeadlines(data.upcoming_deadlines);
renderAppointments(data.upcoming_appointments);
renderAgenda();
renderActivity(data.recent_activity);
toggleOnboardingHint(data.user);
}
@@ -307,6 +315,130 @@ function activityHref(e: ActivityEntry): string {
return `/projects/${e.project_id}`;
}
// Render the inline Agenda section. Items are fetched once on mount via
// loadAgenda(); subsequent re-renders (lang change, dashboard poll) reuse
// the cached array. The dashboard inline agenda is read-only — no chip
// filters, default 30-day window — see CollapsibleSection in
// dashboard.tsx for the surrounding shell.
function renderAgenda(): void {
const timeline = document.getElementById("dashboard-agenda-timeline");
const empty = document.getElementById("dashboard-agenda-empty");
if (!timeline || !empty) return;
if (agendaItems === null) {
// Items haven't landed yet — keep the timeline blank but hide empty
// hint so we don't flash "nothing due" before the fetch resolves.
timeline.innerHTML = "";
timeline.style.display = "none";
empty.style.display = "none";
return;
}
if (!agendaItems.length) {
timeline.innerHTML = "";
timeline.style.display = "none";
empty.style.display = "block";
return;
}
empty.style.display = "none";
timeline.style.display = "";
timeline.innerHTML = renderAgendaTimeline(agendaItems);
}
async function loadAgenda(): Promise<void> {
const from = toAgendaDate(startOfToday());
const to = toAgendaDate(addDays(startOfToday(), AGENDA_LOOKAHEAD_DAYS - 1));
try {
const resp = await fetch(`/api/agenda?from=${from}&to=${to}&types=deadlines,appointments`);
if (!resp.ok) {
// Fail silently — the rest of the dashboard still loads. The
// inline agenda is best-effort: a 503 (DB-less knowledge-platform
// deploy) or 401 (session timed out, should be caught by the
// page-level redirect) just leaves the section empty.
agendaItems = [];
renderAgenda();
return;
}
agendaItems = (await resp.json()) as AgendaItem[];
renderAgenda();
} catch {
agendaItems = [];
renderAgenda();
}
}
function startOfToday(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
function addDays(d: Date, days: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + days);
return r;
}
function toAgendaDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
// Wire collapsible-section toggles. Each .dashboard-section carries a
// data-collapse-key and the SSR markup renders aria-expanded="true" so
// unstyled fallback shows everything; here we restore persisted state and
// attach click handlers. Persistence is per-section via localStorage —
// keys live under paliad:dashboard:collapse:<section> per the brief.
function initCollapsibleSections(): void {
const sections = document.querySelectorAll<HTMLElement>(".dashboard-section[data-collapse-key]");
sections.forEach((section) => {
const key = section.dataset.collapseKey || "";
if (!key) return;
const stored = localStorage.getItem(COLLAPSE_KEY_PREFIX + key);
const collapsed = stored === "true";
applyCollapseState(section, collapsed);
const toggle = section.querySelector<HTMLButtonElement>(".dashboard-section-toggle");
if (!toggle) return;
toggle.addEventListener("click", () => {
const nowExpanded = section.getAttribute("aria-expanded") === "true";
const nextCollapsed = nowExpanded; // expanded → collapsing
applyCollapseState(section, nextCollapsed);
try {
localStorage.setItem(COLLAPSE_KEY_PREFIX + key, String(nextCollapsed));
} catch {
// localStorage may be full or disabled (Safari private mode);
// collapse still works for the current page life. Silent.
}
});
});
// Re-localise the toggle aria-labels on language switch so screen
// readers always read the current language. The visible heading text
// is handled by the i18n applyTranslations pass already.
syncCollapseAriaLabels();
}
function applyCollapseState(section: HTMLElement, collapsed: boolean): void {
section.setAttribute("aria-expanded", String(!collapsed));
const toggle = section.querySelector<HTMLButtonElement>(".dashboard-section-toggle");
if (toggle) {
toggle.setAttribute("aria-expanded", String(!collapsed));
toggle.setAttribute(
"aria-label",
collapsed ? t("dashboard.section.expand") : t("dashboard.section.collapse"),
);
}
}
function syncCollapseAriaLabels(): void {
document
.querySelectorAll<HTMLElement>(".dashboard-section[data-collapse-key]")
.forEach((section) => {
const collapsed = section.getAttribute("aria-expanded") !== "true";
applyCollapseState(section, collapsed);
});
}
function toggleOnboardingHint(user: DashboardUser | null): void {
// Belt-and-braces: the server-side gate (gateOnboarded in handlers.go)
// already redirects users without a paliad.users row to /onboarding before
@@ -369,15 +501,28 @@ function escAttr(s: string): string {
function schedulePolling(): void {
// Refresh the payload every minute so open dashboards stay current when
// teammates create Akten/Fristen. Uses the JSON endpoint — no page reload.
// The inline agenda is refreshed on the same cadence to stay in sync
// with the deadlines/appointments rails above it.
window.setInterval(() => {
void loadDashboard();
void loadAgenda();
}, POLL_INTERVAL_MS);
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
onLangChange(render);
initCollapsibleSections();
onLangChange(() => {
render();
syncCollapseAriaLabels();
});
// Inline agenda fetch is independent of the main dashboard payload.
// Kicked off in parallel so the agenda section paints as soon as the
// /api/agenda response lands instead of waiting on the dashboard
// payload poll.
void loadAgenda();
const inlined = window.__PALIAD_DASHBOARD__;
if (inlined !== undefined) {

View File

@@ -24,6 +24,20 @@ interface Deadline {
created_at: string;
completed_at?: string;
event_type_ids?: string[];
// t-paliad-138 + t-paliad-160. approval_status='pending' means an
// approval_request is in flight; pending_request_id resolves to it
// and the controls flip to a withdraw affordance for the requester.
approval_status?: "approved" | "pending" | "legacy";
pending_request_id?: string | null;
}
interface PendingApprovalRequest {
id: string;
status: string;
requested_by: string;
requested_at: string;
required_role: string;
requester_name?: string;
}
let eventTypePicker: PickerHandle | null = null;
@@ -54,6 +68,7 @@ let project: Project | null = null;
let rule: DeadlineRule | null = null;
let me: Me | null = null;
let allProjects: Project[] = [];
let pendingRequest: PendingApprovalRequest | null = null;
function parseDeadlineID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
@@ -170,6 +185,23 @@ async function loadMe() {
}
}
// loadPendingRequest hydrates the in-flight approval_request when the
// entity carries approval_status='pending'. Used to populate the badge
// tooltip + decide whether to show the Withdraw button (only the
// requester can withdraw).
async function loadPendingRequest(): Promise<void> {
pendingRequest = null;
if (!deadline || deadline.approval_status !== "pending" || !deadline.pending_request_id) {
return;
}
try {
const resp = await fetch(`/api/approval-requests/${deadline.pending_request_id}`);
if (resp.ok) pendingRequest = await resp.json();
} catch {
/* non-fatal — badge still renders without the tooltip details */
}
}
function render() {
if (!deadline) return;
(document.getElementById("deadline-title-display") as HTMLElement).textContent = deadline.title;
@@ -249,19 +281,49 @@ function render() {
const completeBtn = document.getElementById("deadline-complete-btn") as HTMLButtonElement;
const reopenBtn = document.getElementById("deadline-reopen-btn") as HTMLButtonElement;
const withdrawBtn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement;
const editBtn = document.getElementById("deadline-edit-btn") as HTMLButtonElement;
const badge = document.getElementById("deadline-pending-approval-badge") as HTMLElement | null;
// t-paliad-160 §C+E — approval_status='pending' freezes the action
// controls and surfaces the badge + a Withdraw button (visible only to
// the requester). Other authenticated viewers see only the badge.
const isPending = deadline.approval_status === "pending";
const isRequester = !!(me && pendingRequest && me.id === pendingRequest.requested_by);
if (badge) {
if (isPending) {
badge.style.display = "";
const labelDe = t("approvals.pending.badge") || "Wartet auf Genehmigung";
badge.textContent = labelDe;
// Tooltip carries requester + required_role + age (best-effort).
if (pendingRequest) {
const role = tDyn(`approvals.required_role.${pendingRequest.required_role}`) || pendingRequest.required_role;
const requester = pendingRequest.requester_name || pendingRequest.requested_by;
const when = fmtDateTime(pendingRequest.requested_at);
badge.title = `${labelDe} · ${role}+ · ${requester} · ${when}`;
} else {
badge.title = labelDe;
}
} else {
badge.style.display = "none";
badge.title = "";
}
}
// Buttons.
if (deadline.status === "completed") {
completeBtn.style.display = "none";
// Reopen is admin-gated server-side; the button is shown for global
// admins/partners here as a client-side hint. Project leads who lack a
// global admin/partner role won't see the inline button — they get a 403
// only if they try, but the button itself stays hidden. They can still
// PATCH the endpoint directly.
if (me && (me.global_role === "global_admin")) {
if (me && (me.global_role === "global_admin") && !isPending) {
reopenBtn.style.display = "";
reopenBtn.disabled = false;
} else {
reopenBtn.style.display = "none";
}
} else if (isPending) {
// Lifecycle frozen — server returns 409 to anyone who tries.
completeBtn.style.display = "none";
reopenBtn.style.display = "none";
} else {
completeBtn.style.display = "";
completeBtn.disabled = false;
@@ -269,8 +331,22 @@ function render() {
reopenBtn.style.display = "none";
}
// Edit button: hidden during pending so users don't fight a 409.
if (editBtn) editBtn.style.display = isPending ? "none" : "";
// Withdraw button: visible only when caller is the requester of the
// in-flight request.
if (withdrawBtn) {
if (isPending && isRequester) {
withdrawBtn.style.display = "";
withdrawBtn.disabled = false;
} else {
withdrawBtn.style.display = "none";
}
}
const deleteWrap = document.getElementById("deadline-delete-wrap")!;
if (me && (me.global_role === "global_admin")) {
if (me && (me.global_role === "global_admin") && !isPending) {
deleteWrap.style.display = "";
} else {
deleteWrap.style.display = "none";
@@ -377,6 +453,25 @@ function initComplete() {
const resp = await fetch(`/api/deadlines/${deadline.id}/complete`, { method: "PATCH" });
if (resp.ok) {
deadline = await resp.json();
// The complete may have created an approval_request rather than
// completed the deadline outright (4-eye-required). Re-fetch the
// entity + pending request to surface the right state.
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
if (fresh.ok) deadline = await fresh.json();
await loadPendingRequest();
render();
} else if (resp.status === 409) {
// The handler returns the t-paliad-160 §B body shape. Surface
// the human message and refresh state — likely a concurrent
// request was already in flight.
const body = await resp.json().catch(() => null);
const msg = (body && body.message) || t("approvals.error.awaiting_approval") || "Diese Anforderung wartet auf Genehmigung.";
window.alert(msg);
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
if (fresh.ok) {
deadline = await fresh.json();
await loadPendingRequest();
}
render();
} else {
btn.disabled = false;
@@ -406,6 +501,48 @@ function initReopen() {
});
}
// initWithdraw — t-paliad-160 §C+E. Reuses the existing
// /api/approval-requests/{id}/revoke endpoint (no new server route
// needed). After the revoke lands, the entity goes back to
// approval_status='approved' and the page reloads to refresh the
// in-memory state cleanly.
function initWithdraw() {
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", async () => {
if (!deadline || !pendingRequest) return;
if (!window.confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
btn.disabled = true;
try {
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (resp.ok) {
// Re-fetch the entity so approval_status flips back to 'approved'
// and the badge / buttons rerender accordingly.
const r = await fetch(`/api/deadlines/${deadline.id}`);
if (r.ok) {
deadline = await r.json();
await loadPendingRequest();
render();
} else {
window.location.reload();
}
} else {
btn.disabled = false;
const body = await resp.json().catch(() => null);
const msg = (body && (body.message || body.error)) || (t("approvals.withdraw.error") || "Fehler beim Zurückziehen");
window.alert(msg);
}
} catch (e) {
btn.disabled = false;
window.alert((t("approvals.withdraw.error") || "Fehler beim Zurückziehen") + ": " + e);
}
});
}
function initDelete() {
const btn = document.getElementById("deadline-delete-btn")!;
const modal = document.getElementById("deadline-delete-modal")!;
@@ -455,7 +592,7 @@ async function main() {
notfound.style.display = "block";
return;
}
await Promise.all([loadProject(deadline.project_id), loadAllProjects()]);
await Promise.all([loadProject(deadline.project_id), loadAllProjects(), loadPendingRequest()]);
if (deadline.rule_id) await loadRule(deadline.rule_id);
// Load event types in parallel; render once ready (the picker re-renders
@@ -485,6 +622,7 @@ async function main() {
initEdit();
initComplete();
initReopen();
initWithdraw();
initDelete();
const notes = document.getElementById("notes-container");

View File

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

View File

@@ -9,6 +9,18 @@ import {
} from "./event-types";
import { projectIndent } from "./project-indent";
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
// pill markup trivially short and inherits the user's emoji font.
const APPROVAL_PILL_GLYPH = "👀";
// Sparkle glyph ✨ inside .approval-pill--agent (t-paliad-161). Renders
// next to (not in place of) 👀 when the pending row originated from a
// Paliadin chat suggestion. The two glyphs are orthogonal: 👀 = "needs
// approval", ✨ = "Paliadin drafted this". Both can coexist; either can
// appear alone in future autopilot states.
const AGENT_PILL_GLYPH = "✨";
// EventsPage shared client (t-paliad-110). Drives /deadlines and
// /appointments off the same shell — the route handler injects
// `window.__PALIAD_EVENTS__ = { defaultType: "deadline" | "appointment" }`
@@ -40,6 +52,9 @@ interface EventListItem {
// Approval workflow (t-paliad-138). "pending" → render the warning pill.
approval_status?: "approved" | "pending" | "legacy";
// t-paliad-161: when approval_status='pending', tells us whether the row
// was drafted by a user or by Paliadin (✨ glyph). NULL when not pending.
requester_kind?: "user" | "agent";
// deadline-only
due_date?: string;
@@ -507,19 +522,28 @@ function renderRow(item: EventListItem, showReopen: boolean): string {
? `<span class="termin-type-chip termin-type-${esc(item.appointment_type)}">${esc(tDyn(`appointments.type.${item.appointment_type}`) || item.appointment_type)}</span>`
: "&mdash;";
// Approval pending pill (t-paliad-138). Soft-tint the row + insert a
// ⚠ chip next to the title. Generic "pending approval" — the inbox
// shows the lifecycle detail.
// Approval pending pill (t-paliad-138 / m's 2026-05-08 cosmetic ask).
// Soft-tint the row + drop an eye-icon pill next to the title; hover
// reveals the lifecycle label. Inbox surface shows the full detail.
//
// t-paliad-161 ✨: when the pending row came from a Paliadin
// suggestion (requester_kind='agent'), drop a second pill next to 👀.
// Two glyphs read together as "needs approval, drafted by Paliadin".
const pendingClass = item.approval_status === "pending" ? " entity-row--pending-update" : "";
const pendingLabel = item.approval_status === "pending" ? t("approvals.pending_update.label") : "";
const pendingPill = item.approval_status === "pending"
? `<span class="approval-pill" title="${esc(t("approvals.pending_update.label"))}">${esc(t("approvals.pending_update.label"))}</span>`
? `<span class="approval-pill approval-pill--icon" title="${esc(pendingLabel)}" aria-label="${esc(pendingLabel)}">${APPROVAL_PILL_GLYPH}</span>`
: "";
const agentLabel = t("approvals.agent.label");
const agentPill = item.approval_status === "pending" && item.requester_kind === "agent"
? `<span class="approval-pill approval-pill--agent" title="${esc(agentLabel)}" aria-label="${esc(agentLabel)}">${AGENT_PILL_GLYPH}</span>`
: "";
return `<tr class="frist-row events-row events-row-${item.type}${pendingClass}" data-id="${esc(item.id)}" data-type="${item.type}">
<td class="frist-col-check">${checkCell}</td>
<td class="events-col-row-type">${rowTypeChip(item)}</td>
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${esc(dateLabel)}</td>
<td class="frist-col-title ${titleClass}">${esc(item.title)}${pendingPill ? " " + pendingPill : ""}</td>
<td class="frist-col-title ${titleClass}">${esc(item.title)}${pendingPill ? " " + pendingPill : ""}${agentPill ? " " + agentPill : ""}</td>
<td class="frist-col-project">${projectCell}</td>
<td class="frist-col-rule events-col-rule">${ruleLabel || "&mdash;"}</td>
<td class="entity-col-event-type">${eventTypeCell || "&mdash;"}</td>

View File

@@ -0,0 +1,512 @@
// 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;
}
// RenderAxisOpts — per-surface tuning the bar threads through to axis
// renderers. Currently only time-axis chip presets; future axes can grow
// here without changing every call site.
export interface RenderAxisOpts {
timePresets?: NonNullable<BarState["time"]>["horizon"][];
}
// 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, opts?: RenderAxisOpts): HTMLElement | null {
switch (axis) {
case "time": return renderTimeAxis(ctx, opts?.timePresets);
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 "project_event_kind": return renderProjectEventKindAxis(ctx);
case "timeline_status": return renderTimelineStatusAxis(ctx);
case "timeline_track": return renderTimelineTrackAxis(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 component.
case "deadline_event_type":
return null;
}
}
// ----------------------------------------------------------------------
// time — chip cluster (presets + Anpassen)
// ----------------------------------------------------------------------
type TimeHorizonValue = NonNullable<BarState["time"]>["horizon"];
const TIME_PRESET_LABELS: Record<TimeHorizonValue, I18nKey> = {
next_7d: "views.bar.time.next_7d",
next_30d: "views.bar.time.next_30d",
next_90d: "views.bar.time.next_90d",
past_7d: "views.bar.time.past_7d",
past_30d: "views.bar.time.past_30d",
past_90d: "views.bar.time.past_90d",
any: "views.bar.time.any",
all: "views.bar.time.all",
custom: "views.bar.time.custom",
};
const DEFAULT_TIME_PRESETS: TimeHorizonValue[] = [
"next_7d", "next_30d", "next_90d", "past_30d", "any",
];
function renderTimeAxis(ctx: AxisCtx, presetOverride?: TimeHorizonValue[]): HTMLElement {
const wrap = group("views.bar.label.time");
const row = chipRow();
const presets = presetOverride && presetOverride.length ? presetOverride : DEFAULT_TIME_PRESETS;
// "any" / "all" are both unbounded — clearing state is the cleanest
// representation, so each maps to "no overlay" rather than a stored
// horizon. The chip's active state then keys off "no time set".
const current = ctx.get("time")?.horizon ?? "any";
for (const preset of presets) {
if (preset === "custom") continue; // custom rendered separately below
const isUnbounded = preset === "any" || preset === "all";
const isActive = isUnbounded
? !ctx.get("time")
: preset === current;
const chip = chipBtn(t(TIME_PRESET_LABELS[preset]), isActive);
chip.addEventListener("click", () => {
if (isUnbounded) {
ctx.patch({ time: undefined });
} else {
ctx.patch({ time: { horizon: preset } });
}
});
row.appendChild(chip);
}
// Custom range — placeholder chip; opens a small popover with two
// <input type="date"> in Phase 2. For Phase 1 we render the chip
// disabled with a tooltip so the affordance is discoverable.
const customChip = chipBtn(t("views.bar.time.custom"), current === "custom");
customChip.classList.add("filter-bar-chip-pending");
customChip.title = t("views.bar.time.custom.coming_soon");
customChip.disabled = true;
row.appendChild(customChip);
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// personal_only — single chip (binary)
// ----------------------------------------------------------------------
function renderPersonalOnlyAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.personal");
const chip = chipBtn(t("views.bar.personal.on"), !!ctx.get("personal_only"));
chip.addEventListener("click", () => {
ctx.patch({ personal_only: !ctx.get("personal_only") });
});
wrap.appendChild(chip);
return wrap;
}
// ----------------------------------------------------------------------
// approval_viewer_role — chip cluster (3 mutually exclusive)
// ----------------------------------------------------------------------
const APPROVAL_ROLES: Array<{ value: NonNullable<BarState["approval_viewer_role"]>; key: I18nKey }> = [
{ value: "approver_eligible", key: "views.bar.approval_role.approver_eligible" },
{ value: "self_requested", key: "views.bar.approval_role.self_requested" },
{ value: "any_visible", key: "views.bar.approval_role.any_visible" },
];
function renderApprovalRoleAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.approval_role");
const row = chipRow();
// Default to "any_visible" so the surface lands on a populated view
// for every user. The InboxSystemView's base spec also defaults here;
// these two defaults must stay in sync — otherwise the chip and the
// server narrow disagree on the empty URL.
const current = ctx.get("approval_viewer_role") ?? "any_visible";
for (const role of APPROVAL_ROLES) {
const chip = chipBtn(t(role.key), role.value === current);
chip.addEventListener("click", () => {
ctx.patch({ approval_viewer_role: role.value });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// approval_status — chip cluster (multi-select)
// ----------------------------------------------------------------------
const APPROVAL_STATUSES: Array<{ value: string; key: I18nKey }> = [
{ value: "pending", key: "views.bar.approval_status.pending" },
{ value: "approved", key: "views.bar.approval_status.approved" },
{ value: "rejected", key: "views.bar.approval_status.rejected" },
{ value: "revoked", key: "views.bar.approval_status.revoked" },
];
function renderApprovalStatusAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.approval_status");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("approval_status")?.length);
all.addEventListener("click", () => ctx.patch({ approval_status: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("approval_status") ?? []);
for (const status of APPROVAL_STATUSES) {
const chip = chipBtn(t(status.key), current.has(status.value));
chip.addEventListener("click", () => {
if (current.has(status.value)) current.delete(status.value);
else current.add(status.value);
ctx.patch({ approval_status: current.size ? [...current] : undefined });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// approval_entity_type — chip pair (multi-select; deadline / appointment)
// ----------------------------------------------------------------------
const APPROVAL_ENTITY_TYPES: Array<{ value: string; key: I18nKey }> = [
{ value: "deadline", key: "views.bar.approval_entity.deadline" },
{ value: "appointment", key: "views.bar.approval_entity.appointment" },
];
function renderApprovalEntityTypeAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.approval_entity");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("approval_entity_type")?.length);
all.addEventListener("click", () => ctx.patch({ approval_entity_type: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("approval_entity_type") ?? []);
for (const ent of APPROVAL_ENTITY_TYPES) {
const chip = chipBtn(t(ent.key), current.has(ent.value));
chip.addEventListener("click", () => {
if (current.has(ent.value)) current.delete(ent.value);
else current.add(ent.value);
ctx.patch({ approval_entity_type: current.size ? [...current] : undefined });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// deadline_status — chip cluster (multi-select)
// ----------------------------------------------------------------------
const DEADLINE_STATUSES: Array<{ value: string; key: I18nKey }> = [
{ value: "pending", key: "views.bar.deadline_status.pending" },
{ value: "completed", key: "views.bar.deadline_status.completed" },
];
function renderDeadlineStatusAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.deadline_status");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("deadline_status")?.length);
all.addEventListener("click", () => ctx.patch({ deadline_status: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("deadline_status") ?? []);
for (const s of DEADLINE_STATUSES) {
const chip = chipBtn(t(s.key), current.has(s.value));
chip.addEventListener("click", () => {
if (current.has(s.value)) current.delete(s.value);
else current.add(s.value);
ctx.patch({ deadline_status: current.size ? [...current] : undefined });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// appointment_type — chip cluster (multi-select)
// ----------------------------------------------------------------------
const APPOINTMENT_TYPES: Array<{ value: string; key: I18nKey }> = [
{ value: "hearing", key: "views.bar.appointment_type.hearing" },
{ value: "meeting", key: "views.bar.appointment_type.meeting" },
{ value: "consultation", key: "views.bar.appointment_type.consultation" },
{ value: "deadline_hearing", key: "views.bar.appointment_type.deadline_hearing" },
];
function renderAppointmentTypeAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.appointment_type");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("appointment_type")?.length);
all.addEventListener("click", () => ctx.patch({ appointment_type: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("appointment_type") ?? []);
for (const ty of APPOINTMENT_TYPES) {
const chip = chipBtn(t(ty.key), current.has(ty.value));
chip.addEventListener("click", () => {
if (current.has(ty.value)) current.delete(ty.value);
else current.add(ty.value);
ctx.patch({ appointment_type: current.size ? [...current] : undefined });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// project_event_kind — chip cluster (multi-select)
//
// Mirrors KnownProjectEventKinds in internal/services/filter_spec.go.
// Labels reuse the existing `event.title.<kind>` translation table so
// the chip text matches the Verlauf row title for the same event type.
// ----------------------------------------------------------------------
const PROJECT_EVENT_KINDS: string[] = [
"project_created",
"project_archived",
"project_reparented",
"project_type_changed",
"status_changed",
"deadline_created",
"deadline_completed",
"deadline_reopened",
"appointment_created",
"appointment_updated",
"appointment_deleted",
"approval_decided",
"member_role_changed",
];
function renderProjectEventKindAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.project_event_kind");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("project_event_kind")?.length);
all.addEventListener("click", () => ctx.patch({ project_event_kind: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("project_event_kind") ?? []);
for (const kind of PROJECT_EVENT_KINDS) {
const label = tDyn(`event.title.${kind}`);
const chip = chipBtn(label, current.has(kind));
chip.addEventListener("click", () => {
if (current.has(kind)) current.delete(kind);
else current.add(kind);
ctx.patch({ project_event_kind: current.size ? [...current] : undefined });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// timeline_status — chip cluster (multi-select)
//
// SmartTimeline (t-paliad-173) status vocabulary spans actuals +
// projections. Default: all. Macro chip pair "Zukunft anzeigen" /
// "Nur vergangenes" toggles the [predicted, court_set] subset on
// or off in one click.
// ----------------------------------------------------------------------
const TIMELINE_STATUSES: Array<{ value: string; key: I18nKey }> = [
{ value: "done", key: "views.bar.timeline_status.done" },
{ value: "open", key: "views.bar.timeline_status.open" },
{ value: "overdue", key: "views.bar.timeline_status.overdue" },
{ value: "predicted", key: "views.bar.timeline_status.predicted" },
{ value: "predicted_overdue", key: "views.bar.timeline_status.predicted_overdue" },
{ value: "court_set", key: "views.bar.timeline_status.court_set" },
{ value: "off_script", key: "views.bar.timeline_status.off_script" },
];
function renderTimelineStatusAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.timeline_status");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("timeline_status")?.length);
all.addEventListener("click", () => ctx.patch({ timeline_status: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("timeline_status") ?? []);
for (const s of TIMELINE_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({ timeline_status: current.size ? [...current] : undefined });
});
row.appendChild(chip);
}
// Macro chips. "Zukunft anzeigen" = include predicted+court_set; "Nur
// vergangenes" = strip them. Implemented in terms of timeline_status.
const future = chipBtn(t("views.bar.timeline_status.macro.future"), false);
future.classList.add("filter-bar-chip-macro");
future.addEventListener("click", () => {
const next = new Set(["done", "open", "overdue", "predicted", "court_set", "predicted_overdue", "off_script"]);
ctx.patch({ timeline_status: [...next] });
});
row.appendChild(future);
const past = chipBtn(t("views.bar.timeline_status.macro.past"), false);
past.classList.add("filter-bar-chip-macro");
past.addEventListener("click", () => {
ctx.patch({ timeline_status: ["done", "overdue", "off_script"] });
});
row.appendChild(past);
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// timeline_track — chip cluster (multi-select)
//
// Slice 2 only renders parent + off_script; counterclaim and child:<id>
// values land with Slice 3's CCR sub-project FK migration. The renderer
// stays ready for those values — chip rendering is dynamic on the
// state set, not hard-coded to the catalogue below.
// ----------------------------------------------------------------------
const TIMELINE_TRACKS: Array<{ value: string; key: I18nKey }> = [
{ value: "parent", key: "views.bar.timeline_track.parent" },
{ value: "counterclaim", key: "views.bar.timeline_track.counterclaim" },
{ value: "off_script", key: "views.bar.timeline_track.off_script" },
];
function renderTimelineTrackAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.timeline_track");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("timeline_track")?.length);
all.addEventListener("click", () => ctx.patch({ timeline_track: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("timeline_track") ?? []);
for (const tr of TIMELINE_TRACKS) {
const chip = chipBtn(t(tr.key), current.has(tr.value));
chip.addEventListener("click", () => {
if (current.has(tr.value)) current.delete(tr.value);
else current.add(tr.value);
ctx.patch({ timeline_track: 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;
}
// ----------------------------------------------------------------------
// shared helpers — group + chip + row
// ----------------------------------------------------------------------
function group(labelKey: I18nKey): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "filter-group filter-bar-group";
const label = document.createElement("span");
label.className = "filter-bar-label";
label.textContent = t(labelKey);
wrap.appendChild(label);
return wrap;
}
function chipRow(): HTMLElement {
const row = document.createElement("div");
row.className = "filter-bar-chip-row";
return row;
}
function chipBtn(text: string, active: boolean): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "agenda-chip filter-bar-chip" + (active ? " agenda-chip-active" : "");
btn.textContent = text;
return btn;
}

View File

@@ -0,0 +1,349 @@
// 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, type RenderAxisOpts } 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 {
if (!!opts.customRunner === !!opts.systemViewSlug) {
throw new Error(
"mountFilterBar: exactly one of customRunner or systemViewSlug must be provided",
);
}
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 {
let result: ViewRunResult;
if (opts.customRunner) {
// Hand the runner a frozen snapshot of the bar state so it can
// read axes the EffectiveSpec doesn't round-trip (SmartTimeline
// timeline_status / timeline_track on the Verlauf surface).
result = await opts.customRunner(effective, Object.freeze({ ...state }));
} else {
const slug = opts.systemViewSlug as string; // ctor guard guarantees this
const r = await fetch(`/api/views/${encodeURIComponent(slug)}/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;
}
result = (await r.json()) as ViewRunResult;
}
if (myVersion !== runVersion) return;
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();
},
};
const axisRenderOpts: RenderAxisOpts = {
timePresets: opts.timePresets,
};
// First paint.
const renderToolbar = () => {
toolbar.innerHTML = "";
for (const axis of opts.axes) {
const el = renderAxis(axis as AxisKey, ctx, axisRenderOpts);
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);
},
getState() {
// Hand back a frozen snapshot so callers can't smuggle mutations
// back into the bar's owned state — the bar is the single writer.
return Object.freeze({ ...state });
},
destroy() {
destroyed = true;
toolbar.remove();
},
};
renderToolbar();
void runAndRender();
return handle;
}
// hydratePrefs reads the saved `paliad.bar.<surfaceKey>` blob and fills
// in render axes the URL didn't already pin. URL wins over prefs.
function hydratePrefs(state: BarState, surfaceKey: string): void {
let blob: PrefsBlob;
try {
const raw = localStorage.getItem(PREFS_PREFIX + surfaceKey);
if (!raw) return;
blob = JSON.parse(raw) as PrefsBlob;
} catch { return; }
if (!state.shape && (blob.shape === "list" || blob.shape === "cards" || blob.shape === "calendar")) {
state.shape = blob.shape;
}
if (!state.density && (blob.density === "comfortable" || blob.density === "compact")) {
state.density = blob.density;
}
if (!state.sort && (blob.sort === "date_asc" || blob.sort === "date_desc")) {
state.sort = blob.sort;
}
}
// computeEffective overlays the BarState onto the base FilterSpec +
// RenderSpec to produce the spec that gets POSTed to the substrate.
//
// Server-side validator (FilterSpec.Validate) is the final gate; we
// produce shapes the validator will accept, but defer to it for the
// hard rejection case (e.g. PersonalOnly + ScopeExplicit).
export function computeEffective(
base: FilterSpec,
baseRender: RenderSpec,
state: BarState,
): EffectiveSpec {
// Deep-clone to avoid mutating the caller's base. JSON round-trip is
// fine here — every field on FilterSpec is a primitive / array /
// object literal (no class instances, no Date, no functions).
const filter = JSON.parse(JSON.stringify(base)) as FilterSpec;
const render = JSON.parse(JSON.stringify(baseRender)) as RenderSpec;
if (state.time) {
filter.time = {
...filter.time,
horizon: state.time.horizon,
from: state.time.horizon === "custom" ? state.time.from : undefined,
to: state.time.horizon === "custom" ? state.time.to : undefined,
};
}
if (state.project) {
if (state.project.mode === "personal") {
filter.scope = {
...filter.scope,
personal_only: true,
// When personal_only takes over, leave projects on the base
// mode (typically all_visible). Validator rejects ScopeExplicit
// + personal_only so we don't overwrite the mode here.
};
} else if (state.project.id) {
filter.scope = {
...filter.scope,
projects: { mode: "explicit", ids: [state.project.id] },
};
}
}
if (state.personal_only) {
filter.scope = { ...filter.scope, personal_only: true };
}
// Per-source predicates. Build the predicates map idempotently;
// never inject a predicate for a source the spec doesn't list.
const sources = new Set(filter.sources);
filter.predicates = filter.predicates ?? {};
if (sources.has("deadline") && (state.deadline_status || state.deadline_event_type)) {
const cur = filter.predicates.deadline ?? {};
const next = { ...cur };
if (state.deadline_status) next.status = state.deadline_status;
if (state.deadline_event_type) {
next.event_types = state.deadline_event_type.ids;
next.include_untyped = state.deadline_event_type.include_untyped;
}
filter.predicates.deadline = next;
}
if (sources.has("appointment") && state.appointment_type) {
const cur = filter.predicates.appointment ?? {};
filter.predicates.appointment = { ...cur, appointment_types: state.appointment_type };
}
if (sources.has("approval_request") && (state.approval_viewer_role || state.approval_status || state.approval_entity_type)) {
const cur = filter.predicates.approval_request ?? {};
const next = { ...cur };
if (state.approval_viewer_role) next.viewer_role = state.approval_viewer_role;
if (state.approval_status) next.status = state.approval_status;
if (state.approval_entity_type) next.entity_types = state.approval_entity_type;
filter.predicates.approval_request = next;
}
if (sources.has("project_event") && state.project_event_kind) {
const cur = filter.predicates.project_event ?? {};
filter.predicates.project_event = { ...cur, event_types: state.project_event_kind };
}
// Render overlays.
if (state.shape) render.shape = state.shape;
if (state.sort) {
if (render.shape === "list" || (state.shape === "list" && !render.list)) {
render.list = { ...(render.list ?? {}), sort: state.sort };
}
if (render.shape === "cards" || state.shape === "cards") {
render.cards = { ...(render.cards ?? {}), sort: state.sort };
}
}
if (state.density && (render.shape === "list" || state.shape === "list")) {
render.list = { ...(render.list ?? {}), density: state.density };
}
return { filter, render };
}
// isDirty — used to enable the Reset button only when there's something
// to reset to.
function isDirty(state: BarState): boolean {
for (const k of Object.keys(state) as (keyof BarState)[]) {
const v = state[k];
if (v === undefined || v === null || v === false) continue;
if (Array.isArray(v) && v.length === 0) continue;
return true;
}
return false;
}

View File

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

View File

@@ -0,0 +1,161 @@
// 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"
| "timeline_status"
| "timeline_track"
| "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[];
// SmartTimeline axes (t-paliad-173). timeline_status spans actuals +
// projections; timeline_track is parent / counterclaim / off_script
// and grows once Slice 3 lands the CCR sub-project FK (child:<id>
// values dynamically populated then).
timeline_status?: string[];
timeline_track?: string[];
// Render
shape?: RenderShape;
sort?: "date_asc" | "date_desc";
density?: "comfortable" | "compact";
}
export interface TimeOverlay {
horizon: "next_7d" | "next_30d" | "next_90d" | "past_7d" | "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
// unless `customRunner` is supplied — see below. When the bar runs
// through this endpoint it is the substrate's canonical entry.
systemViewSlug?: string;
// Custom runner. When set, the bar bypasses the substrate POST and
// hands the effective spec + raw BarState to this function instead.
// Used by surfaces that need axes the EffectiveSpec doesn't round-trip
// (e.g. SmartTimeline's timeline_status / timeline_track, t-paliad-176).
// The state argument is a frozen snapshot — same shape getState()
// returns on the handle, but available on the very first run before
// the handle has been assigned. Must be either this OR systemViewSlug
// — the bar throws if both / neither are provided.
customRunner?: (effective: EffectiveSpec, state: Readonly<BarState>) => Promise<ViewRunResult>;
// Per-surface override of the time-axis chip presets. Order is
// preserved. Default presets are forward-looking (next_*+past_30d+any)
// — backward-looking surfaces (Verlauf, audit) pass past_*+all here.
timePresets?: NonNullable<BarState["time"]>["horizon"][];
// 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;
// Read-only raw BarState. Surfaces with axes the EffectiveSpec doesn't
// round-trip (timeline_status / timeline_track on the SmartTimeline
// surface — the substrate FilterSpec has no per-source predicate for
// those) read state directly to drive client-side filtering. Returns
// a frozen snapshot; callers must not mutate.
getState(): Readonly<BarState>;
}

View File

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

View File

@@ -0,0 +1,198 @@
// 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);
// SmartTimeline (t-paliad-173) — status + track axes.
const tlStatus = params.get(k("tl_status"));
if (tlStatus) out.timeline_status = parseCSV(tlStatus);
const tlTrack = params.get(k("tl_track"));
if (tlTrack) out.timeline_track = parseCSV(tlTrack);
// 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",
"tl_status", "tl_track",
"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.timeline_status?.length) params.set(k("tl_status"), state.timeline_status.join(","));
if (state.timeline_track?.length) params.set(k("tl_track"), state.timeline_track.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_7d":
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 };

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.home": "Home",
"nav.kostenrechner": "Kostenrechner",
"nav.fristenrechner": "Fristenrechner",
"nav.verfahrensablauf": "Verfahrensablauf",
"nav.downloads": "Downloads",
"nav.links": "Links",
"nav.glossar": "Glossar",
@@ -38,10 +39,8 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.paliadin": "Paliadin",
"nav.team": "Team",
"nav.group.uebersicht": "\u00dcbersicht",
"nav.group.arbeit": "Arbeit",
"nav.group.ansichten": "Ansichten",
"nav.group.werkzeuge": "Werkzeuge",
"nav.group.wissen": "Wissen",
"nav.group.ressourcen": "Ressourcen",
"nav.neuigkeiten": "Neuigkeiten",
"nav.soon.tooltip": "Bald verf\u00fcgbar",
@@ -199,6 +198,12 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.title": "Fristenrechner \u2014 Paliad",
"deadlines.heading": "Fristenrechner",
"deadlines.subtitle": "Berechnung von Verfahrensfristen f\u00fcr UPC-, deutsche und EPA-Verfahren.",
// Verfahrensablauf (t-paliad-179 Slice 1)
"tools.verfahrensablauf.title": "Verfahrensablauf \u2014 Paliad",
"tools.verfahrensablauf.heading": "Verfahrensablauf",
"tools.verfahrensablauf.subtitle": "Typischen Verfahrensablauf einsehen \u2014 Verfahrensart w\u00e4hlen, Datum optional setzen.",
"deadlines.step1": "Verfahrensart w\u00e4hlen",
"deadlines.step2": "Ausgangsdatum eingeben",
"deadlines.step3": "Ergebnis",
@@ -243,6 +248,40 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.party.both": "Beide",
"deadlines.party.both.label": "beide Seiten",
"deadlines.court.set": "vom Gericht bestimmt",
"deadlines.court.indirect": "unbestimmt",
"deadlines.optional.badge": "auf Antrag",
"deadlines.proceeding.selected": "Verfahren:",
"deadlines.proceeding.reselect": "Anderes Verfahren wählen",
"deadlines.step1.heading": "Schritt 1 — Welche Akte?",
"deadlines.step1.search.placeholder": "Akte suchen…",
"deadlines.step1.search.empty": "Keine passende Akte gefunden.",
"deadlines.step1.divider.new": "oder eine neue Akte",
"deadlines.step1.divider.adhoc": "oder ad-hoc, ohne Akte",
"deadlines.step1.new.cta": "+ Neue Akte anlegen",
"deadlines.step1.adhoc.upc": "Custom UPC-Verfahren",
"deadlines.step1.adhoc.de": "Custom DE-Verfahren",
"deadlines.step1.adhoc.epa": "Custom EPA-Verfahren",
"deadlines.step1.adhoc.dpma": "Custom DPMA-Verfahren",
"deadlines.step1.selected": "Akte:",
"deadlines.step1.reselect": "Andere Akte",
"deadlines.step1.summary.adhoc.suffix": "ohne Akte (Erkundung)",
"deadlines.step2.heading": "Schritt 2 — Was möchten Sie tun?",
"deadlines.step2.file.title": "Etwas einreichen",
"deadlines.step2.file.desc": "Outgoing — eine Frist tritt aus eigener Handlung ein.",
"deadlines.step2.happened.title": "Etwas ist passiert",
"deadlines.step2.happened.desc": "Incoming — ein Ereignis hat eine Frist ausgelöst.",
"deadlines.step2.browse.title": "Verfahrensablauf einsehen",
"deadlines.step2.browse.desc": "Browse / Learn — sehen, was wann passiert. Keine Frist eintragen.",
"deadlines.save.cta.adhoc.hint": "Ad-hoc — kein Projekt, kein Speichern",
"deadlines.step3a.heading": "Was möchten Sie einreichen?",
"deadlines.step3a.back": "zurück zur Auswahl",
"deadlines.step3a.file.title": "Schriftsatz einreichen",
"deadlines.step3a.file.desc": "Verfahrensablauf laden — Frist berechnen und zur Akte hinzufügen.",
"deadlines.step3a.draft.title": "Schriftsatz entwerfen",
"deadlines.step3a.draft.desc": "Vorbereitung — später mit Drafting-Surface verknüpft.",
"deadlines.step3a.enter.title": "Frist manuell erfassen",
"deadlines.step3a.enter.desc": "Direkt eintragen — bereits bekanntes Datum / bekannter Typ.",
"deadlines.step3a.soon": "kommt bald",
"deadlines.date.edit.hint": "Datum bearbeiten — Folgefristen werden neu berechnet",
"deadlines.view.label": "Ansicht:",
"deadlines.view.timeline": "Zeitstrahl",
@@ -320,6 +359,12 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.pathway.b.tree.empty": "Keine Treffer für diesen Pfad.",
"deadlines.pathway.b.tree.reset": "Neu starten",
"deadlines.pathway.b.tree.start_question": "Was ist passiert?",
"deadlines.inbox.label": "Wo kam es an?",
"deadlines.inbox.cms.title": "UPC — über CMS",
"deadlines.inbox.bea.title": "Nationale Verfahren — über beA",
"deadlines.inbox.posteingang.title": "Nationale Verfahren — Postzustellung",
"deadlines.inbox.posteingang": "Posteingang",
"deadlines.inbox.all": "Alle",
"deadlines.filter.forum.label": "Gericht / System:",
"deadlines.filter.forum.upc_cfi": "UPC CFI",
"deadlines.filter.forum.upc_coa": "UPC CoA",
@@ -334,7 +379,13 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.perspective.label": "Ich vertrete:",
"deadlines.perspective.claimant": "Klägerseite (Proactive)",
"deadlines.perspective.defendant": "Beklagtenseite (Reactive)",
"deadlines.perspective.claimant.short": "Kläger",
"deadlines.perspective.defendant.short": "Beklagter",
"deadlines.perspective.both.short": "Beide",
"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",
@@ -699,6 +750,10 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.field.due": "F\u00e4lligkeitsdatum",
"deadlines.field.rule": "Regel (optional)",
"deadlines.field.rule.none": "Keine Regel",
"deadlines.field.rule.autofill": "Typ vorgegeben durch Regel — entfernen, um zu überschreiben.",
"deadlines.field.rule.autofill_inline": " (vorgegeben durch Regel)",
"deadlines.field.rule.mismatch": "Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.",
"deadlines.field.rule.override": "Anderen Typ wählen",
"deadlines.field.notes": "Notizen (optional)",
"deadlines.field.notes.placeholder": "Hinweise, Verweise, n\u00e4chste Schritte\u2026",
"deadlines.error.required": "Akte, Titel und F\u00e4lligkeitsdatum sind Pflichtfelder.",
@@ -808,6 +863,17 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.activity.empty": "Noch keine Aktivit\u00e4t erfasst.",
"dashboard.activity.system": "System",
"dashboard.activity.event": "Ereignis",
// Inline Agenda section on the dashboard (t-paliad-162). The
// standalone /agenda page keeps its own copy under the agenda.* keys;
// these are dashboard-scoped so the headline reads as a section
// title rather than a page title.
"dashboard.agenda.heading": "Agenda",
"dashboard.agenda.empty": "Keine F\u00e4lligkeiten in den n\u00e4chsten 30 Tagen.",
"dashboard.agenda.full_link": "Vollst\u00e4ndige Agenda \u00f6ffnen \u2192",
// Collapsible-section toggle a11y labels (t-paliad-162). Both states
// are needed because the aria-label flips with the expanded state.
"dashboard.section.collapse": "Abschnitt einklappen",
"dashboard.section.expand": "Abschnitt ausklappen",
"dashboard.urgency.overdue": "\u00dcberf\u00e4llig",
"dashboard.urgency.today": "Heute",
"dashboard.urgency.urgent": "Dringend",
@@ -823,6 +889,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",
@@ -844,6 +911,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",
@@ -1073,6 +1141,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:",
@@ -1080,12 +1156,50 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.back": "\u2190 Zur\u00fcck zur \u00dcbersicht",
"projects.detail.loading": "L\u00e4dt\u2026",
"projects.detail.notfound": "Projekt nicht gefunden oder keine Berechtigung.",
"projects.detail.smarttimeline.open_chart": "Als Chart anzeigen \u2197",
"projects.chart.title": "Projekt-Chart \u2014 Paliad",
"projects.chart.back": "\u2190 Zur\u00fcck zum Verlauf",
"projects.chart.loading": "L\u00e4dt\u2026",
"projects.chart.notfound": "Projekt nicht gefunden oder keine Berechtigung.",
"projects.chart.error.mount": "Chart konnte nicht initialisiert werden.",
"projects.chart.control.layout.horizontal": "Layout: Horizontal",
"projects.chart.control.columns.auto": "Spalten: Auto",
"projects.chart.control.density.standard": "Dichte: Standard",
"projects.chart.control.palette.default": "Palette: Standard",
"projects.chart.control.export.soon": "Export \u2193 (Slice 2)",
"projects.chart.control.palette.label": "Palette:",
"projects.chart.palette.default": "Standard",
"projects.chart.palette.kind_coded": "Nach Ereignistyp",
"projects.chart.palette.track_coded": "Nach Spur",
"projects.chart.palette.high_contrast": "Hoher Kontrast",
"projects.chart.palette.print": "Druck (S/W)",
"projects.chart.control.density.label": "Dichte:",
"projects.chart.density.compact": "Kompakt",
"projects.chart.density.standard": "Standard",
"projects.chart.density.spacious": "Großzügig",
"projects.chart.control.range.label": "Zeitraum:",
"projects.chart.range.1y": "1 Jahr",
"projects.chart.range.2y": "2 Jahre",
"projects.chart.range.all": "Alles anzeigen",
"projects.chart.range.custom": "Eigener Bereich…",
"projects.chart.range.from": "Von:",
"projects.chart.range.to": "Bis:",
"projects.chart.permalink.copy": "🔗 Link kopieren",
"projects.chart.permalink.title": "URL mit allen Filtern in die Zwischenablage kopieren",
"nav.context.project_chart": "Als Chart anzeigen",
"projects.chart.export.menu": "⇓ Export",
"projects.chart.export.svg": "SVG (Vektorgrafik)",
"projects.chart.export.png": "PNG (Bild, 2× HiDPI)",
"projects.chart.export.print": "PDF (Drucken)",
"projects.chart.export.csv": "CSV (Excel-Tabelle)",
"projects.chart.export.json": "JSON (Rohdaten)",
"projects.chart.export.ics": "iCal (.ics — Outlook / Apple)",
"projects.detail.edit": "Bearbeiten",
"projects.detail.edit.modal.title": "Projekt bearbeiten",
"projects.detail.save": "Speichern",
"projects.detail.tab.verlauf": "Verlauf",
"projects.detail.tab.team": "Team",
"projects.detail.tab.kinder": "Untergeordnet",
"projects.detail.tab.kinder": "Projektbaum",
"projects.detail.tab.parteien": "Parteien",
"projects.detail.tab.fristen": "Fristen",
"projects.detail.tab.termine": "Termine",
@@ -1093,6 +1207,79 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.tab.checklisten": "Checklisten",
"projects.detail.verlauf.empty": "Noch keine Ereignisse aufgezeichnet.",
"projects.detail.verlauf.loadMore": "Mehr laden",
// SmartTimeline (t-paliad-171, Slice 1).
"projects.detail.smarttimeline.empty": "Noch keine Ereignisse erfasst.",
"projects.detail.smarttimeline.today": "Heute",
"projects.detail.smarttimeline.section.past": "Vergangenheit",
"projects.detail.smarttimeline.section.future": "Zukunft",
"projects.detail.smarttimeline.section.undated": "Ohne Datum",
"projects.detail.smarttimeline.kind.deadline": "Frist",
"projects.detail.smarttimeline.kind.appointment": "Termin",
"projects.detail.smarttimeline.kind.milestone": "Meilenstein",
"projects.detail.smarttimeline.kind.projected": "Vorhersage",
"projects.detail.smarttimeline.status.done": "Erledigt",
"projects.detail.smarttimeline.status.open": "Offen",
"projects.detail.smarttimeline.status.overdue": "Überfällig",
"projects.detail.smarttimeline.status.court_set": "Datum vom Gericht",
"projects.detail.smarttimeline.status.predicted": "Voraussichtlich",
"projects.detail.smarttimeline.status.off_script": "Eigener Eintrag",
"projects.detail.smarttimeline.audit.toggle.show": "Audit-Log anzeigen",
"projects.detail.smarttimeline.audit.toggle.hide": "Nur Timeline-Einträge",
"projects.detail.smarttimeline.add.cta": "+ Eintrag",
"projects.detail.smarttimeline.add.modal.title": "Neuer Eintrag im SmartTimeline",
"projects.detail.smarttimeline.add.choice.deadline": "Frist anlegen",
"projects.detail.smarttimeline.add.choice.appointment": "Termin anlegen",
"projects.detail.smarttimeline.add.choice.counterclaim": "Widerklage (CCR)",
"projects.detail.smarttimeline.add.choice.amend": "Antrag auf Änderung (R.30)",
"projects.detail.smarttimeline.add.choice.milestone": "Eigener Meilenstein",
"projects.detail.smarttimeline.add.choice.disabled": "Kommt mit Slice 3",
"projects.detail.smarttimeline.add.cancel": "Abbrechen",
"projects.detail.smarttimeline.add.submit": "Speichern",
"projects.detail.smarttimeline.milestone.title": "Titel",
"projects.detail.smarttimeline.milestone.date": "Datum (optional)",
"projects.detail.smarttimeline.milestone.description": "Beschreibung (optional)",
"projects.detail.smarttimeline.error.title_required": "Bitte einen Titel angeben.",
"projects.detail.smarttimeline.error.generic": "Konnte den Eintrag nicht speichern.",
"projects.detail.smarttimeline.status.predicted_overdue": "Überfällig (vorhergesagt)",
"projects.detail.smarttimeline.lookahead.more": "+ Mehr anzeigen",
"projects.detail.smarttimeline.lookahead.less": " Weniger",
"projects.detail.smarttimeline.depends_on.prefix": "Folgt aus",
"projects.detail.smarttimeline.depends_on.date_open": "Datum offen",
"projects.detail.smarttimeline.depends_on.show_path": "Pfad anzeigen",
"projects.detail.smarttimeline.depends_on.hide_path": "Pfad verbergen",
"projects.detail.smarttimeline.depends_on.path_hint": "Klicke die übergeordnete Zeile, um deren Abhängigkeit zu sehen.",
"projects.detail.smarttimeline.anchor.set": "Datum setzen",
"projects.detail.smarttimeline.anchor.save": "Speichern",
"projects.detail.smarttimeline.anchor.cancel": "Abbrechen",
"projects.detail.smarttimeline.anchor.saving": "Speichere …",
"projects.detail.smarttimeline.anchor.saved": "Gespeichert.",
"projects.detail.smarttimeline.anchor.error": "Konnte das Datum nicht setzen.",
"projects.detail.smarttimeline.anchor.invalid_date": "Ungültiges Datum (YYYY-MM-DD).",
"projects.detail.smarttimeline.track.label": "Track",
"projects.detail.smarttimeline.track.both": "Beide",
"projects.detail.smarttimeline.track.only.parent": "Nur Hauptverfahren",
"projects.detail.smarttimeline.track.only.counterclaim": "Nur Widerklage",
"projects.detail.smarttimeline.track.only.parent_context": "Nur Hauptverfahren (Kontext)",
"projects.detail.smarttimeline.track.header.parent": "Hauptverfahren",
"projects.detail.smarttimeline.track.header.counterclaim": "Widerklage (CCR)",
"projects.detail.smarttimeline.track.header.parent_context": "Hauptverfahren (Kontext)",
"projects.detail.smarttimeline.counterclaim.procedure": "Verfahrenstyp",
"projects.detail.smarttimeline.counterclaim.title": "Titel (optional)",
"projects.detail.smarttimeline.counterclaim.case_number": "CCR-Aktenzeichen (optional)",
"projects.detail.smarttimeline.counterclaim.flip_override": "Unsere Seite NICHT umkehren (Stimmt nicht?)",
"projects.detail.smarttimeline.counterclaim.flip_hint": "Im Standardfall (CCR-Nichtigkeit) kehrt sich unsere Seite um (Kläger ↔ Beklagter). Aktivieren bei R.49.2.b CCI.",
"projects.detail.smarttimeline.counterclaim.submit": "Widerklage anlegen",
"projects.detail.smarttimeline.counterclaim.saving": "Lege Widerklage an …",
"projects.detail.smarttimeline.lane.empty": "Keine Einträge in dieser Spur.",
"projects.detail.smarttimeline.lane.filter.label": "Spuren",
"projects.detail.smarttimeline.lane.filter.all": "Alle",
"projects.detail.smarttimeline.client.toggle.lanes": "Timeline-Ansicht",
"projects.detail.smarttimeline.client.toggle.matter_list": "Mandatsliste",
"projects.detail.smarttimeline.client.matter_list.heading": "Verfahren des Mandanten",
"projects.detail.smarttimeline.client.matter_list.hint": "Klicke ein Verfahren an, um die Detail-Timeline zu öffnen, oder schalte oben auf „Timeline-Ansicht“.",
"projects.detail.smarttimeline.client.matter_list.empty": "Noch keine Verfahren angelegt.",
"projects.detail.smarttimeline.milestone.bubble_up": "In übergeordneten Akten anzeigen",
"projects.detail.smarttimeline.milestone.bubble_up_hint": "Beim Aktivieren erscheint dieser Meilenstein auf Patent-, Verfahrens- und Mandantsicht.",
"projects.detail.team.form.user": "Benutzer",
"projects.detail.team.form.role": "Rolle",
"projects.detail.team.form.responsibility": "Rolle im Projekt",
@@ -1511,6 +1698,8 @@ const translations: Record<Lang, Record<string, string>> = {
"team.broadcast.title": "E-Mail an Auswahl",
"team.broadcast.recipients": "Empfänger",
"team.broadcast.show_all": "Alle anzeigen",
"team.broadcast.mailto.label": "Im Mail-Client öffnen",
"team.broadcast.mailto.tooltip": "Öffnet den lokalen Mail-Client mit allen Empfängern in der To-Zeile",
"team.broadcast.template": "Vorlage",
"team.broadcast.template_optional": "optional",
"team.broadcast.template_freeform": "Freitext",
@@ -1558,8 +1747,28 @@ const translations: Record<Lang, Record<string, string>> = {
"paliadin.stop": "Stop",
"paliadin.reset": "Neue Unterhaltung",
"paliadin.error.local_only": "Paliadin läuft nur lokal. Diese Instanz hat kein tmux/claude installiert — lokal mit ./paliad starten.",
"paliadin.error.mriver_unreachable": "mRiver ist offline — Paliadin nicht erreichbar. Mach mRiver an, oder nutze Paliadin lokal mit ./paliad.",
"paliadin.error.shim_auth_failed": "Paliadin-Authentifizierung fehlgeschlagen. SSH-Schlüssel oder Berechtigung auf mRiver prüfen.",
"paliadin.error.shim_error": "Paliadin-Fehler auf mRiver. tmux/claude-Pane prüfen.",
"paliadin.error.timeout": "Paliadin antwortet nicht (Timeout 60s). Nochmal versuchen.",
"paliadin.error.connection_lost": "Verbindung verloren.",
"paliadin.error.upstream": "Fehler beim Senden.",
"paliadin.late.waiting": "Antwort wird nachgereicht, sobald sie eintrifft …",
"paliadin.late.marker": "verspätet",
"paliadin.widget.title": "Paliadin",
"paliadin.widget.trigger": "Paliadin (Cmd+J)",
"paliadin.widget.empty": "Was kann ich für dich tun?",
"paliadin.widget.input.placeholder": "Frage an Paliadin...",
"paliadin.widget.input.label": "Nachricht an Paliadin",
"paliadin.widget.send": "Senden",
"paliadin.widget.reset": "Konversation zurücksetzen",
"paliadin.widget.reset.confirm": "Konversation hier und auf dem Server zurücksetzen?",
"paliadin.widget.fullscreen": "Vollbild-Modus",
"paliadin.widget.close": "Schließen",
"paliadin.widget.context.on_page": "Auf dieser Seite",
"approvals.agent.label": "Paliadin hat das vorgeschlagen",
"approvals.agent.byline": "Paliadin",
"approvals.agent.suggestion_pending": "Vorschlag wartet auf deine Genehmigung",
"nav.admin.paliadin": "Paliadin Monitor",
"admin.paliadin.title": "Paliadin Monitor — Paliad",
"admin.paliadin.heading": "Paliadin Monitor",
@@ -1576,8 +1785,11 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.paliadin.col.prompt": "Anfrage",
"admin.paliadin.col.count": "Anzahl",
"admin.paliadin.col.started": "Zeit",
"admin.paliadin.col.user": "Nutzer",
"admin.paliadin.col.classifier": "Art",
"admin.paliadin.col.response": "Antwort",
"admin.paliadin.col.tools": "Tools",
"admin.paliadin.col.origin": "Seite",
"admin.paliadin.col.duration": "Dauer",
"admin.paliadin.loading": "Lade…",
@@ -1611,6 +1823,57 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.card.feature_flags.desc": "Funktionen pro Standort, Partner Unit oder Rolle aktivieren.",
"admin.card.broadcasts.title": "Broadcasts",
"admin.card.broadcasts.desc": "Versendete Massen-E-Mails an Teamauswahlen einsehen.",
"admin.card.approval_policies.title": "Genehmigungspflichten",
"admin.card.approval_policies.desc": "4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.",
"admin.approval_policies.title": "Genehmigungspflichten — Paliad",
"admin.approval_policies.heading": "Genehmigungspflichten",
"admin.approval_policies.subtitle": "4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.",
"admin.approval_policies.loading": "Lädt …",
"admin.approval_policies.section.units": "Partner-Unit-Standards",
"admin.approval_policies.section.units.hint": "Standardregeln, die jedes Projekt erbt, das einer Partner Unit zugeordnet ist. Bei mehreren Partner Units gewinnt die strengste Regel.",
"admin.approval_policies.section.projects": "Projekt-spezifisch",
"admin.approval_policies.section.projects.hint": "Eigene Regeln für ein Projekt. Überschreiben Standards aus Partner Units und geerbte Projektregeln.",
"admin.approval_policies.units.empty": "Keine Partner Units vorhanden.",
"admin.approval_policies.picker.label": "Projekt wählen",
"admin.approval_policies.picker.placeholder": "Suchen…",
"admin.approval_policies.picker.no_results": "Keine Treffer.",
"admin.approval_policies.entity.deadline": "Fristen",
"admin.approval_policies.entity.appointment": "Termine",
"admin.approval_policies.lifecycle.create": "Erstellen",
"admin.approval_policies.lifecycle.update": "Ändern",
"admin.approval_policies.lifecycle.complete": "Erledigen",
"admin.approval_policies.lifecycle.delete": "Löschen",
"admin.approval_policies.role.partner": "Partner",
"admin.approval_policies.role.of_counsel": "Of Counsel",
"admin.approval_policies.role.associate": "Associate",
"admin.approval_policies.role.senior_pa": "Senior PA",
"admin.approval_policies.role.pa": "PA",
"admin.approval_policies.role.none": "Keine Genehmigung",
"admin.approval_policies.role.no_rule": "— keine Regel —",
"admin.approval_policies.source.project": "Projekt",
"admin.approval_policies.source.ancestor": "Geerbt",
"admin.approval_policies.source.unit_default": "Standard",
"admin.approval_policies.source.no_approval": "keine Genehmigung",
"admin.approval_policies.cell.requires": "Genehmigung erforderlich",
"admin.approval_policies.cell.clear": "—",
"admin.approval_policies.cell.clear.title": "Regel zurücksetzen (erben)",
"admin.approval_policies.cell.saved_msg": "Gespeichert.",
"admin.approval_policies.cell.error_msg": "Fehler",
"admin.approval_policies.bulk.cta": "Auf Unterprojekte anwenden",
"admin.approval_policies.bulk.no_descendants": "Keine Unterprojekte vorhanden.",
"admin.approval_policies.bulk.modal.title": "Auf Unterprojekte anwenden",
"admin.approval_policies.bulk.modal.body": "Die folgenden Unterprojekte erhalten die effektive Matrix dieses Projekts als projektspezifische Regeln. Bestehende projektspezifische Regeln werden überschrieben. Standards aus Partner Units bleiben unberührt.",
"admin.approval_policies.bulk.modal.cancel": "Abbrechen",
"admin.approval_policies.bulk.modal.confirm": "Übernehmen",
"admin.approval_policies.bulk.modal.applying": "Übernehme …",
"admin.approval_policies.bulk.modal.done": "Übernommen",
"admin.approval_policies.bulk.modal.writes_label": "Schreibvorgänge",
"admin.approval_policies.bulk.modal.targets_label": "Projekte",
"inbox.empty.admin_nudge.title": "Noch keine Genehmigungspflichten konfiguriert?",
"inbox.empty.admin_nudge.body": "Lege fest, welche Lifecycle-Events 4-Augen-Prüfung erfordern.",
"inbox.empty.admin_nudge.cta": "Genehmigungspflichten konfigurieren",
"deadlines.form.approval_hint": "4-Augen-Prüfung erforderlich",
"appointments.form.approval_hint": "4-Augen-Prüfung erforderlich",
"admin.email_templates.title": "Email-Templates — Paliad",
"admin.email_templates.heading": "Email-Templates",
"admin.email_templates.subtitle": "Vorlagen für Einladungen, Erinnerungen und das Layout-Wrapper anpassen.",
@@ -1912,7 +2175,12 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.error.not_authorized": "Sie haben nicht die erforderliche Rolle.",
"approvals.error.no_qualified_approver": "Kein qualifizierter Approver verfügbar — bitte einen Approver ins Projekt-Team aufnehmen oder Admin kontaktieren.",
"approvals.error.concurrent_pending": "Es liegt bereits eine Genehmigungsanfrage auf diesem Eintrag vor.",
"approvals.error.awaiting_approval": "Diese Anforderung wartet auf Genehmigung.",
"approvals.error.request_not_pending": "Diese Anfrage ist nicht mehr offen.",
"approvals.pending.badge": "Wartet auf Genehmigung",
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
"approvals.withdraw.error": "Fehler beim Zurückziehen",
"approvals.pending_create.label": "Erstellung wartet auf Genehmigung",
"approvals.pending_update.label": "Änderung wartet auf Genehmigung",
"approvals.pending_complete.label": "Erledigung wartet auf Genehmigung",
@@ -1938,6 +2206,8 @@ const translations: Record<Lang, Record<string, string>> = {
"views.shape.list": "Liste",
"views.shape.cards": "Karten",
"views.shape.calendar": "Kalender",
"views.shape.timeline": "Timeline",
"views.timeline.caveat.body": "Custom Views zeigen nur eingetretene Ereignisse. Für prognostizierte Fristen das Projekt-Chart öffnen.",
"views.save_as": "Als Ansicht speichern",
"views.action.edit": "Bearbeiten",
"views.empty.title": "Keine Einträge gefunden.",
@@ -2021,6 +2291,81 @@ 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.project_event_kind": "Ereignis",
"views.bar.label.timeline_status": "Timeline-Status",
"views.bar.label.timeline_track": "Track",
"views.bar.timeline_status.done": "Erledigt",
"views.bar.timeline_status.open": "Offen",
"views.bar.timeline_status.overdue": "Überfällig",
"views.bar.timeline_status.predicted": "Voraussichtlich",
"views.bar.timeline_status.predicted_overdue": "Überfällig (vorhergesagt)",
"views.bar.timeline_status.court_set": "Gerichtsdatum",
"views.bar.timeline_status.off_script": "Eigener Eintrag",
"views.bar.timeline_status.macro.future": "Zukunft anzeigen",
"views.bar.timeline_status.macro.past": "Nur vergangenes",
"views.bar.timeline_track.parent": "Hauptverfahren",
"views.bar.timeline_track.counterclaim": "Widerklage",
"views.bar.timeline_track.off_script": "Off-Script",
"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_7d": "Letzte 7 T.",
"views.bar.time.past_30d": "Letzte 30 T.",
"views.bar.time.past_90d": "Letzte 90 T.",
"views.bar.time.any": "Beliebig",
"views.bar.time.all": "Alle Zeit",
"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: {
@@ -2028,6 +2373,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.home": "Home",
"nav.kostenrechner": "Cost Calculator",
"nav.fristenrechner": "Deadline Calculator",
"nav.verfahrensablauf": "Procedure Roadmap",
"nav.downloads": "Downloads",
"nav.links": "Links",
"nav.glossar": "Glossary",
@@ -2045,10 +2391,8 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.paliadin": "Paliadin",
"nav.team": "Team",
"nav.group.uebersicht": "Overview",
"nav.group.arbeit": "Work",
"nav.group.ansichten": "Views",
"nav.group.werkzeuge": "Tools",
"nav.group.wissen": "Knowledge",
"nav.group.ressourcen": "Resources",
"nav.neuigkeiten": "What's New",
"nav.soon.tooltip": "Coming soon",
@@ -2203,6 +2547,12 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.title": "Deadline Calculator \u2014 Paliad",
"deadlines.heading": "Patent Deadline Calculator",
"deadlines.subtitle": "Calculate procedural deadlines for UPC, German, and EPA proceedings.",
// Verfahrensablauf (t-paliad-179 Slice 1)
"tools.verfahrensablauf.title": "Procedure Roadmap \u2014 Paliad",
"tools.verfahrensablauf.heading": "Procedure Roadmap",
"tools.verfahrensablauf.subtitle": "Browse the typical proceeding shape \u2014 pick a proceeding type, optionally set a trigger date.",
"deadlines.step1": "Select Proceeding Type",
"deadlines.step2": "Enter Trigger Date",
"deadlines.step3": "Result",
@@ -2247,6 +2597,40 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.party.both": "Both",
"deadlines.party.both.label": "both parties",
"deadlines.court.set": "set by court",
"deadlines.court.indirect": "tbd",
"deadlines.optional.badge": "on request",
"deadlines.proceeding.selected": "Proceeding:",
"deadlines.proceeding.reselect": "Choose another proceeding",
"deadlines.step1.heading": "Step 1 — Which matter?",
"deadlines.step1.search.placeholder": "Search matters…",
"deadlines.step1.search.empty": "No matching matter.",
"deadlines.step1.divider.new": "or a new matter",
"deadlines.step1.divider.adhoc": "or ad-hoc, without a matter",
"deadlines.step1.new.cta": "+ Create new matter",
"deadlines.step1.adhoc.upc": "Custom UPC proceeding",
"deadlines.step1.adhoc.de": "Custom DE proceeding",
"deadlines.step1.adhoc.epa": "Custom EPA proceeding",
"deadlines.step1.adhoc.dpma": "Custom DPMA proceeding",
"deadlines.step1.selected": "Matter:",
"deadlines.step1.reselect": "Other matter",
"deadlines.step1.summary.adhoc.suffix": "no matter (exploration)",
"deadlines.step2.heading": "Step 2 — What do you want to do?",
"deadlines.step2.file.title": "File something",
"deadlines.step2.file.desc": "Outgoing — your action triggers a deadline.",
"deadlines.step2.happened.title": "Something happened",
"deadlines.step2.happened.desc": "Incoming — an event triggered a deadline.",
"deadlines.step2.browse.title": "Browse procedure roadmap",
"deadlines.step2.browse.desc": "Browse / Learn — see what happens when. No deadline entered.",
"deadlines.save.cta.adhoc.hint": "Ad-hoc — no matter, no save",
"deadlines.step3a.heading": "What do you want to file?",
"deadlines.step3a.back": "back to selection",
"deadlines.step3a.file.title": "File a submission",
"deadlines.step3a.file.desc": "Open the Verfahrensablauf — compute deadline and add to the matter.",
"deadlines.step3a.draft.title": "Draft a submission",
"deadlines.step3a.draft.desc": "Preparation — later linked to the drafting surface.",
"deadlines.step3a.enter.title": "Enter deadline manually",
"deadlines.step3a.enter.desc": "Direct entry — date and type already known.",
"deadlines.step3a.soon": "coming soon",
"deadlines.date.edit.hint": "Edit date — downstream deadlines will recalculate",
"deadlines.view.label": "View:",
"deadlines.view.timeline": "Timeline",
@@ -2331,6 +2715,12 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.pathway.b.tree.empty": "No matches for this path.",
"deadlines.pathway.b.tree.reset": "Restart",
"deadlines.pathway.b.tree.start_question": "What happened?",
"deadlines.inbox.label": "Where did it arrive?",
"deadlines.inbox.cms.title": "UPC — via CMS",
"deadlines.inbox.bea.title": "National-DE — via beA",
"deadlines.inbox.posteingang.title": "National-DE — postal mail",
"deadlines.inbox.posteingang": "Postal",
"deadlines.inbox.all": "All",
"deadlines.filter.forum.label": "Forum / System:",
"deadlines.filter.forum.upc_cfi": "UPC CFI",
"deadlines.filter.forum.upc_coa": "UPC CoA",
@@ -2345,7 +2735,13 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.perspective.label": "I represent:",
"deadlines.perspective.claimant": "Claimant side (Proactive)",
"deadlines.perspective.defendant": "Defendant side (Reactive)",
"deadlines.perspective.claimant.short": "Claimant",
"deadlines.perspective.defendant.short": "Defendant",
"deadlines.perspective.both.short": "Both",
"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",
@@ -2703,6 +3099,10 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.field.due": "Due date",
"deadlines.field.rule": "Rule (optional)",
"deadlines.field.rule.none": "No rule",
"deadlines.field.rule.autofill": "Type set by rule — remove to override.",
"deadlines.field.rule.autofill_inline": " (set by rule)",
"deadlines.field.rule.mismatch": "Note: type contradicts rule — you have overridden the type.",
"deadlines.field.rule.override": "Choose another type",
"deadlines.field.notes": "Notes (optional)",
"deadlines.field.notes.placeholder": "References, hints, next steps\u2026",
"deadlines.error.required": "Matter, title and due date are required.",
@@ -2812,6 +3212,11 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.activity.empty": "No activity recorded yet.",
"dashboard.activity.system": "System",
"dashboard.activity.event": "event",
"dashboard.agenda.heading": "Agenda",
"dashboard.agenda.empty": "Nothing due in the next 30 days.",
"dashboard.agenda.full_link": "Open full agenda →",
"dashboard.section.collapse": "Collapse section",
"dashboard.section.expand": "Expand section",
"dashboard.urgency.overdue": "Overdue",
"dashboard.urgency.today": "Today",
"dashboard.urgency.urgent": "Urgent",
@@ -2823,6 +3228,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",
@@ -2844,6 +3250,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",
@@ -3071,6 +3478,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:",
@@ -3078,12 +3493,50 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.back": "\u2190 Back to overview",
"projects.detail.loading": "Loading\u2026",
"projects.detail.notfound": "Project not found or no access.",
"projects.detail.smarttimeline.open_chart": "View as chart \u2197",
"projects.chart.title": "Project Chart \u2014 Paliad",
"projects.chart.back": "\u2190 Back to Activity",
"projects.chart.loading": "Loading\u2026",
"projects.chart.notfound": "Project not found or no access.",
"projects.chart.error.mount": "Chart could not be initialised.",
"projects.chart.control.layout.horizontal": "Layout: horizontal",
"projects.chart.control.columns.auto": "Columns: auto",
"projects.chart.control.density.standard": "Density: standard",
"projects.chart.control.palette.default": "Palette: default",
"projects.chart.control.export.soon": "Export \u2193 (Slice 2)",
"projects.chart.control.palette.label": "Palette:",
"projects.chart.palette.default": "Default",
"projects.chart.palette.kind_coded": "By event kind",
"projects.chart.palette.track_coded": "By track",
"projects.chart.palette.high_contrast": "High contrast",
"projects.chart.palette.print": "Print (B/W)",
"projects.chart.control.density.label": "Density:",
"projects.chart.density.compact": "Compact",
"projects.chart.density.standard": "Standard",
"projects.chart.density.spacious": "Spacious",
"projects.chart.control.range.label": "Range:",
"projects.chart.range.1y": "1 year",
"projects.chart.range.2y": "2 years",
"projects.chart.range.all": "Show all",
"projects.chart.range.custom": "Custom range…",
"projects.chart.range.from": "From:",
"projects.chart.range.to": "To:",
"projects.chart.permalink.copy": "🔗 Copy link",
"projects.chart.permalink.title": "Copy the URL with all filters to clipboard",
"nav.context.project_chart": "View as chart",
"projects.chart.export.menu": "⇓ Export",
"projects.chart.export.svg": "SVG (vector graphic)",
"projects.chart.export.png": "PNG (raster, 2× HiDPI)",
"projects.chart.export.print": "PDF (print)",
"projects.chart.export.csv": "CSV (Excel table)",
"projects.chart.export.json": "JSON (raw data)",
"projects.chart.export.ics": "iCal (.ics — Outlook / Apple)",
"projects.detail.edit": "Edit",
"projects.detail.edit.modal.title": "Edit project",
"projects.detail.save": "Save",
"projects.detail.tab.verlauf": "Activity",
"projects.detail.tab.team": "Team",
"projects.detail.tab.kinder": "Sub-projects",
"projects.detail.tab.kinder": "Project Tree",
"projects.detail.tab.parteien": "Parties",
"projects.detail.tab.fristen": "Deadlines",
"projects.detail.tab.termine": "Appointments",
@@ -3091,6 +3544,78 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.tab.checklisten": "Checklists",
"projects.detail.verlauf.empty": "No events recorded yet.",
"projects.detail.verlauf.loadMore": "Load more",
"projects.detail.smarttimeline.empty": "No events captured yet.",
"projects.detail.smarttimeline.today": "Today",
"projects.detail.smarttimeline.section.past": "Past",
"projects.detail.smarttimeline.section.future": "Future",
"projects.detail.smarttimeline.section.undated": "Undated",
"projects.detail.smarttimeline.kind.deadline": "Deadline",
"projects.detail.smarttimeline.kind.appointment": "Appointment",
"projects.detail.smarttimeline.kind.milestone": "Milestone",
"projects.detail.smarttimeline.kind.projected": "Predicted",
"projects.detail.smarttimeline.status.done": "Done",
"projects.detail.smarttimeline.status.open": "Open",
"projects.detail.smarttimeline.status.overdue": "Overdue",
"projects.detail.smarttimeline.status.court_set": "Court-set date",
"projects.detail.smarttimeline.status.predicted": "Predicted",
"projects.detail.smarttimeline.status.off_script": "Custom",
"projects.detail.smarttimeline.audit.toggle.show": "Show audit log",
"projects.detail.smarttimeline.audit.toggle.hide": "Timeline only",
"projects.detail.smarttimeline.add.cta": "+ Entry",
"projects.detail.smarttimeline.add.modal.title": "New SmartTimeline entry",
"projects.detail.smarttimeline.add.choice.deadline": "Add a deadline",
"projects.detail.smarttimeline.add.choice.appointment": "Add an appointment",
"projects.detail.smarttimeline.add.choice.counterclaim": "Counterclaim (CCR)",
"projects.detail.smarttimeline.add.choice.amend": "Application to amend (R.30)",
"projects.detail.smarttimeline.add.choice.milestone": "Custom milestone",
"projects.detail.smarttimeline.add.choice.disabled": "Coming in Slice 3",
"projects.detail.smarttimeline.add.cancel": "Cancel",
"projects.detail.smarttimeline.add.submit": "Save",
"projects.detail.smarttimeline.milestone.title": "Title",
"projects.detail.smarttimeline.milestone.date": "Date (optional)",
"projects.detail.smarttimeline.milestone.description": "Description (optional)",
"projects.detail.smarttimeline.error.title_required": "Please enter a title.",
"projects.detail.smarttimeline.error.generic": "Could not save the entry.",
"projects.detail.smarttimeline.status.predicted_overdue": "Overdue (predicted)",
"projects.detail.smarttimeline.lookahead.more": "+ Show more",
"projects.detail.smarttimeline.lookahead.less": " Show less",
"projects.detail.smarttimeline.depends_on.prefix": "Follows from",
"projects.detail.smarttimeline.depends_on.date_open": "Date open",
"projects.detail.smarttimeline.depends_on.show_path": "Show path",
"projects.detail.smarttimeline.depends_on.hide_path": "Hide path",
"projects.detail.smarttimeline.depends_on.path_hint": "Click the parent row to see its dependency.",
"projects.detail.smarttimeline.anchor.set": "Set date",
"projects.detail.smarttimeline.anchor.save": "Save",
"projects.detail.smarttimeline.anchor.cancel": "Cancel",
"projects.detail.smarttimeline.anchor.saving": "Saving…",
"projects.detail.smarttimeline.anchor.saved": "Saved.",
"projects.detail.smarttimeline.anchor.error": "Could not set the date.",
"projects.detail.smarttimeline.anchor.invalid_date": "Invalid date (YYYY-MM-DD).",
"projects.detail.smarttimeline.track.label": "Track",
"projects.detail.smarttimeline.track.both": "Both",
"projects.detail.smarttimeline.track.only.parent": "Main proceeding only",
"projects.detail.smarttimeline.track.only.counterclaim": "Counterclaim only",
"projects.detail.smarttimeline.track.only.parent_context": "Main proceeding only (context)",
"projects.detail.smarttimeline.track.header.parent": "Main proceeding",
"projects.detail.smarttimeline.track.header.counterclaim": "Counterclaim (CCR)",
"projects.detail.smarttimeline.track.header.parent_context": "Main proceeding (context)",
"projects.detail.smarttimeline.counterclaim.procedure": "Proceeding type",
"projects.detail.smarttimeline.counterclaim.title": "Title (optional)",
"projects.detail.smarttimeline.counterclaim.case_number": "CCR case number (optional)",
"projects.detail.smarttimeline.counterclaim.flip_override": "Do NOT flip our side („Stimmt nicht?”)",
"projects.detail.smarttimeline.counterclaim.flip_hint": "In the standard case (CCR on validity) our side flips (claimant ↔ defendant). Enable for the R.49.2.b CCI edge case.",
"projects.detail.smarttimeline.counterclaim.submit": "Create counterclaim",
"projects.detail.smarttimeline.counterclaim.saving": "Creating counterclaim…",
"projects.detail.smarttimeline.lane.empty": "No entries in this lane.",
"projects.detail.smarttimeline.lane.filter.label": "Lanes",
"projects.detail.smarttimeline.lane.filter.all": "All",
"projects.detail.smarttimeline.client.toggle.lanes": "Timeline view",
"projects.detail.smarttimeline.client.toggle.matter_list": "Matter list",
"projects.detail.smarttimeline.client.matter_list.heading": "Matters of this client",
"projects.detail.smarttimeline.client.matter_list.hint": "Click a matter to open its detailed timeline, or switch to „Timeline view“ above.",
"projects.detail.smarttimeline.client.matter_list.empty": "No matters yet.",
"projects.detail.smarttimeline.milestone.bubble_up": "Show on parent matters",
"projects.detail.smarttimeline.milestone.bubble_up_hint": "When checked, this milestone surfaces on patent, litigation, and client SmartTimelines.",
"projects.detail.team.form.user": "User",
"projects.detail.team.form.role": "Role",
"projects.detail.team.form.responsibility": "Project role",
@@ -3506,6 +4031,8 @@ const translations: Record<Lang, Record<string, string>> = {
"team.broadcast.title": "Email selection",
"team.broadcast.recipients": "Recipients",
"team.broadcast.show_all": "Show all",
"team.broadcast.mailto.label": "Open in mail client",
"team.broadcast.mailto.tooltip": "Opens your local mail client with every recipient prefilled in the To: line",
"team.broadcast.template": "Template",
"team.broadcast.template_optional": "optional",
"team.broadcast.template_freeform": "Free-form",
@@ -3553,8 +4080,28 @@ const translations: Record<Lang, Record<string, string>> = {
"paliadin.stop": "Stop",
"paliadin.reset": "New conversation",
"paliadin.error.local_only": "Paliadin only runs locally. This instance has no tmux/claude installed — start it locally via ./paliad.",
"paliadin.error.mriver_unreachable": "mRiver is offline — Paliadin can't reach it. Wake mRiver, or run Paliadin locally with ./paliad.",
"paliadin.error.shim_auth_failed": "Paliadin auth failed. Check the SSH key or authorized_keys on mRiver.",
"paliadin.error.shim_error": "Paliadin error on mRiver. Check the tmux/claude pane.",
"paliadin.error.timeout": "Paliadin didn't respond in time (60s). Try again.",
"paliadin.error.connection_lost": "Connection lost.",
"paliadin.error.upstream": "Send failed.",
"paliadin.late.waiting": "Will fill in the response when it arrives …",
"paliadin.late.marker": "late",
"paliadin.widget.title": "Paliadin",
"paliadin.widget.trigger": "Paliadin (Cmd+J)",
"paliadin.widget.empty": "What can I help you with?",
"paliadin.widget.input.placeholder": "Ask Paliadin...",
"paliadin.widget.input.label": "Message to Paliadin",
"paliadin.widget.send": "Send",
"paliadin.widget.reset": "Reset conversation",
"paliadin.widget.reset.confirm": "Reset the conversation here and on the server?",
"paliadin.widget.fullscreen": "Fullscreen mode",
"paliadin.widget.close": "Close",
"paliadin.widget.context.on_page": "On this page",
"approvals.agent.label": "Paliadin suggested this",
"approvals.agent.byline": "Paliadin",
"approvals.agent.suggestion_pending": "Suggestion awaiting your approval",
"nav.admin.paliadin": "Paliadin Monitor",
"admin.paliadin.title": "Paliadin Monitor — Paliad",
"admin.paliadin.heading": "Paliadin Monitor",
@@ -3571,8 +4118,11 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.paliadin.col.prompt": "Query",
"admin.paliadin.col.count": "Count",
"admin.paliadin.col.started": "Time",
"admin.paliadin.col.user": "User",
"admin.paliadin.col.classifier": "Type",
"admin.paliadin.col.response": "Answer",
"admin.paliadin.col.tools": "Tools",
"admin.paliadin.col.origin": "Page",
"admin.paliadin.col.duration": "Duration",
"admin.paliadin.loading": "Loading…",
@@ -3606,6 +4156,57 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.card.feature_flags.desc": "Enable features per office, partner unit or role.",
"admin.card.broadcasts.title": "Broadcasts",
"admin.card.broadcasts.desc": "Inspect bulk emails sent to team selections.",
"admin.card.approval_policies.title": "Approval Policies",
"admin.card.approval_policies.desc": "Configure 4-eye review per project and partner unit.",
"admin.approval_policies.title": "Approval Policies — Paliad",
"admin.approval_policies.heading": "Approval Policies",
"admin.approval_policies.subtitle": "Configure 4-eye review per project and partner unit.",
"admin.approval_policies.loading": "Loading …",
"admin.approval_policies.section.units": "Partner Unit Defaults",
"admin.approval_policies.section.units.hint": "Default rules that every project attached to a partner unit inherits. When multiple partner units apply, the strictest rule wins.",
"admin.approval_policies.section.projects": "Project-specific",
"admin.approval_policies.section.projects.hint": "Per-project rules. Override partner-unit defaults and inherited project rules.",
"admin.approval_policies.units.empty": "No partner units yet.",
"admin.approval_policies.picker.label": "Pick a project",
"admin.approval_policies.picker.placeholder": "Search…",
"admin.approval_policies.picker.no_results": "No matches.",
"admin.approval_policies.entity.deadline": "Deadlines",
"admin.approval_policies.entity.appointment": "Appointments",
"admin.approval_policies.lifecycle.create": "Create",
"admin.approval_policies.lifecycle.update": "Edit",
"admin.approval_policies.lifecycle.complete": "Complete",
"admin.approval_policies.lifecycle.delete": "Delete",
"admin.approval_policies.role.partner": "Partner",
"admin.approval_policies.role.of_counsel": "Of Counsel",
"admin.approval_policies.role.associate": "Associate",
"admin.approval_policies.role.senior_pa": "Senior PA",
"admin.approval_policies.role.pa": "PA",
"admin.approval_policies.role.none": "No approval",
"admin.approval_policies.role.no_rule": "— no rule —",
"admin.approval_policies.source.project": "Project",
"admin.approval_policies.source.ancestor": "Inherited",
"admin.approval_policies.source.unit_default": "Default",
"admin.approval_policies.source.no_approval": "no approval",
"admin.approval_policies.cell.requires": "Approval required",
"admin.approval_policies.cell.clear": "—",
"admin.approval_policies.cell.clear.title": "Reset to inheritance",
"admin.approval_policies.cell.saved_msg": "Saved.",
"admin.approval_policies.cell.error_msg": "Error",
"admin.approval_policies.bulk.cta": "Apply to descendants",
"admin.approval_policies.bulk.no_descendants": "No descendants.",
"admin.approval_policies.bulk.modal.title": "Apply to descendants",
"admin.approval_policies.bulk.modal.body": "The descendants below receive this project's effective matrix as project-specific rules. Existing project-specific rules will be overwritten. Partner-unit defaults remain intact.",
"admin.approval_policies.bulk.modal.cancel": "Cancel",
"admin.approval_policies.bulk.modal.confirm": "Apply",
"admin.approval_policies.bulk.modal.applying": "Applying …",
"admin.approval_policies.bulk.modal.done": "Applied",
"admin.approval_policies.bulk.modal.writes_label": "writes",
"admin.approval_policies.bulk.modal.targets_label": "projects",
"inbox.empty.admin_nudge.title": "No approval policies configured yet?",
"inbox.empty.admin_nudge.body": "Set which lifecycle events require 4-eye review.",
"inbox.empty.admin_nudge.cta": "Configure approval policies",
"deadlines.form.approval_hint": "4-eye review required",
"appointments.form.approval_hint": "4-eye review required",
"admin.email_templates.title": "Email Templates — Paliad",
"admin.email_templates.heading": "Email Templates",
"admin.email_templates.subtitle": "Customise templates for invitations, reminders, and the shared layout wrapper.",
@@ -3907,7 +4508,12 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.error.not_authorized": "You don't have the required role.",
"approvals.error.no_qualified_approver": "No qualified approver available — please add an approver to the project team or contact an admin.",
"approvals.error.concurrent_pending": "Another approval request is already in flight on this entity.",
"approvals.error.awaiting_approval": "This entity is awaiting approval.",
"approvals.error.request_not_pending": "This request is no longer open.",
"approvals.pending.badge": "Awaiting approval",
"approvals.withdraw.cta": "Withdraw approval request",
"approvals.withdraw.confirm": "Withdraw the approval request?",
"approvals.withdraw.error": "Failed to withdraw",
"approvals.pending_create.label": "Awaits approval (creation)",
"approvals.pending_update.label": "Awaits approval (change)",
"approvals.pending_complete.label": "Awaits approval (completion)",
@@ -3933,6 +4539,8 @@ const translations: Record<Lang, Record<string, string>> = {
"views.shape.list": "List",
"views.shape.cards": "Cards",
"views.shape.calendar": "Calendar",
"views.shape.timeline": "Timeline",
"views.timeline.caveat.body": "Custom Views show actual events only. Open the project's chart for projected rules.",
"views.save_as": "Save as view",
"views.action.edit": "Edit",
"views.empty.title": "No matches found.",
@@ -4016,6 +4624,80 @@ 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.project_event_kind": "Event",
"views.bar.label.timeline_status": "Timeline status",
"views.bar.label.timeline_track": "Track",
"views.bar.timeline_status.done": "Done",
"views.bar.timeline_status.open": "Open",
"views.bar.timeline_status.overdue": "Overdue",
"views.bar.timeline_status.predicted": "Predicted",
"views.bar.timeline_status.predicted_overdue": "Overdue (predicted)",
"views.bar.timeline_status.court_set": "Court date",
"views.bar.timeline_status.off_script": "Custom",
"views.bar.timeline_status.macro.future": "Show future",
"views.bar.timeline_status.macro.past": "Past only",
"views.bar.timeline_track.parent": "Main proceeding",
"views.bar.timeline_track.counterclaim": "Counterclaim",
"views.bar.timeline_track.off_script": "Off-script",
"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_7d": "Past 7d",
"views.bar.time.past_30d": "Past 30 d.",
"views.bar.time.past_90d": "Past 90 d.",
"views.bar.time.any": "Any",
"views.bar.time.all": "All time",
"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.",
},
};
@@ -4091,6 +4773,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,266 +1,200 @@
import { initI18n, t, getLang, type I18nKey } from "./i18n";
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
import { mountFilterBar, type BarHandle } from "./filter-bar";
import type { AxisKey } from "./filter-bar";
import type { FilterSpec, RenderSpec, SystemView, ViewRunResult } from "./views/types";
import { renderListShape } from "./views/shape-list";
// /inbox client. Two tabs (pending-mine / mine), action buttons (approve /
// reject / revoke), and a small inline diff for update / complete / delete
// lifecycle events.
// /inbox client — t-paliad-163 universal-filter migration.
//
// State is URL-driven via ?tab= so back/forward buttons work and the bell
// badge can deep-link to either tab. The badge in the sidebar (id
// sidebar-inbox-badge) is updated by the shared global polling loop in
// sidebar.ts; this module just keeps the page content in sync.
// The bar owns every axis the old tab UI exposed plus more:
// - approval_viewer_role: "Zur Genehmigung" / "Eigene Anfragen" /
// "Alle sichtbaren" (collapses the legacy two-tab UI per Q4 lock-in)
// - approval_status: chip cluster (default: pending)
// - approval_entity_type: chip pair (Frist / Termin)
// - time: chip cluster (Any default)
// - density: comfortable / compact
// - sort: date asc / desc
//
// Row rendering: shape-list.ts with row_action="approve" stamps the
// inbox markup (entity title, diff, approve/reject/revoke buttons).
// We wire action click handlers in onResult and refresh through the
// bar handle.
type Lifecycle = "create" | "update" | "complete" | "delete";
type RequestStatus = "pending" | "approved" | "rejected" | "revoked" | "superseded";
type DecisionKind = "peer" | "admin_override";
const INBOX_AXES: AxisKey[] = [
"time",
"approval_viewer_role",
"approval_status",
"approval_entity_type",
"density",
"sort",
];
interface ApprovalRequestView {
id: string;
project_id: string;
project_title: string;
entity_type: "deadline" | "appointment";
entity_id: string;
entity_title?: string;
lifecycle_event: Lifecycle;
pre_image?: Record<string, unknown> | null;
payload?: Record<string, unknown> | null;
required_role: string;
status: RequestStatus;
requested_at: string;
requested_by: string;
requester_name: string;
decided_at?: string;
decided_by?: string;
decider_name?: string;
decision_kind?: DecisionKind;
decision_note?: string;
}
type Tab = "pending-mine" | "mine";
let currentTab: Tab = "pending-mine";
initI18n();
initSidebar();
let bar: BarHandle | null = null;
document.addEventListener("DOMContentLoaded", () => {
const url = new URL(window.location.href);
const t = url.searchParams.get("tab");
if (t === "mine") currentTab = "mine";
bindTabs();
refresh();
initI18n();
initSidebar();
applyLegacyTabRedirect();
void hydrate();
});
function bindTabs() {
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
const tab = (btn.dataset.tab as Tab) || "pending-mine";
if (tab === currentTab) return;
currentTab = tab;
const url = new URL(window.location.href);
url.searchParams.set("tab", tab);
history.replaceState({}, "", url.toString());
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((b) => {
b.classList.toggle("active", b.dataset.tab === tab);
});
refresh();
// ?tab=pending-mine | mine -> ?a_role=approver_eligible | self_requested.
// Done client-side because /inbox serves a static dist file (no Go
// router involvement). Bookmarks from the sidebar bell + outbound
// emails keep landing on the right sub-view through the bar.
function applyLegacyTabRedirect(): void {
const url = new URL(window.location.href);
const tab = url.searchParams.get("tab");
if (!tab) return;
url.searchParams.delete("tab");
if (tab === "mine") {
url.searchParams.set("a_role", "self_requested");
} else if (tab === "pending-mine") {
url.searchParams.set("a_role", "approver_eligible");
}
history.replaceState(null, "", url.toString());
}
async function hydrate(): Promise<void> {
const host = document.getElementById("inbox-filter-bar");
const loading = document.getElementById("inbox-loading");
const results = document.getElementById("inbox-results");
const empty = document.getElementById("inbox-empty");
if (!host || !loading || !results || !empty) return;
const sys = await fetchInboxSystemView();
if (!sys) {
loading.style.display = "none";
empty.style.display = "";
empty.textContent = t("approvals.error.internal");
return;
}
bar = mountFilterBar(host, {
baseFilter: sys.Filter,
baseRender: sys.Render,
axes: INBOX_AXES,
surfaceKey: "inbox",
systemViewSlug: sys.Slug,
onResult: (result, effective) => paint(result, effective.render, results, empty, loading),
});
}
async function 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;
}
});
});
}
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) rows = (await r.json()) as ApprovalRequestView[];
} 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 = "";
return;
}
for (const row of rows) list.appendChild(renderRow(row));
}
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);
meta.textContent = `${row.project_title} · ${reqByLabel} ${row.requester_name} · ${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;
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`;
// 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
async function maybeShowAdminNudge(): Promise<void> {
const nudge = document.getElementById("inbox-admin-nudge");
if (!nudge) return;
try {
const meR = await fetch("/api/me", { credentials: "include" });
if (!meR.ok) return;
const me = (await meR.json()) as { global_role?: string };
if (me.global_role !== "global_admin") return;
const seedR = await fetch("/api/admin/approval-policies/seeded", { credentials: "include" });
if (!seedR.ok) return;
const data = (await seedR.json()) as { any: boolean };
if (data.any) return;
nudge.style.display = "";
} catch (_e) { /* keep hidden */ }
}
// Update the sidebar inbox badge (shared with sidebar.ts polling).
async function refreshInboxBadge() {
function hideAdminNudge(): void {
const nudge = document.getElementById("inbox-admin-nudge");
if (nudge) nudge.style.display = "none";
}
async function refreshInboxBadge(): Promise<void> {
const badge = document.getElementById("sidebar-inbox-badge");
if (!badge) return;
try {
@@ -273,7 +207,5 @@ async function refreshInboxBadge() {
} else {
badge.style.display = "none";
}
} catch (_e) {
/* noop */
}
} catch (_e) { /* noop */ }
}

View File

@@ -0,0 +1,196 @@
// paliadin-context.ts — structured page-context payload builder for the
// Paliadin inline widget (t-paliad-161).
//
// The standalone /paliadin page submits turns with only `page_origin`
// (single string, the URL pathname). The inline widget submits a richer
// payload: route_name + primary_entity_type + primary_entity_id + the
// user's text selection + UI hints. The Go backend persists this jsonb
// in paliad.paliadin_turns.context (migration 070) AND prepends a
// flattened `[ctx …]` block to the tmux envelope so SKILL.md can branch
// on it before answering.
//
// Design: docs/design-paliadin-inline-2026-05-08.md §4.
export interface PaliadinContext {
route_name: string;
page_origin: string;
primary_entity_type?: "project" | "deadline" | "appointment";
primary_entity_id?: string;
user_selection_text?: string;
view_mode?: "list" | "cards" | "calendar" | "tree";
filter_summary?: string;
}
const SELECTION_MAX = 1000;
// UUID match — relaxed: any 8-4-4-4-12 hex pattern. Catches /projects/<id>
// and /deadlines/<id> regardless of trailing path segments.
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Compute the Paliadin context for the current page. Reads
* window.location + window.getSelection() at call time, so callers
* should invoke this immediately before sending a turn — not at widget
* boot — to capture the user's selection in the moment they typed.
*
* Returns null when the visibility predicate fails (e.g. on /paliadin,
* /login, /onboarding) — callers SHOULD short-circuit on null instead
* of sending an empty payload.
*/
export function computePaliadinContext(): PaliadinContext | null {
const pathname = window.location.pathname || "";
if (!shouldSendContext(pathname)) {
return null;
}
const search = window.location.search || "";
const ctx: PaliadinContext = {
route_name: routeNameFor(pathname),
page_origin: pathname + search,
};
const entity = extractPrimaryEntity(pathname);
if (entity) {
ctx.primary_entity_type = entity.type;
ctx.primary_entity_id = entity.id;
}
const selection = readSelection();
if (selection) {
ctx.user_selection_text = selection;
}
const view = readViewMode();
if (view) {
ctx.view_mode = view;
}
const filter = readFilterSummary();
if (filter) {
ctx.filter_summary = filter;
}
return ctx;
}
/**
* The widget hides itself on routes where Paliadin is either redundant
* (the standalone /paliadin) or unavailable (auth flows). Mirrored here
* for the context-payload predicate so a stray send from one of those
* pages doesn't surface an empty `[ctx]` block.
*/
export function shouldSendContext(pathname: string): boolean {
if (pathname === "/paliadin" || pathname.startsWith("/paliadin/")) return false;
if (pathname === "/login" || pathname.startsWith("/login/")) return false;
if (pathname === "/onboarding") return false;
return true;
}
/**
* Map a URL pathname to a stable route key. Stable across query-string
* + ID variations so the SKILL.md / starter registry can branch on it
* without fragile URL parsing.
*/
export function routeNameFor(pathname: string): string {
// Order matters — most-specific first.
if (/^\/projects\/[^/]+$/.test(pathname)) return "projects.detail";
if (pathname === "/projects" || pathname === "/projects/") return "projects.list";
if (/^\/projects\/[^/]+\/settings/.test(pathname)) return "projects.settings";
if (/^\/deadlines\/[^/]+$/.test(pathname)) return "deadlines.detail";
if (pathname === "/deadlines/new") return "deadlines.new";
if (pathname === "/deadlines/calendar") return "deadlines.calendar";
if (pathname === "/deadlines") return "deadlines.list";
if (/^\/appointments\/[^/]+$/.test(pathname)) return "appointments.detail";
if (pathname === "/appointments/new") return "appointments.new";
if (pathname === "/appointments/calendar") return "appointments.calendar";
if (pathname === "/appointments") return "appointments.list";
if (pathname === "/agenda") return "agenda";
if (pathname === "/inbox") return "inbox";
if (pathname === "/dashboard" || pathname === "/") return "dashboard";
if (pathname === "/team") return "team";
if (pathname === "/courts") return "courts";
if (pathname === "/glossary") return "glossary";
if (pathname === "/links") return "links";
if (pathname === "/downloads") return "downloads";
if (pathname === "/checklists") return "checklists";
if (pathname.startsWith("/tools/fristenrechner")) return "tools.fristenrechner";
if (pathname.startsWith("/tools/kostenrechner")) return "tools.kostenrechner";
if (pathname.startsWith("/tools/gebuehrentabellen")) return "tools.gebuehrentabellen";
if (pathname === "/events") return "events";
if (pathname.startsWith("/views/")) return "views.detail";
if (pathname === "/views") return "views.list";
if (pathname.startsWith("/admin/")) return "admin." + pathname.slice("/admin/".length).split("/")[0];
if (pathname === "/admin") return "admin";
if (pathname === "/settings") return "settings";
return "other";
}
/**
* Pull the primary entity (type + uuid) out of the URL when the route
* encodes one. Returns null on routes that have no primary entity
* (dashboard, agenda, lists, tools).
*/
export function extractPrimaryEntity(
pathname: string,
): { type: "project" | "deadline" | "appointment"; id: string } | null {
const projectMatch = pathname.match(/^\/projects\/([^/]+)(?:\/|$)/);
if (projectMatch && UUID_RE.test(projectMatch[1])) {
return { type: "project", id: projectMatch[1] };
}
const deadlineMatch = pathname.match(/^\/deadlines\/([^/]+)$/);
if (deadlineMatch && UUID_RE.test(deadlineMatch[1])) {
return { type: "deadline", id: deadlineMatch[1] };
}
const apptMatch = pathname.match(/^\/appointments\/([^/]+)$/);
if (apptMatch && UUID_RE.test(apptMatch[1])) {
return { type: "appointment", id: apptMatch[1] };
}
return null;
}
/**
* Capture the user's current text selection, capped at SELECTION_MAX.
* Returns empty string when there's no selection or when the selection
* is collapsed (caret with no range).
*
* Privacy floor (§4.3): respects the widget's "send selection" toggle,
* stored in localStorage under `paliadin:send-selection`. Default on
* (m's Q5 lock-in); flip to off → returns empty string regardless of
* what's selected.
*/
export function readSelection(): string {
if (localStorage.getItem("paliadin:send-selection") === "off") {
return "";
}
const sel = window.getSelection();
if (!sel || sel.isCollapsed) return "";
const text = sel.toString().trim();
if (!text) return "";
if (text.length > SELECTION_MAX) {
return text.slice(0, SELECTION_MAX);
}
return text;
}
/**
* Probe the page for an active "view mode" hint — set by /events,
* /projects (tree vs list), /deadlines (calendar vs list). The frontend
* stores these as `data-view-mode` attributes on a known root element
* or in localStorage; this helper centralises the lookup so future
* pages adding a new view mode don't have to teach the widget about
* themselves.
*/
export function readViewMode(): "list" | "cards" | "calendar" | "tree" | "" {
const root = document.querySelector<HTMLElement>("[data-view-mode]");
if (!root) return "";
const v = root.dataset.viewMode || "";
if (v === "list" || v === "cards" || v === "calendar" || v === "tree") return v;
return "";
}
/**
* Pull a short human-readable summary of active list filters from a
* known DOM hook. Pages that want to participate set
* `data-filter-summary="status=overdue · project=Acme"` on a root
* element. Empty = no summary.
*/
export function readFilterSummary(): string {
const root = document.querySelector<HTMLElement>("[data-filter-summary]");
if (!root) return "";
return (root.dataset.filterSummary || "").trim();
}

View File

@@ -0,0 +1,84 @@
// Late-response polling. The Go backend's pollForResponse window is
// 60 s; if Claude writes the response file after that (because the
// tmux pane was busy mid-turn when the message arrived), the SSE
// stream has already closed with an `error` event. The Janitor
// (services.LocalPaliadinService.runJanitor) then patches the
// paliadin_turns row when the file lands.
//
// This module is the FE half of that loop: after the bubble shows an
// error, the caller registers the turn here. We poll
// `/api/paliadin/turns/{id}` every 3 s for up to 10 minutes; once the
// row has a non-empty response, we hand it back so the caller can
// swap the bubble content in place.
export interface LateTurn {
turn_id: string;
response: string | null;
error_code: string | null;
finished_at: string | null;
duration_ms: number | null;
used_tools: string[];
rows_seen: number[];
chip_count: number;
classifier_tag: string | null;
}
export interface LatePollOptions {
turnId: string;
intervalMs?: number; // default 3000
maxDurationMs?: number; // default 600000 (10 min)
onLateResponse: (turn: LateTurn) => void;
onGiveUp?: () => void;
}
export interface LatePollHandle {
cancel: () => void;
}
export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
const interval = opts.intervalMs ?? 3000;
const maxDuration = opts.maxDurationMs ?? 10 * 60 * 1000;
const startedAt = Date.now();
let cancelled = false;
let timer: number | undefined;
const tick = async () => {
if (cancelled) return;
if (Date.now() - startedAt > maxDuration) {
opts.onGiveUp?.();
return;
}
try {
const r = await fetch(`/api/paliadin/turns/${opts.turnId}`, {
credentials: "same-origin",
});
if (r.ok) {
const turn = (await r.json()) as LateTurn;
if (turn.response && turn.response.length > 0) {
opts.onLateResponse(turn);
return;
}
}
// 404: row gone (very unlikely) — give up.
if (r.status === 404) {
opts.onGiveUp?.();
return;
}
} catch {
// Transient network error; retry on next tick.
}
timer = window.setTimeout(tick, interval);
};
// First poll deliberately runs after one interval so we don't race
// the 60 s timeout on the very first tick.
timer = window.setTimeout(tick, interval);
return {
cancel: () => {
cancelled = true;
if (timer != null) window.clearTimeout(timer);
},
};
}

View File

@@ -0,0 +1,134 @@
// Shared Paliadin response renderer — used by both the standalone
// /paliadin page (client/paliadin.ts) and the inline drawer widget
// (client/paliadin-widget.ts). Extracted from paliadin.ts so the
// widget renders the same markdown + chips as the dedicated page
// without re-implementing the pipeline.
const CHIP_RE = /\[(?:#([a-z]+)-OPEN:([A-Za-z0-9\-_]+)|chip:([a-z]+):([^\]]+))\]/g;
const MD_LINK_RE = /\[([^\]\n]+)\]\(((?:https?:\/\/|\/)[^\s)]+)\)/g;
const BARE_URL_RE = /(^|[^"=>])(https?:\/\/[^\s<>"']+)/g;
function chipURL(kind: string, id: string): string {
switch (kind) {
case "deadline":
case "frist":
return "/deadlines/" + id;
case "projekt":
case "project":
return "/projects/" + id;
case "termin":
case "appointment":
return "/appointments/" + id;
default:
return "#";
}
}
function chipLabel(kind: string): string {
switch (kind) {
case "deadline":
case "frist":
return "Frist öffnen";
case "projekt":
case "project":
return "Akte ansehen";
case "termin":
case "appointment":
return "Termin öffnen";
default:
return "öffnen";
}
}
function renderBlocks(escapedHtml: string): string {
const out: string[] = [];
let listItems: string[] = [];
let paraLines: string[] = [];
const flushList = () => {
if (listItems.length === 0) return;
out.push(`<ul class="paliadin-list">${listItems.map((li) => `<li>${li}</li>`).join("")}</ul>`);
listItems = [];
};
const flushPara = () => {
if (paraLines.length === 0) return;
out.push(`<p>${paraLines.join("<br>")}</p>`);
paraLines = [];
};
for (const rawLine of escapedHtml.split("\n")) {
const line = rawLine.trim();
if (line === "") {
flushList();
flushPara();
continue;
}
let m: RegExpMatchArray | null;
if ((m = line.match(/^###\s+(.+)$/))) {
flushList();
flushPara();
out.push(`<h3>${m[1]}</h3>`);
} else if ((m = line.match(/^##\s+(.+)$/))) {
flushList();
flushPara();
out.push(`<h2>${m[1]}</h2>`);
} else if ((m = line.match(/^[-*]\s+(.+)$/))) {
flushPara();
listItems.push(m[1]);
} else if (line.match(/^---+$/)) {
flushList();
flushPara();
out.push(`<hr>`);
} else {
flushList();
paraLines.push(line);
}
}
flushList();
flushPara();
return out.join("");
}
export function renderResponseHTML(raw: string): string {
let html = raw
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
const chipHTML: string[] = [];
html = html.replace(CHIP_RE, (_match, kind, id, chipKind, chipArg) => {
let rendered = "";
if (kind && id) {
const url = chipURL(kind, id);
const label = chipLabel(kind);
rendered = `<a class="paliadin-chip" href="${url}">${label}</a>`;
} else if (chipKind === "nav") {
rendered = `<a class="paliadin-chip" href="${chipArg}">öffnen</a>`;
} else if (chipKind === "filter") {
rendered = `<a class="paliadin-chip" href="/inbox?${chipArg}">Filter anwenden</a>`;
}
if (!rendered) return "";
chipHTML.push(rendered);
return `CHIP${chipHTML.length - 1}`;
});
html = renderBlocks(html);
html = html.replace(MD_LINK_RE, (_m, text, url) => {
const ext = url.startsWith("http");
const attrs = ext ? ` target="_blank" rel="noopener noreferrer"` : "";
return `<a href="${url}" class="paliadin-link"${attrs}>${text}</a>`;
});
html = html.replace(BARE_URL_RE, (_m, prefix, url) => {
return `${prefix}<a href="${url}" class="paliadin-link" target="_blank" rel="noopener noreferrer">${url}</a>`;
});
html = html.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
html = html.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
html = html.replace(/CHIP(\d+)/g, (_m, idx) => chipHTML[Number(idx)] || "");
return html;
}

View File

@@ -0,0 +1,223 @@
// paliadin-starters.ts — per-route starter-prompt registry for the
// Paliadin inline widget (t-paliad-161).
//
// The drawer's empty state renders the matching starter list. Click →
// the prompt populates the textarea; if the prompt ends with `: ` it
// stays in the textarea so the user finishes the sentence.
//
// Static registry by design. LLM-generated starters were considered and
// rejected (latency, determinism, translatability — see design doc §5.2).
export interface Starter {
label_de: string;
label_en: string;
prompt_de: string;
prompt_en: string;
}
export const paliadinStarters: Record<string, Starter[]> = {
"dashboard": [
{
label_de: "Heute",
label_en: "Today",
prompt_de: "Was steht heute an?",
prompt_en: "What's on my plate today?",
},
{
label_de: "Diese Woche",
label_en: "This week",
prompt_de: "Welche Fristen sind diese Woche?",
prompt_en: "Which deadlines are this week?",
},
{
label_de: "Nächste Schritte",
label_en: "Next steps",
prompt_de: "Was sollte ich als nächstes erledigen?",
prompt_en: "What should I tackle next?",
},
],
"projects.detail": [
{
label_de: "Status der Akte",
label_en: "Project status",
prompt_de: "Was ist der aktuelle Status dieser Akte?",
prompt_en: "What's the status of this project?",
},
{
label_de: "Diese Woche",
label_en: "This week",
prompt_de: "Was steht für diese Akte diese Woche an?",
prompt_en: "What's on for this project this week?",
},
{
label_de: "Frist anlegen",
label_en: "Add a deadline",
prompt_de: "Lege eine Frist für diese Akte an: ",
prompt_en: "Add a deadline for this project: ",
},
],
"projects.list": [
{
label_de: "Aktive Akten",
label_en: "Active projects",
prompt_de: "Welche Akten sind aktuell aktiv?",
prompt_en: "Which projects are currently active?",
},
{
label_de: "UPC-Akten",
label_en: "UPC projects",
prompt_de: "Zeige mir alle UPC-Akten.",
prompt_en: "Show me all UPC projects.",
},
],
"deadlines.detail": [
{
label_de: "Erkläre die Frist",
label_en: "Explain this deadline",
prompt_de: "Erkläre mir die Frist auf dieser Seite.",
prompt_en: "Explain this deadline.",
},
{
label_de: "Rechtsgrundlage",
label_en: "Legal basis",
prompt_de: "Welche Norm ist hier einschlägig?",
prompt_en: "What's the relevant rule?",
},
{
label_de: "Folgefristen",
label_en: "Follow-on deadlines",
prompt_de: "Welche Fristen ergeben sich aus dieser?",
prompt_en: "What follow-on deadlines flow from this?",
},
],
"deadlines.list": [
{
label_de: "Überfällige",
label_en: "Overdue",
prompt_de: "Welche Fristen sind überfällig?",
prompt_en: "Which deadlines are overdue?",
},
{
label_de: "Diese Woche",
label_en: "This week",
prompt_de: "Was steht diese Woche an?",
prompt_en: "What's due this week?",
},
{
label_de: "Frist anlegen",
label_en: "Add a deadline",
prompt_de: "Lege eine Frist an: ",
prompt_en: "Add a deadline: ",
},
],
"appointments.list": [
{
label_de: "Heute",
label_en: "Today",
prompt_de: "Welche Termine habe ich heute?",
prompt_en: "What appointments do I have today?",
},
{
label_de: "Termin anlegen",
label_en: "Add an appointment",
prompt_de: "Lege einen Termin an: ",
prompt_en: "Add an appointment: ",
},
],
"appointments.detail": [
{
label_de: "Erkläre den Termin",
label_en: "Explain this appointment",
prompt_de: "Was ist auf diesem Termin zu klären?",
prompt_en: "What needs to be addressed at this appointment?",
},
],
"agenda": [
{
label_de: "Diese Woche",
label_en: "This week",
prompt_de: "Welche Termine und Fristen habe ich diese Woche?",
prompt_en: "What appointments and deadlines do I have this week?",
},
{
label_de: "Konflikte prüfen",
label_en: "Check conflicts",
prompt_de: "Gibt es Terminkonflikte in dieser Ansicht?",
prompt_en: "Are there scheduling conflicts in this view?",
},
],
"events": [
{
label_de: "Diese Woche",
label_en: "This week",
prompt_de: "Was steht diese Woche an?",
prompt_en: "What's on for this week?",
},
{
label_de: "Überfällige",
label_en: "Overdue",
prompt_de: "Was ist überfällig?",
prompt_en: "What's overdue?",
},
],
"inbox": [
{
label_de: "Was wartet",
label_en: "What's waiting",
prompt_de: "Was wartet auf meine Genehmigung?",
prompt_en: "What's waiting for my approval?",
},
],
"tools.fristenrechner": [
{
label_de: "Erkläre den Rechner",
label_en: "Explain the calculator",
prompt_de: "Wie funktioniert der Fristenrechner?",
prompt_en: "How does the deadline calculator work?",
},
{
label_de: "Verfahrensablauf",
label_en: "Proceeding flow",
prompt_de: "Welche Folgefristen kommen typischerweise nach einer Klage?",
prompt_en: "What deadlines typically follow a complaint?",
},
],
"tools.kostenrechner": [
{
label_de: "Erkläre die Berechnung",
label_en: "Explain the calculation",
prompt_de: "Wie wird der Streitwert berechnet?",
prompt_en: "How is the matter value calculated?",
},
],
"glossary": [
{
label_de: "Begriff erklären",
label_en: "Explain a term",
prompt_de: "Erkläre mir den Begriff: ",
prompt_en: "Explain the term: ",
},
],
"courts": [
{
label_de: "UPC-Divisionen",
label_en: "UPC divisions",
prompt_de: "Zeige mir alle UPC Local Divisions.",
prompt_en: "Show me all UPC Local Divisions.",
},
],
// Fallback for unmapped routes — the textarea stays empty, the user
// types from scratch.
"_default": [
{
label_de: "Was kann ich für dich tun?",
label_en: "What can I help with?",
prompt_de: "",
prompt_en: "",
},
],
};
export function startersFor(routeName: string): Starter[] {
return paliadinStarters[routeName] || paliadinStarters["_default"];
}

View File

@@ -0,0 +1,598 @@
// paliadin-widget.ts — runtime for the inline Paliadin floating button +
// slide-out drawer (t-paliad-161).
//
// Lifecycle:
// 1. On DOMContentLoaded, fetch /api/me. If the email matches the
// Paliadin owner gate (the same gate the standalone /paliadin
// route uses) AND the route is one where the widget shows, reveal
// the trigger button.
// 2. Click trigger or press Cmd+J / Ctrl+J → open drawer + populate
// starter prompts from paliadin-starters.ts.
// 3. Submit form → POST /api/paliadin/turn with structured context
// from computePaliadinContext() → consume the SSE stream → render
// assistant bubble.
// 4. Conversation history persists in localStorage per session id.
//
// Notes:
// - Cmd+K is reserved for the global search palette (client/search.ts).
// The widget uses Cmd+J / Ctrl+J as the keyboard trigger.
// - The standalone /paliadin page's client (client/paliadin.ts) is
// unchanged — this widget reuses /api/paliadin/turn but ships its
// own UI and history bucket so the two surfaces stay independent.
// - Visibility predicate mirrors paliadin-context.shouldSendContext()
// so the widget never sends a turn from a route where it shouldn't
// show.
import { initI18n, getLang, t } from "./i18n";
import { computePaliadinContext, shouldSendContext, routeNameFor } from "./paliadin-context";
import { startersFor, type Starter } from "./paliadin-starters";
import { renderResponseHTML } from "./paliadin-render";
import { pollForLateResponse, type LateTurn, type LatePollHandle } from "./paliadin-late-poll";
interface MeResponse {
id: string;
email: string;
display_name?: string;
global_role?: string;
}
interface HistoryEntry {
role: "user" | "assistant";
text: string;
ts: string;
}
interface TurnResponse {
turn_id: string;
sse_url: string;
}
// Shared session key — the inline drawer and the standalone /paliadin
// page must use the same browser-session id so both surfaces show the
// same conversation. Migration on first run: if a legacy
// `paliadin:widget:session` exists but the shared `paliadin:session`
// does not, copy across so the user doesn't lose drawer state on the
// rollover.
const SESSION_KEY = "paliadin:session";
const LEGACY_WIDGET_SESSION_KEY = "paliadin:widget:session";
// History bucket — render-cache only; DB is source of truth (server
// hydrates via /api/paliadin/history on every mount). The cache is keyed
// by session id so a session reset gives a clean slate.
const HISTORY_PREFIX = "paliadin:history:";
let sessionId: string;
let history: HistoryEntry[] = [];
let drawerOpen = false;
let activeStream: EventSource | null = null;
let pending = false;
// Late-response pollers per turn_id (see paliadin-late-poll.ts).
const lateWidgetPolls = new Map<string, LatePollHandle>();
document.addEventListener("DOMContentLoaded", () => {
const trigger = document.getElementById("paliadin-widget-trigger");
const drawer = document.getElementById("paliadin-widget-drawer");
if (!trigger || !drawer) return; // page didn't include the widget — skip silently
initI18n();
bootSession();
void revealIfOwner();
wireTrigger();
wireDrawerControls();
wireForm();
wireKeyboardShortcut();
});
function bootSession(): void {
let s = localStorage.getItem(SESSION_KEY);
if (!s) {
// One-time migration: previous widget builds wrote
// `paliadin:widget:session` instead of the shared key. Carry over
// the existing id so the user keeps their conversation thread.
const legacy = localStorage.getItem(LEGACY_WIDGET_SESSION_KEY);
s = legacy || crypto.randomUUID();
localStorage.setItem(SESSION_KEY, s);
}
// Drop the legacy key now that we've migrated; harmless if it's
// already absent.
localStorage.removeItem(LEGACY_WIDGET_SESSION_KEY);
sessionId = s;
loadHistory();
}
function loadHistory(): void {
const stored = localStorage.getItem(HISTORY_PREFIX + sessionId);
if (!stored) {
history = [];
return;
}
try {
const parsed = JSON.parse(stored);
history = Array.isArray(parsed) ? parsed.slice(-30) : [];
} catch {
history = [];
}
}
function saveHistory(): void {
try {
localStorage.setItem(HISTORY_PREFIX + sessionId, JSON.stringify(history.slice(-30)));
} catch {
/* localStorage quota or disabled — non-fatal */
}
}
async function revealIfOwner(): Promise<void> {
if (!shouldSendContext(window.location.pathname)) return; // route excluded
let me: MeResponse;
try {
const r = await fetch("/api/me", { credentials: "same-origin" });
if (!r.ok) return;
me = await r.json();
} catch {
return;
}
// The server-side handler returns 404 for non-owners on every paliadin
// route, so we don't need to know the owner email client-side. Probe
// /api/paliadin/me-check (a 200/404 endpoint) — but that endpoint
// doesn't exist; instead reuse the same reveal hook the sidebar uses,
// which checks an `is_paliadin_owner` flag the /api/me payload includes
// when paliadinSvc is wired and the caller matches.
if (!isPaliadinOwner(me)) return;
showTrigger();
renderStarters();
rehydrateHistory();
// Refresh from DB in the background so cross-surface activity (a
// turn typed on the standalone /paliadin page) shows up here without
// a manual reload.
void hydrateFromServer();
}
function isPaliadinOwner(me: MeResponse): boolean {
// Server-driven flag (matches the pattern client/sidebar.ts uses to
// reveal the /paliadin sidebar entry). Fallback to email match only
// if the flag is absent — this keeps the widget working on a server
// build that hasn't shipped the flag yet.
const flag = (me as unknown as { is_paliadin_owner?: boolean }).is_paliadin_owner;
if (typeof flag === "boolean") return flag;
// Fallback: hardcoded owner match. Same string as
// services.PaliadinOwnerEmail in Go — keep in sync.
return (me.email || "").toLowerCase() === "matthias.siebels@hoganlovells.com";
}
function showTrigger(): void {
const trigger = document.getElementById("paliadin-widget-trigger");
if (trigger) trigger.style.display = "";
}
function wireTrigger(): void {
const trigger = document.getElementById("paliadin-widget-trigger");
trigger?.addEventListener("click", () => openDrawer());
}
function wireDrawerControls(): void {
document.getElementById("paliadin-widget-close")?.addEventListener("click", () => closeDrawer());
document.getElementById("paliadin-widget-scrim")?.addEventListener("click", () => closeDrawer());
document.getElementById("paliadin-widget-reset")?.addEventListener("click", () => void resetSession());
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && drawerOpen) {
e.preventDefault();
closeDrawer();
}
});
}
function wireKeyboardShortcut(): void {
// Cmd+J / Ctrl+J — open or close the drawer. Cmd+K is reserved for
// global search (client/search.ts), so we use J ("Junior assistant").
document.addEventListener("keydown", (e) => {
const isCmdJ = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "j";
if (!isCmdJ) return;
const trigger = document.getElementById("paliadin-widget-trigger");
if (!trigger || trigger.style.display === "none") return; // widget not revealed
e.preventDefault();
e.stopPropagation();
if (drawerOpen) {
closeDrawer();
} else {
openDrawer();
}
});
}
function openDrawer(): void {
if (drawerOpen) return;
drawerOpen = true;
const drawer = document.getElementById("paliadin-widget-drawer");
const scrim = document.getElementById("paliadin-widget-scrim");
if (drawer) {
drawer.style.display = "";
drawer.dataset.open = "true";
drawer.setAttribute("aria-hidden", "false");
}
if (scrim) {
scrim.style.display = "";
}
// Force reflow so the slide-in animation runs (CSS transitions need a
// flip from off-canvas to on-canvas).
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
drawer?.offsetWidth;
if (drawer) drawer.classList.add("paliadin-widget-drawer--visible");
if (scrim) scrim.classList.add("paliadin-widget-scrim--visible");
refreshContextChip();
renderStarters();
// Pull the canonical conversation from the DB on every open so a
// turn the user typed on /paliadin (or another tab) since the last
// open is reflected here.
void hydrateFromServer();
setTimeout(() => {
document.getElementById("paliadin-widget-input")?.focus();
}, 60);
}
function closeDrawer(): void {
if (!drawerOpen) return;
drawerOpen = false;
const drawer = document.getElementById("paliadin-widget-drawer");
const scrim = document.getElementById("paliadin-widget-scrim");
drawer?.classList.remove("paliadin-widget-drawer--visible");
scrim?.classList.remove("paliadin-widget-scrim--visible");
// Wait for transition before display:none so the slide-out animates.
setTimeout(() => {
if (drawerOpen) return; // re-opened during transition
if (drawer) {
drawer.style.display = "none";
drawer.dataset.open = "false";
drawer.setAttribute("aria-hidden", "true");
}
if (scrim) scrim.style.display = "none";
}, 220);
}
function refreshContextChip(): void {
const chip = document.getElementById("paliadin-widget-context-chip");
const value = document.getElementById("paliadin-widget-context-value");
if (!chip || !value) return;
const ctx = computePaliadinContext();
if (!ctx) {
chip.style.display = "none";
return;
}
const labelParts: string[] = [];
if (ctx.primary_entity_type === "project") {
labelParts.push(getLang() === "en" ? "Project" : "Akte");
} else if (ctx.primary_entity_type === "deadline") {
labelParts.push(getLang() === "en" ? "Deadline" : "Frist");
} else if (ctx.primary_entity_type === "appointment") {
labelParts.push(getLang() === "en" ? "Appointment" : "Termin");
}
labelParts.push(humanRouteName(ctx.route_name));
value.textContent = labelParts.join(" · ");
chip.style.display = "";
}
function humanRouteName(route: string): string {
// Prefer i18n key if present; fall back to a tidied form of the
// route key itself.
const key = "paliadin.widget.route." + route;
const translated = t(key);
if (translated && translated !== key) return translated;
return route;
}
function renderStarters(): void {
const host = document.getElementById("paliadin-widget-starters");
if (!host) return;
const route = routeNameFor(window.location.pathname);
const lang = getLang();
const list = startersFor(route);
host.innerHTML = "";
list.forEach((s) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "paliadin-widget-starter";
btn.textContent = lang === "en" ? s.label_en : s.label_de;
btn.addEventListener("click", () => onStarterClick(s));
host.appendChild(btn);
});
}
function onStarterClick(s: Starter): void {
const lang = getLang();
const promptText = lang === "en" ? s.prompt_en : s.prompt_de;
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
if (!input) return;
if (!promptText) {
input.value = "";
input.focus();
return;
}
// Prompts that end with ": " are intentional partial seeds — leave
// the textarea so the user finishes the sentence.
if (promptText.endsWith(": ")) {
input.value = promptText;
input.focus();
input.setSelectionRange(promptText.length, promptText.length);
return;
}
input.value = promptText;
// Auto-send for fully-formed prompts.
void sendTurn();
}
function wireForm(): void {
const form = document.getElementById("paliadin-widget-form") as HTMLFormElement | null;
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
if (!form || !input) return;
form.addEventListener("submit", (e) => {
e.preventDefault();
void sendTurn();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void sendTurn();
}
});
}
async function sendTurn(): Promise<void> {
if (pending) return;
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
if (!input) return;
const text = input.value.trim();
if (!text) return;
input.value = "";
hideEmpty();
appendBubble("user", text);
history.push({ role: "user", text, ts: new Date().toISOString() });
saveHistory();
pending = true;
setSendDisabled(true);
const placeholder = appendBubble("assistant", "Paliadin denkt nach …");
placeholder.dataset.streaming = "true";
let turnRes: TurnResponse;
try {
const ctx = computePaliadinContext();
const r = await fetch("/api/paliadin/turn", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
user_message: text,
session_id: sessionId,
page_origin: window.location.pathname + window.location.search,
context: ctx ?? undefined,
}),
});
if (!r.ok) throw new Error("HTTP " + r.status);
turnRes = await r.json();
} catch {
setBubbleText(placeholder, t("paliadin.error.upstream"));
placeholder.classList.add("paliadin-widget-bubble--error");
placeholder.dataset.streaming = "false";
pending = false;
setSendDisabled(false);
return;
}
const es = new EventSource(turnRes.sse_url);
activeStream = es;
let fullText = "";
es.addEventListener("content", (ev) => {
try {
const data = JSON.parse((ev as MessageEvent).data);
fullText = String(data.text || "");
setBubbleText(placeholder, fullText);
} catch {
/* ignore parse error */
}
});
es.addEventListener("end", () => {
placeholder.dataset.streaming = "false";
history.push({ role: "assistant", text: fullText || "", ts: new Date().toISOString() });
saveHistory();
cleanupStream();
});
es.addEventListener("error", () => {
const errText = t("paliadin.error.connection_lost");
setBubbleText(placeholder, errText + " " + t("paliadin.late.waiting"));
placeholder.classList.add("paliadin-widget-bubble--error");
placeholder.classList.add("paliadin-widget-bubble--late-pending");
placeholder.dataset.streaming = "false";
placeholder.dataset.turnId = turnRes.turn_id;
startWidgetLatePoll(turnRes.turn_id, placeholder);
cleanupStream();
});
es.addEventListener("ping", () => {
/* heartbeat */
});
}
function cleanupStream(): void {
activeStream?.close();
activeStream = null;
pending = false;
setSendDisabled(false);
}
function startWidgetLatePoll(turnId: string, bubble: HTMLElement): void {
lateWidgetPolls.get(turnId)?.cancel();
const handle = pollForLateResponse({
turnId,
onLateResponse: (turn) => {
lateWidgetPolls.delete(turnId);
applyWidgetLateResponse(bubble, turn);
},
onGiveUp: () => {
lateWidgetPolls.delete(turnId);
},
});
lateWidgetPolls.set(turnId, handle);
}
function applyWidgetLateResponse(bubble: HTMLElement, turn: LateTurn): void {
if (!turn.response) return;
bubble.classList.remove(
"paliadin-widget-bubble--error",
"paliadin-widget-bubble--late-pending",
);
bubble.classList.add("paliadin-widget-bubble--late");
setBubbleText(bubble, turn.response);
// Append a small "(verspätet)" tag so the late arrival is visible.
const tag = document.createElement("span");
tag.className = "paliadin-widget-bubble-late-tag";
tag.textContent = " · " + t("paliadin.late.marker");
bubble.appendChild(tag);
history.push({
role: "assistant",
text: turn.response,
ts: new Date().toISOString(),
});
saveHistory();
}
function setSendDisabled(disabled: boolean): void {
const btn = document.getElementById("paliadin-widget-send-btn") as HTMLButtonElement | null;
if (btn) btn.disabled = disabled;
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
if (input) input.disabled = disabled;
}
function hideEmpty(): void {
const empty = document.getElementById("paliadin-widget-empty");
if (empty) empty.style.display = "none";
}
function appendBubble(role: "user" | "assistant", text: string): HTMLElement {
const messages = document.getElementById("paliadin-widget-messages");
const wrap = document.createElement("div");
wrap.className = `paliadin-widget-bubble paliadin-widget-bubble--${role}`;
const body = document.createElement("div");
body.className = "paliadin-widget-bubble-text";
// Assistant bubbles get the same markdown + chip pipeline as the
// standalone /paliadin page (client/paliadin-render.ts). User bubbles
// stay plain text — no need to interpret the user's typed markup.
if (role === "assistant") {
body.innerHTML = renderResponseHTML(text);
} else {
body.textContent = text;
}
wrap.appendChild(body);
messages?.appendChild(wrap);
if (messages) messages.scrollTop = messages.scrollHeight;
return wrap;
}
function setBubbleText(bubble: HTMLElement, text: string): void {
const body = bubble.querySelector(".paliadin-widget-bubble-text");
if (body) {
const isAssistant = bubble.classList.contains("paliadin-widget-bubble--assistant");
if (isAssistant) {
(body as HTMLElement).innerHTML = renderResponseHTML(text);
} else {
body.textContent = text;
}
}
const messages = document.getElementById("paliadin-widget-messages");
if (messages) messages.scrollTop = messages.scrollHeight;
}
function rehydrateHistory(): void {
if (!history.length) return;
hideEmpty();
history.forEach((h) => appendBubble(h.role, h.text));
}
// PaliadinTurnRow mirrors the JSON shape /api/paliadin/history returns
// (services.PaliadinTurn). Fields we don't render yet (used_tools etc.)
// are typed as unknown to keep the contract loose.
interface PaliadinTurnRow {
turn_id: string;
session_id: string;
started_at: string;
user_message: string;
response?: string | null;
error_code?: string | null;
}
// Hydrate from the DB on every mount. Crash-resistant: a typed turn
// always lands in paliad.paliadin_turns, so even if the user closes
// the tab mid-flight or the device dies, the next mount picks it up.
//
// Reconciliation: DB > localStorage. If the DB returns rows, we trust
// them entirely and overwrite the cache. If the DB call fails or
// returns empty, we keep whatever's in localStorage (offline cushion).
async function hydrateFromServer(): Promise<void> {
let rows: PaliadinTurnRow[] = [];
try {
const r = await fetch(
"/api/paliadin/history?session=" + encodeURIComponent(sessionId) + "&limit=50",
{ credentials: "same-origin" },
);
if (!r.ok) return;
const body = (await r.json()) as PaliadinTurnRow[] | null;
rows = Array.isArray(body) ? body : [];
} catch {
return;
}
if (!rows.length) return;
// Project DB rows into the {role, text, ts} shape the cache + render
// path expect. Each turn becomes two entries (user prompt then
// assistant response). Skip turns with no response (in-flight, or
// errored without a recovery) so the bubble doesn't show
// half-rendered placeholders on reload.
const reconstructed: HistoryEntry[] = [];
for (const row of rows) {
reconstructed.push({ role: "user", text: row.user_message, ts: row.started_at });
if (typeof row.response === "string" && row.response.length > 0) {
reconstructed.push({ role: "assistant", text: row.response, ts: row.started_at });
}
}
history = reconstructed;
saveHistory();
// Re-render: clear the message list + replay the canonical history.
const messages = document.getElementById("paliadin-widget-messages");
const empty = document.getElementById("paliadin-widget-empty");
if (messages) {
// Strip every prior bubble but keep the empty-state placeholder so
// it can be hidden by hideEmpty() if we end up rendering anything.
messages.querySelectorAll(".paliadin-widget-bubble").forEach((n) => n.remove());
if (empty) empty.style.display = "none";
history.forEach((h) => appendBubble(h.role, h.text));
}
}
async function resetSession(): Promise<void> {
if (!confirm(t("paliadin.widget.reset.confirm"))) return;
history = [];
saveHistory();
const messages = document.getElementById("paliadin-widget-messages");
if (messages) {
messages.innerHTML = "";
const empty = document.createElement("div");
empty.className = "paliadin-widget-empty";
empty.id = "paliadin-widget-empty";
const label = document.createElement("p");
label.className = "paliadin-widget-empty-prompt";
label.setAttribute("data-i18n", "paliadin.widget.empty");
label.textContent = t("paliadin.widget.empty");
const starters = document.createElement("div");
starters.className = "paliadin-widget-starters";
starters.id = "paliadin-widget-starters";
empty.appendChild(label);
empty.appendChild(starters);
messages.appendChild(empty);
renderStarters();
}
try {
await fetch("/api/paliadin/reset", { method: "POST", credentials: "same-origin" });
} catch {
/* non-fatal */
}
}

View File

@@ -1,5 +1,7 @@
import { initI18n, getLang, t } from "./i18n";
import { initSidebar } from "./sidebar";
import { renderResponseHTML } from "./paliadin-render";
import { pollForLateResponse, type LateTurn, type LatePollHandle } from "./paliadin-late-poll";
// Paliadin chat panel client (t-paliad-146 PoC).
//
@@ -32,6 +34,10 @@ let sessionId: string;
let history: HistoryEntry[] = [];
let currentEventSource: EventSource | null = null;
let currentTurnId: string | null = null;
// Late-response polls keyed by turn_id. Each entry runs until the
// response arrives or the 10-min cap expires. Stays alive across
// turns — m can keep chatting while we wait for the slow one.
const latePolls = new Map<string, LatePollHandle>();
document.addEventListener("DOMContentLoaded", () => {
initI18n();
@@ -41,6 +47,10 @@ document.addEventListener("DOMContentLoaded", () => {
wireStarters();
wireReset();
renderHistory();
// Pull the canonical conversation from the DB so a turn typed in the
// inline drawer (which shares this session id) shows up here on
// mount. DB > localStorage when both have data.
void hydrateFromServer();
});
function bootSession(): void {
@@ -164,6 +174,12 @@ async function sendTurn(text: string): Promise<void> {
es.addEventListener("content", (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
const text = String(data.text || "");
// Cache the full text on the bubble so finishBubble can render the
// complete response even when the typewriter is mid-flight when end
// arrives. textContent reflects only what's been typed so far and
// would otherwise truncate the rendered Markdown (m, 2026-05-08 —
// saw "## Proje" instead of the full 1408-byte body).
placeholder.dataset.fullText = text;
typewriter(placeholder, text);
});
@@ -173,7 +189,12 @@ async function sendTurn(text: string): Promise<void> {
finishBubble(placeholder, data);
history.push({
role: "assistant",
text: getBubbleText(placeholder),
// Save the raw Markdown body (with [#deadline-OPEN:...] chip markers
// intact), not the rendered textContent. Otherwise on reload the
// chip-anchor text replaces the markers and renderResponseHTML can
// no longer reconstruct the links (m, 2026-05-08 14:11 — links
// disappeared on second load).
text: placeholder.dataset.fullText ?? getBubbleText(placeholder),
meta: {
used_tools: data.used_tools,
rows_seen: data.rows_seen,
@@ -188,10 +209,21 @@ async function sendTurn(text: string): Promise<void> {
});
es.addEventListener("error", (ev) => {
const errText = friendlyErrorMessage((ev as MessageEvent).data);
// Annotate the error bubble with a "warten auf späte Antwort" hint
// so m knows the turn isn't dead; if Claude finishes after the
// 60 s window the Janitor (services.LocalPaliadinService.runJanitor)
// patches the row and pollForLateResponse swaps in the real reply.
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
friendlyErrorMessage((ev as MessageEvent).data);
errText + " " + t("paliadin.late.waiting");
placeholder.classList.add("paliadin-bubble--error");
placeholder.classList.add("paliadin-bubble--late-pending");
placeholder.dataset.streaming = "false";
placeholder.dataset.errorText = errText;
if (currentTurnId) {
placeholder.dataset.turnId = currentTurnId;
startLatePoll(currentTurnId, placeholder);
}
cleanupTurn();
});
@@ -210,8 +242,24 @@ function friendlyErrorMessage(data: unknown): string {
}
try {
const parsed = JSON.parse(data) as { code?: string };
if (parsed.code === "tmux_unavailable") {
return t("paliadin.error.local_only");
switch (parsed.code) {
case "tmux_unavailable":
// Local PoC path: paliad is running on a host without tmux/claude
// (typically the legacy laptop-only build).
return t("paliadin.error.local_only");
case "mriver_unreachable":
// t-paliad-151: prod path's mRiver is offline (laptop asleep, off
// tailnet, or paliadin-shim missing).
return t("paliadin.error.mriver_unreachable");
case "shim_auth_failed":
// SSH key wrong or authorized_keys drifted.
return t("paliadin.error.shim_auth_failed");
case "shim_error":
case "bootstrap_failed":
// Generic remote shim failure or system-prompt bootstrap error.
return t("paliadin.error.shim_error");
case "timeout":
return t("paliadin.error.timeout");
}
} catch {
// Not JSON — fall through to the generic connection-lost message
@@ -266,8 +314,9 @@ function typewriter(bubble: HTMLElement, text: string): void {
const speed = 6;
const tick = () => {
if (bubble.dataset.streaming !== "true") {
// Aborted — flush remaining text instantly.
node.textContent = text;
// Streaming finished — finishBubble has already rendered the full
// Markdown via dataset.fullText. Return without writing so we
// don't replace the rendered HTML with raw text on a delayed tick.
return;
}
if (i >= text.length) return;
@@ -291,7 +340,9 @@ function getBubbleText(bubble: HTMLElement): string {
// "ran search_my_deadlines (3 results)".
function finishBubble(bubble: HTMLElement, data: any): void {
const textNode = bubble.querySelector(".paliadin-bubble-text")! as HTMLElement;
const raw = textNode.textContent || "";
// Prefer the full text cached on the bubble at content-event time;
// textContent may still reflect the typewriter's partial state.
const raw = bubble.dataset.fullText ?? textNode.textContent ?? "";
textNode.innerHTML = renderResponseHTML(raw);
const metaEl = bubble.querySelector(".paliadin-bubble-meta") as HTMLElement | null;
@@ -309,71 +360,127 @@ function finishBubble(bubble: HTMLElement, data: any): void {
}
}
// Marker → button render. Mirrors §4.4 of the design.
const CHIP_RE = /\[(?:#([a-z]+)-OPEN:([A-Za-z0-9\-_]+)|chip:([a-z]+):([^\]]+))\]/g;
function renderResponseHTML(raw: string): string {
// First escape any HTML in the raw text (simple textContent → innerHTML
// would have been fine but we then need to inject anchors, so the
// manual escape is unavoidable).
const esc = raw
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
// Walk markers; replace each with a paliadin-chip anchor.
return esc.replace(CHIP_RE, (_match, kind, id, chipKind, chipArg) => {
if (kind && id) {
const url = chipURL(kind, id);
const label = chipLabel(kind);
return `<a class="paliadin-chip" href="${url}">${label}</a>`;
}
if (chipKind === "nav") {
return `<a class="paliadin-chip" href="${chipArg}">öffnen</a>`;
}
if (chipKind === "filter") {
return `<a class="paliadin-chip" href="/inbox?${chipArg}">Filter anwenden</a>`;
}
return "";
// startLatePoll registers the Janitor-patched row poller for one
// errored turn. When the row gains a response we swap the bubble's
// content + drop the error class + retroactively replace the history
// entry (which was never written for the failed turn — append now so
// reload renders the late reply).
function startLatePoll(turnId: string, bubble: HTMLElement): void {
// Avoid duplicate pollers for the same turn (e.g. SSE error fires
// twice in some browsers when the connection drops).
latePolls.get(turnId)?.cancel();
const handle = pollForLateResponse({
turnId,
onLateResponse: (turn) => {
latePolls.delete(turnId);
applyLateResponse(bubble, turn);
},
onGiveUp: () => {
latePolls.delete(turnId);
},
});
latePolls.set(turnId, handle);
}
function chipURL(kind: string, id: string): string {
switch (kind) {
case "deadline":
case "frist":
return "/deadlines/" + id;
case "projekt":
case "project":
return "/projects/" + id;
case "termin":
case "appointment":
return "/appointments/" + id;
default:
return "#";
}
}
function chipLabel(kind: string): string {
switch (kind) {
case "deadline":
case "frist":
return "Frist öffnen";
case "projekt":
case "project":
return "Akte ansehen";
case "termin":
case "appointment":
return "Termin öffnen";
default:
return "öffnen";
function applyLateResponse(bubble: HTMLElement, turn: LateTurn): void {
if (!turn.response) return;
bubble.classList.remove("paliadin-bubble--error", "paliadin-bubble--late-pending");
bubble.classList.add("paliadin-bubble--late");
bubble.dataset.fullText = turn.response;
bubble.dataset.streaming = "false";
finishBubble(bubble, {
used_tools: turn.used_tools,
rows_seen: turn.rows_seen,
classifier_tag: turn.classifier_tag,
duration_ms: turn.duration_ms,
chip_count: turn.chip_count,
});
// Inject a small "(verspätet)" marker into the meta row so it's
// visible at a glance that this bubble was patched after the fact.
const metaEl = bubble.querySelector(".paliadin-bubble-meta") as HTMLElement | null;
if (metaEl) {
const lateTag = document.createElement("span");
lateTag.className = "paliadin-bubble-late-tag";
lateTag.textContent = " · " + t("paliadin.late.marker");
metaEl.appendChild(lateTag);
metaEl.style.display = "";
}
// Persist so a reload shows the late response in place of the error.
history.push({
role: "assistant",
text: turn.response,
meta: {
used_tools: turn.used_tools,
rows_seen: turn.rows_seen,
classifier_tag: turn.classifier_tag ?? undefined,
duration_ms: turn.duration_ms ?? undefined,
chip_count: turn.chip_count,
},
ts: new Date().toISOString(),
});
saveHistory();
}
function saveHistory(): void {
localStorage.setItem(HISTORY_PREFIX + sessionId, JSON.stringify(history));
}
// PaliadinTurnRow mirrors the JSON returned by /api/paliadin/history
// (services.PaliadinTurn). Fields we don't render yet are skipped.
interface PaliadinTurnRow {
turn_id: string;
session_id: string;
started_at: string;
user_message: string;
response?: string | null;
used_tools?: string[] | null;
rows_seen?: number[] | null;
classifier_tag?: string | null;
duration_ms?: number | null;
chip_count?: number | null;
}
// Hydrate from /api/paliadin/history, replacing the localStorage cache
// when the DB returns rows. Fail-quiet on network / auth errors —
// localStorage is a perfectly good offline fallback.
async function hydrateFromServer(): Promise<void> {
let rows: PaliadinTurnRow[] = [];
try {
const r = await fetch(
"/api/paliadin/history?session=" + encodeURIComponent(sessionId) + "&limit=50",
{ credentials: "same-origin" },
);
if (!r.ok) return;
const body = (await r.json()) as PaliadinTurnRow[] | null;
rows = Array.isArray(body) ? body : [];
} catch {
return;
}
if (!rows.length) return;
const reconstructed: HistoryEntry[] = [];
for (const row of rows) {
reconstructed.push({ role: "user", text: row.user_message, ts: row.started_at });
if (typeof row.response === "string" && row.response.length > 0) {
reconstructed.push({
role: "assistant",
text: row.response,
ts: row.started_at,
meta: {
used_tools: row.used_tools ?? undefined,
rows_seen: row.rows_seen ?? undefined,
classifier_tag: row.classifier_tag ?? undefined,
duration_ms: row.duration_ms ?? undefined,
chip_count: row.chip_count ?? undefined,
},
});
}
}
history = reconstructed;
saveHistory();
renderHistory();
}
function renderHistory(): void {
const stream = document.getElementById("paliadin-stream");
if (!stream) return;

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

@@ -0,0 +1,489 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
import {
ALL_DENSITIES,
ALL_PALETTES,
ALL_RANGE_PRESETS,
mount,
type ChartHandle,
type Density,
type Palette,
type RangePreset,
} from "./views/shape-timeline-chart";
import {
exportCSV,
exportJSON,
exportPNG,
exportPrint,
exportSVG,
type ExportContext,
} from "./views/chart-export";
// t-paliad-177 Slice 1 — boot client for the standalone Project Timeline
// / Chart page. Reads the project id from the URL path, loads the
// project metadata (for title + breadcrumb), mounts the SVG renderer
// inside #projects-chart-host. Slice 1 keeps the controls inert; Slice 3
// wires density / palette / zoom against this same surface.
//
// Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
interface Project {
id: string;
title: string;
reference?: string;
client_matter?: string;
type?: string;
}
const PROJECT_ID_RE = /^\/projects\/([0-9a-fA-F-]{36})\/chart\/?$/;
function projectIdFromPath(): string | null {
const match = PROJECT_ID_RE.exec(window.location.pathname);
return match ? match[1] : null;
}
const PALETTE_SET: ReadonlySet<string> = new Set(ALL_PALETTES);
/** Reads ?palette=... from the URL; returns the default when missing /
* unknown so a hostile or stale URL can't break the chart. */
function paletteFromURL(): Palette {
const raw = new URLSearchParams(window.location.search).get("palette");
if (raw && PALETTE_SET.has(raw)) return raw as Palette;
return "default";
}
/** Mirrors paletteFromURL but for writing — pushes a new history entry
* so the URL stays bookmarkable / shareable per design §8.2. */
function writePaletteToURL(palette: Palette): void {
writeParamToURL("palette", palette, "default");
}
const DENSITY_SET: ReadonlySet<string> = new Set(ALL_DENSITIES);
function densityFromURL(): Density {
const raw = new URLSearchParams(window.location.search).get("density");
if (raw && DENSITY_SET.has(raw)) return raw as Density;
return "standard";
}
function writeDensityToURL(density: Density): void {
writeParamToURL("density", density, "standard");
}
const RANGE_SET: ReadonlySet<string> = new Set(ALL_RANGE_PRESETS);
interface RangeState {
preset: RangePreset;
from?: string;
to?: string;
}
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
function rangeFromURL(): RangeState {
const params = new URLSearchParams(window.location.search);
const raw = params.get("range");
const preset: RangePreset = raw && RANGE_SET.has(raw) ? (raw as RangePreset) : "1y";
if (preset === "custom") {
const from = params.get("from") || "";
const to = params.get("to") || "";
return {
preset,
from: ISO_DATE_RE.test(from) ? from : undefined,
to: ISO_DATE_RE.test(to) ? to : undefined,
};
}
return { preset };
}
function writeRangeToURL(state: RangeState): void {
const params = new URLSearchParams(window.location.search);
if (state.preset === "1y") {
params.delete("range");
} else {
params.set("range", state.preset);
}
if (state.preset === "custom") {
if (state.from) params.set("from", state.from);
else params.delete("from");
if (state.to) params.set("to", state.to);
else params.delete("to");
} else {
params.delete("from");
params.delete("to");
}
const qs = params.toString();
const next = window.location.pathname + (qs ? "?" + qs : "");
window.history.replaceState(null, "", next);
}
/** Read ?lanes=id1,id2 from the URL. Empty / missing → null (show all).
* Defence: ids that look hostile (commas embedded, oversized) are dropped
* on render via the renderer's allow-set intersection. */
function lanesFromURL(): string[] | null {
const raw = new URLSearchParams(window.location.search).get("lanes");
if (!raw) return null;
const ids = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0 && s.length < 200);
return ids.length === 0 ? null : ids;
}
function writeLanesToURL(lanes: string[] | null): void {
const params = new URLSearchParams(window.location.search);
if (!lanes || lanes.length === 0) {
params.delete("lanes");
} else {
params.set("lanes", lanes.join(","));
}
const qs = params.toString();
const next = window.location.pathname + (qs ? "?" + qs : "");
window.history.replaceState(null, "", next);
}
/** Shared URL writer — omits the param when it equals its default, so the
* canonical URL stays short and dedupable. */
function writeParamToURL(name: string, value: string, defaultValue: string): void {
const params = new URLSearchParams(window.location.search);
if (value === defaultValue) {
params.delete(name);
} else {
params.set(name, value);
}
const qs = params.toString();
const next = window.location.pathname + (qs ? "?" + qs : "");
window.history.replaceState(null, "", next);
}
async function loadProject(id: string): Promise<Project | null> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`);
if (!resp.ok) return null;
return (await resp.json()) as Project;
} catch {
return null;
}
}
function formatMeta(p: Project): string {
const parts: string[] = [];
if (p.reference) parts.push(p.reference);
if (p.client_matter) parts.push(p.client_matter);
return parts.join(" • ");
}
async function boot(): Promise<void> {
initI18n();
initSidebar();
const loadingEl = document.getElementById("projects-chart-loading");
const notfoundEl = document.getElementById("projects-chart-notfound");
const bodyEl = document.getElementById("projects-chart-body");
const titleEl = document.getElementById("projects-chart-title");
const metaEl = document.getElementById("projects-chart-meta");
const backLink = document.getElementById("projects-chart-back-link") as HTMLAnchorElement | null;
const host = document.getElementById("projects-chart-host");
const undatedHint = document.getElementById("projects-chart-undated");
const id = projectIdFromPath();
if (!id || !host || !bodyEl || !loadingEl || !notfoundEl) {
if (loadingEl) loadingEl.style.display = "none";
if (notfoundEl) notfoundEl.style.display = "block";
return;
}
const project = await loadProject(id);
if (!project) {
loadingEl.style.display = "none";
notfoundEl.style.display = "block";
return;
}
// Wire back-link to the Verlauf tab specifically — projects-detail.ts
// reads the /history sub-path on init and switches to that tab. Going
// back to the bare /projects/{id} also lands on Verlauf today, but the
// /history form is explicit + survives a future default-tab change.
if (backLink) backLink.href = `/projects/${encodeURIComponent(id)}/history`;
if (titleEl) titleEl.textContent = project.title || t("projects.chart.title");
if (metaEl) metaEl.textContent = formatMeta(project);
loadingEl.style.display = "none";
bodyEl.style.display = "";
const initialPalette = paletteFromURL();
const initialDensity = densityFromURL();
const initialRange = rangeFromURL();
const initialLanes = lanesFromURL();
let handle: ChartHandle | null = null;
// Module-scope mirrors so the chip click handlers (rendered later)
// can reach the live state without threading it through callbacks.
moduleVisibleLanes = initialLanes;
try {
handle = mount(host, {
projectId: id,
palette: initialPalette,
density: initialDensity,
rangePreset: initialRange.preset,
rangeFrom: initialRange.from,
rangeTo: initialRange.to,
visibleLanes: initialLanes,
onDataLoaded: ({ lanes }) => {
renderLaneFilter(lanes);
},
});
} catch (err) {
console.error("chart mount failed", err);
host.textContent = t("projects.chart.error.mount");
return;
}
moduleHandleRef = handle;
// Wire the palette picker. Reflect the URL-decoded initial value, then
// re-write the URL + flip the data-palette attribute on every change.
const paletteSel = document.getElementById("projects-chart-palette") as HTMLSelectElement | null;
if (paletteSel) {
paletteSel.value = initialPalette;
paletteSel.addEventListener("change", () => {
const next = paletteSel.value;
if (!PALETTE_SET.has(next)) return;
const p = next as Palette;
handle!.setPalette(p);
writePaletteToURL(p);
});
}
// Density picker — same URL-state pattern. Density triggers a repaint
// (lane height + mark radius change), palette is a pure CSS swap.
const densitySel = document.getElementById("projects-chart-density") as HTMLSelectElement | null;
if (densitySel) {
densitySel.value = initialDensity;
densitySel.addEventListener("change", () => {
const next = densitySel.value;
if (!DENSITY_SET.has(next)) return;
const d = next as Density;
handle!.setDensity(d);
writeDensityToURL(d);
});
}
// Range chips — 4-option select plus a custom date-pair that shows
// only when preset === "custom". Per design §8.2 + faraday-Q8 default.
const rangeSel = document.getElementById("projects-chart-range") as HTMLSelectElement | null;
const rangeCustomWrap = document.getElementById("projects-chart-range-custom");
const rangeFromInput = document.getElementById("projects-chart-range-from") as HTMLInputElement | null;
const rangeToInput = document.getElementById("projects-chart-range-to") as HTMLInputElement | null;
if (rangeSel && rangeCustomWrap && rangeFromInput && rangeToInput) {
rangeSel.value = initialRange.preset;
if (initialRange.preset === "custom") {
rangeCustomWrap.style.display = "";
if (initialRange.from) rangeFromInput.value = initialRange.from;
if (initialRange.to) rangeToInput.value = initialRange.to;
}
const applyRange = () => {
const preset = rangeSel.value;
if (!RANGE_SET.has(preset)) return;
const p = preset as RangePreset;
rangeCustomWrap.style.display = p === "custom" ? "" : "none";
const from = rangeFromInput.value || undefined;
const to = rangeToInput.value || undefined;
handle!.setRange(p, from, to);
writeRangeToURL({ preset: p, from, to });
};
rangeSel.addEventListener("change", applyRange);
rangeFromInput.addEventListener("change", applyRange);
rangeToInput.addEventListener("change", applyRange);
}
// Export menu. Each button maps to one chart-export function; the
// handle exposes the live SVG + last-fetched data needed to compose
// an ExportContext. Errors land in the host's message area so the
// user gets feedback instead of a silent failure.
function ctxNow(): ExportContext {
const data = handle!.getData();
return {
projectId: id,
projectTitle: project.title || t("projects.chart.title"),
svgEl: handle!.getSVGElement(),
events: data.events,
lanes: data.lanes,
};
}
function runExport(fn: (ctx: ExportContext) => void | Promise<void>): void {
void Promise.resolve()
.then(() => fn(ctxNow()))
.catch((err) => {
console.error("export failed", err);
if (host) {
host.setAttribute("data-export-error", "1");
}
});
}
wirePermalinkCopy("projects-chart-copylink");
wireExport("projects-chart-export-svg", () => runExport(exportSVG));
wireExport("projects-chart-export-png", () => runExport(exportPNG));
wireExport("projects-chart-export-csv", () => runExport(exportCSV));
wireExport("projects-chart-export-json", () => runExport(exportJSON));
wireExport("projects-chart-export-print", () => exportPrint());
// iCal goes server-side so it reuses the existing caldav_ical formatter
// (faraday-Q6 / m's pick: deadlines + appointments only — no projected).
wireExport("projects-chart-export-ics", () => {
window.location.href = `/api/projects/${encodeURIComponent(id)}/timeline.ics`;
});
// After the first paint, surface the undated hint when the renderer
// reports clipped/undated rows. Re-checked on resize-debounced repaint.
const checkUndated = () => {
if (!undatedHint || !handle) return;
const layout = handle.getLayout();
if (!layout) return;
if (layout.undatedCount > 0) {
undatedHint.style.display = "";
undatedHint.textContent = `${layout.undatedCount} Ereignis(se) ohne Datum (links angeheftet).`;
} else {
undatedHint.style.display = "none";
}
};
// Poll once after the initial fetch settles. mount() kicks the fetch
// synchronously; layout becomes available after the network round-trip.
setTimeout(checkUndated, 400);
setTimeout(checkUndated, 1500);
}
/** Render the lane-filter chip group once the renderer has lanes from
* the server. One toggle button per lane; clicking flips inclusion in
* the visible-lane allow-set. Hidden when there's only one lane (or
* none) — the filter is pointless on a single-track render. */
function renderLaneFilter(lanes: ReadonlyArray<{ id: string; label: string }>): void {
const container = document.getElementById("projects-chart-lanes-filter");
if (!container) return;
// Hide and bail when the filter wouldn't add value.
if (lanes.length < 2) {
container.innerHTML = "";
container.style.display = "none";
return;
}
container.style.display = "";
container.innerHTML = "";
const titleEl = document.createElement("span");
titleEl.className = "smart-timeline-chart-lanes-label";
titleEl.textContent = "Spuren:";
container.appendChild(titleEl);
for (const lane of lanes) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "smart-timeline-chart-lane-chip";
const isVisible = laneIsVisible(lane.id);
btn.setAttribute("aria-pressed", isVisible ? "true" : "false");
btn.dataset.laneId = lane.id;
btn.textContent = lane.label || lane.id;
btn.addEventListener("click", () => {
toggleLane(lane.id, lanes);
// Reflect new state immediately on this button + siblings.
for (const sibling of container.querySelectorAll<HTMLButtonElement>("button[data-lane-id]")) {
const sid = sibling.dataset.laneId || "";
sibling.setAttribute("aria-pressed", laneIsVisible(sid) ? "true" : "false");
}
});
container.appendChild(btn);
}
}
/** Permalink copy. The URL already aggregates every chip's state via the
* individual writeParamToURL writers (palette + density + range + lanes),
* so window.location.href IS the canonical shareable link. We copy it
* to the clipboard and flash a "kopiert" confirmation on the button. */
function wirePermalinkCopy(buttonId: string): void {
const btn = document.getElementById(buttonId) as HTMLButtonElement | null;
if (!btn) return;
const originalLabel = btn.textContent || "";
let resetTimer: ReturnType<typeof setTimeout> | null = null;
btn.addEventListener("click", async () => {
const url = window.location.href;
const ok = await copyToClipboard(url);
if (resetTimer) clearTimeout(resetTimer);
btn.textContent = ok ? "✓ Kopiert" : "⚠ Konnte nicht kopieren";
btn.classList.add(ok ? "is-success" : "is-error");
resetTimer = setTimeout(() => {
btn.textContent = originalLabel;
btn.classList.remove("is-success", "is-error");
}, 1800);
});
}
async function copyToClipboard(text: string): Promise<boolean> {
// Prefer the async Clipboard API. Falls back to the legacy exec hack
// for browsers / contexts where it's unavailable (some iframes, file://).
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// fall through
}
}
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
function wireExport(buttonId: string, handler: () => void): void {
const btn = document.getElementById(buttonId) as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", (e) => {
e.preventDefault();
handler();
// Close the <details> dropdown so the user sees the chart-area
// update (download notification, print preview, etc).
const details = btn.closest("details");
if (details) details.removeAttribute("open");
});
}
// Lane-filter mutable state lives at module scope so renderLaneFilter
// closures over the same set as toggleLane / laneIsVisible. We can't
// access boot()'s `visibleLanes` from here cleanly, so we mirror it.
let moduleVisibleLanes: string[] | null = null;
let moduleHandleRef: ChartHandle | null = null;
function laneIsVisible(id: string): boolean {
if (moduleVisibleLanes === null) return true;
return moduleVisibleLanes.includes(id);
}
function toggleLane(id: string, allLanes: ReadonlyArray<{ id: string }>): void {
if (moduleVisibleLanes === null) {
// Currently "show all" — turning a chip off means everyone except this one.
moduleVisibleLanes = allLanes.map((l) => l.id).filter((l) => l !== id);
} else if (moduleVisibleLanes.includes(id)) {
moduleVisibleLanes = moduleVisibleLanes.filter((l) => l !== id);
} else {
moduleVisibleLanes = [...moduleVisibleLanes, id];
}
// If user toggled every lane back on, collapse to null (show all).
if (moduleVisibleLanes.length === allLanes.length) {
moduleVisibleLanes = null;
}
// If user toggled every lane off, snap back to null too — an empty
// chart is never useful, treat as "you didn't mean that, show all".
if (moduleVisibleLanes !== null && moduleVisibleLanes.length === 0) {
moduleVisibleLanes = null;
}
if (moduleHandleRef) {
moduleHandleRef.setVisibleLanes(moduleVisibleLanes);
}
writeLanesToURL(moduleVisibleLanes);
}
document.addEventListener("DOMContentLoaded", () => {
void boot();
});

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,21 @@ function $(id: string): HTMLElement {
return el;
}
// sanitizeReturnUrl restricts the post-create bounce-back to same-origin
// paths. Any value that could escape to a different origin (protocol-
// relative `//foo`, absolute `https://...`, or non-rooted relative
// paths) is rejected and the form falls back to /projects/{id}. m's
// 2026-05-08 Determinator Slice 2: the /tools/fristenrechner Step 1
// "Neue Akte anlegen" link sends ?return=/tools/fristenrechner so the
// new project preselects itself when control bounces back.
function sanitizeReturnUrl(raw: string | null): string | null {
if (!raw) return null;
if (raw.startsWith("//")) return null;
if (raw.includes("://")) return null;
if (!raw.startsWith("/")) return null;
return raw;
}
function submitForm() {
const form = $("project-new-form") as HTMLFormElement;
const msg = $("project-new-msg") as HTMLParagraphElement;
@@ -41,6 +56,20 @@ function submitForm() {
return;
}
const p = (await resp.json()) as { id: string };
// Honour ?return=<path> if it's a same-origin rooted path. The
// caller is responsible for ensuring the destination knows what
// to do with the appended ?project= param; see Slice 1's Step 1
// hydration.
const qs = new URLSearchParams(window.location.search);
const returnUrl = sanitizeReturnUrl(qs.get("return"));
if (returnUrl) {
const dest = new URL(returnUrl, window.location.origin);
dest.searchParams.set("project", p.id);
window.location.href = dest.pathname + dest.search + dest.hash;
return;
}
window.location.href = `/projects/${p.id}`;
} catch (e) {
msg.textContent = String(e);

View File

@@ -73,6 +73,7 @@ export function initSidebar() {
initInboxBadge();
initAdminGroup();
initPaliadinLinks();
initProjectContextChartLink();
initUserViewsGroup();
initThemeToggle();
const sidebar = document.querySelector<HTMLElement>(".sidebar");
@@ -443,6 +444,11 @@ function initUserViewsGroup(): void {
});
}
// fixVerfahrensablaufActive removed (t-paliad-179 Slice 1). The two
// sidebar entries now map 1:1 to distinct URLs (/tools/fristenrechner
// vs /tools/verfahrensablauf), so the SSR navItem helper picks the
// correct active class by pathname alone.
function renderUserViewItem(view: UserViewLite, currentPath: string): HTMLElement {
const a = document.createElement("a");
a.href = `/views/${encodeURIComponent(view.slug)}`;
@@ -544,6 +550,31 @@ function initPaliadinLinks(): void {
});
}
// initProjectContextChartLink (t-paliad-177 Slice 3) reveals an "Als Chart
// anzeigen" entry in the sidebar when the user is browsing a project
// detail page. Hidden everywhere else, hidden on the chart page itself
// (the chart is the destination, not the source).
//
// Self-contained on URL parsing — no per-page handshake needed. Pages
// don't have to know about the sidebar slot; this function walks the
// pathname and renders the link if it matches.
//
// Layout intent: chip sits directly under the "Übersicht" group so it's
// visible on every project sub-tab (Verlauf / Team / Parteien / …).
function initProjectContextChartLink(): void {
const link = document.getElementById("sidebar-project-chart-link") as HTMLAnchorElement | null;
if (!link) return;
const match = /^\/projects\/([0-9a-fA-F-]{36})(\/.*)?$/.exec(window.location.pathname);
if (!match) return;
const id = match[1];
const rest = match[2] || "";
// Hide on the chart page itself — a reciprocal "Zurück zum Verlauf"
// affordance lives on the chart page header (separate slice).
if (rest === "/chart" || rest === "/chart/") return;
link.href = `/projects/${encodeURIComponent(id)}/chart`;
link.style.display = "";
}
// initAdminGroup reveals the Admin section in the sidebar when the caller's
// /api/me lookup confirms global_role='global_admin'. The markup is in the
// DOM with display:none for everyone — flipping it on after the fetch lands

View File

@@ -0,0 +1,190 @@
// /tools/verfahrensablauf client (t-paliad-179 Slice 1)
//
// Abstract-browse surface: pick a proceeding, pick a trigger date,
// see the typical timeline. No Akte, no save-to-project, no anchor
// override editing, no Pathway B cascade. Variant chips + lane view
// (Slice 3) and compare (Slice 4) layer on top of this in later
// slices. Court picker + view toggle + calc fetch + renderers all
// come from ./views/verfahrensablauf-core, which fristenrechner.ts
// shares.
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import {
type DeadlineResponse,
calculateDeadlines,
formatDate,
populateCourtPicker,
renderColumnsBody,
renderTimelineBody,
} from "./views/verfahrensablauf-core";
let selectedType = "";
let lastResponse: DeadlineResponse | null = null;
type ProcedureView = "timeline" | "columns";
let procedureView: ProcedureView = "columns";
// Auto-calc plumbing — sequence + debounce mirror /tools/fristenrechner
// so rapid input changes never let a stale response overwrite a fresh
// one.
let calcSeq = 0;
let calcTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleCalc(delayMs = 200) {
if (calcTimer !== null) clearTimeout(calcTimer);
calcTimer = setTimeout(() => {
calcTimer = null;
void doCalc();
}, delayMs);
}
function showStep(n: number) {
for (let i = 1; i <= 3; i++) {
const el = document.getElementById(`step-${i}`);
if (el) el.style.display = i <= n ? "block" : "none";
}
}
async function doCalc() {
const seq = ++calcSeq;
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
const triggerDate = dateInput?.value || "";
if (!triggerDate || !selectedType) return;
const courtPickerRow = document.getElementById("court-picker-row");
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
const courtId = courtPickerRow && courtPickerRow.style.display !== "none" && courtPicker?.value
? courtPicker.value
: "";
const data = await calculateDeadlines({
proceedingType: selectedType,
triggerDate,
courtId,
});
if (seq !== calcSeq) return;
if (!data) return;
lastResponse = data;
renderResults(data);
showStep(3);
}
function renderResults(data: DeadlineResponse) {
const container = document.getElementById("timeline-container");
if (!container) return;
const printBtn = document.getElementById("fristen-print-btn");
const toggle = document.getElementById("fristen-view-toggle");
const procName = tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
const headerHtml = `<div class="timeline-header">
<strong>${procName}</strong>
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
</div>`;
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data)
: renderTimelineBody(data);
container.innerHTML = headerHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block";
if (toggle) toggle.style.display = "";
}
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
const groups = document.querySelectorAll<HTMLElement>(".proceeding-group");
const summary = document.getElementById("proceeding-summary") as HTMLElement | null;
const summaryName = document.getElementById("proceeding-summary-name");
groups.forEach((g) => { g.style.display = collapsed ? "none" : ""; });
if (summary) summary.style.display = collapsed ? "" : "none";
if (summaryName && displayName) summaryName.textContent = displayName;
}
function selectProceeding(btn: HTMLButtonElement) {
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
selectedType = btn.dataset.code || "";
const name = btn.querySelector("strong")?.textContent || "";
const triggerEventEl = document.getElementById("trigger-event");
if (triggerEventEl) triggerEventEl.textContent = name;
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
setProceedingPickerCollapsed(true, name);
showStep(2);
scheduleCalc(0);
}
function initViewToggle() {
const toggle = document.getElementById("fristen-view-toggle");
if (!toggle) return;
const initial = new URLSearchParams(window.location.search).get("view");
if (initial === "timeline") procedureView = "timeline";
toggle.querySelectorAll<HTMLInputElement>("input[name=fristen-view]").forEach((input) => {
input.checked = input.value === procedureView;
input.addEventListener("change", () => {
if (!input.checked) return;
procedureView = input.value === "columns" ? "columns" : "timeline";
const url = new URL(window.location.href);
if (procedureView === "columns") {
url.searchParams.delete("view");
} else {
url.searchParams.set("view", procedureView);
}
history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
if (lastResponse) renderResults(lastResponse);
});
});
toggle.style.display = "none";
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
btn.addEventListener("click", () => selectProceeding(btn));
});
document.getElementById("proceeding-summary-reselect")?.addEventListener("click", () => {
setProceedingPickerCollapsed(false);
});
document.getElementById("calculate-btn")?.addEventListener("click", () => scheduleCalc(0));
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
if (dateInput) {
dateInput.addEventListener("change", () => scheduleCalc());
dateInput.addEventListener("input", () => scheduleCalc());
dateInput.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") scheduleCalc(0);
});
}
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
initViewToggle();
onLangChange(() => {
if (lastResponse) renderResults(lastResponse);
const activeBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
if (activeBtn) {
const name = activeBtn.querySelector("strong")?.textContent || "";
const triggerEventEl = document.getElementById("trigger-event");
if (triggerEventEl) triggerEventEl.textContent = name;
}
});
// Pre-select the first proceeding tile so users see a timeline
// immediately on landing — matches /tools/fristenrechner behaviour.
const firstBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
if (firstBtn) selectProceeding(firstBtn);
});

View File

@@ -4,6 +4,8 @@ import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape } fro
import { renderListShape } from "./views/shape-list";
import { renderCardsShape } from "./views/shape-cards";
import { renderCalendarShape } from "./views/shape-calendar";
import { renderTimelineShape } from "./views/shape-timeline-cv";
import type { ChartHandle } from "./views/shape-timeline-chart";
// /views and /views/{slug} client. Loads the saved or system view, runs
// it via /api/views/{slug}/run, and dispatches to the matching render-
@@ -143,7 +145,7 @@ async function runAndRender(meta: ViewMeta): Promise<void> {
}
function setActiveShape(shape: RenderShape): void {
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar"]) {
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar", "views-shape-timeline"]) {
const el = document.getElementById(host);
if (el) el.hidden = !host.endsWith("-" + shape);
}
@@ -152,9 +154,17 @@ function setActiveShape(shape: RenderShape): void {
});
}
let timelineHandle: ChartHandle | null = null;
function renderShape(shape: RenderShape, render: RenderSpec, rows: ViewRunResult["rows"]): void {
const host = document.getElementById(`views-shape-${shape}`);
if (!host) return;
// Switching away from timeline → dispose the prior chart handle so we
// don't leak resize listeners / SVG nodes between shape flips.
if (shape !== "timeline" && timelineHandle) {
timelineHandle.dispose();
timelineHandle = null;
}
switch (shape) {
case "list":
renderListShape(host, rows, render);
@@ -165,6 +175,47 @@ function renderShape(shape: RenderShape, render: RenderSpec, rows: ViewRunResult
case "calendar":
renderCalendarShape(host, rows, render);
break;
case "timeline": {
// Tear down any previous chart inside this host before re-mounting
// (the CV adapter clears chart-host innerHTML on its own, but we
// need to dispose the prior handle's resize/click listeners too).
if (timelineHandle) {
timelineHandle.dispose();
timelineHandle = null;
}
const chartHost = document.getElementById("views-timeline-chart-host");
if (chartHost) {
timelineHandle = renderTimelineShape(chartHost, rows, render);
}
maybeShowTimelineCaveat();
break;
}
}
}
/** First-open caveat banner. sessionStorage flag means the user sees it
* once per browser session — dismissive but not annoying. Design §13.4
* documents the limitation; this is the user-facing surface. */
function maybeShowTimelineCaveat(): void {
const FLAG = "paliad-views-timeline-caveat-dismissed";
const banner = document.getElementById("views-timeline-caveat");
const closeBtn = document.getElementById("views-timeline-caveat-close");
if (!banner) return;
if (sessionStorage.getItem(FLAG) === "1") {
banner.hidden = true;
return;
}
banner.hidden = false;
if (closeBtn && !closeBtn.dataset.bound) {
closeBtn.addEventListener("click", () => {
banner.hidden = true;
try {
sessionStorage.setItem(FLAG, "1");
} catch {
/* sessionStorage may be unavailable in strict modes — silently noop */
}
});
closeBtn.dataset.bound = "1";
}
}

View File

@@ -0,0 +1,274 @@
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
// chart-export (t-paliad-177 Slice 2) — client-side export helpers for
// the Project Timeline / Chart page.
//
// Five formats land in Slice 2 (per design §7.1, m's pick on faraday-Q4
// to rule out server-side PDF via chromedp):
//
// SVG — XMLSerializer of the live SVG element
// PNG — SVG → <img> → <canvas> at 2× HiDPI, toBlob("image/png")
// PDF — window.print() with @media print stylesheet (browser handles
// the PDF engine; no chromedp dep on Dokploy)
// CSV — flat tabular dump of TimelineEvent[] (UTF-8 BOM for Excel-DE)
// JSON — wire envelope verbatim + export-metadata header
//
// iCal lands in a follow-up commit (C5) and goes via a server-side
// endpoint that reuses internal/services/caldav_ical.go (faraday-Q6).
//
// Design ref: docs/design-project-chart-2026-05-09.md §7.
export interface ExportContext {
projectId: string;
projectTitle: string;
svgEl: SVGSVGElement;
events: ReadonlyArray<TimelineEvent>;
lanes: ReadonlyArray<LaneInfo>;
}
// ---------------------------------------------------------------------------
// Public surface
// ---------------------------------------------------------------------------
export async function exportSVG(ctx: ExportContext): Promise<void> {
const svgString = serialiseSVG(ctx.svgEl);
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
triggerDownload(blob, filename(ctx, "svg"));
}
export async function exportPNG(ctx: ExportContext): Promise<void> {
const svgString = serialiseSVG(ctx.svgEl);
const blob = await rasterise(svgString, ctx.svgEl);
if (!blob) {
throw new Error("PNG raster failed");
}
triggerDownload(blob, filename(ctx, "png"));
}
export function exportCSV(ctx: ExportContext): void {
const rows: string[][] = [csvHeader()];
for (const event of ctx.events) {
rows.push(csvRow(event, ctx));
}
// UTF-8 BOM keeps Excel-DE from mis-detecting ANSI; ISO-8601 dates
// round-trip correctly into German Excel as text.
const text = "" + rows.map(csvLine).join("\r\n") + "\r\n";
const blob = new Blob([text], { type: "text/csv;charset=utf-8" });
triggerDownload(blob, filename(ctx, "csv"));
}
export function exportJSON(ctx: ExportContext): void {
const envelope = {
project_id: ctx.projectId,
project_title: ctx.projectTitle,
exported_at: new Date().toISOString(),
events: ctx.events,
lanes: ctx.lanes,
};
const text = JSON.stringify(envelope, null, 2) + "\n";
const blob = new Blob([text], { type: "application/json;charset=utf-8" });
triggerDownload(blob, filename(ctx, "json"));
}
export function exportPrint(): void {
// The @media print stylesheet in global.css does the layout work;
// we just invoke the browser's print dialog. User picks "Save as PDF"
// (Chrome/Edge), "Drucken in Datei" (Firefox), etc.
window.print();
}
// ---------------------------------------------------------------------------
// SVG / PNG plumbing
// ---------------------------------------------------------------------------
function serialiseSVG(svgEl: SVGSVGElement): string {
// Clone so we can inline computed styles without polluting the live DOM.
// For a true cross-environment-portable SVG, we'd compute every used
// CSS-var into a literal value. v1 keeps it light: the receiver inherits
// colours via document context when opened standalone, and the rendered
// bars still work because palette tokens fall through to the .smart-
// timeline-chart root selector via inline class. Add a fallback width /
// height attribute so headless viewers don't render 0×0.
const clone = svgEl.cloneNode(true) as SVGSVGElement;
if (!clone.getAttribute("width") && svgEl.getAttribute("width")) {
clone.setAttribute("width", svgEl.getAttribute("width") || "1000");
}
if (!clone.getAttribute("height") && svgEl.getAttribute("height")) {
clone.setAttribute("height", svgEl.getAttribute("height") || "400");
}
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
// Inline the chart's computed palette tokens so the standalone SVG
// paints the same way when opened in an image viewer (which has no
// document.css). Read every --chart-* property off the live element.
const computed = window.getComputedStyle(svgEl);
const styleLines: string[] = [];
for (const prop of [
"--chart-mark-deadline",
"--chart-mark-appointment",
"--chart-mark-milestone",
"--chart-mark-projected",
"--chart-mark-overdue",
"--chart-mark-done",
"--chart-today-rule",
"--chart-grid-line",
"--chart-lane-label",
"--chart-tick-label",
"--chart-bg",
]) {
const val = computed.getPropertyValue(prop).trim();
if (val) styleLines.push(`${prop}: ${val};`);
}
if (styleLines.length > 0) {
const existing = clone.getAttribute("style") || "";
clone.setAttribute("style", existing + styleLines.join(" "));
}
return new XMLSerializer().serializeToString(clone);
}
async function rasterise(svgString: string, svgEl: SVGSVGElement): Promise<Blob | null> {
const widthAttr = svgEl.getAttribute("width") || "1000";
const heightAttr = svgEl.getAttribute("height") || "400";
const width = Number(widthAttr) || 1000;
const height = Number(heightAttr) || 400;
// 2× device pixel ratio for HiDPI exports (design §7.1 "PNG, 2× HiDPI").
const scale = 2;
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
try {
const img = await loadImage(url);
const canvas = document.createElement("canvas");
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
return await new Promise<Blob | null>((resolve) => {
canvas.toBlob((b) => resolve(b), "image/png");
});
} finally {
URL.revokeObjectURL(url);
}
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("Image load failed"));
img.src = src;
});
}
// ---------------------------------------------------------------------------
// CSV plumbing
// ---------------------------------------------------------------------------
const CSV_COLUMNS = [
"project_id",
"project_title",
"kind",
"status",
"track",
"lane_id",
"date",
"title",
"description",
"rule_code",
"depends_on_rule_code",
"depends_on_date",
"depends_on_rule_name",
"sub_project_id",
"sub_project_title",
"bubble_up",
"deadline_id",
"appointment_id",
"project_event_id",
"project_event_type",
] as const;
function csvHeader(): string[] {
return [...CSV_COLUMNS];
}
function csvRow(event: TimelineEvent, ctx: ExportContext): string[] {
return [
ctx.projectId,
ctx.projectTitle,
event.kind,
event.status,
event.track,
event.lane_id ?? "",
isoOnly(event.date),
event.title,
event.description ?? "",
event.rule_code ?? "",
event.depends_on_rule_code ?? "",
isoOnly(event.depends_on_date),
event.depends_on_rule_name ?? "",
event.sub_project_id ?? "",
event.sub_project_title ?? "",
event.bubble_up ? "true" : "false",
event.deadline_id ?? "",
event.appointment_id ?? "",
event.project_event_id ?? "",
event.project_event_type ?? "",
];
}
function csvLine(fields: string[]): string {
return fields.map(csvEscape).join(",");
}
/** RFC 4180 quoting: double quotes inside the field are doubled; wrap
* the whole field in quotes if it contains comma / quote / newline. */
function csvEscape(value: string): string {
if (/[,"\r\n]/.test(value)) {
return '"' + value.replace(/"/g, '""') + '"';
}
return value;
}
function isoOnly(date: string | null | undefined): string {
if (!date) return "";
return date.slice(0, 10);
}
// ---------------------------------------------------------------------------
// Download trigger
// ---------------------------------------------------------------------------
function triggerDownload(blob: Blob, name: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
// Some browsers (Safari < 14) ignore the download attribute unless
// the link is in the document tree. Inserting + removing is cheap.
a.style.display = "none";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Give the browser a tick to start the download before we revoke.
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function filename(ctx: ExportContext, ext: string): string {
// Keep filenames diff-friendly + filesystem-safe. Replace anything that
// isn't ASCII alnum/dot/hyphen with "_". Truncate the title to 60 chars.
const safeTitle = (ctx.projectTitle || "timeline")
.normalize("NFKD")
.replace(/[^\x20-\x7e]/g, "")
.replace(/[^A-Za-z0-9.-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "")
.slice(0, 60) || "timeline";
const dateStr = new Date().toISOString().slice(0, 10);
return `paliad-${safeTitle}-${dateStr}.${ext}`;
}

View File

@@ -0,0 +1,109 @@
import { describe, expect, test } from "bun:test";
import {
formatDate,
formatRelative,
formatRowTime,
formatTime,
isDateOnly,
parseDateOnly,
} from "./format";
import type { ViewRow } from "./types";
// Regression tests for t-paliad-153: deadline due_date renders as 02:00 in
// CEST. The substrate marshals deadline.due_date as "YYYY-MM-DDT00:00:00Z";
// the formatters must treat that as a calendar day with no time component.
const stubRow = (overrides: Partial<ViewRow> = {}): ViewRow => ({
kind: "deadline",
id: "00000000-0000-0000-0000-000000000000",
title: "Call me",
event_date: "2026-05-08T00:00:00Z",
detail: {},
...overrides,
});
describe("isDateOnly / parseDateOnly", () => {
test("recognises YYYY-MM-DD", () => {
expect(isDateOnly("2026-05-08")).toBe(true);
expect(parseDateOnly("2026-05-08")).not.toBeNull();
});
test("recognises the substrate's UTC-midnight serialisation", () => {
expect(isDateOnly("2026-05-08T00:00:00Z")).toBe(true);
expect(parseDateOnly("2026-05-08T00:00:00Z")).not.toBeNull();
});
test("rejects timestamps with a real time component", () => {
expect(isDateOnly("2026-05-08T14:30:00Z")).toBe(false);
expect(parseDateOnly("2026-05-08T14:30:00Z")).toBeNull();
});
test("rejects garbage", () => {
expect(isDateOnly("not-a-date")).toBe(false);
expect(parseDateOnly("not-a-date")).toBeNull();
});
});
describe("formatTime", () => {
test("returns empty string for date-only inputs (no phantom 02:00)", () => {
expect(formatTime("2026-05-08T00:00:00Z")).toBe("");
expect(formatTime("2026-05-08")).toBe("");
});
test("renders HH:MM for real timestamps", () => {
expect(formatTime("2026-05-08T14:30:00Z")).toMatch(/\d{2}:\d{2}/);
});
});
describe("formatDate", () => {
test("date-only input formats the source day in any timezone", () => {
// Whatever locale getLang() resolves to, the day portion must be 08.
const out = formatDate("2026-05-08T00:00:00Z");
expect(out).toContain("08");
expect(out).toContain("2026");
});
});
describe("formatRowTime", () => {
test("deadline + dateAvailable=true returns empty (heading shows the day)", () => {
expect(formatRowTime(stubRow(), { dateAvailable: true })).toBe("");
});
test("deadline + dateAvailable=false falls back to the date", () => {
expect(formatRowTime(stubRow(), { dateAvailable: false })).toContain("2026");
});
test("appointment with a real start_at still renders HH:MM", () => {
const row = stubRow({ kind: "appointment", event_date: "2026-05-08T14:30:00Z" });
expect(formatRowTime(row, { dateAvailable: true })).toMatch(/\d{2}:\d{2}/);
});
test("appointment with date-only event_date does not leak phantom time", () => {
const row = stubRow({ kind: "appointment", event_date: "2026-05-08T00:00:00Z" });
// Belt-and-braces: even if a stray date-only value shows up under a
// non-deadline kind, the helper detects it and returns "" instead of
// "02:00" / "01:00" / etc.
expect(formatRowTime(row, { dateAvailable: true })).toBe("");
});
});
describe("formatRelative", () => {
test("deadline kind reduces to day precision", () => {
const today = new Date();
const todayISO = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}T00:00:00Z`;
const out = formatRelative(todayISO, "deadline");
expect(out.toLowerCase()).toMatch(/heute|today/);
});
test("date-only iso reduces to day precision even without an explicit kind", () => {
const today = new Date();
const todayISO = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const out = formatRelative(todayISO);
expect(out.toLowerCase()).toMatch(/heute|today/);
});
test("real timestamp keeps moment-precision relative", () => {
const inAnHour = new Date(Date.now() + 60 * 60 * 1000).toISOString();
expect(formatRelative(inAnHour, "appointment")).toMatch(/\d/);
});
});

View File

@@ -0,0 +1,122 @@
import { getLang } from "../i18n";
import type { ViewRow } from "./types";
// Shared date/time formatters for the views shapes (list / cards / calendar).
//
// The substrate marshals deadline.due_date as time.Date(...,0,0,0,0,UTC), so
// the JSON arrives as "YYYY-MM-DDT00:00:00Z" — UTC midnight, no real time
// component. Feeding that into new Date() + toLocaleTimeString() in a
// non-UTC browser produces "02:00" (CEST), "01:00" (CET), "20:00" the day
// before (EST), and so on — a phantom hour that the source data never had.
//
// The fix is to recognise the date-only shape and either render the date
// (formatted in UTC so the day matches the source day everywhere) or render
// nothing in the time slot. The kind-aware helpers below thread that
// distinction through the shapes; see t-paliad-153.
const DATE_ONLY_RE = /^(\d{4})-(\d{2})-(\d{2})(?:T00:00:00(?:\.0+)?Z)?$/;
export function isDateOnly(iso: string): boolean {
return typeof iso === "string" && DATE_ONLY_RE.test(iso);
}
export function parseDateOnly(iso: string): Date | null {
if (typeof iso !== "string") return null;
const m = iso.match(DATE_ONLY_RE);
if (!m) return null;
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
return isNaN(d.getTime()) ? null : d;
}
function locale(): string {
return getLang() === "de" ? "de-DE" : "en-GB";
}
export function formatDate(iso: string): string {
const dateOnly = parseDateOnly(iso);
if (dateOnly) {
return dateOnly.toLocaleDateString(locale(), {
day: "2-digit", month: "2-digit", year: "numeric", timeZone: "UTC",
});
}
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString(locale(), {
day: "2-digit", month: "2-digit", year: "numeric",
});
}
export function formatLongDate(iso: string): string {
const dateOnly = parseDateOnly(iso);
if (dateOnly) {
return dateOnly.toLocaleDateString(locale(), {
weekday: "long", year: "numeric", month: "long", day: "numeric", timeZone: "UTC",
});
}
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString(locale(), {
weekday: "long", year: "numeric", month: "long", day: "numeric",
});
}
// formatTime returns "" for date-only inputs — they have no real time and
// rendering them as HH:MM leaks the local UTC offset.
export function formatTime(iso: string): string {
if (isDateOnly(iso)) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleTimeString(locale(), { hour: "2-digit", minute: "2-digit" });
}
// formatRowTime: the time-slot helper used by shape-cards. When the
// surrounding shape already shows the date (e.g. day-grouped headings),
// deadlines render nothing — the date is implicit. Otherwise the deadline
// row falls back to its date so the user still knows when it's due.
export function formatRowTime(row: ViewRow, opts: { dateAvailable: boolean }): string {
if (row.kind === "deadline" || isDateOnly(row.event_date)) {
return opts.dateAvailable ? "" : formatDate(row.event_date);
}
return formatTime(row.event_date);
}
// formatRelative: deadlines reduce to day precision so a Frist due
// "tomorrow" never shows up as "in 2h" because of the UTC offset.
export function formatRelative(iso: string, kind?: ViewRow["kind"]): string {
if (kind === "deadline" || isDateOnly(iso)) return formatDayRelative(iso);
return formatMomentRelative(iso);
}
function formatDayRelative(iso: string): string {
const due = parseDateOnly(iso);
if (!due) return formatMomentRelative(iso);
const today = new Date();
const todayUTC = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate());
const diffDays = Math.round((due.getTime() - todayUTC) / 86400000);
const lang = getLang();
if (diffDays < 0) {
const n = Math.abs(diffDays);
return lang === "de"
? (n === 1 ? "vor 1 Tag" : `vor ${n} Tagen`)
: (n === 1 ? "1 day ago" : `${n} days ago`);
}
if (diffDays === 0) return lang === "de" ? "heute" : "today";
if (diffDays === 1) return lang === "de" ? "morgen" : "tomorrow";
return lang === "de" ? `in ${diffDays} Tagen` : `in ${diffDays} days`;
}
function formatMomentRelative(iso: string): string {
const t0 = Date.parse(iso);
if (isNaN(t0)) return iso;
const diffMs = t0 - Date.now();
const past = diffMs < 0;
const sec = Math.abs(Math.floor(diffMs / 1000));
const lang = getLang();
if (sec < 60) return past ? (lang === "de" ? `vor ${sec}s` : `${sec}s ago`) : (lang === "de" ? `in ${sec}s` : `in ${sec}s`);
const min = Math.floor(sec / 60);
if (min < 60) return past ? (lang === "de" ? `vor ${min}m` : `${min}m ago`) : (lang === "de" ? `in ${min}m` : `in ${min}m`);
const hr = Math.floor(min / 60);
if (hr < 24) return past ? (lang === "de" ? `vor ${hr}h` : `${hr}h ago`) : (lang === "de" ? `in ${hr}h` : `in ${hr}h`);
const day = Math.floor(hr / 24);
return past ? (lang === "de" ? `vor ${day}d` : `${day}d ago`) : (lang === "de" ? `in ${day}d` : `in ${day}d`);
}

View File

@@ -1,5 +1,6 @@
import { t, type I18nKey, getLang } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
import { formatLongDate, formatRowTime, parseDateOnly } from "./format";
// shape-cards: day-grouped chronological cards. Same layout style as the
// existing /agenda timeline; works for any source mix.
@@ -11,13 +12,13 @@ export function renderCardsShape(host: HTMLElement, rows: ViewRow[], render: Ren
const sort = cfg.sort ?? "date_asc";
const sorted = [...rows].sort((a, b) => {
const aT = Date.parse(a.event_date);
const bT = Date.parse(b.event_date);
const aT = sortKey(a.event_date);
const bT = sortKey(b.event_date);
return sort === "date_asc" ? aT - bT : bT - aT;
});
if (groupBy === "none") {
host.appendChild(renderCardList(sorted));
host.appendChild(renderCardList(sorted, "none"));
return;
}
@@ -29,14 +30,17 @@ export function renderCardsShape(host: HTMLElement, rows: ViewRow[], render: Ren
heading.className = "views-cards-day-heading";
heading.textContent = key;
section.appendChild(heading);
section.appendChild(renderCardList(items));
section.appendChild(renderCardList(items, groupBy));
host.appendChild(section);
}
}
function renderCardList(rows: ViewRow[]): HTMLElement {
function renderCardList(rows: ViewRow[], groupBy: "day" | "week" | "none"): HTMLElement {
const ul = document.createElement("ul");
ul.className = "views-cards-list";
// The day-grouped heading already shows the date — only that mode lets the
// per-row time slot stay blank for date-only sources.
const dateAvailable = groupBy === "day";
for (const row of rows) {
const li = document.createElement("li");
li.className = `views-card views-card--${row.kind}`;
@@ -55,9 +59,12 @@ function renderCardList(rows: ViewRow[]): HTMLElement {
const meta = document.createElement("div");
meta.className = "views-card-meta";
const time = document.createElement("span");
time.textContent = formatTime(row.event_date);
meta.appendChild(time);
const timeText = formatRowTime(row, { dateAvailable });
if (timeText) {
const time = document.createElement("span");
time.textContent = timeText;
meta.appendChild(time);
}
if (row.project_title) {
const proj = document.createElement("span");
proj.className = "views-card-project";
@@ -95,11 +102,14 @@ function groupRows(rows: ViewRow[], groupBy: "day" | "week"): Array<[string, Vie
}
function bucketKey(iso: string, groupBy: "day" | "week"): string {
const d = new Date(iso);
// Date-only inputs (deadlines) are anchored to UTC midnight so the bucket
// matches the source day in every timezone — otherwise a UTC-X user would
// see deadlines slip into the previous day.
const dateOnly = parseDateOnly(iso);
const d = dateOnly ?? new Date(iso);
if (isNaN(d.getTime())) return iso;
const lang = getLang() === "de" ? "de-DE" : "en-GB";
if (groupBy === "week") {
// Round down to Monday, format as "KW NN, YYYY".
const monday = new Date(d);
const day = monday.getDay() || 7; // Sunday=0 → 7
monday.setDate(monday.getDate() - day + 1);
@@ -107,12 +117,12 @@ function bucketKey(iso: string, groupBy: "day" | "week"): string {
const weekNo = Math.ceil(((monday.getTime() - yearStart.getTime()) / 86400000 + yearStart.getDay() + 1) / 7);
return `KW ${weekNo}, ${monday.getFullYear()}`;
}
if (dateOnly) return formatLongDate(iso);
return d.toLocaleDateString(lang, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const lang = getLang() === "de" ? "de-DE" : "en-GB";
return d.toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit" });
function sortKey(iso: string): number {
const dateOnly = parseDateOnly(iso);
if (dateOnly) return dateOnly.getTime();
return Date.parse(iso);
}

View File

@@ -1,23 +1,37 @@
import { t, type I18nKey, getLang } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
import { t, tDyn, getLang, type I18nKey } from "../i18n";
import type { ListRowAction, RenderSpec, ViewRow } from "./types";
import { formatDate, formatRelative, parseDateOnly } from "./format";
// shape-list: renders ViewRows as a table (density=comfortable) or a
// compact one-line stream (density=compact). The "activity feed" look
// is just density=compact + actor/time columns — see Q4 lock-in
// 2026-05-07 (3 shapes; no separate "activity").
//
// Row interaction is controlled by render.list.row_action
// (t-paliad-163 schema bump). Default "navigate" keeps every existing
// caller's contract — clicking a row goes to the per-kind detail
// page. "approve" produces the approval-list layout for /inbox.
// "complete_toggle" is wired in Phase 3 (/events). "none" suppresses
// any row interaction (audit views).
export function renderListShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
host.innerHTML = "";
const list = render.list ?? {};
const density = list.density ?? "comfortable";
const sort = list.sort ?? "date_asc";
const rowAction: ListRowAction = list.row_action ?? "navigate";
const sorted = [...rows].sort((a, b) => {
const aT = Date.parse(a.event_date);
const bT = Date.parse(b.event_date);
const aT = sortKey(a.event_date);
const bT = sortKey(b.event_date);
return sort === "date_asc" ? aT - bT : bT - aT;
});
if (rowAction === "approve") {
host.appendChild(renderApprovalList(sorted));
return;
}
if (density === "compact") {
host.appendChild(renderCompact(sorted));
} else {
@@ -34,7 +48,7 @@ function renderCompact(rows: ViewRow[]): HTMLElement {
const time = document.createElement("span");
time.className = "views-list-time";
time.textContent = formatRelative(row.event_date);
time.textContent = formatRelative(row.event_date, row.kind);
li.appendChild(time);
const kindIcon = document.createElement("span");
@@ -122,7 +136,7 @@ function formatColumn(row: ViewRow, col: string): string {
case "date":
return formatDate(row.event_date);
case "time":
return formatRelative(row.event_date);
return formatRelative(row.event_date, row.kind);
case "title":
return row.title;
case "project":
@@ -156,26 +170,171 @@ function kindLabel(kind: string): string {
return t(("views.kind." + kind) as I18nKey);
}
function formatDate(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric", month: "2-digit", day: "2-digit",
});
function sortKey(iso: string): number {
const dateOnly = parseDateOnly(iso);
if (dateOnly) return dateOnly.getTime();
return Date.parse(iso);
}
function formatRelative(iso: string): string {
// ----------------------------------------------------------------------
// 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 = t0 - Date.now();
const past = diffMs < 0;
const sec = Math.abs(Math.floor(diffMs / 1000));
const lang = getLang();
if (sec < 60) return past ? (lang === "de" ? `vor ${sec}s` : `${sec}s ago`) : (lang === "de" ? `in ${sec}s` : `in ${sec}s`);
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 past ? (lang === "de" ? `vor ${min}m` : `${min}m ago`) : (lang === "de" ? `in ${min}m` : `in ${min}m`);
if (min < 60) return getLang() === "de" ? `vor ${min}m` : `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return past ? (lang === "de" ? `vor ${hr}h` : `${hr}h ago`) : (lang === "de" ? `in ${hr}h` : `in ${hr}h`);
if (hr < 24) return getLang() === "de" ? `vor ${hr}h` : `${hr}h ago`;
const day = Math.floor(hr / 24);
return past ? (lang === "de" ? `vor ${day}d` : `${day}d ago`) : (lang === "de" ? `in ${day}d` : `in ${day}d`);
return getLang() === "de" ? `vor ${day}d` : `${day}d ago`;
}
// Suppress unused warning for tDyn — kept available for future axes.
void tDyn;

View File

@@ -0,0 +1,254 @@
import { describe, expect, test } from "bun:test";
import { layout, type ChartViewport } from "./shape-timeline-chart";
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
// t-paliad-177 Slice 1 — table-driven tests for the pure `layout()`
// function. `layout` translates a TimelineEvent[] + LaneInfo[] + viewport
// into deterministic SVG-ready geometry. Tests pin the math so subtle
// drift (off-by-one days, axis tick density, lane stacking) surfaces fast.
//
// Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §15.
const vp = (overrides: Partial<ChartViewport> = {}): ChartViewport => ({
width: 1000,
height: 400,
laneLabelWidth: 200,
dateAxisHeight: 40,
todayISO: "2026-06-15",
rangeFrom: "2026-01-01",
rangeTo: "2026-12-31",
density: "standard",
...overrides,
});
const ev = (overrides: Partial<TimelineEvent> = {}): TimelineEvent => ({
kind: "deadline",
status: "open",
track: "parent",
date: "2026-06-15",
title: "Test event",
...overrides,
});
describe("layout — base geometry", () => {
test("chart canvas sits to the right of lane labels and below date axis", () => {
const out = layout([], [], vp());
expect(out.chartLeft).toBe(200);
expect(out.chartTop).toBe(40);
expect(out.chartWidth).toBe(800);
expect(out.chartHeight).toBeGreaterThan(0);
});
test("pxPerDay = chartWidth / total_days", () => {
// 2026 is 365 days; range Jan 1..Dec 31 is 364 day-deltas + 1 = 365 days.
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
expect(out.pxPerDay).toBeCloseTo(800 / 364, 5);
});
test("invalid range (to before from) falls back to a 1-day span", () => {
const out = layout([], [], vp({ rangeFrom: "2026-06-01", rangeTo: "2026-05-01" }));
// Sanity: pxPerDay finite, no division-by-zero.
expect(Number.isFinite(out.pxPerDay)).toBe(true);
expect(out.pxPerDay).toBeGreaterThan(0);
});
});
describe("layout — today rule", () => {
test("today inside range produces a non-null todayX in the chart canvas", () => {
const out = layout([], [], vp({ todayISO: "2026-06-15" }));
expect(out.todayX).not.toBeNull();
expect(out.todayX!).toBeGreaterThan(out.chartLeft);
expect(out.todayX!).toBeLessThan(out.chartLeft + out.chartWidth);
});
test("today before range.from → todayX is null", () => {
const out = layout([], [], vp({ todayISO: "2025-12-15" }));
expect(out.todayX).toBeNull();
});
test("today after range.to → todayX is null", () => {
const out = layout([], [], vp({ todayISO: "2027-01-15" }));
expect(out.todayX).toBeNull();
});
test("today equals range.from → todayX sits at chartLeft", () => {
const out = layout([], [], vp({ todayISO: "2026-01-01" }));
expect(out.todayX).toBeCloseTo(out.chartLeft, 1);
});
});
describe("layout — lane stacking", () => {
test("empty lanes synthesises a single 'self' lane", () => {
const out = layout([], [], vp());
expect(out.laneRows).toHaveLength(1);
expect(out.laneRows[0].id).toBe("self");
});
test("multiple lanes stack vertically in input order", () => {
const lanes: LaneInfo[] = [
{ id: "self", label: "Hauptverfahren" },
{ id: "counterclaim:abc", label: "Widerklage" },
{ id: "parent_context:xyz", label: "Parent" },
];
const out = layout([], lanes, vp());
expect(out.laneRows).toHaveLength(3);
expect(out.laneRows[0].y).toBe(out.chartTop);
expect(out.laneRows[1].y).toBeGreaterThan(out.laneRows[0].y);
expect(out.laneRows[2].y).toBeGreaterThan(out.laneRows[1].y);
// All same height.
expect(out.laneRows[0].height).toBe(out.laneRows[1].height);
expect(out.laneRows[1].height).toBe(out.laneRows[2].height);
});
test("density compact gives smaller lane height than spacious", () => {
const compact = layout([], [], vp({ density: "compact" }));
const spacious = layout([], [], vp({ density: "spacious" }));
expect(compact.laneRows[0].height).toBeLessThan(spacious.laneRows[0].height);
});
});
describe("layout — marks", () => {
test("single deadline maps to one mark in the self lane", () => {
const events: TimelineEvent[] = [ev({ date: "2026-06-15" })];
const out = layout(events, [], vp());
expect(out.marks).toHaveLength(1);
expect(out.marks[0].eventIndex).toBe(0);
expect(out.marks[0].laneId).toBe("self");
expect(out.marks[0].undated).toBe(false);
});
test("event's x position matches its date offset from range.from", () => {
// June 15 is day 165 of 2026 (0-indexed from Jan 1).
const events: TimelineEvent[] = [ev({ date: "2026-06-15" })];
const out = layout(events, [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
const expectedX = out.chartLeft + 165 * out.pxPerDay;
expect(out.marks[0].x).toBeCloseTo(expectedX, 1);
});
test("event bucketed by lane_id matches the corresponding lane row", () => {
const lanes: LaneInfo[] = [
{ id: "self", label: "Self" },
{ id: "ccr", label: "CCR" },
];
const events: TimelineEvent[] = [
ev({ date: "2026-06-15", lane_id: "ccr" }),
];
const out = layout(events, lanes, vp());
const ccrRow = out.laneRows.find((r) => r.id === "ccr")!;
expect(out.marks[0].laneId).toBe("ccr");
expect(out.marks[0].y).toBeCloseTo(ccrRow.y + ccrRow.height / 2, 1);
});
test("unknown lane_id falls back to the first lane (defensive)", () => {
const lanes: LaneInfo[] = [{ id: "self", label: "Self" }];
const events: TimelineEvent[] = [
ev({ date: "2026-06-15", lane_id: "deleted-lane-id" }),
];
const out = layout(events, lanes, vp());
expect(out.marks[0].laneId).toBe("self");
});
test("events outside range are clipped (not emitted)", () => {
const events: TimelineEvent[] = [
ev({ date: "2025-01-01", title: "before" }),
ev({ date: "2026-06-15", title: "inside" }),
ev({ date: "2027-12-31", title: "after" }),
];
const out = layout(events, [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
expect(out.marks).toHaveLength(1);
expect(out.marks[0].eventIndex).toBe(1);
});
test("undated events go to the undated zone with undated=true", () => {
const events: TimelineEvent[] = [ev({ date: null, title: "court-set" })];
const out = layout(events, [], vp());
expect(out.marks).toHaveLength(1);
expect(out.marks[0].undated).toBe(true);
// Undated marks sit in the lane label gutter (x < chartLeft).
expect(out.marks[0].x).toBeLessThan(out.chartLeft);
});
});
describe("layout — mark shapes by kind+status", () => {
test("deadline.done → dot, deadline.open → dot, deadline.overdue → dot", () => {
const events: TimelineEvent[] = [
ev({ kind: "deadline", status: "done" }),
ev({ kind: "deadline", status: "open" }),
ev({ kind: "deadline", status: "overdue" }),
];
const out = layout(events, [], vp());
expect(out.marks.map((m) => m.shape)).toEqual(["dot", "dot", "dot"]);
});
test("milestone → diamond", () => {
const events: TimelineEvent[] = [ev({ kind: "milestone", status: "done" })];
const out = layout(events, [], vp());
expect(out.marks[0].shape).toBe("diamond");
});
test("appointment → dot (Slice 1 keeps it simple; bar variant deferred)", () => {
const events: TimelineEvent[] = [ev({ kind: "appointment", status: "open" })];
const out = layout(events, [], vp());
expect(out.marks[0].shape).toBe("dot");
});
test("projected.predicted → hatched-dot", () => {
const events: TimelineEvent[] = [ev({ kind: "projected", status: "predicted" })];
const out = layout(events, [], vp());
expect(out.marks[0].shape).toBe("hatched-dot");
});
test("projected.court_set → dashed-dot", () => {
const events: TimelineEvent[] = [ev({ kind: "projected", status: "court_set" })];
const out = layout(events, [], vp());
expect(out.marks[0].shape).toBe("dashed-dot");
});
});
describe("layout — axis ticks", () => {
test("short range (<90d) emits month ticks", () => {
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-02-28" }));
const kinds = new Set(out.axisTicks.map((t) => t.kind));
expect(kinds.has("month")).toBe(true);
});
test("medium range (90-730d) emits quarter ticks", () => {
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
const kinds = new Set(out.axisTicks.map((t) => t.kind));
expect(kinds.has("quarter")).toBe(true);
});
test("long range (>730d) emits year ticks", () => {
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2029-12-31" }));
const kinds = new Set(out.axisTicks.map((t) => t.kind));
expect(kinds.has("year")).toBe(true);
});
test("year-boundary ticks are flagged", () => {
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2027-12-31" }));
const yearBoundaries = out.axisTicks.filter((t) => t.isYearBoundary);
expect(yearBoundaries.length).toBeGreaterThanOrEqual(1);
});
test("all ticks fall inside the chart canvas horizontally", () => {
const out = layout([], [], vp());
for (const tick of out.axisTicks) {
expect(tick.x).toBeGreaterThanOrEqual(out.chartLeft - 0.5);
expect(tick.x).toBeLessThanOrEqual(out.chartLeft + out.chartWidth + 0.5);
}
});
});
describe("layout — undated counting", () => {
test("undated marks tallied separately from inside-range count", () => {
const events: TimelineEvent[] = [
ev({ date: "2026-06-15" }),
ev({ date: null }),
ev({ date: null }),
ev({ date: "2025-01-01" }), // out of range
];
const out = layout(events, [], vp());
expect(out.undatedCount).toBe(2);
expect(out.marks).toHaveLength(3); // 1 dated + 2 undated, the out-of-range one is clipped
});
});

View File

@@ -0,0 +1,974 @@
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
// shape-timeline-chart (t-paliad-177 Slice 1) — horizontal SVG Gantt
// renderer for the standalone Project Timeline / Chart page.
//
// Split into two concerns:
//
// layout(events, lanes, viewport): ChartLayout
// pure function — translates the wire shape into deterministic
// SVG-ready geometry (axis ticks, lane row y/height, mark x/y/shape,
// today-rule x). No DOM access. Table-driven tests pin this in
// shape-timeline-chart.test.ts.
//
// paint(layout, root): void (Slice 1, next commit)
// DOM-mutates an SVGSVGElement. Reads layout, never recomputes
// positions. Idempotent — calling twice with the same layout
// produces the same DOM.
//
// mount(host, opts): ChartHandle (Slice 1, next commit)
// End-to-end: fetches /api/projects/{id}/timeline, computes layout,
// paints, returns a handle with .refresh() / .dispose().
//
// Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §3.2 + §12.
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export type Density = "compact" | "standard" | "spacious";
export interface ChartViewport {
width: number;
height: number;
/** Reserved on the left for lane labels (and the undated zone). */
laneLabelWidth: number;
/** Reserved on top for the date axis. */
dateAxisHeight: number;
/** Today's date as ISO YYYY-MM-DD. Used to position the today rule. */
todayISO: string;
/** Inclusive ISO YYYY-MM-DD start of the chart's date range. */
rangeFrom: string;
/** Inclusive ISO YYYY-MM-DD end of the chart's date range. */
rangeTo: string;
density: Density;
}
export interface AxisTick {
x: number;
label: string;
kind: "year" | "quarter" | "month";
isYearBoundary: boolean;
}
export interface LaneRow {
id: string;
label: string;
y: number;
height: number;
}
export type MarkShape =
| "dot"
| "diamond"
| "hatched-dot"
| "dashed-dot";
export interface Mark {
/** Index into the original events array — paint() reuses this for tooltips + deep-links. */
eventIndex: number;
x: number;
y: number;
/** Radius for dot / hatched-dot / dashed-dot, half-diagonal for diamond. */
radius: number;
shape: MarkShape;
kind: TimelineEvent["kind"];
status: TimelineEvent["status"];
laneId: string;
undated: boolean;
}
export interface ChartLayout {
viewport: ChartViewport;
pxPerDay: number;
chartLeft: number;
chartTop: number;
chartWidth: number;
chartHeight: number;
axisTicks: AxisTick[];
laneRows: LaneRow[];
marks: Mark[];
/** Pixel x of the today rule, or null when today is outside the range. */
todayX: number | null;
undatedCount: number;
}
// ---------------------------------------------------------------------------
// Density tokens — single source of truth, used by layout() and CSS swap.
// ---------------------------------------------------------------------------
const LANE_HEIGHT: Record<Density, number> = {
compact: 24,
standard: 40,
spacious: 64,
};
const MARK_RADIUS: Record<Density, number> = {
compact: 5,
standard: 7,
spacious: 10,
};
// ---------------------------------------------------------------------------
// Date helpers — UTC throughout to avoid DST drift in day-math.
// ---------------------------------------------------------------------------
const DAY_MS = 86_400_000;
function parseISODay(iso: string): number | null {
// Accept "YYYY-MM-DD" and "YYYY-MM-DDTHH:MM:SSZ" (substrate marshals
// deadline.due_date as the UTC-midnight form — see format.ts).
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
if (!m) return null;
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
if (
!Number.isFinite(y) || !Number.isFinite(mo) || !Number.isFinite(d) ||
mo < 1 || mo > 12 || d < 1 || d > 31
) {
return null;
}
return Date.UTC(y, mo - 1, d);
}
function dayDelta(fromMs: number, toMs: number): number {
return Math.round((toMs - fromMs) / DAY_MS);
}
// ---------------------------------------------------------------------------
// Mark shape resolution — single mapping table, mirrors §6.2 of the design.
// ---------------------------------------------------------------------------
function markShape(kind: TimelineEvent["kind"], status: TimelineEvent["status"]): MarkShape {
if (kind === "milestone") return "diamond";
if (kind === "projected") {
if (status === "court_set") return "dashed-dot";
return "hatched-dot"; // predicted, predicted_overdue, off_script
}
// deadline + appointment + everything else → plain dot. Status drives
// colour saturation (see CSS palette tokens), not shape.
return "dot";
}
// ---------------------------------------------------------------------------
// Axis tick generation — granularity by total span.
// ---------------------------------------------------------------------------
function generateTicks(
fromMs: number,
toMs: number,
chartLeft: number,
pxPerDay: number,
): AxisTick[] {
const totalDays = dayDelta(fromMs, toMs);
const ticks: AxisTick[] = [];
// Walk from the first day-of-month >= fromMs forward.
const start = new Date(fromMs);
const yStart = start.getUTCFullYear();
const mStart = start.getUTCMonth();
// Density rules:
// <90d → month ticks (every month-start)
// 90-730 → quarter ticks (Jan, Apr, Jul, Oct)
// >730 → year ticks (Jan only)
let kind: AxisTick["kind"];
let monthStep: number;
if (totalDays < 90) {
kind = "month";
monthStep = 1;
} else if (totalDays <= 730) {
kind = "quarter";
monthStep = 3;
} else {
kind = "year";
monthStep = 12;
}
// For quarter/year ticks, snap the starting month to the next aligned
// boundary so the labels are calendar-aligned (Jan/Apr/Jul/Oct, not
// Feb/May/Aug/Nov).
let mCursor = mStart;
let yCursor = yStart;
if (kind === "quarter") {
const offset = mCursor % 3;
if (offset !== 0) mCursor += 3 - offset;
} else if (kind === "year") {
if (mCursor !== 0) {
mCursor = 0;
yCursor += 1;
}
}
// If the first day of fromMs is not month-1, advance by one month so we
// don't double-print the partial month at the very start.
if (kind === "month" && start.getUTCDate() !== 1) {
mCursor += 1;
}
while (mCursor >= 12) {
mCursor -= 12;
yCursor += 1;
}
// Emit ticks until past toMs.
while (true) {
const tickMs = Date.UTC(yCursor, mCursor, 1);
if (tickMs > toMs) break;
const days = dayDelta(fromMs, tickMs);
const x = chartLeft + days * pxPerDay;
const label = formatTickLabel(yCursor, mCursor, kind);
ticks.push({
x,
label,
kind,
isYearBoundary: mCursor === 0,
});
mCursor += monthStep;
while (mCursor >= 12) {
mCursor -= 12;
yCursor += 1;
}
}
return ticks;
}
const MONTH_DE = [
"Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez",
];
function formatTickLabel(year: number, month: number, kind: AxisTick["kind"]): string {
if (kind === "year") return String(year);
if (kind === "quarter") {
const q = Math.floor(month / 3) + 1;
return `Q${q} ${year}`;
}
return MONTH_DE[month];
}
// ---------------------------------------------------------------------------
// Public: layout
// ---------------------------------------------------------------------------
export function layout(
events: ReadonlyArray<TimelineEvent>,
lanes: ReadonlyArray<LaneInfo>,
viewport: ChartViewport,
): ChartLayout {
// -- Canvas geometry --------------------------------------------------
const chartLeft = viewport.laneLabelWidth;
const chartTop = viewport.dateAxisHeight;
const chartWidth = Math.max(0, viewport.width - chartLeft);
// chartHeight is derived from the number of lane rows so the SVG grows
// / shrinks vertically with the data, not the supplied viewport.height
// (which the caller uses as a hint — actual height comes back in
// viewport.height after the paint pass).
const laneCount = Math.max(1, lanes.length);
const laneHeight = LANE_HEIGHT[viewport.density];
const chartHeight = laneCount * laneHeight;
// -- Date math --------------------------------------------------------
const fromMs = parseISODay(viewport.rangeFrom);
const toMsRaw = parseISODay(viewport.rangeTo);
if (fromMs === null || toMsRaw === null) {
// Degenerate input — return an empty layout rather than NaN-paint.
return {
viewport,
pxPerDay: 0,
chartLeft,
chartTop,
chartWidth,
chartHeight,
axisTicks: [],
laneRows: synthLaneRows(lanes, chartTop, laneHeight),
marks: [],
todayX: null,
undatedCount: 0,
};
}
// Guard against to < from. Clamp the inverted case to a 1-day span so
// pxPerDay stays positive and finite.
const toMs = toMsRaw <= fromMs ? fromMs + DAY_MS : toMsRaw;
const totalDays = Math.max(1, dayDelta(fromMs, toMs));
const pxPerDay = chartWidth / totalDays;
// -- Today rule -------------------------------------------------------
const todayMs = parseISODay(viewport.todayISO);
let todayX: number | null = null;
if (todayMs !== null && todayMs >= fromMs && todayMs <= toMs) {
todayX = chartLeft + dayDelta(fromMs, todayMs) * pxPerDay;
}
// -- Lane rows --------------------------------------------------------
const laneRows = synthLaneRows(lanes, chartTop, laneHeight);
const laneIndex = new Map<string, LaneRow>();
for (const row of laneRows) laneIndex.set(row.id, row);
const fallbackLane = laneRows[0];
// -- Marks ------------------------------------------------------------
const marks: Mark[] = [];
let undatedCount = 0;
const radius = MARK_RADIUS[viewport.density];
for (let i = 0; i < events.length; i++) {
const event = events[i];
const laneRow = (event.lane_id && laneIndex.get(event.lane_id)) || fallbackLane;
if (!event.date) {
// Undated rows live in a gutter to the left of the chart canvas.
// We pile them up vertically inside the lane label area so they
// remain hover-/click-targets, but they don't compete with the
// date-axis-positioned marks for screen space.
undatedCount++;
const undatedX = chartLeft - viewport.laneLabelWidth * 0.25;
marks.push({
eventIndex: i,
x: undatedX,
y: laneRow.y + laneRow.height / 2,
radius,
shape: markShape(event.kind, event.status),
kind: event.kind,
status: event.status,
laneId: laneRow.id,
undated: true,
});
continue;
}
const ms = parseISODay(event.date);
if (ms === null) continue; // unparseable date, drop defensively
if (ms < fromMs || ms > toMs) continue; // outside range — clipped
const x = chartLeft + dayDelta(fromMs, ms) * pxPerDay;
const y = laneRow.y + laneRow.height / 2;
marks.push({
eventIndex: i,
x,
y,
radius,
shape: markShape(event.kind, event.status),
kind: event.kind,
status: event.status,
laneId: laneRow.id,
undated: false,
});
}
// -- Axis ticks -------------------------------------------------------
const axisTicks = generateTicks(fromMs, toMs, chartLeft, pxPerDay);
return {
viewport,
pxPerDay,
chartLeft,
chartTop,
chartWidth,
chartHeight,
axisTicks,
laneRows,
marks,
todayX,
undatedCount,
};
}
function synthLaneRows(
lanes: ReadonlyArray<LaneInfo>,
chartTop: number,
laneHeight: number,
): LaneRow[] {
if (lanes.length === 0) {
return [{ id: "self", label: "", y: chartTop, height: laneHeight }];
}
return lanes.map((lane, idx) => ({
id: lane.id,
label: lane.label,
y: chartTop + idx * laneHeight,
height: laneHeight,
}));
}
// ---------------------------------------------------------------------------
// Public: paint
// ---------------------------------------------------------------------------
const SVG_NS = "http://www.w3.org/2000/svg";
function svg(name: string, attrs: Record<string, string | number> = {}): SVGElement {
const el = document.createElementNS(SVG_NS, name);
for (const [k, v] of Object.entries(attrs)) {
el.setAttribute(k, String(v));
}
return el;
}
/**
* paint mutates an existing SVGSVGElement to reflect a ChartLayout.
* Idempotent: clears prior children before painting, so calling twice
* with the same layout produces the same DOM.
*
* Events are *not* wired here — mount() attaches the delegated listeners
* after paint() returns. paint() stays pure-render so it stays cheap to
* call from a resize / palette swap.
*/
export function paint(
chart: ChartLayout,
root: SVGSVGElement,
events: ReadonlyArray<TimelineEvent>,
): void {
// Clear prior contents.
while (root.firstChild) root.removeChild(root.firstChild);
const totalHeight = chart.chartTop + chart.chartHeight + 24; // 24px bottom pad for axis labels
root.setAttribute("viewBox", `0 0 ${chart.viewport.width} ${totalHeight}`);
root.setAttribute("preserveAspectRatio", "xMinYMin meet");
root.setAttribute("role", "img");
root.setAttribute("aria-label", "Project Timeline / Chart");
// <defs> — hatched pattern for projected marks.
const defs = svg("defs");
const pattern = svg("pattern", {
id: "chart-hatch",
patternUnits: "userSpaceOnUse",
width: 4,
height: 4,
});
pattern.appendChild(svg("path", {
d: "M0,4 L4,0",
stroke: "currentColor",
"stroke-width": 1,
fill: "none",
}));
defs.appendChild(pattern);
root.appendChild(defs);
// Layer order: grid → lane separators → today rule → marks → labels.
const gGrid = svg("g", { class: "chart-grid" });
root.appendChild(gGrid);
// Date axis ticks — vertical guidelines + labels at top.
for (const tick of chart.axisTicks) {
gGrid.appendChild(svg("line", {
class: tick.isYearBoundary
? "chart-tick chart-tick--year"
: "chart-tick",
x1: tick.x,
y1: chart.chartTop,
x2: tick.x,
y2: chart.chartTop + chart.chartHeight,
}));
const label = svg("text", {
class: "chart-tick-label",
x: tick.x + 4,
y: chart.chartTop - 8,
});
label.textContent = tick.label;
gGrid.appendChild(label);
}
// Lane separators — horizontal lines between rows + labels in the gutter.
for (let i = 0; i < chart.laneRows.length; i++) {
const row = chart.laneRows[i];
if (i > 0) {
gGrid.appendChild(svg("line", {
class: "chart-lane-separator",
x1: 0,
y1: row.y,
x2: chart.viewport.width,
y2: row.y,
}));
}
if (row.label) {
const labelEl = svg("text", {
class: "chart-lane-label",
x: 8,
y: row.y + row.height / 2 + 4,
});
labelEl.textContent = row.label;
gGrid.appendChild(labelEl);
}
}
// Today rule — vertical lime line + "Heute" label.
if (chart.todayX !== null) {
gGrid.appendChild(svg("line", {
class: "chart-today-rule",
x1: chart.todayX,
y1: chart.chartTop - 4,
x2: chart.todayX,
y2: chart.chartTop + chart.chartHeight + 4,
}));
const todayLabel = svg("text", {
class: "chart-today-label",
x: chart.todayX + 4,
y: chart.chartTop + chart.chartHeight + 18,
});
todayLabel.textContent = "Heute";
gGrid.appendChild(todayLabel);
}
// Marks.
const gMarks = svg("g", { class: "chart-marks" });
root.appendChild(gMarks);
for (const mark of chart.marks) {
const event = events[mark.eventIndex];
const markEl = paintMark(mark, event);
gMarks.appendChild(markEl);
}
}
function paintMark(mark: Mark, event: TimelineEvent): SVGElement {
// Wrap every mark in a <g> with data-* attributes so mount() can do
// event-delegation off the top-level <svg> without per-mark listeners.
const g = svg("g", {
class: markClassName(mark),
"data-event-index": mark.eventIndex,
"data-kind": mark.kind,
"data-status": mark.status,
"data-lane": mark.laneId,
"data-undated": mark.undated ? "1" : "0",
"data-deadline-id": event.deadline_id || "",
"data-appointment-id": event.appointment_id || "",
"data-project-event-id": event.project_event_id || "",
role: "img",
tabindex: 0,
});
// ARIA label so screen-readers can read each mark (§13).
const title = svg("title");
title.textContent = markAriaLabel(mark, event);
g.appendChild(title);
// Generous invisible hit-target so dots are easy to click without
// hunting (12px hit halo around a 7px standard radius).
g.appendChild(svg("circle", {
class: "chart-mark-hit",
cx: mark.x,
cy: mark.y,
r: mark.radius + 6,
fill: "transparent",
}));
switch (mark.shape) {
case "dot": {
const c = svg("circle", {
class: "chart-mark-dot",
cx: mark.x,
cy: mark.y,
r: mark.radius,
});
g.appendChild(c);
break;
}
case "diamond": {
const r = mark.radius;
g.appendChild(svg("polygon", {
class: "chart-mark-diamond",
points: `${mark.x},${mark.y - r} ${mark.x + r},${mark.y} ${mark.x},${mark.y + r} ${mark.x - r},${mark.y}`,
}));
break;
}
case "hatched-dot": {
g.appendChild(svg("circle", {
class: "chart-mark-hatched",
cx: mark.x,
cy: mark.y,
r: mark.radius,
fill: "url(#chart-hatch)",
}));
break;
}
case "dashed-dot": {
g.appendChild(svg("circle", {
class: "chart-mark-dashed",
cx: mark.x,
cy: mark.y,
r: mark.radius,
}));
break;
}
}
return g;
}
function markClassName(mark: Mark): string {
const parts = ["chart-mark", `chart-mark--${mark.kind}`, `chart-mark--status-${mark.status}`];
if (mark.undated) parts.push("chart-mark--undated");
return parts.join(" ");
}
function markAriaLabel(mark: Mark, event: TimelineEvent): string {
const dateStr = event.date ? event.date.slice(0, 10) : "Datum offen";
return `${event.title}${event.kind} (${event.status}) — ${dateStr}`;
}
// ---------------------------------------------------------------------------
// Public: mount
// ---------------------------------------------------------------------------
/** Palette presets from design §5.1. Each is a CSS-var override hung off
* `.smart-timeline-chart[data-palette="<name>"]`; the renderer never
* reads palette state directly. */
export type Palette =
| "default"
| "kind-coded"
| "track-coded"
| "high-contrast"
| "print";
export const ALL_PALETTES: ReadonlyArray<Palette> = [
"default",
"kind-coded",
"track-coded",
"high-contrast",
"print",
];
export const ALL_DENSITIES: ReadonlyArray<Density> = [
"compact",
"standard",
"spacious",
];
/** Range presets from design §10 + faraday-Q8 default. The chart caller
* drives the active preset via setRange; "all" derives bounds from the
* loaded events at repaint time so adding / completing a row reflows. */
export type RangePreset = "1y" | "2y" | "all" | "custom";
export const ALL_RANGE_PRESETS: ReadonlyArray<RangePreset> = [
"1y",
"2y",
"all",
"custom",
];
export interface ChartMountOpts {
projectId: string;
todayISO?: string;
density?: Density;
palette?: Palette;
/** Initial range preset. Default "1y" (today-1y..today+1y) per design Q8. */
rangePreset?: RangePreset;
/** When rangePreset === "custom", these supply the bounds. Ignored for
* preset values — those derive bounds from the preset + todayISO (or,
* for "all", from the loaded events). */
rangeFrom?: string;
rangeTo?: string;
/** Optional callback fired when the user clicks a mark with a known
* deep-link target. Receives the underlying TimelineEvent. */
onMarkClick?: (event: TimelineEvent) => void;
/** Optional callback fired after every refresh() so the host can
* re-render dynamic UI (e.g. lane filter chips). */
onDataLoaded?: (data: { events: TimelineEvent[]; lanes: LaneInfo[] }) => void;
/** Initial visible-lane allowlist. null = show all (default).
* Lane ids not present in the response are silently dropped. */
visibleLanes?: string[] | null;
/** Pre-loaded data — used by Custom Views (Slice 4) where the rows
* come from ViewService not /api/projects/{id}/timeline. When set,
* mount() skips the initial fetch and paints from this data; the
* handle's refresh() still hits the project endpoint (caller can
* swap the chart back to project-mode via the standalone /chart URL). */
staticData?: { events: TimelineEvent[]; lanes: LaneInfo[] };
}
export interface ChartHandle {
/** Re-fetches the timeline and re-paints. */
refresh: () => Promise<void>;
/** Removes event listeners + tears down the SVG. */
dispose: () => void;
/** Returns the last computed layout (useful for tests / debugging). */
getLayout: () => ChartLayout | null;
/** Swap palette via data-palette attribute. Pure CSS-var swap — no repaint. */
setPalette: (palette: Palette) => void;
/** Swap density. Re-runs layout() since lane height / mark radius change. */
setDensity: (density: Density) => void;
/** Switch range preset. "all" derives bounds from the loaded events;
* "custom" expects customFrom + customTo (otherwise it falls back to
* today-1y..today+1y). All others are time-shifted from todayISO. */
setRange: (preset: RangePreset, customFrom?: string, customTo?: string) => void;
/** Set the lane allowlist. null = show all lanes (default). Unknown
* ids in the passed array are silently dropped on repaint. */
setVisibleLanes: (lanes: string[] | null) => void;
/** The raw SVG node — chart-export.ts reads this for SVG / PNG / print. */
getSVGElement: () => SVGSVGElement;
/** Last-loaded data — chart-export.ts reads this for CSV / JSON / iCal. */
getData: () => { events: TimelineEvent[]; lanes: LaneInfo[] };
}
interface TimelineEnvelope {
events: TimelineEvent[];
lanes: LaneInfo[];
}
/**
* mount builds a chart inside the given host element. The host's
* dimensions drive the SVG width; height grows from the lane row count.
* Returns a handle for refresh / dispose.
*/
export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
host.classList.add("smart-timeline-chart-host");
// Empty / error placeholders.
const messageEl = document.createElement("div");
messageEl.className = "smart-timeline-chart-message";
messageEl.textContent = "";
host.appendChild(messageEl);
// The SVG root we paint into.
const svgEl = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
svgEl.classList.add("smart-timeline-chart");
svgEl.setAttribute("data-palette", opts.palette ?? "default");
svgEl.setAttribute("data-density", opts.density ?? "standard");
host.appendChild(svgEl);
let lastEvents: TimelineEvent[] = [];
let lastLayout: ChartLayout | null = null;
const todayISO = opts.todayISO ?? today();
let currentDensity: Density = opts.density ?? "standard";
let currentRangePreset: RangePreset = opts.rangePreset ?? "1y";
let customRangeFrom: string = opts.rangeFrom ?? shiftYears(todayISO, -1);
let customRangeTo: string = opts.rangeTo ?? shiftYears(todayISO, 1);
let currentVisibleLanes: Set<string> | null = opts.visibleLanes
? new Set(opts.visibleLanes)
: null;
function resolveRange(): { from: string; to: string } {
switch (currentRangePreset) {
case "1y":
return { from: shiftYears(todayISO, -1), to: shiftYears(todayISO, 1) };
case "2y":
return { from: shiftYears(todayISO, -2), to: shiftYears(todayISO, 2) };
case "all":
return rangeFromEvents(lastEvents, todayISO);
case "custom":
return { from: customRangeFrom, to: customRangeTo };
}
}
function repaint(): void {
const rect = host.getBoundingClientRect();
// Minimum width keeps the canvas usable when the host is hidden /
// about to be sized; resize listener will repaint on real layout.
const width = Math.max(640, rect.width || 1000);
const { from, to } = resolveRange();
const viewport: ChartViewport = {
width,
height: 400,
laneLabelWidth: 200,
dateAxisHeight: 40,
todayISO,
rangeFrom: from,
rangeTo: to,
density: currentDensity,
};
// Lane allowlist filter. null = show all; otherwise drop both the
// lane rows AND the events whose lane_id sits outside the allowlist.
// (We don't fall back to "first lane" here — that's only sensible
// when a stale id slips through; an explicit hide is a hide.)
let renderLanes = [...currentLanes];
let renderEvents: TimelineEvent[] = lastEvents;
if (currentVisibleLanes !== null) {
const allow = currentVisibleLanes;
renderLanes = currentLanes.filter((l) => allow.has(l.id));
renderEvents = lastEvents.filter((e) => {
// Empty / missing lane_id is treated as "self" — included only
// when the synthetic "self" lane is allowed.
const id = e.lane_id || "self";
return allow.has(id);
});
}
const chart = layout(renderEvents, renderLanes, viewport);
lastLayout = chart;
paint(chart, svgEl, renderEvents);
svgEl.setAttribute("width", String(width));
svgEl.setAttribute("height", String(chart.chartTop + chart.chartHeight + 32));
}
let currentLanes: LaneInfo[] = [];
async function refresh(): Promise<void> {
messageEl.textContent = "Lädt …";
messageEl.classList.remove("smart-timeline-chart-message--error");
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(opts.projectId)}/timeline`,
);
if (!resp.ok) {
messageEl.textContent = "Timeline konnte nicht geladen werden.";
messageEl.classList.add("smart-timeline-chart-message--error");
return;
}
const body = await resp.json();
// Defensive: tolerate the legacy []TimelineEvent shape (pre-Slice-4)
// even though the Slice-4 envelope is the contract today.
if (Array.isArray(body)) {
lastEvents = body as TimelineEvent[];
currentLanes = [];
} else {
const env = body as TimelineEnvelope;
lastEvents = env.events ?? [];
currentLanes = env.lanes ?? [];
}
if (lastEvents.length === 0) {
messageEl.textContent = "Keine Ereignisse im gewählten Zeitraum.";
} else {
messageEl.textContent = "";
}
// Drop stale lane ids from the allowlist — a deleted CCR / child
// case shouldn't keep its lane id alive across re-fetches.
if (currentVisibleLanes !== null) {
const valid = new Set(currentLanes.map((l) => l.id));
valid.add("self"); // synthetic lane always allowed
const trimmed = new Set<string>();
for (const id of currentVisibleLanes) {
if (valid.has(id)) trimmed.add(id);
}
currentVisibleLanes = trimmed.size === 0 ? null : trimmed;
}
repaint();
if (opts.onDataLoaded) {
opts.onDataLoaded({ events: lastEvents, lanes: currentLanes });
}
} catch (err) {
messageEl.textContent = "Netzwerkfehler beim Laden der Timeline.";
messageEl.classList.add("smart-timeline-chart-message--error");
}
}
// Click delegation — read data-* attrs to deep-link.
function handleClick(e: Event) {
const target = e.target as Element | null;
if (!target) return;
const g = target.closest("g.chart-mark") as Element | null;
if (!g) return;
const indexAttr = g.getAttribute("data-event-index");
if (!indexAttr) return;
const idx = Number(indexAttr);
const event = lastEvents[idx];
if (!event) return;
if (opts.onMarkClick) {
opts.onMarkClick(event);
return;
}
if (event.deadline_id) {
window.location.href = `/deadlines/${encodeURIComponent(event.deadline_id)}`;
} else if (event.appointment_id) {
window.location.href = `/appointments/${encodeURIComponent(event.appointment_id)}`;
}
// Milestones + projected rows have no detail page today — no-op.
}
// Resize handler — debounced.
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
function handleResize() {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
repaint();
}, 120);
}
svgEl.addEventListener("click", handleClick);
window.addEventListener("resize", handleResize);
// If the caller supplied data up front (Custom Views host path), skip
// the project-timeline fetch entirely — paint from the supplied rows.
// Otherwise kick off the initial /api/projects/{id}/timeline load.
if (opts.staticData) {
lastEvents = opts.staticData.events;
currentLanes = opts.staticData.lanes;
if (lastEvents.length === 0) {
messageEl.textContent = "Keine Ereignisse im gewählten Zeitraum.";
} else {
messageEl.textContent = "";
}
repaint();
if (opts.onDataLoaded) {
opts.onDataLoaded({ events: lastEvents, lanes: currentLanes });
}
} else {
void refresh();
}
return {
refresh,
getLayout: () => lastLayout,
setPalette: (palette: Palette) => {
svgEl.setAttribute("data-palette", palette);
},
setDensity: (density: Density) => {
currentDensity = density;
svgEl.setAttribute("data-density", density);
repaint();
},
setRange: (preset: RangePreset, customFrom?: string, customTo?: string) => {
currentRangePreset = preset;
if (preset === "custom") {
if (customFrom) customRangeFrom = customFrom;
if (customTo) customRangeTo = customTo;
}
svgEl.setAttribute("data-range-preset", preset);
repaint();
},
setVisibleLanes: (lanes: string[] | null) => {
currentVisibleLanes = lanes ? new Set(lanes) : null;
repaint();
},
getSVGElement: () => svgEl,
getData: () => ({ events: lastEvents, lanes: currentLanes }),
dispose: () => {
svgEl.removeEventListener("click", handleClick);
window.removeEventListener("resize", handleResize);
if (resizeTimer) clearTimeout(resizeTimer);
if (svgEl.parentNode) svgEl.parentNode.removeChild(svgEl);
if (messageEl.parentNode) messageEl.parentNode.removeChild(messageEl);
},
};
}
/** Resolve the "all" preset bounds from the loaded events. Empty data
* falls back to the 1y default so the chart canvas isn't degenerate. */
function rangeFromEvents(
events: ReadonlyArray<TimelineEvent>,
todayISO: string,
): { from: string; to: string } {
let minMs: number | null = null;
let maxMs: number | null = null;
for (const ev of events) {
if (!ev.date) continue;
const ms = parseISODay(ev.date);
if (ms === null) continue;
if (minMs === null || ms < minMs) minMs = ms;
if (maxMs === null || ms > maxMs) maxMs = ms;
}
if (minMs === null || maxMs === null) {
return { from: shiftYears(todayISO, -1), to: shiftYears(todayISO, 1) };
}
// Pad +30d at the right so the last event isn't flush against the edge.
const fromDate = new Date(minMs);
const toDate = new Date(maxMs + 30 * 86_400_000);
return {
from: toISO(fromDate),
to: toISO(toDate),
};
}
function toISO(d: Date): string {
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
}
function today(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${dd}`;
}
function shiftYears(iso: string, delta: number): string {
const ms = parseISODay(iso);
if (ms === null) return iso;
const d = new Date(ms);
return `${d.getUTCFullYear() + delta}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
}

View File

@@ -0,0 +1,140 @@
import { describe, expect, test } from "bun:test";
import { adapt } from "./shape-timeline-cv";
import type { ViewRow } from "./types";
// t-paliad-177 Slice 4 — adapter contract tests for ViewRow →
// TimelineEvent + LaneInfo. Pure function, no DOM access.
// The actual chart-render math is pinned by shape-timeline-chart.test.ts;
// this file pins the adapter's lossy translation rules from §13.4.
const baseRow = (overrides: Partial<ViewRow> = {}): ViewRow => ({
kind: "deadline",
id: "d1",
title: "Test",
event_date: "2026-06-15",
detail: {},
...overrides,
});
describe("adapt — kind mapping", () => {
test("deadline → kind='deadline' + deadline_id", () => {
const out = adapt([baseRow({ kind: "deadline", id: "abc" })]);
expect(out.events).toHaveLength(1);
expect(out.events[0].kind).toBe("deadline");
expect(out.events[0].deadline_id).toBe("abc");
expect(out.events[0].appointment_id).toBeUndefined();
expect(out.events[0].project_event_id).toBeUndefined();
});
test("appointment → kind='appointment' + appointment_id", () => {
const out = adapt([baseRow({ kind: "appointment", id: "x" })]);
expect(out.events[0].kind).toBe("appointment");
expect(out.events[0].appointment_id).toBe("x");
});
test("project_event → kind='milestone' + project_event_id", () => {
const out = adapt([baseRow({ kind: "project_event", id: "y" })]);
expect(out.events[0].kind).toBe("milestone");
expect(out.events[0].project_event_id).toBe("y");
});
test("approval_request is skipped", () => {
const out = adapt([
baseRow({ kind: "deadline" }),
baseRow({ kind: "approval_request" }),
baseRow({ kind: "appointment" }),
]);
expect(out.events).toHaveLength(2);
expect(out.events.map((e) => e.kind)).toEqual(["deadline", "appointment"]);
});
});
describe("adapt — lane bucketing by project_id (cross-project chart)", () => {
test("one lane per unique project_id, first-seen order", () => {
const out = adapt([
baseRow({ project_id: "p1", project_title: "Project 1" }),
baseRow({ project_id: "p2", project_title: "Project 2" }),
baseRow({ project_id: "p1", project_title: "Project 1" }),
]);
expect(out.lanes).toHaveLength(2);
expect(out.lanes[0].id).toBe("p1");
expect(out.lanes[0].label).toBe("Project 1");
expect(out.lanes[1].id).toBe("p2");
});
test("project_title preferred over project_reference for the label", () => {
const out = adapt([
baseRow({ project_id: "p1", project_title: "Nice Name", project_reference: "REF-1" }),
]);
expect(out.lanes[0].label).toBe("Nice Name");
});
test("falls back to project_reference when title missing", () => {
const out = adapt([
baseRow({ project_id: "p1", project_reference: "REF-1" }),
]);
expect(out.lanes[0].label).toBe("REF-1");
});
test("missing project_id collapses to synthetic 'self' lane", () => {
const out = adapt([baseRow({ project_id: undefined })]);
expect(out.lanes).toHaveLength(1);
expect(out.lanes[0].id).toBe("self");
expect(out.events[0].lane_id).toBe("self");
expect(out.events[0].track).toBe("parent");
});
test("event lane_id matches its lane row id", () => {
const out = adapt([
baseRow({ project_id: "p1", project_title: "A" }),
baseRow({ project_id: "p2", project_title: "B" }),
]);
expect(out.events[0].lane_id).toBe("p1");
expect(out.events[1].lane_id).toBe("p2");
});
});
describe("adapt — status extraction", () => {
test("deadline status 'done' comes through from detail", () => {
const out = adapt([
baseRow({ kind: "deadline", detail: { status: "done" } }),
]);
expect(out.events[0].status).toBe("done");
});
test("deadline status 'overdue' comes through", () => {
const out = adapt([
baseRow({ kind: "deadline", detail: { status: "overdue" } }),
]);
expect(out.events[0].status).toBe("overdue");
});
test("unknown / missing detail.status defaults to 'open'", () => {
const out = adapt([
baseRow({ kind: "deadline", detail: { status: "weird-value" } }),
baseRow({ kind: "appointment" }),
baseRow({ kind: "project_event" }),
]);
expect(out.events.map((e) => e.status)).toEqual(["open", "open", "open"]);
});
});
describe("adapt — date passthrough", () => {
test("event_date is forwarded to TimelineEvent.date", () => {
const out = adapt([baseRow({ event_date: "2026-08-15T00:00:00Z" })]);
expect(out.events[0].date).toBe("2026-08-15T00:00:00Z");
});
test("empty event_date becomes null (undated)", () => {
const out = adapt([baseRow({ event_date: "" })]);
expect(out.events[0].date).toBeNull();
});
});
describe("adapt — empty input", () => {
test("empty rows array returns empty events + empty lanes", () => {
const out = adapt([]);
expect(out.events).toHaveLength(0);
expect(out.lanes).toHaveLength(0);
});
});

View File

@@ -0,0 +1,141 @@
import {
mount,
type ChartHandle,
type Density,
type Palette,
type RangePreset,
} from "./shape-timeline-chart";
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
import type { RenderSpec, ViewRow } from "./types";
// shape-timeline-cv (t-paliad-177 Slice 4, faraday-Q7) — Custom Views
// host for the chart renderer.
//
// Adapter contract: ViewRow → TimelineEvent + LaneInfo.
// - deadline + appointment + project_event rows render as actual marks.
// - approval_request rows are skipped (no chart-meaningful date).
// - Lane axis = project_id; the cross-project chart use case (design
// §10) groups events by their owning project. Rows without a
// project_id collapse into a synthetic "self" lane.
// - NO projected rows. ViewService doesn't run the fristenrechner
// calculator, so the CV chart shows actuals only. The host page
// ships a one-time caveat tooltip (see C3) explaining this.
//
// Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5 + §13.4.
export function renderTimelineShape(
host: HTMLElement,
rows: ReadonlyArray<ViewRow>,
render: RenderSpec,
): ChartHandle {
// Tear down any previous mount so re-rendering the shape (e.g. shape
// chip switch on /views/{slug}) doesn't stack SVGs.
host.innerHTML = "";
const { events, lanes } = adapt(rows);
const cfg = render.timeline ?? {};
// The CV adapter has no per-project "id" to fetch live timeline data
// for — we hand mount() a placeholder projectId and the staticData
// pre-loaded array so it skips the project endpoint entirely. If the
// user clicks a mark, the renderer's default click handler still
// resolves /deadlines/{id} / /appointments/{id} from the adapted
// event's id field, so deep-links land on the correct entity page.
return mount(host, {
projectId: "cv",
staticData: { events, lanes },
palette: (cfg.palette as Palette | undefined) ?? "default",
density: (cfg.density as Density | undefined) ?? "standard",
rangePreset: (cfg.range_preset as RangePreset | undefined) ?? "1y",
rangeFrom: cfg.range_from,
rangeTo: cfg.range_to,
});
}
export interface AdapterResult {
events: TimelineEvent[];
lanes: LaneInfo[];
}
/** Exported for tests (shape-timeline-cv.test.ts). Pure — no DOM. */
export function adapt(rows: ReadonlyArray<ViewRow>): AdapterResult {
const events: TimelineEvent[] = [];
// Lane order = first-seen order of project_ids in rows, so the user
// sees lanes in the order their data was returned (typically date-
// sorted). Deterministic, no surprise re-ordering on re-renders.
const laneIndex = new Map<string, LaneInfo>();
for (const row of rows) {
if (row.kind === "approval_request") {
// Approval requests have no event_date in the chart sense; they
// represent pending decisions, not scheduled work. Skip.
continue;
}
const laneId = row.project_id || "self";
if (!laneIndex.has(laneId)) {
laneIndex.set(laneId, {
id: laneId,
label: row.project_title || row.project_reference || laneLabelFallback(laneId),
project_id: row.project_id,
});
}
const event: TimelineEvent = {
kind: toTimelineKind(row.kind),
status: extractStatus(row),
track: laneId === "self" ? "parent" : "child:" + laneId,
date: row.event_date || null,
title: row.title,
description: row.subtitle,
lane_id: laneId,
};
// Set the right provenance id so the renderer's click handler can
// deep-link to /deadlines/{id} / /appointments/{id}.
switch (row.kind) {
case "deadline":
event.deadline_id = row.id;
break;
case "appointment":
event.appointment_id = row.id;
break;
case "project_event":
event.project_event_id = row.id;
break;
}
events.push(event);
}
return { events, lanes: [...laneIndex.values()] };
}
function toTimelineKind(kind: ViewRow["kind"]): TimelineEvent["kind"] {
// ViewRow "project_event" maps to chart "milestone" — they're the
// same underlying paliad.project_events row, the chart just uses a
// different name because milestones are the chart-meaningful subset.
if (kind === "project_event") return "milestone";
// Defensive: approval_request was filtered earlier, but TS doesn't
// know that. Default to "milestone" for any unexpected kind.
if (kind === "deadline" || kind === "appointment") return kind;
return "milestone";
}
/** Status defaults to "open" — ViewRow doesn't carry chart-status
* semantics directly, and the underlying detail json shape varies per
* kind. The chart's color saturation maps status → fill / ring style,
* so "open" gives every mark a sensible default (filled, full color).
* Detail-driven status lookup is a polish job for a future slice. */
function extractStatus(row: ViewRow): TimelineEvent["status"] {
if (row.kind === "deadline") {
const d = row.detail as { status?: string };
if (d.status === "done" || d.status === "overdue") {
return d.status as TimelineEvent["status"];
}
}
return "open";
}
function laneLabelFallback(id: string): string {
if (id === "self") return "(ohne Projekt)";
// Truncated UUID is more useful than a bare 36-char string.
return id.slice(0, 8);
}

View File

@@ -0,0 +1,966 @@
import { t, getLang } from "../i18n";
// shape-timeline (t-paliad-171 → t-paliad-175) — vertical timeline render
// for the SmartTimeline. Two-column layout (date / event card), "Heute →"
// rule separating past from future, status icon + kind chip per row.
//
// Slice 2 (t-paliad-173) adds:
// - Kind="projected" rows in three flavours via Status:
// "predicted" — fade-grey (future)
// "court_set" — dashed border (court-determined)
// "predicted_overdue" — amber-faded (past, no anchor yet)
// - "[Datum setzen]" inline date editor → POST /timeline/anchor.
// 200 → re-fetch + re-render. 409 → render the predecessor_missing
// payload as inline error with a "Stattdessen <predecessor> erfassen"
// link that pre-fills the editor for the parent rule.
// - "Folgt aus: <Name> (<Date|„Datum offen“>)" footer on every row
// with depends_on_rule_code, plus a "[Pfad anzeigen]" expander that
// walks the parent chain back to the trigger.
// - "[+ Mehr anzeigen]" / "[ Weniger]" lookahead toggle after the 7th
// projected row, cap remembered in localStorage per project.
//
// Slice 4 (t-paliad-175) adds parent-node lane aggregation:
// - When `lanes.length > 1` (Patent / Litigation / Client view), render
// a horizontal lane-strip with one column per lane. Time axis stays
// vertical within each lane; the lane sub-header names the child
// project. CSS Grid handles the desktop side-by-side and collapses
// to single-column on mobile (≤640px).
// - Lane filter chip (multiselect) sits in the timeline header above
// the strip; selecting a subset dims the others.
// - Single-column flow stays the default at Case level (lanes mirror
// tracks one-for-one).
//
// Wire shape: renderSmartTimeline(host, rows, opts). The TimelineEvent
// shape is the wire contract from /api/projects/{id}/timeline.events;
// LaneInfo[] from .lanes drives the lane-grouped layout.
//
// Design ref: docs/design-smart-timeline-2026-05-08.md §3 + §5 + §6 +
// m/paliad#31 layered requirements.
export interface TimelineEvent {
kind: "deadline" | "appointment" | "milestone" | "projected";
status:
| "done"
| "open"
| "overdue"
| "court_set"
| "predicted"
| "predicted_overdue"
| "off_script";
track: string;
date?: string | null;
title: string;
description?: string;
rule_code?: string;
deadline_id?: string;
appointment_id?: string;
project_event_id?: string;
deadline_rule_id?: string;
deadline_rule_party?: string;
sub_project_id?: string;
sub_project_title?: string;
depends_on_rule_code?: string;
depends_on_date?: string | null;
depends_on_rule_name?: string;
// Slice 4 — parent-node aggregation (t-paliad-175). lane_id buckets
// the row into one of the columns described by RenderOptions.lanes.
// Empty / missing is treated as "self" (the legacy single-lane case).
lane_id?: string;
bubble_up?: boolean;
// t-paliad-176 — underlying paliad.project_events.event_type for
// milestone rows. Empty for deadline / appointment / projected rows.
// Powers the FilterBar's project_event_kind chip on the Verlauf tab
// (matched against KnownProjectEventKinds in filter_spec.go).
project_event_type?: string;
}
export interface LaneInfo {
id: string;
label: string;
project_id?: string;
primary?: boolean;
}
export interface PredecessorMissingPayload {
error: "predecessor_missing";
missing_rule_code: string;
missing_rule_name_de: string;
missing_rule_name_en: string;
requested_rule_code: string;
requested_rule_name_de: string;
requested_rule_name_en: string;
message_de: string;
message_en: string;
}
export interface RenderOptions {
// Today's date as ISO YYYY-MM-DD; defaults to "now in browser TZ".
today?: string;
// The project the timeline belongs to. Required for anchor / skip
// POSTs. When undefined, projected rows don't expose "Datum setzen".
projectId?: string;
// Language hint — falls back to getLang() when omitted.
lang?: "de" | "en";
// Called after a successful anchor write so the host can re-fetch
// and re-render. Skipped when omitted.
onChange?: () => void | Promise<void>;
// Lookahead state for projected rows. Default 7 = backend default.
lookahead?: number;
// Total number of future predicted rows the backend knows about
// (read from X-Projection-Total). When > visible projected count,
// "Mehr anzeigen" is shown.
projectedTotal?: number;
// Called when the user toggles "Mehr / Weniger anzeigen". The host
// updates state + re-fetches with the new ?lookahead=N.
onLookaheadChange?: (next: number) => void | Promise<void>;
// Slice 3 — counterclaim parallel tracks. availableTracks lists every
// track tag present in the response (parsed from X-Projection-Tracks).
// When the list contains a non-"parent" entry, the [Track ▼] chip
// surfaces. selectedTrack is the user's filter ("all" = render every
// available track in parallel; otherwise render only the named tag).
availableTracks?: string[];
selectedTrack?: string;
onTrackChange?: (next: string) => void | Promise<void>;
// Slice 4 — parent-node lane aggregation. When lanes.length > 1,
// renderSmartTimeline renders a lane-strip layout (one column per
// lane) instead of the single-column flow. selectedLanes is the
// user's lane-filter chip; defaults to all lanes selected. Empty
// array = nothing rendered (defensible for the user explicitly
// unchecking every lane).
lanes?: LaneInfo[];
selectedLanes?: string[]; // ids; undefined = all lanes selected
onLaneFilterChange?: (next: string[]) => void | Promise<void>;
}
export function renderSmartTimeline(
host: HTMLElement,
rows: TimelineEvent[],
opts: RenderOptions = {},
): void {
host.innerHTML = "";
host.classList.add("smart-timeline");
// Slice 4 — lane-grouped rendering (t-paliad-175 §5). When the
// backend reports more than one lane, every event already carries a
// lane_id and the layout switches from single-column to lane strip.
// Lane mode takes precedence over Track-mode (the two are different
// axes — lanes group by *direct child project*, tracks group by
// CCR-vs-parent on a single Case).
const lanes = opts.lanes ?? [];
const isLaneMode = lanes.length > 1;
if (isLaneMode) {
host.appendChild(renderLaneStrip(rows, lanes, opts));
return;
}
// Slice 3 — track filtering. The bar header carries the [Track ▼]
// chip whenever the response advertised more than the default
// "parent" track; the filter is applied here before any flow render.
const availableTracks = (opts.availableTracks ?? []).filter((t) => !!t);
const hasMultipleTracks = availableTracks.length > 1;
const selectedTrack = opts.selectedTrack ?? "all";
if (hasMultipleTracks) {
host.appendChild(renderTrackChip(availableTracks, selectedTrack, opts));
}
// Filter rows by the selected track. "all" leaves rows untouched
// (parallel layout decides per-track partitioning below).
const filteredRows =
selectedTrack === "all"
? rows
: rows.filter((r) => (r.track ?? "parent") === selectedTrack);
if (filteredRows.length === 0) {
const empty = document.createElement("div");
empty.className = "smart-timeline-empty";
empty.textContent = t("projects.detail.smarttimeline.empty");
host.appendChild(empty);
return;
}
// When the user has selected "all" AND there are multiple tracks
// present, render parallel columns side-by-side. Otherwise the
// existing single-column flow serves both single-track projects and
// an explicit "Nur Hauptverfahren / Nur Widerklage" filter.
if (selectedTrack === "all" && hasMultipleTracks) {
host.appendChild(renderParallelTracks(filteredRows, availableTracks, opts));
return;
}
// Single-column flow.
host.appendChild(renderTimelineFlow(filteredRows, opts));
}
// renderLaneStrip builds the parent-node aggregated layout (Slice 4).
// One column per lane, each column shows the lane's own past/today/
// future flow. Lane filter chip (multiselect) sits above the strip.
// Lanes the user has unchecked render dimmed but still take up the
// column slot — this preserves the time-axis alignment across lanes.
function renderLaneStrip(
rows: TimelineEvent[],
lanes: LaneInfo[],
opts: RenderOptions,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "smart-timeline-lanes-wrap";
// Lane filter chip (Slice 4) — multiselect with "alle" / "keine".
// Sits above the strip.
wrap.appendChild(renderLaneFilterChip(lanes, opts));
const selected = new Set(opts.selectedLanes ?? lanes.map((l) => l.id));
const grid = document.createElement("div");
grid.className = "smart-timeline-lanes";
grid.style.setProperty("--smart-timeline-lane-count", String(lanes.length));
// Group rows by lane_id. Rows without a lane_id default to the first
// lane id so they don't disappear. For lane mode the backend always
// sets lane_id explicitly; this fallback is defensive.
const byLane = new Map<string, TimelineEvent[]>();
for (const l of lanes) byLane.set(l.id, []);
for (const r of rows) {
const id = r.lane_id || lanes[0].id;
if (!byLane.has(id)) byLane.set(id, []);
byLane.get(id)!.push(r);
}
for (const lane of lanes) {
const col = document.createElement("div");
col.className = "smart-timeline-lane";
if (!selected.has(lane.id)) {
col.classList.add("smart-timeline-lane--dimmed");
}
if (lane.primary) {
col.classList.add("smart-timeline-lane--primary");
}
const header = document.createElement("h4");
header.className = "smart-timeline-lane-header";
if (lane.project_id) {
const link = document.createElement("a");
link.href = `/projects/${encodeURIComponent(lane.project_id)}`;
link.textContent = lane.label;
link.className = "smart-timeline-lane-header-link";
header.appendChild(link);
} else {
header.textContent = lane.label;
}
col.appendChild(header);
const laneRows = byLane.get(lane.id) ?? [];
if (laneRows.length === 0) {
const empty = document.createElement("div");
empty.className = "smart-timeline-lane-empty";
empty.textContent = t("projects.detail.smarttimeline.lane.empty");
col.appendChild(empty);
} else {
col.appendChild(renderTimelineFlow(laneRows, opts));
}
grid.appendChild(col);
}
wrap.appendChild(grid);
return wrap;
}
// renderLaneFilterChip — multiselect chip-row for the lane filter.
// Defaults to all lanes selected; user toggles individual chips. The
// "Alle" pseudo-chip resets to all selected.
function renderLaneFilterChip(
lanes: LaneInfo[],
opts: RenderOptions,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "smart-timeline-lane-filter";
const label = document.createElement("span");
label.className = "smart-timeline-lane-filter-label";
label.textContent = t("projects.detail.smarttimeline.lane.filter.label");
wrap.appendChild(label);
const selected = new Set(opts.selectedLanes ?? lanes.map((l) => l.id));
const allBtn = document.createElement("button");
allBtn.type = "button";
allBtn.className = "smart-timeline-lane-chip smart-timeline-lane-chip--all";
if (selected.size === lanes.length) {
allBtn.classList.add("is-active");
}
allBtn.textContent = t("projects.detail.smarttimeline.lane.filter.all");
allBtn.addEventListener("click", () => {
if (opts.onLaneFilterChange) void opts.onLaneFilterChange(lanes.map((l) => l.id));
});
wrap.appendChild(allBtn);
for (const lane of lanes) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "smart-timeline-lane-chip";
if (selected.has(lane.id)) chip.classList.add("is-active");
chip.textContent = lane.label;
chip.addEventListener("click", () => {
const next = new Set(selected);
if (next.has(lane.id)) {
next.delete(lane.id);
} else {
next.add(lane.id);
}
if (opts.onLaneFilterChange) void opts.onLaneFilterChange(Array.from(next));
});
wrap.appendChild(chip);
}
return wrap;
}
// renderParallelTracks builds a CSS-grid wrapper with one column per
// track. Each column is a self-contained smart-timeline-flow with its
// own past / today / future sections, plus a sub-header that names the
// track ("Hauptverfahren" / "Widerklage — <CCR title>" / "Hauptverfahren
// (Kontext)" for the parent_context view on a CCR child).
//
// Mobile collapse (≤640px) is owned by CSS via .smart-timeline-tracks
// and a media query — the grid switches to a single column there with
// each sub-header preserved so the user knows which track they're on.
function renderParallelTracks(
rows: TimelineEvent[],
availableTracks: string[],
opts: RenderOptions,
): HTMLElement {
const grid = document.createElement("div");
grid.className = "smart-timeline-tracks";
grid.style.setProperty("--smart-timeline-track-count", String(availableTracks.length));
// Group rows by track. Rows with no track default to "parent".
const byTrack = new Map<string, TimelineEvent[]>();
for (const tr of availableTracks) byTrack.set(tr, []);
for (const r of rows) {
const key = r.track && byTrack.has(r.track) ? r.track : "parent";
if (!byTrack.has(key)) byTrack.set(key, []);
byTrack.get(key)!.push(r);
}
for (const trackTag of availableTracks) {
const trackRows = byTrack.get(trackTag) ?? [];
const col = document.createElement("div");
col.className = `smart-timeline-track ${trackClassFor(trackTag)}`;
const header = document.createElement("h4");
header.className = "smart-timeline-track-header";
header.textContent = trackHeaderLabel(trackTag, trackRows);
col.appendChild(header);
if (trackRows.length === 0) {
const empty = document.createElement("div");
empty.className = "smart-timeline-track-empty";
empty.textContent = t("projects.detail.smarttimeline.empty");
col.appendChild(empty);
} else {
col.appendChild(renderTimelineFlow(trackRows, opts));
}
grid.appendChild(col);
}
return grid;
}
// renderTimelineFlow renders the past / today / future / undated flow
// for the given row set into a fresh container. Extracted from the
// pre-Slice-3 renderSmartTimeline so it can be reused as a per-track
// column in the parallel layout.
function renderTimelineFlow(rows: TimelineEvent[], opts: RenderOptions): HTMLElement {
const todayISO = opts.today ?? todayLocalISO();
const past: TimelineEvent[] = [];
const todays: TimelineEvent[] = [];
const future: TimelineEvent[] = [];
const undated: TimelineEvent[] = [];
for (const r of rows) {
const iso = dateOnlyISO(r.date);
if (!iso) {
undated.push(r);
continue;
}
if (iso < todayISO) past.push(r);
else if (iso === todayISO) todays.push(r);
else future.push(r);
}
past.sort(byDateAsc);
todays.sort(byDateAsc);
future.sort(byDateAsc);
const wrap = document.createElement("div");
wrap.className = "smart-timeline-flow";
if (past.length > 0) {
const section = document.createElement("section");
section.className = "smart-timeline-section smart-timeline-section--past";
const heading = document.createElement("h3");
heading.className = "smart-timeline-heading";
heading.textContent = t("projects.detail.smarttimeline.section.past");
section.appendChild(heading);
for (const ev of past) section.appendChild(renderRow(ev, opts));
wrap.appendChild(section);
}
const todayRule = document.createElement("div");
todayRule.className = "smart-timeline-today-rule";
const todayLabel = document.createElement("span");
todayLabel.className = "smart-timeline-today-label";
todayLabel.textContent = `${t("projects.detail.smarttimeline.today")} (${formatDateOnly(todayISO)})`;
todayRule.appendChild(todayLabel);
wrap.appendChild(todayRule);
if (todays.length > 0) {
const section = document.createElement("section");
section.className = "smart-timeline-section smart-timeline-section--today";
for (const ev of todays) section.appendChild(renderRow(ev, opts));
wrap.appendChild(section);
}
if (future.length > 0) {
const section = document.createElement("section");
section.className = "smart-timeline-section smart-timeline-section--future";
const heading = document.createElement("h3");
heading.className = "smart-timeline-heading";
heading.textContent = t("projects.detail.smarttimeline.section.future");
section.appendChild(heading);
for (const ev of future) section.appendChild(renderRow(ev, opts));
section.appendChild(renderLookaheadToggle(future, opts));
wrap.appendChild(section);
} else {
const lookaheadHost = renderLookaheadToggle(future, opts);
if (lookaheadHost.childElementCount > 0) {
wrap.appendChild(lookaheadHost);
}
}
if (undated.length > 0) {
const section = document.createElement("section");
section.className = "smart-timeline-section smart-timeline-section--undated";
const heading = document.createElement("h3");
heading.className = "smart-timeline-heading";
heading.textContent = t("projects.detail.smarttimeline.section.undated");
section.appendChild(heading);
for (const ev of undated) section.appendChild(renderRow(ev, opts));
wrap.appendChild(section);
}
return wrap;
}
// renderTrackChip builds the [Track ▼] selector. Options are derived
// from the response's available_tracks header — i18n keys translate
// each option label, with the sub-project title surfacing for CCR
// tracks ("Widerklage — <title>"). Persists the user's selection via
// the host through opts.onTrackChange.
function renderTrackChip(
availableTracks: string[],
selected: string,
opts: RenderOptions,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "smart-timeline-track-chip";
const label = document.createElement("label");
label.className = "smart-timeline-track-chip-label";
label.textContent = t("projects.detail.smarttimeline.track.label");
wrap.appendChild(label);
const select = document.createElement("select");
select.className = "smart-timeline-track-chip-select";
const allOpt = document.createElement("option");
allOpt.value = "all";
allOpt.textContent = t("projects.detail.smarttimeline.track.both");
select.appendChild(allOpt);
for (const trackTag of availableTracks) {
const opt = document.createElement("option");
opt.value = trackTag;
opt.textContent = trackOnlyLabel(trackTag);
select.appendChild(opt);
}
select.value = selected;
select.addEventListener("change", () => {
if (opts.onTrackChange) void opts.onTrackChange(select.value);
});
wrap.appendChild(select);
return wrap;
}
// trackClassFor maps a track tag to its CSS modifier so the column
// gets the appropriate visual treatment (lime for parent, light shade
// for counterclaim, faded for parent_context).
function trackClassFor(trackTag: string): string {
if (trackTag === "parent") return "smart-timeline-track--parent";
if (trackTag.startsWith("counterclaim:")) return "smart-timeline-track--counterclaim";
if (trackTag.startsWith("parent_context:")) return "smart-timeline-track--parent-context";
return "smart-timeline-track--other";
}
// trackHeaderLabel picks the column sub-header. For CCR tracks pulls
// the sub_project_title from the first row in the track so the user
// sees "Widerklage — <child title>". Falls back to a generic label
// when the title is empty.
function trackHeaderLabel(trackTag: string, rows: TimelineEvent[]): string {
if (trackTag === "parent") {
return t("projects.detail.smarttimeline.track.header.parent");
}
const firstWithTitle = rows.find((r) => r.sub_project_title);
const subTitle = firstWithTitle?.sub_project_title ?? "";
if (trackTag.startsWith("counterclaim:")) {
const base = t("projects.detail.smarttimeline.track.header.counterclaim");
return subTitle ? `${base}${subTitle}` : base;
}
if (trackTag.startsWith("parent_context:")) {
const base = t("projects.detail.smarttimeline.track.header.parent_context");
return subTitle ? `${base}${subTitle}` : base;
}
return trackTag;
}
// trackOnlyLabel is the chip dropdown label for "show only this track".
function trackOnlyLabel(trackTag: string): string {
if (trackTag === "parent") {
return t("projects.detail.smarttimeline.track.only.parent");
}
if (trackTag.startsWith("counterclaim:")) {
return t("projects.detail.smarttimeline.track.only.counterclaim");
}
if (trackTag.startsWith("parent_context:")) {
return t("projects.detail.smarttimeline.track.only.parent_context");
}
return trackTag;
}
function renderLookaheadToggle(
futureRows: TimelineEvent[],
opts: RenderOptions,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "smart-timeline-lookahead";
const total = opts.projectedTotal ?? 0;
const projectedShown = futureRows.filter((r) => r.kind === "projected").length;
const cur = opts.lookahead ?? 7;
if (total > projectedShown && opts.onLookaheadChange) {
const more = document.createElement("button");
more.type = "button";
more.className = "smart-timeline-lookahead-btn";
more.textContent = t("projects.detail.smarttimeline.lookahead.more");
more.setAttribute(
"aria-label",
`${t("projects.detail.smarttimeline.lookahead.more")} (${total - projectedShown})`,
);
more.addEventListener("click", () => {
const next = Math.min(50, cur + 7);
void opts.onLookaheadChange?.(next);
});
wrap.appendChild(more);
}
if (cur > 7 && opts.onLookaheadChange) {
const less = document.createElement("button");
less.type = "button";
less.className = "smart-timeline-lookahead-btn smart-timeline-lookahead-btn--less";
less.textContent = t("projects.detail.smarttimeline.lookahead.less");
less.addEventListener("click", () => {
void opts.onLookaheadChange?.(7);
});
wrap.appendChild(less);
}
return wrap;
}
function renderRow(ev: TimelineEvent, opts: RenderOptions): HTMLElement {
const li = document.createElement("article");
li.className =
`smart-timeline-row smart-timeline-row--${ev.kind} ` +
`smart-timeline-row--${ev.status}`;
if (ev.deadline_rule_party) {
li.classList.add(`smart-timeline-row--party-${ev.deadline_rule_party}`);
}
const dateCol = document.createElement("div");
dateCol.className = "smart-timeline-date";
dateCol.textContent = ev.date ? formatDateOnly(dateOnlyISO(ev.date) ?? "") : "—";
li.appendChild(dateCol);
const body = document.createElement("div");
body.className = "smart-timeline-body";
const head = document.createElement("div");
head.className = "smart-timeline-row-head";
const icon = document.createElement("span");
icon.className = "smart-timeline-status-icon";
icon.textContent = statusGlyph(ev.status);
icon.setAttribute("aria-label", t(statusKey(ev.status)));
head.appendChild(icon);
const titleEl = document.createElement("span");
titleEl.className = "smart-timeline-title";
const href = deepLinkHref(ev);
if (href) {
const a = document.createElement("a");
a.className = "smart-timeline-link";
a.href = href;
a.textContent = ev.title;
titleEl.appendChild(a);
} else {
titleEl.textContent = ev.title;
}
head.appendChild(titleEl);
const kindChip = document.createElement("span");
kindChip.className = `smart-timeline-kind-chip smart-timeline-kind-chip--${ev.kind}`;
kindChip.textContent = t(kindKey(ev.kind));
head.appendChild(kindChip);
if (ev.rule_code) {
const ruleChip = document.createElement("span");
ruleChip.className = "smart-timeline-rule-chip";
ruleChip.textContent = ev.rule_code;
head.appendChild(ruleChip);
}
// "voraussichtlich" / "vom Gericht" / "überfällig" status pill on
// projected rows so the user reads the row's nature at a glance.
if (ev.kind === "projected") {
const statusPill = document.createElement("span");
statusPill.className = `smart-timeline-status-pill smart-timeline-status-pill--${ev.status}`;
statusPill.textContent = t(statusKey(ev.status));
head.appendChild(statusPill);
}
body.appendChild(head);
if (ev.description) {
const desc = document.createElement("div");
desc.className = "smart-timeline-desc";
desc.textContent = ev.description;
body.appendChild(desc);
}
// Depends-on footer (#31 layer 2) — surface the parent rule + its
// date right under the title so the user reads the dependency at a
// glance. "[Pfad anzeigen]" expands the full chain on demand.
if (ev.depends_on_rule_code) {
body.appendChild(renderDependsOn(ev));
}
// Click-to-anchor affordance (Slice 2 §6.2) — projected rows expose
// "[Datum setzen]" inline editor; actuals from rules expose a
// "[Datum ändern]" variant that PATCHes via the same endpoint.
if (ev.kind === "projected" && ev.deadline_rule_id && opts.projectId) {
body.appendChild(renderAnchorAction(ev, opts));
}
li.appendChild(body);
// Row-level navigation — same pattern as .entity-event (t-paliad-103).
if (href) {
li.classList.add("smart-timeline-row--clickable");
li.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.closest("a") || target.closest("button") || target.closest("input")) return;
window.location.href = href;
});
}
return li;
}
function renderDependsOn(ev: TimelineEvent): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "smart-timeline-depends-on";
const code = ev.depends_on_rule_code ?? "";
const name = ev.depends_on_rule_name || code;
const dateText = ev.depends_on_date
? formatDateOnly(dateOnlyISO(ev.depends_on_date) ?? "")
: t("projects.detail.smarttimeline.depends_on.date_open");
const prefix = t("projects.detail.smarttimeline.depends_on.prefix");
const txt = document.createElement("span");
txt.textContent = `${prefix}: ${name} (${code}, ${dateText})`;
wrap.appendChild(txt);
const expand = document.createElement("button");
expand.type = "button";
expand.className = "smart-timeline-depends-on-expand";
expand.textContent = t("projects.detail.smarttimeline.depends_on.show_path");
expand.addEventListener("click", () => {
if (wrap.classList.contains("smart-timeline-depends-on--expanded")) {
wrap.classList.remove("smart-timeline-depends-on--expanded");
const list = wrap.querySelector(".smart-timeline-depends-on-path");
if (list) list.remove();
expand.textContent = t("projects.detail.smarttimeline.depends_on.show_path");
return;
}
wrap.classList.add("smart-timeline-depends-on--expanded");
const list = document.createElement("div");
list.className = "smart-timeline-depends-on-path";
// The walked chain isn't pre-computed server-side beyond the
// immediate parent; the backend annotation gives one hop. Future
// slice can deepen this — for v1 we surface the immediate parent
// (already in the prefix line) and a hint that the user can click
// the parent's row to see its own dependency.
const hint = document.createElement("span");
hint.className = "smart-timeline-depends-on-hint";
hint.textContent = t("projects.detail.smarttimeline.depends_on.path_hint");
list.appendChild(hint);
wrap.appendChild(list);
expand.textContent = t("projects.detail.smarttimeline.depends_on.hide_path");
});
wrap.appendChild(expand);
return wrap;
}
function renderAnchorAction(ev: TimelineEvent, opts: RenderOptions): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "smart-timeline-anchor";
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "smart-timeline-anchor-btn";
trigger.textContent = t("projects.detail.smarttimeline.anchor.set");
wrap.appendChild(trigger);
trigger.addEventListener("click", () => {
if (wrap.classList.contains("smart-timeline-anchor--editing")) return;
wrap.classList.add("smart-timeline-anchor--editing");
trigger.style.display = "none";
wrap.appendChild(buildAnchorEditor(ev, opts, wrap));
});
return wrap;
}
function buildAnchorEditor(
ev: TimelineEvent,
opts: RenderOptions,
wrap: HTMLElement,
): HTMLElement {
const editor = document.createElement("form");
editor.className = "smart-timeline-anchor-form";
editor.setAttribute("aria-label", t("projects.detail.smarttimeline.anchor.set"));
editor.addEventListener("submit", (e) => e.preventDefault());
const dateInput = document.createElement("input");
dateInput.type = "date";
dateInput.className = "smart-timeline-anchor-date";
dateInput.required = true;
if (ev.date) dateInput.value = dateOnlyISO(ev.date) ?? "";
editor.appendChild(dateInput);
const submit = document.createElement("button");
submit.type = "submit";
submit.className = "smart-timeline-anchor-submit";
submit.textContent = t("projects.detail.smarttimeline.anchor.save");
editor.appendChild(submit);
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "smart-timeline-anchor-cancel";
cancel.textContent = t("projects.detail.smarttimeline.anchor.cancel");
cancel.addEventListener("click", () => {
wrap.innerHTML = "";
const trig = document.createElement("button");
trig.type = "button";
trig.className = "smart-timeline-anchor-btn";
trig.textContent = t("projects.detail.smarttimeline.anchor.set");
wrap.classList.remove("smart-timeline-anchor--editing");
wrap.appendChild(trig);
trig.addEventListener("click", () => {
wrap.innerHTML = "";
wrap.appendChild(buildAnchorEditor(ev, opts, wrap));
wrap.classList.add("smart-timeline-anchor--editing");
});
});
editor.appendChild(cancel);
const msg = document.createElement("div");
msg.className = "smart-timeline-anchor-msg";
editor.appendChild(msg);
editor.addEventListener("submit", async () => {
if (!opts.projectId) return;
if (!ev.rule_code) return;
const date = dateInput.value;
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
msg.textContent = t("projects.detail.smarttimeline.anchor.invalid_date");
msg.classList.add("smart-timeline-anchor-msg--error");
return;
}
submit.disabled = true;
cancel.disabled = true;
msg.classList.remove("smart-timeline-anchor-msg--error");
msg.textContent = t("projects.detail.smarttimeline.anchor.saving");
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(opts.projectId)}/timeline/anchor`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rule_code: ev.rule_code,
actual_date: date,
}),
},
);
if (resp.ok) {
msg.textContent = t("projects.detail.smarttimeline.anchor.saved");
if (opts.onChange) await opts.onChange();
return;
}
if (resp.status === 409) {
const payload = (await resp.json()) as PredecessorMissingPayload;
renderPredecessorError(msg, payload, ev, opts, dateInput, submit, cancel);
return;
}
msg.textContent = t("projects.detail.smarttimeline.anchor.error");
msg.classList.add("smart-timeline-anchor-msg--error");
} catch {
msg.textContent = t("projects.detail.smarttimeline.anchor.error");
msg.classList.add("smart-timeline-anchor-msg--error");
} finally {
submit.disabled = false;
cancel.disabled = false;
}
});
return editor;
}
function renderPredecessorError(
msg: HTMLElement,
payload: PredecessorMissingPayload,
_ev: TimelineEvent,
opts: RenderOptions,
_dateInput: HTMLInputElement,
_submit: HTMLButtonElement,
_cancel: HTMLButtonElement,
): void {
msg.innerHTML = "";
msg.classList.add("smart-timeline-anchor-msg--error");
msg.classList.add("smart-timeline-anchor-msg--predecessor");
const lang = (opts.lang ?? getLang()) === "en" ? "en" : "de";
const message = lang === "en" ? payload.message_en : payload.message_de;
const main = document.createElement("p");
main.textContent = message;
msg.appendChild(main);
// "Stattdessen <predecessor> erfassen" — pre-fills the editor for
// the missing parent rule, scrolls to its row if present, falls back
// to a fresh editor in-place.
const link = document.createElement("button");
link.type = "button";
link.className = "smart-timeline-anchor-predecessor-link";
const predName =
lang === "en" ? payload.missing_rule_name_en : payload.missing_rule_name_de;
link.textContent =
lang === "en"
? `Anchor „${predName}“ instead`
: `Stattdessen „${predName}“ erfassen`;
link.addEventListener("click", () => {
// Find the projected row for missing_rule_code and scroll into view;
// the row's own [Datum setzen] button takes it from there.
const targetRow = findRowForRuleCode(payload.missing_rule_code);
if (targetRow) {
targetRow.scrollIntoView({ behavior: "smooth", block: "center" });
const btn = targetRow.querySelector<HTMLButtonElement>(
".smart-timeline-anchor-btn",
);
if (btn) btn.click();
}
});
msg.appendChild(link);
}
function findRowForRuleCode(ruleCode: string): HTMLElement | null {
const rows = document.querySelectorAll<HTMLElement>(".smart-timeline-row");
for (const r of Array.from(rows)) {
const chip = r.querySelector(".smart-timeline-rule-chip");
if (chip && chip.textContent === ruleCode) return r;
}
return null;
}
function deepLinkHref(ev: TimelineEvent): string | null {
if (ev.kind === "deadline" && ev.deadline_id) {
return `/deadlines/${ev.deadline_id}`;
}
if (ev.kind === "appointment" && ev.appointment_id) {
return `/appointments/${ev.appointment_id}`;
}
return null;
}
function statusGlyph(status: TimelineEvent["status"]): string {
switch (status) {
case "done": return "✓";
case "open": return "…";
case "overdue": return "!";
case "court_set": return "▢";
case "predicted": return "░";
case "predicted_overdue": return "░!";
case "off_script": return "⊕";
default: return "·";
}
}
function statusKey(status: TimelineEvent["status"]) {
return `projects.detail.smarttimeline.status.${status}` as const;
}
function kindKey(kind: TimelineEvent["kind"]) {
return `projects.detail.smarttimeline.kind.${kind}` as const;
}
function dateOnlyISO(raw: string | null | undefined): string | null {
if (!raw) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
const d = new Date(raw);
if (isNaN(d.getTime())) return null;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function todayLocalISO(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function byDateAsc(a: TimelineEvent, b: TimelineEvent): number {
const ai = dateOnlyISO(a.date) ?? "";
const bi = dateOnlyISO(b.date) ?? "";
if (ai === bi) return a.title.localeCompare(b.title);
return ai < bi ? -1 : 1;
}
function formatDateOnly(iso: string): string {
if (!iso) return "—";
const parts = iso.split("-");
if (parts.length !== 3) return iso;
const d = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}

View File

@@ -20,7 +20,7 @@ export interface ScopeSpec {
export type TimeHorizon =
| "next_7d" | "next_30d" | "next_90d"
| "past_30d" | "past_90d"
| "past_7d" | "past_30d" | "past_90d"
| "any" | "all" | "custom";
export type TimeField = "auto" | "created_at";
@@ -69,12 +69,23 @@ export interface FilterSpec {
predicates?: Partial<Record<DataSource, Predicates>>;
}
export type RenderShape = "list" | "cards" | "calendar";
export type RenderShape = "list" | "cards" | "calendar" | "timeline";
export interface TimelineCVConfig {
palette?: "default" | "kind-coded" | "track-coded" | "high-contrast" | "print";
density?: "compact" | "standard" | "spacious";
range_preset?: "1y" | "2y" | "all" | "custom";
range_from?: string;
range_to?: string;
}
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 {
@@ -93,6 +104,7 @@ export interface RenderSpec {
list?: ListConfig;
cards?: CardsConfig;
calendar?: CalendarConfig;
timeline?: TimelineCVConfig;
}
// ViewRow — the discriminated row shape from ViewService.RunSpec.

View File

@@ -0,0 +1,447 @@
// Shared core for Fristenrechner-style proceeding-timeline rendering.
//
// Both /tools/fristenrechner (deadline determination) and
// /tools/verfahrensablauf (abstract browse — t-paliad-179 Slice 1) call
// POST /api/tools/fristenrechner and paint the result with the same
// renderers. The module is pure-functional: no shared mutable state, all
// language / overrides / editability flow in through args so the two
// pages can wire their own per-page concerns (Akte save, anchor edits,
// Pathway B etc. on fristenrechner; variant chips, compare etc. coming
// to verfahrensablauf in later slices) without leaking into each other.
import { t, tDyn, getLang } from "../i18n";
export interface AdjustmentHoliday {
Date: string;
Name: string;
IsVacation: boolean;
IsClosure: boolean;
}
export interface AdjustmentReason {
kind: "weekend" | "public_holiday" | "vacation";
holidays?: AdjustmentHoliday[];
vacation_name?: string;
vacation_start?: string;
vacation_end?: string;
original_weekday?: string;
}
export interface CalculatedDeadline {
code: string;
name: string;
nameEN: string;
party: string;
isMandatory: boolean;
ruleRef: string;
legalSource?: string;
notes?: string;
notesEN?: string;
dueDate: string;
originalDate: string;
wasAdjusted: boolean;
adjustmentReason?: AdjustmentReason;
isRootEvent: boolean;
isCourtSet: boolean;
isCourtSetIndirect?: boolean;
isOptional?: boolean;
isOverridden?: boolean;
}
export interface DeadlineResponse {
proceedingType: string;
proceedingName: string;
triggerDate: string;
deadlines: CalculatedDeadline[];
}
export interface CourtRow {
id: string;
code: string;
nameDE: string;
nameEN: string;
country: string;
regime?: string;
courtType: string;
}
export interface CalcParams {
proceedingType: string;
triggerDate: string;
priorityDate?: string;
flags?: string[];
anchorOverrides?: Record<string, string>;
courtId?: string;
}
const PARTY_CLASS: Record<string, string> = {
claimant: "party-claimant",
defendant: "party-defendant",
court: "party-court",
both: "party-both",
};
// ─── small helpers ─────────────────────────────────────────────────────────
export function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
export function escHtml(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
export function formatDate(dateStr: string): string {
if (!dateStr) return "—";
const d = new Date(dateStr + "T00:00:00");
if (getLang() === "en") {
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${weekday}, ${yyyy}-${mm}-${dd}`;
}
return d.toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
function formatDateSpan(startISO: string, endISO: string): string {
const start = new Date(startISO + "T00:00:00");
const end = new Date(endISO + "T00:00:00");
if (getLang() === "en") {
const fmt = (d: Date) => d.toLocaleDateString("en-US", { day: "numeric", month: "short" });
return `${fmt(start)} ${fmt(end)}`;
}
const fmt = (d: Date) => `${d.getDate()}.${d.getMonth() + 1}.`;
return `${fmt(start)}${fmt(end)}`;
}
function localizeWeekday(en: string): string {
if (en === "Saturday") return t("deadlines.adjusted.weekend.saturday");
if (en === "Sunday") return t("deadlines.adjusted.weekend.sunday");
return en;
}
// Vacation names come straight from paliad.holidays (e.g. "UPC judicial
// vacation"). Not translated — they're proper names of court-set closures.
function localizeVacationName(name: string): string {
return name;
}
function renderAdjustmentReason(r: AdjustmentReason): string {
if (r.kind === "vacation" && r.vacation_name && r.vacation_start && r.vacation_end) {
const span = formatDateSpan(r.vacation_start, r.vacation_end);
return tDyn("deadlines.adjusted.vacation")
.replace("{name}", localizeVacationName(r.vacation_name))
.replace("{span}", span);
}
if (r.kind === "public_holiday" && r.holidays && r.holidays.length > 0) {
return tDyn("deadlines.adjusted.holiday").replace("{name}", r.holidays[0].Name);
}
if (r.kind === "weekend" && r.original_weekday) {
return localizeWeekday(r.original_weekday);
}
return t("deadlines.adjusted.weekend");
}
function formatAdjustedNote(dl: CalculatedDeadline): string {
const arrow = `${formatDate(dl.originalDate)}${formatDate(dl.dueDate)}`;
const reason = dl.adjustmentReason
? renderAdjustmentReason(dl.adjustmentReason)
: t("deadlines.adjusted.reason");
if (getLang() === "en") {
return `${t("deadlines.adjusted")} (${reason}): ${arrow}`;
}
return `${t("deadlines.adjusted")} wegen ${reason}: ${arrow}`;
}
export function partyBadge(party: string): string {
const cls = PARTY_CLASS[party] || "party-both";
return `<span class="party-badge ${cls}">${tDyn("deadlines.party." + party)}</span>`;
}
// ─── card + body renderers ────────────────────────────────────────────────
export interface CardOpts {
showParty: boolean;
// editable=true wires the click-to-edit affordance: data-rule-code,
// role=button, tabindex, hover hint. Fristenrechner enables it; the
// verfahrensablauf abstract-browse surface keeps editable=false because
// there's no anchor-override state on that page in Slice 1.
editable?: boolean;
}
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
const wantsEditable = !!opts.editable;
const editable = wantsEditable && !dl.isRootEvent && dl.code !== "";
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
const editAttrs = editable
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
: "";
const courtLabelKey = dl.isCourtSetIndirect
? "deadlines.court.indirect"
: "deadlines.court.set";
const dateStr = dl.isCourtSet
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
const mandatoryBadge = dl.isMandatory
? ""
: '<span class="optional-badge">optional</span>';
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
const adjustedNote = dl.wasAdjusted
? `<div class="timeline-adjusted">⚠ ${formatAdjustedNote(dl)}</div>`
: "";
const ruleRef = dl.ruleRef
? `<span class="timeline-rule">${dl.ruleRef}</span>`
: "";
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
const notes = noteText
? `<div class="timeline-notes">${noteText}</div>`
: "";
const meta = (opts.showParty || ruleRef)
? `<div class="timeline-meta">
${opts.showParty ? partyBadge(dl.party) : ""}
${ruleRef}
</div>`
: "";
return `<div class="timeline-item-header">
<span class="timeline-name">
${dlName}
${mandatoryBadge}
</span>
${dateStr}
</div>
${meta}
${adjustedNote}
${notes}`;
}
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
let html = '<div class="timeline">';
for (const dl of data.deadlines) {
html += `
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
<div class="timeline-dot-col">
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
<div class="timeline-line"></div>
</div>
<div class="timeline-content">
${deadlineCardHtml(dl, opts)}
</div>
</div>
`;
}
html += "</div>";
return html;
}
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
// (defendant). Each grid row shares a dueDate so same-day events line up
// across columns; party=both renders in BOTH the Proactive and Reactive
// cells of the row. Undated rows (Urteil etc.) trail the dated tail, each
// keyed by sequence-order so e.g. Urteil precedes Berufungseinlegung.
export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "showParty"> = {}): string {
type Cell = CalculatedDeadline[];
type Row = { proactive: Cell; court: Cell; reactive: Cell };
const UNSCHEDULED_PREFIX = "__unscheduled__";
const rowsMap = new Map<string, Row>();
const ensureRow = (key: string): Row => {
let r = rowsMap.get(key);
if (!r) {
r = { proactive: [], court: [], reactive: [] };
rowsMap.set(key, r);
}
return r;
};
data.deadlines.forEach((dl, idx) => {
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
const row = ensureRow(key);
switch (dl.party) {
case "claimant":
row.proactive.push(dl);
break;
case "defendant":
row.reactive.push(dl);
break;
case "court":
row.court.push(dl);
break;
case "both":
row.proactive.push(dl);
row.reactive.push(dl);
break;
default:
row.court.push(dl);
}
});
const datedKeys: string[] = [];
const unscheduledKeys: string[] = [];
for (const k of rowsMap.keys()) {
if (k.startsWith(UNSCHEDULED_PREFIX)) unscheduledKeys.push(k);
else datedKeys.push(k);
}
datedKeys.sort();
unscheduledKeys.sort();
const keys = [...datedKeys, ...unscheduledKeys];
const cardOpts: CardOpts = { showParty: false, editable: opts.editable };
const renderCell = (items: CalculatedDeadline[]): string => {
if (items.length === 0) {
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
}
const cards = items
.map((dl) => {
const mirrorTag = dl.party === "both"
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
: "";
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
${deadlineCardHtml(dl, cardOpts)}
${mirrorTag}
</div>`;
})
.join("");
return `<div class="fr-col-cell">${cards}</div>`;
};
const headerCell = (label: string, cls: string) =>
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
let html = '<div class="fr-columns-view">';
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
html += headerCell(t("deadlines.col.court"), "fr-col-court");
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
for (const key of keys) {
const row = rowsMap.get(key)!;
html += renderCell(row.proactive);
html += renderCell(row.court);
html += renderCell(row.reactive);
}
html += "</div>";
return html;
}
// ─── calculate fetch wrapper ──────────────────────────────────────────────
export async function calculateDeadlines(params: CalcParams): Promise<DeadlineResponse | null> {
if (!params.proceedingType || !params.triggerDate) return null;
try {
const resp = await fetch("/api/tools/fristenrechner", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
proceedingType: params.proceedingType,
triggerDate: params.triggerDate,
priorityDate: params.priorityDate || undefined,
flags: params.flags && params.flags.length > 0 ? params.flags : undefined,
anchorOverrides: params.anchorOverrides && Object.keys(params.anchorOverrides).length > 0
? params.anchorOverrides
: undefined,
courtId: params.courtId || undefined,
}),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
console.error("API error:", err);
return null;
}
return (await resp.json()) as DeadlineResponse;
} catch (e) {
console.error("Fetch error:", e);
return null;
}
}
// ─── court picker ─────────────────────────────────────────────────────────
const courtCache = new Map<string, CourtRow[]>();
export function courtTypesFor(proceedingType: string): string[] {
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
return ["UPC-CoA"];
}
if (proceedingType === "UPC_REV") {
return ["UPC-CD", "UPC-LD"];
}
if (proceedingType.startsWith("UPC_")) {
return ["UPC-LD"];
}
return [];
}
export function defaultCourtFor(proceedingType: string): string {
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
return "upc-coa-luxembourg";
}
if (proceedingType === "UPC_REV") {
return "upc-cd-paris";
}
return "upc-ld-muenchen";
}
export async function fetchCourts(courtType: string): Promise<CourtRow[]> {
if (courtCache.has(courtType)) return courtCache.get(courtType)!;
try {
const resp = await fetch(`/api/tools/courts?courtType=${encodeURIComponent(courtType)}`);
if (!resp.ok) return [];
const rows = (await resp.json()) as CourtRow[];
courtCache.set(courtType, rows);
return rows;
} catch {
return [];
}
}
// populateCourtPicker fills the <select> for the proceeding's compatible
// court types. The row + select IDs are passed in so each page can own
// its own DOM scope. Visible only when the proceeding has ≥2 compatible
// courts; otherwise hidden (server resolves the jurisdiction default).
export async function populateCourtPicker(
rowId: string,
selectId: string,
proceedingType: string,
): Promise<void> {
const row = document.getElementById(rowId);
const select = document.getElementById(selectId) as HTMLSelectElement | null;
if (!row || !select) return;
const types = courtTypesFor(proceedingType);
if (types.length === 0) {
row.style.display = "none";
select.innerHTML = "";
return;
}
const lists = await Promise.all(types.map((c) => fetchCourts(c)));
const courts = lists.flat();
if (courts.length <= 1) {
row.style.display = "none";
select.innerHTML = "";
return;
}
const lang = getLang();
const defaultID = defaultCourtFor(proceedingType);
select.innerHTML = courts.map((c) => {
const name = lang === "en" ? c.nameEN : c.nameDE;
return `<option value="${escAttr(c.id)}"${c.id === defaultID ? " selected" : ""}>${escHtml(name)}</option>`;
}).join("");
row.style.display = "";
}

View File

@@ -2,7 +2,6 @@ import { h, Fragment } from "../jsx";
const ICON_GAUGE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 14l3.5-3.5"/><path d="M3 12a9 9 0 0 1 18 0"/><path d="M12 3v2"/><path d="M3 12H5"/><path d="M19 12h2"/><path d="M5.6 5.6l1.4 1.4"/><path d="M17 7l1.4-1.4"/></svg>';
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
const ICON_AGENDA = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="7" y1="13" x2="13" y2="13"/><line x1="7" y1="17" x2="17" y2="17"/></svg>';
const ICON_MENU = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>';
const ICON_PLUS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
const ICON_DEADLINE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
@@ -39,7 +38,10 @@ export function BottomNav({ currentPath }: BottomNavProps): string {
<span className="bottom-nav-label" data-i18n="bottomnav.add">Anlegen</span>
</button>
{slot("/agenda", ICON_AGENDA, "nav.agenda", "Agenda", currentPath, "bottom-nav-agenda-badge")}
{/* t-paliad-162 — Agenda lives inline on the dashboard now; the
mobile slot points at Fristen so today's-deadline access stays
one tap away from the bottom rail. */}
{slot("/events?type=deadline", ICON_DEADLINE, "nav.fristen", "Fristen", currentPath, "bottom-nav-agenda-badge")}
<button type="button" className="bottom-nav-slot" id="bottom-nav-menu" aria-label="Menü">
<span className="bottom-nav-icon" dangerouslySetInnerHTML={{ __html: ICON_MENU }} />

View File

@@ -0,0 +1,177 @@
import { h, Fragment } from "../jsx";
// PaliadinWidget — inline floating-button + slide-out drawer for the
// Paliad assistant (t-paliad-161).
//
// Rendered on every authenticated page near </body>. Hidden by default
// (display:none) and revealed by client/paliadin-widget.ts after a
// /api/me call confirms the caller is the Paliadin owner — same fail-
// closed pattern as the sidebar /paliadin link.
//
// Visibility is also gated on the current pathname: hidden on /paliadin
// (the standalone page IS the assistant), /login, and /onboarding.
//
// Trigger: click the floating ✨ button or press Cmd+J / Ctrl+J. (Cmd+K
// is reserved for the global search palette in client/search.ts.)
const ICON_SPARKLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v4"/><path d="M12 17v4"/><path d="M3 12h4"/><path d="M17 12h4"/><path d="M5.6 5.6l2.8 2.8"/><path d="M15.6 15.6l2.8 2.8"/><path d="M5.6 18.4l2.8-2.8"/><path d="M15.6 8.4l2.8-2.8"/></svg>';
const ICON_CLOSE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
const ICON_RESET = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><polyline points="3 4 3 10 9 10"/></svg>';
const ICON_FULLSCREEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17l-4 4"/><path d="M21 3l-4 4"/><polyline points="14 3 21 3 21 10"/><polyline points="10 21 3 21 3 14"/></svg>';
const ICON_SEND = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
export function PaliadinWidget(): string {
return (
<Fragment>
{/*
Floating trigger button. Hidden by default (display:none); revealed
by client/paliadin-widget.ts after /api/me confirms the caller is
the Paliadin owner AND the route is one where the widget should
show. The widget hides itself again on /paliadin, /login,
/onboarding via the same predicate.
*/}
<button
type="button"
id="paliadin-widget-trigger"
className="paliadin-widget-trigger"
style="display:none"
aria-label="Paliadin"
title="Paliadin (Cmd+J)"
data-i18n-title="paliadin.widget.trigger"
>
<span className="paliadin-widget-trigger-glyph"
dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
</button>
{/*
Backdrop scrim — receives clicks to close. Pointer-events guard
on display:none (CSS) until the drawer opens.
*/}
<div
id="paliadin-widget-scrim"
className="paliadin-widget-scrim"
aria-hidden="true"
style="display:none"
/>
{/*
Slide-out drawer. role="dialog" + aria-modal so screen readers
announce it as a modal panel; aria-labelledby points at the
header h2.
*/}
<aside
id="paliadin-widget-drawer"
className="paliadin-widget-drawer"
role="dialog"
aria-modal="true"
aria-labelledby="paliadin-widget-title"
aria-hidden="true"
style="display:none"
data-open="false"
>
<header className="paliadin-widget-header">
<h2 id="paliadin-widget-title" className="paliadin-widget-title">
<span className="paliadin-widget-title-glyph"
dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
<span data-i18n="paliadin.widget.title">Paliadin</span>
</h2>
<div className="paliadin-widget-actions">
<button
type="button"
id="paliadin-widget-reset"
className="paliadin-widget-action-btn"
aria-label="Reset"
title="Konversation zurücksetzen"
data-i18n-title="paliadin.widget.reset"
>
<span dangerouslySetInnerHTML={{ __html: ICON_RESET }} />
</button>
<a
href="/paliadin"
id="paliadin-widget-fullscreen"
className="paliadin-widget-action-btn"
aria-label="Fullscreen"
title="Vollbild-Modus"
data-i18n-title="paliadin.widget.fullscreen"
>
<span dangerouslySetInnerHTML={{ __html: ICON_FULLSCREEN }} />
</a>
<button
type="button"
id="paliadin-widget-close"
className="paliadin-widget-action-btn"
aria-label="Close"
title="Schließen"
data-i18n-title="paliadin.widget.close"
>
<span dangerouslySetInnerHTML={{ __html: ICON_CLOSE }} />
</button>
</div>
</header>
{/*
Context chip — shows what Paliadin knows about the current page
(route + primary entity). Populated by paliadin-widget.ts from
computePaliadinContext() each time the drawer opens.
*/}
<div
id="paliadin-widget-context-chip"
className="paliadin-widget-context-chip"
style="display:none"
>
<span className="paliadin-widget-context-label"
data-i18n="paliadin.widget.context.on_page">Auf dieser Seite</span>
<span className="paliadin-widget-context-value" id="paliadin-widget-context-value" />
</div>
{/*
Message stream + empty-state starter prompts. Empty state
renders the per-route starter list from
frontend/src/client/paliadin-starters.ts; on first send it
slides out and the messages take over.
*/}
<div
id="paliadin-widget-messages"
className="paliadin-widget-messages"
aria-live="polite"
>
<div className="paliadin-widget-empty" id="paliadin-widget-empty">
<p className="paliadin-widget-empty-prompt"
data-i18n="paliadin.widget.empty">Was kann ich für dich tun?</p>
<div
className="paliadin-widget-starters"
id="paliadin-widget-starters"
>
{/* Populated by paliadin-widget.ts from the per-route registry. */}
</div>
</div>
</div>
<form className="paliadin-widget-form" id="paliadin-widget-form">
<textarea
className="paliadin-widget-input"
id="paliadin-widget-input"
rows={2}
placeholder="Frage an Paliadin..."
data-i18n-placeholder="paliadin.widget.input.placeholder"
aria-label="Nachricht an Paliadin"
data-i18n-aria-label="paliadin.widget.input.label"
maxlength={4000}
/>
<button
type="submit"
className="paliadin-widget-send-btn"
id="paliadin-widget-send-btn"
aria-label="Senden"
title="Senden"
data-i18n-title="paliadin.widget.send"
>
<span dangerouslySetInnerHTML={{ __html: ICON_SEND }} />
</button>
</form>
</aside>
<script src="/assets/paliadin-widget.js" defer />
</Fragment>
);
}

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

@@ -7,6 +7,11 @@ const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" s
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const ICON_LINK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>';
// Open-book icon for the /tools/verfahrensablauf "Verfahrensablauf"
// nav entry (t-paliad-168 → t-paliad-179 Slice 1 split). Distinct from
// ICON_BOOK (Glossar, closed) so the two affordances read as different
// at a glance.
const ICON_BOOK_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/><path d="M22 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>';
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
const ICON_GLOBE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/></svg>';
@@ -17,7 +22,6 @@ const ICON_MENU = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
const ICON_CALENDAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
const ICON_GAUGE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 14l3.5-3.5"/><path d="M3 12a9 9 0 0 1 18 0"/><path d="M12 3v2"/><path d="M3 12H5"/><path d="M19 12h2"/><path d="M5.6 5.6l1.4 1.4"/><path d="M17 7l1.4-1.4"/></svg>';
const ICON_AGENDA = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="7" y1="13" x2="13" y2="13"/><line x1="7" y1="17" x2="17" y2="17"/></svg>';
const ICON_GEAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
const ICON_SEARCH = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
@@ -25,6 +29,10 @@ const ICON_SPARKLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
const ICON_USERS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
const ICON_SHIELD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>';
const ICON_AUDIT_LOG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="15" y2="17"/></svg>';
// Newspaper icon for the /changelog "Neuigkeiten" entry. Sparkle is now
// reserved for the Paliadin AI surface so the two affordances don't
// share a glyph (m's 2026-05-08 21:11 dogfood).
const ICON_NEWSPAPER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8z"/></svg>';
// Bell icon for the /inbox entry (t-paliad-138 4-eye approval inbox).
const ICON_BELL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
// Theme-toggle icons. The button cycles auto → light → dark → auto, and
@@ -112,33 +120,47 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{navItem("/", ICON_HOME, "nav.home", "Home", currentPath)}
{/* Paliadin top-level entry (t-paliad-162) \u2014 owner-only, hidden
by default. sidebar.ts reveals it after /api/me confirms the
caller is the Paliadin owner (t-paliad-146 PoC scope). Same
fail-closed pattern as the admin group below. Sits directly
under Home per m's design call so owners hit their assistant
with one click from anywhere. */}
<a href="/paliadin"
className={`sidebar-item sidebar-paliadin${currentPath === "/paliadin" ? " active" : ""}`}
id="sidebar-paliadin-link" style="display:none">
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
<span className="sidebar-label" data-i18n="nav.paliadin">Paliadin</span>
</a>
{group("nav.group.uebersicht", "\u00DCbersicht",
navItem("/dashboard", ICON_GAUGE, "nav.dashboard", "Dashboard", currentPath) +
navItem("/agenda", ICON_AGENDA, "nav.agenda", "Agenda", currentPath) +
navItem("/projects", ICON_FOLDER, "nav.projekte", "Projekte", currentPath) +
navItem("/inbox", ICON_BELL, "nav.inbox", "Inbox", currentPath, "sidebar-inbox-badge") +
// Paliadin entry \u2014 owner-only, hidden by default. sidebar.ts
// reveals it after /api/me confirms the caller is the
// Paliadin owner (t-paliad-146 PoC scope). Same fail-closed
// pattern as the admin group below.
`<a href="/paliadin" class="sidebar-item sidebar-paliadin${currentPath === "/paliadin" ? " active" : ""}" id="sidebar-paliadin-link" style="display:none">` +
`<span class="sidebar-icon">${ICON_SPARKLE}</span>` +
`<span class="sidebar-label" data-i18n="nav.paliadin">Paliadin</span>` +
`</a>` +
navItem("/team", ICON_USERS, "nav.team", "Team", currentPath),
)}
{group("nav.group.arbeit", "Arbeit",
navItem("/projects", ICON_FOLDER, "nav.projekte", "Projekte", currentPath) +
navItem("/events?type=deadline", ICON_CLOCK, "nav.fristen", "Fristen", currentPath) +
navItem("/events?type=appointment", ICON_CALENDAR, "nav.termine", "Termine", currentPath),
)}
{/* t-paliad-177 \u2014 contextual chart link, revealed by sidebar.ts
when the user is on a /projects/{id}/* page (but NOT on the
chart itself). The href is filled in client-side from the
URL path so the same Sidebar TSX serves every page. */}
<a href="#"
className="sidebar-item sidebar-context-chart"
id="sidebar-project-chart-link"
style="display:none">
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_GAUGE }} />
<span className="sidebar-label" data-i18n="nav.context.project_chart">Als Chart anzeigen</span>
</a>
{/* t-paliad-144 Phase A2 — Meine Sichten group. Hydrated by
client/sidebar.ts from /api/user-views on mount. The
"+ Neue Sicht" entry is always present so first-time
users have an obvious way in. */}
{/* Ansichten \u2014 single consolidated group (m's 2026-05-08 20:32
dogfood: "all views under one — not Ansichten and meine Ansichten").
Holds the built-in Fristen + Termine, the user-defined views
hydrated by client/sidebar.ts from /api/user-views, and the
"+ Neue Sicht" entry. The previous "Meine Sichten" split is gone. */}
<div className="sidebar-group sidebar-views-group" id="sidebar-views-group">
<div className="sidebar-group-label" data-i18n="nav.group.user_views">Meine Sichten</div>
<div className="sidebar-group-label" data-i18n="nav.group.ansichten">Ansichten</div>
{navItem("/events?type=deadline", ICON_CLOCK, "nav.fristen", "Fristen", currentPath)}
{navItem("/events?type=appointment", ICON_CALENDAR, "nav.termine", "Termine", currentPath)}
<div className="sidebar-views-items" id="sidebar-views-items" />
<a href="/views/new" className="sidebar-item sidebar-views-new">
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_FOLDER }} />
@@ -146,21 +168,20 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
</a>
</div>
{/* t-paliad-162 — single Werkzeuge group consolidating the prior
Werkzeuge / Wissen / Ressourcen splits. Order follows m's
brief: calculators first, then reference (Checklisten /
Gerichte / Glossar), then content (Links / Downloads). */}
{group("nav.group.werkzeuge", "Werkzeuge",
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath),
)}
{group("nav.group.wissen", "Wissen",
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +
navItem("/courts", ICON_BUILDING, "nav.gerichte", "Gerichte", currentPath) +
navItem("/glossary", ICON_BOOK, "nav.glossar", "Glossar", currentPath) +
navItem("/courts", ICON_BUILDING, "nav.gerichte", "Gerichte", currentPath),
)}
{group("nav.group.ressourcen", "Ressourcen",
navItem("/downloads", ICON_DOWNLOAD, "nav.downloads", "Downloads", currentPath) +
navItem("/links", ICON_LINK, "nav.links", "Links", currentPath),
navItem("/links", ICON_LINK, "nav.links", "Links", currentPath) +
navItem("/downloads", ICON_DOWNLOAD, "nav.downloads", "Downloads", currentPath),
)}
{group("nav.group.einstellungen", "Einstellungen",
@@ -194,7 +215,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
<div className="sidebar-bottom">
{authenticated ? (
<a href="/changelog" className={`sidebar-item sidebar-changelog${currentPath === "/changelog" ? " active" : ""}`} id="sidebar-changelog-link">
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_NEWSPAPER }} />
<span className="sidebar-label" data-i18n="nav.neuigkeiten">Neuigkeiten</span>
<span className="sidebar-badge" id="sidebar-changelog-badge" style="display:none" aria-hidden="true" />
</a>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -120,6 +121,7 @@ export function renderCourts(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/courts.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -11,6 +12,35 @@ import { PWAHead } from "./components/PWAHead";
const HYDRATION_SCRIPT =
"/*__PALIAD_DASHBOARD_DATA__*/";
// Chevron used as the collapsible-section disclosure indicator. CSS rotates
// it 90deg clockwise when the section is open via the
// .dashboard-section[aria-expanded="true"] selector — see global.css.
const ICON_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>';
// Render a collapsible dashboard section. The toggle button is the entire
// header row so the heading text doubles as the affordance. State is
// hydrated client-side from localStorage by client/dashboard.ts; SSR
// renders all sections expanded so unstyled fallback is sensible.
function CollapsibleSection(props: {
id: string;
headingI18n: string;
headingDe: string;
children: any;
}): string {
return (
<section className="dashboard-section" data-collapse-key={props.id} aria-expanded="true">
<button type="button" className="dashboard-section-toggle" aria-expanded="true">
<h3 className="dashboard-section-heading" data-i18n={props.headingI18n}>{props.headingDe}</h3>
<span className="dashboard-section-chevron" aria-hidden="true"
dangerouslySetInnerHTML={{ __html: ICON_CHEVRON }} />
</button>
<div className="dashboard-section-body">
{props.children}
</div>
</section>
);
}
export function renderDashboard(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
@@ -58,10 +88,7 @@ export function renderDashboard(): string {
</div>
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
<section className="dashboard-summary" aria-labelledby="dashboard-summary-heading">
<h2 id="dashboard-summary-heading" className="dashboard-section-heading" data-i18n="dashboard.summary.heading">
Fristen auf einen Blick
</h2>
<CollapsibleSection id="summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
<div className="dashboard-summary-grid">
<a href="/deadlines?status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
@@ -84,9 +111,11 @@ export function renderDashboard(): string {
<div className="dashboard-card-label" data-i18n="dashboard.summary.later">Sp&auml;ter</div>
</a>
</div>
</section>
</CollapsibleSection>
{/* Matter summary card */}
{/* Matter summary card — single tappable card, kept outside the
collapsible scaffold because its h3 is internal to the card
and doubles as the navigation affordance. */}
<section className="dashboard-matters">
<a href="/projects" className="dashboard-matter-card">
<div className="dashboard-matter-header">
@@ -110,38 +139,59 @@ export function renderDashboard(): string {
</a>
</section>
{/* Two-column lists */}
{/* Two-column lists — each column is its own collapsible section
so users can hide deadlines or appointments independently.
The .dashboard-columns wrapper is preserved so the grid
layout still applies; collapse hides the body of each col
but leaves the heading row in the grid. */}
<div className="dashboard-columns">
<section className="dashboard-col">
<h3 className="dashboard-section-heading" data-i18n="dashboard.deadlines.heading">Kommende Fristen</h3>
<CollapsibleSection id="deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
<ul className="dashboard-list" id="dashboard-deadlines-list"></ul>
<p className="dashboard-empty" id="dashboard-deadlines-empty" style="display:none" data-i18n="dashboard.deadlines.empty">
Keine Fristen in den n&auml;chsten 7 Tagen.
</p>
</section>
</CollapsibleSection>
<section className="dashboard-col">
<h3 className="dashboard-section-heading" data-i18n="dashboard.appointments.heading">Kommende Termine</h3>
<CollapsibleSection id="appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
<ul className="dashboard-list" id="dashboard-appointments-list"></ul>
<p className="dashboard-empty" id="dashboard-appointments-empty" style="display:none" data-i18n="dashboard.appointments.empty">
Keine Termine in den n&auml;chsten 7 Tagen.
</p>
</section>
</CollapsibleSection>
</div>
{/* Activity feed */}
<section className="dashboard-activity">
<h3 className="dashboard-section-heading" data-i18n="dashboard.activity.heading">Letzte Aktivit&auml;t</h3>
{/* Inline Agenda (t-paliad-162). Same item shape as the
standalone /agenda page, rendered via the shared
agenda-render module. The dashboard variant is read-only:
no chip filters, no URL state — a 30-day window of
upcoming items grouped by day. The standalone /agenda
route is unchanged for direct-link compatibility. */}
<CollapsibleSection id="agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
<div className="dashboard-agenda">
<div className="agenda-timeline" id="dashboard-agenda-timeline" />
<p className="dashboard-empty" id="dashboard-agenda-empty" style="display:none" data-i18n="dashboard.agenda.empty">
Keine F&auml;lligkeiten in den n&auml;chsten 30 Tagen.
</p>
<p className="dashboard-agenda-link">
<a href="/agenda" data-i18n="dashboard.agenda.full_link">Vollst&auml;ndige Agenda &ouml;ffnen &rarr;</a>
</p>
</div>
</CollapsibleSection>
{/* Activity feed — moved under Agenda per m's design call
(t-paliad-162). */}
<CollapsibleSection id="activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
<ul className="dashboard-activity-list" id="dashboard-activity-list"></ul>
<p className="dashboard-empty" id="dashboard-activity-empty" style="display:none" data-i18n="dashboard.activity.empty">
Noch keine Aktivit&auml;t erfasst.
</p>
</section>
</CollapsibleSection>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/dashboard.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -75,6 +76,7 @@ export function renderDeadlinesCalendar(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/deadlines-calendar.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -43,6 +44,9 @@ export function renderDeadlinesDetail(): string {
<div className="entity-detail-meta">
<span id="deadline-due-chip" className="frist-due-chip" />
<span id="deadline-status-chip" className="entity-status-chip" />
<span id="deadline-pending-approval-badge" className="approval-pending-badge" style="display:none" data-i18n="approvals.pending.badge" title="">
Wartet auf Genehmigung
</span>
<a id="deadline-project-link" className="entity-ref" href="#" />
<select id="deadline-project-edit" className="entity-ref-select" style="display:none" />
</div>
@@ -54,6 +58,9 @@ export function renderDeadlinesDetail(): string {
<button id="deadline-reopen-btn" type="button" className="btn-primary btn-cta-lime btn-small" style="display:none" data-i18n="deadlines.detail.reopen">
Wieder &ouml;ffnen
</button>
<button id="deadline-withdraw-btn" type="button" className="btn-secondary btn-small" style="display:none" data-i18n="approvals.withdraw.cta">
Genehmigungsanfrage zur&uuml;ckziehen
</button>
<button id="deadline-edit-btn" className="btn-icon" type="button" aria-label="Bearbeiten" data-i18n-title="deadlines.detail.edit" title="Bearbeiten">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
@@ -135,6 +142,7 @@ export function renderDeadlinesDetail(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/deadlines-detail.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -54,9 +55,50 @@ export function renderDeadlinesNew(): string {
/>
</div>
<div className="form-field">
<div className="form-field" id="deadline-event-type-field">
<label data-i18n="deadlines.field.event_type">Typ (optional)</label>
{/* t-paliad-165 follow-up — collapsed view: when a Regel
is selected and a default event_type is known, the
Typ chip is hidden and the type is rendered inline
as a single read-only summary with an "Anderen Typ
wählen" link that re-expands the picker. */}
<div
className="event-type-collapsed"
id="deadline-event-type-collapsed"
style="display:none"
>
<span
className="event-type-collapsed-label"
id="deadline-event-type-collapsed-label"
/>
<span
className="event-type-collapsed-source"
data-i18n="deadlines.field.rule.autofill_inline"
>
&nbsp;(vorgegeben durch Regel)
</span>
<button
type="button"
className="event-type-collapsed-override"
id="deadline-event-type-override-btn"
data-i18n="deadlines.field.rule.override"
>
Anderen Typ w&auml;hlen
</button>
</div>
<div id="deadline-event-types" className="event-type-picker-host" />
{/* Soft warning when the user is in expanded mode AND
has picked an event_type that doesn't include the
rule's canonical default. Reuses the existing
yellow form-hint--warning style; never blocking. */}
<p
className="form-hint form-hint--warning"
id="deadline-event-type-rule-mismatch"
style="display:none"
data-i18n="deadlines.field.rule.mismatch"
>
Hinweis: Typ widerspricht Regel &mdash; Sie haben den Typ &uuml;berschrieben.
</p>
</div>
<div className="form-field-row">
@@ -80,6 +122,16 @@ export function renderDeadlinesNew(): string {
<p className="form-msg" id="deadline-new-msg" />
{/* t-paliad-154 — form-time 4-eye hint. Hidden by default;
revealed by client TS when an effective policy applies
to the chosen project. */}
<div className="approval-hint" id="deadline-approval-hint" style="display:none">
<span className="approval-hint-icon" dangerouslySetInnerHTML={{
__html: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>'
}} />
<span id="deadline-approval-hint-text" />
</div>
<div className="form-actions">
<a href="/events?type=deadline" id="deadline-new-cancel" className="btn-cancel" data-i18n="deadlines.neu.cancel">Abbrechen</a>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="deadlines.neu.submit">Frist anlegen</button>
@@ -90,6 +142,7 @@ export function renderDeadlinesNew(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/deadlines-new.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -75,6 +76,7 @@ export function renderDownloads(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/downloads.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -284,6 +285,7 @@ export function renderEvents(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/events.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -112,27 +113,105 @@ export function renderFristenrechner(): string {
</p>
</div>
{/* v3 landing fork (t-paliad-133) — visible by default, hidden once
the user picks a pathway. URL ?path= drives visibility. */}
<div className="fristen-pathway-fork" id="fristen-pathway-fork" role="group" aria-label="Pathway selector">
<h2 className="fristen-pathway-fork-heading" data-i18n="deadlines.pathway.fork.heading">Was m&ouml;chten Sie tun?</h2>
<div className="fristen-pathway-fork-cards">
<button type="button" className="fristen-pathway-card" data-path="a" id="fristen-pathway-a-cta">
<span className="fristen-pathway-card-icon" aria-hidden="true">&#128214;</span>
<span className="fristen-pathway-card-title" data-i18n="deadlines.pathway.a.title">Verfahrensablauf informieren</span>
<span className="fristen-pathway-card-desc" data-i18n="deadlines.pathway.a.desc">
Verfahrenstyp w&auml;hlen und alle dazugeh&ouml;rigen Fristen auf einer Zeitleiste sehen.
</span>
{/* m's 2026-05-08 18:08 Determinator redesign — Step 1: pick the
Akte (project) that scopes the rest of the flow. Filtered
list of visible projects + "Neue Akte anlegen" link +
four ad-hoc explore-mode chips for users who just want to
look up a rule without saving anywhere. */}
<div className="fristen-step1" id="fristen-step1" role="group" aria-label="Akte picker">
<h2 className="fristen-step-heading" data-i18n="deadlines.step1.heading">
Schritt 1 &mdash; Welche Akte?
</h2>
<div className="fristen-step1-search-row">
<svg className="fristen-search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="search" id="fristen-akte-search"
className="fristen-akte-search" autocomplete="off"
data-i18n-placeholder="deadlines.step1.search.placeholder"
placeholder="Akte suchen&hellip;" />
</div>
<ul className="fristen-akte-list" id="fristen-akte-list" role="listbox" aria-label="Akten"></ul>
<div className="fristen-step1-divider">
<span data-i18n="deadlines.step1.divider.new">oder eine neue Akte</span>
</div>
{/* return-bounce: projects-new.ts honours ?return= and
redirects back to /tools/fristenrechner?project=<new_uuid>
so the new Akte preselects itself in Step 1. */}
<a href="/projects/new?return=/tools/fristenrechner" className="fristen-step1-new" id="fristen-step1-new"
data-i18n="deadlines.step1.new.cta">
+ Neue Akte anlegen
</a>
<div className="fristen-step1-divider">
<span data-i18n="deadlines.step1.divider.adhoc">oder ad-hoc, ohne Akte</span>
</div>
<div className="fristen-adhoc-chips" role="group" aria-label="Ad-hoc proceeding">
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="upc"
data-i18n="deadlines.step1.adhoc.upc">
Custom UPC proceeding
</button>
<button type="button" className="fristen-pathway-card" data-path="b" id="fristen-pathway-b-cta">
<span className="fristen-pathway-card-icon" aria-hidden="true">&#128197;</span>
<span className="fristen-pathway-card-title" data-i18n="deadlines.pathway.b.title">Frist eintragen aufgrund Ereignis</span>
<span className="fristen-pathway-card-desc" data-i18n="deadlines.pathway.b.desc">
Ein Ereignis ist eingetreten &mdash; ich brauche die richtige Frist f&uuml;r meine Akte.
</span>
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="de"
data-i18n="deadlines.step1.adhoc.de">
Custom DE proceeding
</button>
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="epa"
data-i18n="deadlines.step1.adhoc.epa">
Custom EPA proceeding
</button>
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="dpma"
data-i18n="deadlines.step1.adhoc.dpma">
Custom DPMA proceeding
</button>
</div>
<div className="fristen-pathway-fork-shortcut">
</div>
{/* Step 1 collapsed summary, shown after a pick. Mirrors the
proceeding-summary collapse pattern from 097e21c. */}
<div className="fristen-step1-summary" id="fristen-step1-summary" style="display:none" role="group">
<span className="fristen-step1-summary-label" data-i18n="deadlines.step1.selected">Akte:</span>
<strong className="fristen-step1-summary-name" id="fristen-step1-summary-name">&mdash;</strong>
<span className="fristen-step1-summary-meta" id="fristen-step1-summary-meta"></span>
<button type="button" className="fristen-step1-summary-reselect" id="fristen-step1-summary-reselect"
data-i18n="deadlines.step1.reselect">
Andere Akte
</button>
</div>
{/* Step 2 — Do / Happened bifurcation. Hidden until Step 1 is
satisfied. Click on a card routes to the existing Pathway A
(Verfahrensablauf wizard) or Pathway B (cascade) shells —
we keep the routing primitive in showPathway()/showBMode(). */}
<div className="fristen-step2" id="fristen-step2" hidden>
<h2 className="fristen-step-heading" data-i18n="deadlines.step2.heading">
Schritt 2 &mdash; Was m&ouml;chten Sie tun?
</h2>
<div className="fristen-step2-cards">
<button type="button" className="fristen-step2-card" data-action="file" id="fristen-step2-file">
<span className="fristen-step2-card-icon" aria-hidden="true">&#9999;&#65039;</span>
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.file.title">
Etwas einreichen
</span>
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.file.desc">
Outgoing &mdash; eine Frist tritt aus eigener Handlung ein.
</span>
</button>
<button type="button" className="fristen-step2-card" data-action="happened" id="fristen-step2-happened">
<span className="fristen-step2-card-icon" aria-hidden="true">&#128229;</span>
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.happened.title">
Etwas ist passiert
</span>
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.happened.desc">
Incoming &mdash; ein Ereignis hat eine Frist ausgel&ouml;st.
</span>
</button>
{/* t-paliad-179 Slice 1: the third "Verfahrensablauf
einsehen" card retired — abstract-browse intent now
owns its own route at /tools/verfahrensablauf. */}
</div>
<div className="fristen-step2-shortcut">
<div className="fristen-pathway-fork-shortcut-label" data-i18n="deadlines.pathway.shortcut.label">
oder direkt zu einer Frist springen:
</div>
@@ -170,8 +249,62 @@ export function renderFristenrechner(): string {
fristen-b1-cascade hosts the breadcrumb / question / button row.
fristen-b1-results hosts the narrowing concept-card list,
populated by runB1Search() in fristenrechner.ts. The cards
reuse renderConceptCard() (B2's card shape). */}
reuse renderConceptCard() (B2's card shape).
m/paliad#15 follow-up: the inbox-channel chip lives at the
top of THIS panel (not page-level) — m's call: "inside the
decision tree because it helps us to determine what to do
next". The chip narrows the cascade entry-points + B2 fine
forum filter; Pathway A's Verlauf doesn't see it. */}
<div className="fristen-b1-panel" id="fristen-b1-panel" data-mode="tree" hidden>
{/* Slice 3c — perspective chip strip. Klägerseite vs
Beklagtenseite hides cascade leaves whose party tag
contradicts the user's side. "Beide" / no chip
leaves the cascade unfiltered. */}
<div className="fristen-perspective-bar" id="fristen-perspective-bar" role="group" aria-label="Perspective">
<span className="fristen-inbox-bar-label" data-i18n="deadlines.perspective.label">Ich vertrete:</span>
<div className="fristen-inbox-chips">
<button type="button" className="fristen-inbox-chip" data-perspective="claimant"
data-i18n-title="deadlines.perspective.claimant.title" title="Kl&auml;gerseite (Proactive)">
<span data-i18n="deadlines.perspective.claimant.short">Kl&auml;ger</span>
</button>
<button type="button" className="fristen-inbox-chip" data-perspective="defendant"
data-i18n-title="deadlines.perspective.defendant.title" title="Beklagtenseite (Reactive)">
<span data-i18n="deadlines.perspective.defendant.short">Beklagter</span>
</button>
<button type="button" className="fristen-inbox-chip fristen-inbox-chip--clear" data-perspective-clear>
<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>
<div className="fristen-inbox-chips">
<button type="button" className="fristen-inbox-chip" data-inbox="cms"
data-i18n-title="deadlines.inbox.cms.title" title="UPC &mdash; &uuml;ber CMS">
CMS
</button>
<button type="button" className="fristen-inbox-chip" data-inbox="bea"
data-i18n-title="deadlines.inbox.bea.title" title="Nationale Verfahren &mdash; &uuml;ber beA">
beA
</button>
<button type="button" className="fristen-inbox-chip" data-inbox="posteingang"
data-i18n-title="deadlines.inbox.posteingang.title" title="Nationale Verfahren &mdash; Postzustellung">
<span data-i18n="deadlines.inbox.posteingang">Posteingang</span>
</button>
<button type="button" className="fristen-inbox-chip fristen-inbox-chip--clear" data-inbox-clear>
<span data-i18n="deadlines.inbox.all">Alle</span>
</button>
</div>
</div>
<div className="fristen-b1-cascade" id="fristen-b1-cascade"></div>
<div className="fristen-b1-results" id="fristen-b1-results" aria-live="polite"></div>
</div>
@@ -215,6 +348,54 @@ export function renderFristenrechner(): string {
</div>
</div>
{/* Step 3a — outgoing-intent chooser. Reached when the user
picks "Etwas einreichen" on Step 2. Three options per
m's 2026-05-08 18:09 spec: File (drives the Pathway A
wizard), Draft (future drafting surface; v1
placeholder), Enter (routes to the existing manual-
create form). */}
<div className="fristen-pathway-shell" id="fristen-step3a" data-path="outgoing" hidden>
<button type="button" className="fristen-pathway-back" id="fristen-step3a-back">
<span aria-hidden="true">&larr;</span>{" "}
<span data-i18n="deadlines.step3a.back">zur&uuml;ck zur Auswahl</span>
</button>
<h2 className="fristen-pathway-heading">
<span aria-hidden="true">&#9999;&#65039;</span>{" "}
<span data-i18n="deadlines.step3a.heading">Was m&ouml;chten Sie einreichen?</span>
</h2>
<div className="fristen-step2-cards">
<button type="button" className="fristen-step2-card" id="fristen-step3a-file" data-action="file">
<span className="fristen-step2-card-icon" aria-hidden="true">&#128221;</span>
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.file.title">
Schriftsatz einreichen
</span>
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.file.desc">
Verfahrensablauf laden &mdash; Frist berechnen und zur Akte hinzuf&uuml;gen.
</span>
</button>
<button type="button" className="fristen-step2-card fristen-step2-card--soon" id="fristen-step3a-draft" data-action="draft" disabled
data-i18n-title="deadlines.step3a.soon">
<span className="fristen-step2-card-icon" aria-hidden="true">&#128393;</span>
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.draft.title">
Schriftsatz entwerfen
</span>
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.draft.desc">
Vorbereitung &mdash; sp&auml;ter mit Drafting-Surface verkn&uuml;pft.
</span>
<span className="fristen-step2-card-soon" data-i18n="deadlines.step3a.soon">kommt bald</span>
</button>
<button type="button" className="fristen-step2-card" id="fristen-step3a-enter" data-action="enter">
<span className="fristen-step2-card-icon" aria-hidden="true">&#128190;</span>
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.enter.title">
Frist manuell erfassen
</span>
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.enter.desc">
Direkt eintragen &mdash; bereits bekanntes Datum / bekannter Typ.
</span>
</button>
</div>
</div>
{/* Pathway A container — wraps the existing wizard.
Hidden until ?path=a. */}
<div className="fristen-pathway-shell" id="fristen-pathway-a" data-path="a" hidden>
@@ -238,33 +419,47 @@ export function renderFristenrechner(): string {
<span data-i18n="deadlines.step1">Verfahrensart w&auml;hlen</span>
</h3>
<div className="proceeding-group">
<div className="proceeding-group" data-forum="upc">
<h4 data-i18n="deadlines.upc">UPC</h4>
<div className="proceeding-btns">
{UPC_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group">
<div className="proceeding-group" data-forum="de">
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
<div className="proceeding-btns">
{DE_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group">
<div className="proceeding-group" data-forum="epa">
<h4 data-i18n="deadlines.epa">EPA</h4>
<div className="proceeding-btns">
{EPA_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group">
<div className="proceeding-group" data-forum="dpma">
<h4 data-i18n="deadlines.dpma">DPMA</h4>
<div className="proceeding-btns">
{DPMA_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
{/* m's 2026-05-08 18:26: collapse the proceeding picker once
a choice is made; this summary line replaces the four
group blocks with a one-line "Selected: X [Reselect]"
affordance. JS toggles `.proceeding-summary` visibility
in lockstep with `.proceeding-group` blocks. */}
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
<strong className="proceeding-summary-name" id="proceeding-summary-name">&mdash;</strong>
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
data-i18n="deadlines.proceeding.reselect">
Anderes Verfahren w&auml;hlen
</button>
</div>
</div>
<div className="wizard-step" id="step-2" style="display:none">
@@ -329,12 +524,12 @@ export function renderFristenrechner(): string {
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
<label className="fristen-view-option">
<input type="radio" name="fristen-view" value="timeline" checked />
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
<input type="radio" name="fristen-view" value="columns" checked />
<span data-i18n="deadlines.view.columns">Spalten</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="fristen-view" value="columns" />
<span data-i18n="deadlines.view.columns">Spalten</span>
<input type="radio" name="fristen-view" value="timeline" />
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
</label>
</div>
@@ -432,6 +627,7 @@ export function renderFristenrechner(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/fristenrechner.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -259,6 +260,7 @@ export function renderGebuehrentabellen(): string {
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/gebuehrentabellen.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -130,6 +131,7 @@ export function renderGlossary(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/glossary.js"></script>
</body>
</html>

View File

@@ -9,6 +9,50 @@
// `data-i18n*` attributes in TSX/TS sources.
export type I18nKey =
| "admin.approval_policies.bulk.cta"
| "admin.approval_policies.bulk.modal.applying"
| "admin.approval_policies.bulk.modal.body"
| "admin.approval_policies.bulk.modal.cancel"
| "admin.approval_policies.bulk.modal.confirm"
| "admin.approval_policies.bulk.modal.done"
| "admin.approval_policies.bulk.modal.targets_label"
| "admin.approval_policies.bulk.modal.title"
| "admin.approval_policies.bulk.modal.writes_label"
| "admin.approval_policies.bulk.no_descendants"
| "admin.approval_policies.cell.clear"
| "admin.approval_policies.cell.clear.title"
| "admin.approval_policies.cell.error_msg"
| "admin.approval_policies.cell.requires"
| "admin.approval_policies.cell.saved_msg"
| "admin.approval_policies.entity.appointment"
| "admin.approval_policies.entity.deadline"
| "admin.approval_policies.heading"
| "admin.approval_policies.lifecycle.complete"
| "admin.approval_policies.lifecycle.create"
| "admin.approval_policies.lifecycle.delete"
| "admin.approval_policies.lifecycle.update"
| "admin.approval_policies.loading"
| "admin.approval_policies.picker.label"
| "admin.approval_policies.picker.no_results"
| "admin.approval_policies.picker.placeholder"
| "admin.approval_policies.role.associate"
| "admin.approval_policies.role.no_rule"
| "admin.approval_policies.role.none"
| "admin.approval_policies.role.of_counsel"
| "admin.approval_policies.role.pa"
| "admin.approval_policies.role.partner"
| "admin.approval_policies.role.senior_pa"
| "admin.approval_policies.section.projects"
| "admin.approval_policies.section.projects.hint"
| "admin.approval_policies.section.units"
| "admin.approval_policies.section.units.hint"
| "admin.approval_policies.source.ancestor"
| "admin.approval_policies.source.no_approval"
| "admin.approval_policies.source.project"
| "admin.approval_policies.source.unit_default"
| "admin.approval_policies.subtitle"
| "admin.approval_policies.title"
| "admin.approval_policies.units.empty"
| "admin.audit.col.actor"
| "admin.audit.col.description"
| "admin.audit.col.event"
@@ -59,6 +103,8 @@ export type I18nKey =
| "admin.broadcasts.loading"
| "admin.broadcasts.subtitle"
| "admin.broadcasts.title"
| "admin.card.approval_policies.desc"
| "admin.card.approval_policies.title"
| "admin.card.audit.desc"
| "admin.card.audit.title"
| "admin.card.broadcasts.desc"
@@ -170,9 +216,12 @@ export type I18nKey =
| "admin.paliadin.col.classifier"
| "admin.paliadin.col.count"
| "admin.paliadin.col.duration"
| "admin.paliadin.col.origin"
| "admin.paliadin.col.prompt"
| "admin.paliadin.col.response"
| "admin.paliadin.col.started"
| "admin.paliadin.col.tools"
| "admin.paliadin.col.user"
| "admin.paliadin.daily_heading"
| "admin.paliadin.heading"
| "admin.paliadin.last7"
@@ -335,6 +384,7 @@ export type I18nKey =
| "appointments.filter.to"
| "appointments.filter.type"
| "appointments.filter.type.all"
| "appointments.form.approval_hint"
| "appointments.kalender.empty"
| "appointments.kalender.heading"
| "appointments.kalender.list"
@@ -363,6 +413,9 @@ export type I18nKey =
| "approvals.action.approve"
| "approvals.action.reject"
| "approvals.action.revoke"
| "approvals.agent.byline"
| "approvals.agent.label"
| "approvals.agent.suggestion_pending"
| "approvals.decided_by"
| "approvals.decision_kind.admin_override"
| "approvals.decision_kind.derived_peer"
@@ -373,6 +426,7 @@ export type I18nKey =
| "approvals.empty.pending_mine"
| "approvals.entity.appointment"
| "approvals.entity.deadline"
| "approvals.error.awaiting_approval"
| "approvals.error.concurrent_pending"
| "approvals.error.no_qualified_approver"
| "approvals.error.not_authorized"
@@ -384,6 +438,7 @@ export type I18nKey =
| "approvals.lifecycle.delete"
| "approvals.lifecycle.update"
| "approvals.note.placeholder"
| "approvals.pending.badge"
| "approvals.pending_complete.label"
| "approvals.pending_create.label"
| "approvals.pending_delete.label"
@@ -411,6 +466,9 @@ export type I18nKey =
| "approvals.tab.mine"
| "approvals.tab.pending_mine"
| "approvals.title"
| "approvals.withdraw.confirm"
| "approvals.withdraw.cta"
| "approvals.withdraw.error"
| "bottomnav.add"
| "bottomnav.add.appointment"
| "bottomnav.add.appointment.sub"
@@ -594,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"
@@ -612,6 +671,9 @@ export type I18nKey =
| "dashboard.activity.event"
| "dashboard.activity.heading"
| "dashboard.activity.system"
| "dashboard.agenda.empty"
| "dashboard.agenda.full_link"
| "dashboard.agenda.heading"
| "dashboard.appointment_summary.heading"
| "dashboard.appointments.empty"
| "dashboard.appointments.heading"
@@ -623,6 +685,8 @@ export type I18nKey =
| "dashboard.matters.heading"
| "dashboard.matters.total"
| "dashboard.onboarding"
| "dashboard.section.collapse"
| "dashboard.section.expand"
| "dashboard.summary.completed"
| "dashboard.summary.heading"
| "dashboard.summary.later"
@@ -676,6 +740,7 @@ export type I18nKey =
| "deadlines.col.status"
| "deadlines.col.title"
| "deadlines.complete.action"
| "deadlines.court.indirect"
| "deadlines.court.label"
| "deadlines.court.set"
| "deadlines.date.edit.hint"
@@ -755,7 +820,11 @@ export type I18nKey =
| "deadlines.field.notes"
| "deadlines.field.notes.placeholder"
| "deadlines.field.rule"
| "deadlines.field.rule.autofill"
| "deadlines.field.rule.autofill_inline"
| "deadlines.field.rule.mismatch"
| "deadlines.field.rule.none"
| "deadlines.field.rule.override"
| "deadlines.field.title"
| "deadlines.field.title.placeholder"
| "deadlines.filter.akte"
@@ -785,7 +854,14 @@ export type I18nKey =
| "deadlines.flag.inf_amend"
| "deadlines.flag.rev_amend"
| "deadlines.flag.rev_cci"
| "deadlines.form.approval_hint"
| "deadlines.heading"
| "deadlines.inbox.all"
| "deadlines.inbox.bea.title"
| "deadlines.inbox.cms.title"
| "deadlines.inbox.label"
| "deadlines.inbox.posteingang"
| "deadlines.inbox.posteingang.title"
| "deadlines.kalender.empty"
| "deadlines.kalender.heading"
| "deadlines.kalender.list"
@@ -805,6 +881,7 @@ export type I18nKey =
| "deadlines.neu.submit"
| "deadlines.neu.subtitle"
| "deadlines.neu.title"
| "deadlines.optional.badge"
| "deadlines.party.both"
| "deadlines.party.both.label"
| "deadlines.party.claimant"
@@ -825,13 +902,22 @@ export type I18nKey =
| "deadlines.pathway.fork.heading"
| "deadlines.pathway.shortcut.label"
| "deadlines.perspective.appeal_filed_by.label"
| "deadlines.perspective.both.short"
| "deadlines.perspective.claimant"
| "deadlines.perspective.claimant.short"
| "deadlines.perspective.claimant.title"
| "deadlines.perspective.defendant"
| "deadlines.perspective.defendant.short"
| "deadlines.perspective.defendant.title"
| "deadlines.perspective.label"
| "deadlines.perspective.predefined_hint"
| "deadlines.print"
| "deadlines.priority.date"
| "deadlines.proceeding.reselect"
| "deadlines.proceeding.selected"
| "deadlines.reset"
| "deadlines.save.cta"
| "deadlines.save.cta.adhoc.hint"
| "deadlines.save.error"
| "deadlines.save.modal.akte"
| "deadlines.save.modal.akte.choose"
@@ -870,8 +956,37 @@ export type I18nKey =
| "deadlines.status.pending"
| "deadlines.status.waived"
| "deadlines.step1"
| "deadlines.step1.adhoc.de"
| "deadlines.step1.adhoc.dpma"
| "deadlines.step1.adhoc.epa"
| "deadlines.step1.adhoc.upc"
| "deadlines.step1.divider.adhoc"
| "deadlines.step1.divider.new"
| "deadlines.step1.heading"
| "deadlines.step1.new.cta"
| "deadlines.step1.reselect"
| "deadlines.step1.search.empty"
| "deadlines.step1.search.placeholder"
| "deadlines.step1.selected"
| "deadlines.step1.summary.adhoc.suffix"
| "deadlines.step2"
| "deadlines.step2.browse.desc"
| "deadlines.step2.browse.title"
| "deadlines.step2.file.desc"
| "deadlines.step2.file.title"
| "deadlines.step2.happened.desc"
| "deadlines.step2.happened.title"
| "deadlines.step2.heading"
| "deadlines.step3"
| "deadlines.step3a.back"
| "deadlines.step3a.draft.desc"
| "deadlines.step3a.draft.title"
| "deadlines.step3a.enter.desc"
| "deadlines.step3a.enter.title"
| "deadlines.step3a.file.desc"
| "deadlines.step3a.file.title"
| "deadlines.step3a.heading"
| "deadlines.step3a.soon"
| "deadlines.subtitle"
| "deadlines.summary.completed"
| "deadlines.summary.later"
@@ -1001,6 +1116,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"
@@ -1165,6 +1281,9 @@ export type I18nKey =
| "glossar.suggest.success"
| "glossar.suggest.title"
| "glossar.title"
| "inbox.empty.admin_nudge.body"
| "inbox.empty.admin_nudge.cta"
| "inbox.empty.admin_nudge.title"
| "index.checklisten.desc"
| "index.checklisten.title"
| "index.cost.desc"
@@ -1323,6 +1442,7 @@ export type I18nKey =
| "nav.akten"
| "nav.caldav"
| "nav.checklisten"
| "nav.context.project_chart"
| "nav.dashboard"
| "nav.downloads"
| "nav.einstellungen"
@@ -1332,13 +1452,11 @@ export type I18nKey =
| "nav.gerichte"
| "nav.glossar"
| "nav.group.admin"
| "nav.group.arbeit"
| "nav.group.ansichten"
| "nav.group.einstellungen"
| "nav.group.ressourcen"
| "nav.group.uebersicht"
| "nav.group.user_views"
| "nav.group.werkzeuge"
| "nav.group.wissen"
| "nav.home"
| "nav.inbox"
| "nav.kostenrechner"
@@ -1351,6 +1469,7 @@ export type I18nKey =
| "nav.team"
| "nav.termine"
| "nav.user_views.new"
| "nav.verfahrensablauf"
| "notes.cancel"
| "notes.delete"
| "notes.delete.confirm"
@@ -1423,9 +1542,15 @@ export type I18nKey =
| "paliadin.empty"
| "paliadin.error.connection_lost"
| "paliadin.error.local_only"
| "paliadin.error.mriver_unreachable"
| "paliadin.error.shim_auth_failed"
| "paliadin.error.shim_error"
| "paliadin.error.timeout"
| "paliadin.error.upstream"
| "paliadin.heading"
| "paliadin.input.placeholder"
| "paliadin.late.marker"
| "paliadin.late.waiting"
| "paliadin.reset"
| "paliadin.send"
| "paliadin.starter.concept"
@@ -1434,6 +1559,17 @@ export type I18nKey =
| "paliadin.stop"
| "paliadin.tagline"
| "paliadin.title"
| "paliadin.widget.close"
| "paliadin.widget.context.on_page"
| "paliadin.widget.empty"
| "paliadin.widget.fullscreen"
| "paliadin.widget.input.label"
| "paliadin.widget.input.placeholder"
| "paliadin.widget.reset"
| "paliadin.widget.reset.confirm"
| "paliadin.widget.send"
| "paliadin.widget.title"
| "paliadin.widget.trigger"
| "partner_unit.heading"
| "partner_unit.members_label"
| "partner_unit.none"
@@ -1488,6 +1624,42 @@ export type I18nKey =
| "projects.cards.show_all_levels"
| "projects.cards.show_all_levels.hint"
| "projects.cards.team"
| "projects.chart.back"
| "projects.chart.control.columns.auto"
| "projects.chart.control.density.label"
| "projects.chart.control.density.standard"
| "projects.chart.control.export.soon"
| "projects.chart.control.layout.horizontal"
| "projects.chart.control.palette.default"
| "projects.chart.control.palette.label"
| "projects.chart.control.range.label"
| "projects.chart.density.compact"
| "projects.chart.density.spacious"
| "projects.chart.density.standard"
| "projects.chart.error.mount"
| "projects.chart.export.csv"
| "projects.chart.export.ics"
| "projects.chart.export.json"
| "projects.chart.export.menu"
| "projects.chart.export.png"
| "projects.chart.export.print"
| "projects.chart.export.svg"
| "projects.chart.loading"
| "projects.chart.notfound"
| "projects.chart.palette.default"
| "projects.chart.palette.high_contrast"
| "projects.chart.palette.kind_coded"
| "projects.chart.palette.print"
| "projects.chart.palette.track_coded"
| "projects.chart.permalink.copy"
| "projects.chart.permalink.title"
| "projects.chart.range.1y"
| "projects.chart.range.2y"
| "projects.chart.range.all"
| "projects.chart.range.custom"
| "projects.chart.range.from"
| "projects.chart.range.to"
| "projects.chart.title"
| "projects.chip.all"
| "projects.chip.has_open_deadlines"
| "projects.chip.mine"
@@ -1560,6 +1732,79 @@ export type I18nKey =
| "projects.detail.parteien.role.defendant"
| "projects.detail.parteien.role.thirdparty"
| "projects.detail.save"
| "projects.detail.smarttimeline.add.cancel"
| "projects.detail.smarttimeline.add.choice.amend"
| "projects.detail.smarttimeline.add.choice.appointment"
| "projects.detail.smarttimeline.add.choice.counterclaim"
| "projects.detail.smarttimeline.add.choice.deadline"
| "projects.detail.smarttimeline.add.choice.disabled"
| "projects.detail.smarttimeline.add.choice.milestone"
| "projects.detail.smarttimeline.add.cta"
| "projects.detail.smarttimeline.add.modal.title"
| "projects.detail.smarttimeline.add.submit"
| "projects.detail.smarttimeline.anchor.cancel"
| "projects.detail.smarttimeline.anchor.error"
| "projects.detail.smarttimeline.anchor.invalid_date"
| "projects.detail.smarttimeline.anchor.save"
| "projects.detail.smarttimeline.anchor.saved"
| "projects.detail.smarttimeline.anchor.saving"
| "projects.detail.smarttimeline.anchor.set"
| "projects.detail.smarttimeline.audit.toggle.hide"
| "projects.detail.smarttimeline.audit.toggle.show"
| "projects.detail.smarttimeline.client.matter_list.empty"
| "projects.detail.smarttimeline.client.matter_list.heading"
| "projects.detail.smarttimeline.client.matter_list.hint"
| "projects.detail.smarttimeline.client.toggle.lanes"
| "projects.detail.smarttimeline.client.toggle.matter_list"
| "projects.detail.smarttimeline.counterclaim.case_number"
| "projects.detail.smarttimeline.counterclaim.flip_hint"
| "projects.detail.smarttimeline.counterclaim.flip_override"
| "projects.detail.smarttimeline.counterclaim.procedure"
| "projects.detail.smarttimeline.counterclaim.saving"
| "projects.detail.smarttimeline.counterclaim.submit"
| "projects.detail.smarttimeline.counterclaim.title"
| "projects.detail.smarttimeline.depends_on.date_open"
| "projects.detail.smarttimeline.depends_on.hide_path"
| "projects.detail.smarttimeline.depends_on.path_hint"
| "projects.detail.smarttimeline.depends_on.prefix"
| "projects.detail.smarttimeline.depends_on.show_path"
| "projects.detail.smarttimeline.empty"
| "projects.detail.smarttimeline.error.generic"
| "projects.detail.smarttimeline.error.title_required"
| "projects.detail.smarttimeline.kind.appointment"
| "projects.detail.smarttimeline.kind.deadline"
| "projects.detail.smarttimeline.kind.milestone"
| "projects.detail.smarttimeline.kind.projected"
| "projects.detail.smarttimeline.lane.empty"
| "projects.detail.smarttimeline.lane.filter.all"
| "projects.detail.smarttimeline.lane.filter.label"
| "projects.detail.smarttimeline.lookahead.less"
| "projects.detail.smarttimeline.lookahead.more"
| "projects.detail.smarttimeline.milestone.bubble_up"
| "projects.detail.smarttimeline.milestone.bubble_up_hint"
| "projects.detail.smarttimeline.milestone.date"
| "projects.detail.smarttimeline.milestone.description"
| "projects.detail.smarttimeline.milestone.title"
| "projects.detail.smarttimeline.open_chart"
| "projects.detail.smarttimeline.section.future"
| "projects.detail.smarttimeline.section.past"
| "projects.detail.smarttimeline.section.undated"
| "projects.detail.smarttimeline.status.court_set"
| "projects.detail.smarttimeline.status.done"
| "projects.detail.smarttimeline.status.off_script"
| "projects.detail.smarttimeline.status.open"
| "projects.detail.smarttimeline.status.overdue"
| "projects.detail.smarttimeline.status.predicted"
| "projects.detail.smarttimeline.status.predicted_overdue"
| "projects.detail.smarttimeline.today"
| "projects.detail.smarttimeline.track.both"
| "projects.detail.smarttimeline.track.header.counterclaim"
| "projects.detail.smarttimeline.track.header.parent"
| "projects.detail.smarttimeline.track.header.parent_context"
| "projects.detail.smarttimeline.track.label"
| "projects.detail.smarttimeline.track.only.counterclaim"
| "projects.detail.smarttimeline.track.only.parent"
| "projects.detail.smarttimeline.track.only.parent_context"
| "projects.detail.tab.checklisten"
| "projects.detail.tab.fristen"
| "projects.detail.tab.kinder"
@@ -1621,6 +1866,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"
@@ -1748,6 +2001,8 @@ export type I18nKey =
| "team.broadcast.error.no_recipients"
| "team.broadcast.error.subject_required"
| "team.broadcast.error.too_many"
| "team.broadcast.mailto.label"
| "team.broadcast.mailto.tooltip"
| "team.broadcast.markdown_hint"
| "team.broadcast.placeholders_hint"
| "team.broadcast.recipients"
@@ -1796,12 +2051,87 @@ export type I18nKey =
| "theme.toggle.cycle.light"
| "theme.toggle.dark"
| "theme.toggle.light"
| "tools.verfahrensablauf.heading"
| "tools.verfahrensablauf.subtitle"
| "tools.verfahrensablauf.title"
| "unit_role.attorney"
| "unit_role.lead"
| "unit_role.pa"
| "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.project_event_kind"
| "views.bar.label.shape"
| "views.bar.label.sort"
| "views.bar.label.time"
| "views.bar.label.timeline_status"
| "views.bar.label.timeline_track"
| "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.all"
| "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.bar.time.past_7d"
| "views.bar.time.past_90d"
| "views.bar.timeline_status.court_set"
| "views.bar.timeline_status.done"
| "views.bar.timeline_status.macro.future"
| "views.bar.timeline_status.macro.past"
| "views.bar.timeline_status.off_script"
| "views.bar.timeline_status.open"
| "views.bar.timeline_status.overdue"
| "views.bar.timeline_status.predicted"
| "views.bar.timeline_status.predicted_overdue"
| "views.bar.timeline_track.counterclaim"
| "views.bar.timeline_track.off_script"
| "views.bar.timeline_track.parent"
| "views.calendar.mobile_fallback"
| "views.col.actor"
| "views.col.appointment_type"
@@ -1883,11 +2213,13 @@ export type I18nKey =
| "views.shape.calendar"
| "views.shape.cards"
| "views.shape.list"
| "views.shape.timeline"
| "views.source.appointment"
| "views.source.approval_request"
| "views.source.deadline"
| "views.source.project_event"
| "views.subtitle"
| "views.timeline.caveat.body"
| "views.title"
| "views.toast.inaccessible_n"
| "views.toast.inaccessible_one";

View File

@@ -1,16 +1,24 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Approval inbox page (t-paliad-138). Two-tab UI:
// - "Zur Genehmigung": requests where the caller is qualified to approve
// - "Meine Anfragen": requests submitted by the caller
// /inbox — t-paliad-163 universal-filter migration.
//
// Hydrates lazily on load (no inline payload) — unlike the dashboard, the
// inbox doesn't carry SSR state. The client bundle calls /api/inbox/* on
// hydration and re-renders.
// The page is a thin shell around two host divs: one for the
// <FilterBar> primitive and one for the result list. The bar takes
// care of every axis (approval_viewer_role chip cluster replaces the
// two-tab UI; status / entity_type / time chips are new affordances).
// Rows render via shape-list.ts with row_action="approve" — the
// inbox-specific markup that produces the diff + approve/reject/revoke
// buttons. Action handlers are wired in client/inbox.ts.
//
// The legacy `?tab=` URL is preserved by the client: ?tab=mine maps
// to ?a_role=self_requested before the bar mounts so old bookmarks
// (sidebar bell, Genehmigungen email links) keep landing on the
// expected sub-view.
export function renderInbox(): string {
return "<!DOCTYPE html>" + (
@@ -37,21 +45,30 @@ export function renderInbox(): string {
</p>
</div>
<div className="agenda-controls">
<div className="agenda-filter-group" role="group">
<div className="agenda-chip-row" id="inbox-tab-row">
<button type="button" className="agenda-chip active" data-tab="pending-mine" data-i18n="approvals.tab.pending_mine">Zur Genehmigung</button>
<button type="button" className="agenda-chip" data-tab="mine" data-i18n="approvals.tab.mine">Meine Anfragen</button>
</div>
</div>
</div>
<div id="inbox-filter-bar" />
<div className="agenda-loading" id="inbox-loading" data-i18n="agenda.loading">L&auml;dt &hellip;</div>
<div className="entity-empty" id="inbox-empty" style="display:none" />
<ul className="inbox-list" id="inbox-list" />
<div id="inbox-results" />
{/* t-paliad-154 — admin-only nudge surfaced when:
- the user is global_admin
- the inbox is empty (no pending / mine)
- no approval_policies row exists firm-wide
Hidden in all other cases. Wires via /api/admin/approval-policies/seeded. */}
<div className="inbox-admin-nudge" id="inbox-admin-nudge" style="display:none">
<h3 data-i18n="inbox.empty.admin_nudge.title">Noch keine Genehmigungspflichten konfiguriert?</h3>
<p data-i18n="inbox.empty.admin_nudge.body">
Lege fest, welche Lifecycle-Events 4-Augen-Pr&uuml;fung erfordern.
</p>
<a href="/admin/approval-policies" className="btn-primary btn-cta-lime" data-i18n="inbox.empty.admin_nudge.cta">
Genehmigungspflichten konfigurieren
</a>
</div>
</div>
</section>
<Footer />
<PaliadinWidget />
</main>
<script src="/assets/inbox.js" defer />

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -238,6 +239,7 @@ export function renderKostenrechner(): string {
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/kostenrechner.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -127,6 +128,7 @@ export function renderLinks(): string {
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/links.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
@@ -42,6 +43,7 @@ export function renderNotFound(): string {
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/notfound.js"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More