New module: frontend/src/client/components/approval-edit-modal.ts.
The approver clicks "Änderungen vorschlagen" on a pending update-lifecycle
row; this modal opens with the requester's original payload pre-populated
in editable date inputs (per entity_type allowlist):
- deadline: due_date, original_due_date, warning_date
- appointment: start_at, end_at
The pre_image value for each field renders as a "Vorher" hint so the
approver sees what's being changed before they commit a counter.
A free-text "Vorschlagskommentar" textarea sits below the inputs. The
submit button stays disabled until the form is dirty OR the note has
non-whitespace content — mirrors the server's ErrSuggestionRequiresChange
no-op guard so the user doesn't bounce off a server-side 400.
API: openApprovalEditModal({entityType, lifecycleEvent, payload,
preImage}) returns Promise<{counterPayload, note} | null>. null = user
cancelled (ESC, overlay click, Cancel button). counterPayload contains
only fields that the user changed; unchanged keys are omitted (the
server's buildRevertSetClauses ignores absent keys cleanly).
Lifecycles other than "update" are guarded with an alert + resolve null —
shape-list.ts hides the button for them, but the modal is defence-in-
depth.
shape-list.ts:
- Pending-row action group extends to four buttons. suggest_changes is
only rendered for lifecycle='update' rows (the backend rejects other
lifecycles with ErrSuggestionLifecycleInvalid).
- ApprovalAction union widened to "approve" | "reject" | "revoke" |
"suggest_changes". Disabled-reason logic shared with approve/reject
(viewer_can_approve gate).
- Status pill renders "Abgelehnt mit Vorschlag" for changes_requested
via the existing approval-pill--historic style — no new colour token.
- ApprovalDetail picks up counter_payload + next_request_id. When a
row is changes_requested AND a next_request_id is present, render a
back-link "→ Neuer Vorschlag von {name}" pointing at the new pending
row (server-side hydrated via correlated subquery on
previous_request_id, indexed by mig 103's partial index).
filter-bar/axes.ts:
- APPROVAL_STATUSES gains "changes_requested" — the chip shows up in
the /inbox filter cluster alongside pending/approved/rejected/revoked.
m's ask 2026-05-18 18:21: per-rule descriptive notes ("Innerhalb von 1
Monat ab Zustellung der Klage. Drei mögliche Gründe…") are noisy in the
default timeline view. Make them optional — small ⓘ icon next to the
meta line by default with full text on hover; switch in the toggle bar
expands them inline when the user wants the wall of text.
**Renderer (verfahrensablauf-core.ts)** — `CardOpts.showNotes?: boolean`
gates two render paths:
- on → `<div class="timeline-notes">…</div>` (today's behaviour)
- off → `<span class="timeline-note-hint" tabindex=0 role=note
aria-label=… title=…>ⓘ</span>` inside the meta line (browser
title for hover, aria-label for screen readers, tabindex for
keyboard accessibility)
Pass-through wired in renderColumnsBody too so the columns view picks
up the toggle equally.
**Toggle UI** — added a checkbox row to the existing `fristen-view-toggle`
bar on both /tools/verfahrensablauf and /tools/fristenrechner:
"Hinweise anzeigen" / "Show details". CSS modifier
`.fristen-notes-option` separates it from the radio view-picker with
a leading border-left.
**State** — `paliad.fristen.notes-show` localStorage key (shared
between both pages so the preference carries across), default off,
re-render on flip.
i18n: 1 new key DE + EN (deadlines.notes.show). Build clean.
New "Schriftsätze" tab on /projects/{id}, lazy-loaded by the
existing tab switcher (same pattern as the Checklisten tab — only
hits the API when the user actually opens it). Lists the project's
filing rules in a 4-column table: name (with submission_code under
it), party, legal basis, action button.
Action column shows [Generieren] for rules with a resolvable
template and "Keine Vorlage" / "No template" for rules without one.
The generate button fetches the .docx via XHR, parses the
Content-Disposition filename, creates an object URL, and triggers
the browser download via a hidden <a download>. Disabled
mid-flight to prevent double-submits.
The table opts into the `.entity-table--readonly` modifier — rows
themselves don't navigate; only the inline button does (avoids the
"clickable row that isn't" UX lie called out in the project
CLAUDE.md frontend conventions).
11 new i18n keys per language. New CSS block for the submission-row
typography (name + dim-grey code stacked vertically, right-aligned
action cell, italic no-template hint).
Replaces the one-sentence "endpoint" stub with a proper landing: features list, update flow explainer, fresh-install download link, contact line. Renders the served version live from version.json. Paliad palette (midnight/lime). This is what the HL Patents Style ribbon's Info dialog now links to on OK.
Adds a 4th tab "Datenexport" to /settings (after Profil /
Benachrichtigungen / CalDAV) with a single-button card that triggers
GET /api/me/export. Browser handles the download via
Content-Disposition: attachment.
i18n: 12 new keys under einstellungen.export.* (DE primary, EN
secondary) — subtitle, bullets per format, scope notice, audit
notice, button label, post-click hint.
The tab is loaded lazily (idempotent loadExportTab) like every other
settings tab, and the runExport handler swaps in a transient <a download>
to use the browser's normal download pipeline.
m's call 2026-05-19: opening /events with type=appointment was
defaulting status='all' which surfaces every past appointment in
the corpus. The default should hide past events; 'Alle (auch
vergangene)' is opt-in for the one user who actually wants the
historical view.
Replaces the default with the existing DeadlineFilterUpcoming bucket
(already implemented backend-side at internal/services/deadline_service.go:132
as 'today + future'). New status option 'upcoming' at the top of the
appointment list; existing 'all' moves to the bottom with a clearer
label that calls out 'incl. past'.
Deadlines unaffected — they still default to 'pending'.
i18n keys added in both DE + EN slots (events.filter.status.upcoming
'Ab heute' / 'From today'; .all reframed as 'Alle (auch vergangene)'
/ 'All (incl. past)').
m's call 2026-05-19: the /files/hl-patents-style.dotm link on the
anonymous frontpage shouldn't tempt visitors to try downloading. The
/files/{filename} route IS already auth-gated (302 to /login on
anon click), and the macro-update endpoint at /patentstyle/* stays
public for the in-Word update logic per m's note ('with knowledge
of the direct source link it needs to be available').
Authenticated users never see this page anyway — handleRootPage 302s
them to /dashboard. So removing the section costs them nothing and
removes the obvious affordance for anon visitors. ICON_DOWNLOAD
const dropped along with it.
The Downloads page itself (/downloads + Sidebar nav entry) stays —
that's auth-gated and works for logged-in users.
Leftover surface: /patentstyle/HL-Patents-Style.dotm is still anon-
downloadable (necessary for the Word macro's auto-update poll).
That's m's stated requirement — flagged as the known leak path for
anyone who knows the URL.
Hosts the manifest + .dotm that the Word ribbon's Check-for-Updates button polls. paliad.msbls.de is the primary endpoint; hihlc.msbls.de mirrors it (hihlc/main b871ded). Files live in frontend/public/patentstyle/, copied into dist/ by the frontend build. Cache-Control: no-cache via noCacheAssets so version.json never serves stale after a release.
The /views/{slug} runner now mounts the same FilterBar primitive that
/events and /inbox use. The saved view's filter_spec becomes the bar's
baseline, axes are picked client-side per the view's data sources so a
deadline-only view exposes deadline_status, an approval-driven view
exposes approval_viewer_role + approval_status + approval_entity_type,
etc. Universal axes (time, personal_only, sort) always render.
Per-session tweaks overlay the saved baseline without mutating the
stored row; the URL round-trips state through the bar's existing codec
so deep-links share the active narrow. "Speichern als Sicht" stays
available on user-owned views so a tweaked narrow can be forked into a
new saved view.
Shape axis is intentionally excluded from the bar — the existing
top-of-page shape chip cluster (list / cards / calendar / timeline)
already plays that role and switching now mutates the cached render
spec without re-hitting the substrate.
Empty-state hint reuses the saved filter summary as before; the bar's
onResult handler hides all shape hosts when the rows array is empty.
shape-timeline-cv now wraps the chart host with a toolbar carrying
+/- zoom buttons and 1y/2y/all chips. Active zoom persists in the URL as
?tl_zoom=1y|2y|all (URL > render-spec range_preset > "1y" default), so
saved views still control the initial zoom but per-session navigation is
deep-linkable.
shape-timeline-chart paints lane labels inside a foreignObject containing
an HTML <div> with overflow:hidden + text-overflow:ellipsis + a title
attribute carrying the full text. Long project names no longer bleed
across the chart canvas; hover reveals the full label.
i18n: views.timeline.zoom.{label,in,out,1y,2y,all} (DE+EN).
shape-calendar now renders month, week, and day views with a chip switcher
above the grid. Active view + anchor date persist in the URL as
?cal_view=month|week|day&cal_date=YYYY-MM-DD so per-view navigation is
deep-linkable.
Month view: weekday header row now lives inside the same CSS grid as the
day cells (one shared grid-template-columns: repeat(7,1fr)), so day labels
no longer drift relative to the columns below. Day-number is a button
that switches to day view scoped to that date; +N more pill also drills
to day view. Individual row pills route to /deadlines/{id} /
/appointments/{id} via inner anchors with click stopPropagation so they
don't trigger the day-drill.
Week view: 7 columns, full row list per column (no 3-row cap), per-column
vertical scroll for busy days.
Day view: single chronological list. Prev/next-day nav reuses the same
toolbar; week/day views also expose a "Zurück zum Monat" link.
i18n: cal.view.month|week|day + per-view prev/next labels +
cal.day.back_to_month + cal.day.open_day + cal.day.no_entries (DE+EN).
m's correction 2026-05-18: the R.19 Einspruch (preliminary objection)
should not be flag-gated. It's an always-available optional submission
the defendant can make once the SoC is served — same logic as the
appeal-spawn rules in t-paliad-203 F2.3 ("the appeal is always a
possibility"). Removing the gate makes the row a normal optional rule:
priority='optional' (unchanged, set by mig 095) gives the save-modal
the existing pre-uncheck behaviour without a separate checkbox.
**Migration 098** (idempotent): NULLs condition_expr on the two RoP.019.1
rows pinned by proceeding code (`upc.inf.cfi` + `upc.rev.cfi`). Re-apply
is a no-op via the WHERE clause matching the live shape. Live DB row
state will sync when Dokploy applies the migration on next deploy — no
raw prod-write this turn (lesson from the previous shift's friction note).
**Frontend cleanup** — removes the two flag rows added to
verfahrensablauf.tsx + fristenrechner.tsx in the parent t-paliad-207
commit (inf-po-flag-row, rev-po-flag-row), the readFlags()/calculate()
push branches, the syncFlagRows() show/hide entries, and the change
listeners. Drops the 4 i18n keys (deadlines.flag.inf_po + rev_po,
DE + EN). Bun build clean: 2417 keys (was 2419, -2 keys × 2 langs).
Branch: mai/fermi/interactive-session @ third commit on top of Path A.
m's 2026-05-18 ask: the 5 DE proceeding tiles followed three different
labelling conventions ("Verletzungsklage (LG)" / "Berufung OLG" /
"Nichtigkeitsverfahren" — instance in brackets vs not vs not even
present). Path A reshapes both the picker and the labels so a user
scanning "Deutsche Gerichte" sees the type→instance hierarchy at a
glance and every tile reads <court> (<procedural role>) in parallel.
**Picker structure (verfahrensablauf.tsx + fristenrechner.tsx):**
Inside the existing `<.proceeding-group data-forum="de">` block, the
single flat row of 5 tiles is now two sub-groups with mixed-case h5
headings — Verletzungsverfahren over LG/OLG/BGH, Nichtigkeitsverfahren
over BPatG/BGH. DE_TYPES split into DE_INF_TYPES (3) + DE_NULL_TYPES (2)
in both page shells.
**Labels (i18n.ts, DE + EN parallel):**
| Code | Old DE | New DE |
|--- |--- |--- |
| de.inf.lg | Verletzungsklage (LG) | LG (1. Instanz) |
| de.inf.olg | Berufung OLG | OLG (Berufung) |
| de.inf.bgh | Revision/NZB BGH | BGH (Revision / NZB) |
| de.null.bpatg | Nichtigkeitsverfahren | BPatG (1. Instanz) |
| de.null.bgh | Berufung BGH (Nichtigk.) | BGH (Berufung) |
Two new i18n keys carry the sub-group headings:
- deadlines.de.group.inf — "Verletzungsverfahren" / "Infringement proceedings"
- deadlines.de.group.null — "Nichtigkeitsverfahren" / "Nullity proceedings"
**CSS (global.css):**
New `.proceeding-subgroup` + `.proceeding-subgroup-heading` rules,
co-located with `.proceeding-group h4`. Sub-heading sits one tier below
the h4 (mixed-case, no upper-tracking) so the two-level hierarchy reads
at a glance.
**What this does NOT do** — the "one long sequence" combined-timeline
behaviour (m's same ask, larger scope: spawn rules + de-duplication +
multi-instance UI) is filed as m/paliad#41 and stays a separate
delivery. Per-instance tiles keep their meaning either way.
Build hygiene: go build/vet clean; bun run build clean (2419 keys, +2).
Five intertwined fixes m surfaced in the interactive session:
1. **Jurisdiction prefix on the picked proceeding** — the collapsed
summary chip and the result header now read "UPC Verletzungsverfahren"
/ "DE Verletzungsklage (LG)" instead of the bare proceeding name.
Disambiguates the 4 redundancies in the corpus once the picker
collapses. Driven by .proceeding-group[data-forum] which is already
on every group.
2. **Trigger Event label = root rule** — step 2's "Auslösendes Ereignis"
line now shows the first event in the proceeding (e.g. Klageerhebung,
Nichtigkeitsklage) instead of the proceeding name. Populated from
the calc response (isRootEvent=true) on every render; em-dash
placeholder while step 3 hasn't rendered yet. lang-change keeps it
coherent.
3. **Flag rows on /tools/verfahrensablauf** — Slice 1 of t-paliad-179
stripped the with_ccr / with_amend / with_cci toggles when it lifted
the shared renderer; they never came back. Lifted the 4 existing
rows from fristenrechner.tsx plus 2 new with_po rows (RoP 19.1
preliminary objection, mig 095) — same wiring + show/hide rules on
both surfaces. with_amend stays nested under with_ccr on upc.inf.cfi
(R.30 only with a CCR).
4. **Rule references → youpc.org/laws links** — new
BuildLegalSourceURL(src) maps the structured legal_source code to
the youpc permalink for the UPC corpus (UPCRoP / UPCA / UPCS today;
39 of 91 active rules carry UPC.RoP.* and now link). DE/EPA/EU
bodies have no youpc home yet and render as plain display text —
filed as m/paliad#39. Wired through UIDeadline.LegalSourceDisplay +
LegalSourceURL so deadlineCardHtml can render <a target="_blank"
rel="noopener"> when the URL is set.
5. **R.19 label: "Vorab-Einrede" → "Einspruch"** — m's correction. DE
only (EN canonical UPC RoP term stays "Preliminary objection").
Client-side change only — i18n + JSX fallbacks. The matching DB
rename on the two rule-name rows folds into joule's broader mig 097
(legal-citation backfill, t-paliad-208 follow-up). The live UPDATE
applied during the session is captured under that audit reason; the
no-op when joule's mig re-applies is harmless.
Build hygiene:
- go build ./... + go vet ./... clean
- new test TestBuildLegalSourceURL covers UPC corpus + DE/EPA/EU
fall-through + edge cases (empty input, malformed source)
- bun run build clean (2417 i18n keys total)
Rebased on origin/main @ d126913 (ohm's submission_code rename
workstream B) — no conflicts in this commit's surface area.
Branch: mai/fermi/interactive-session. NOT self-merged.
Workstream B frontend sweep — matches mig 098 + the Go sweep. The
/admin/rules surfaces now distinguish submission_code (the rule's
filing identifier within a proceeding, e.g. upc.inf.cfi.soc) from
rule_code (the legal citation, e.g. RoP.013.1).
Admin rules list (/admin/rules):
- Column header renamed "Code" → "Submission Code / Einreichung-Kennung"
- New "Rechtsgrundlage" column shows rule_code alongside the submission
code; the old single-column fallback (rule_code || code) is gone.
- Filter-search placeholder updated to "Name, Submission Code,
Rechtsgrundlage…"
- Rule interface: code → submission_code field.
Admin rules edit (/admin/rules/{id}/edit):
- f-code → f-submission-code; input is now read-only with a
upc.inf.cfi.soc-style placeholder (consistent with the backend
RulePatch which doesn't allow editing the submission code).
- Labels reframe rule_code as "Rechtsgrundlage (Kurzform)" and
legal_source as "Rechtsgrundlage (Langform)" so the legal-citation
pair is named consistently with the list column.
- Rule interface: code → submission_code field.
i18n: new keys admin.rules.col.submission_code,
admin.rules.col.legal_citation, admin.rules.edit.field.submission_code
in both DE + EN; old admin.rules.col.code + admin.rules.edit.field.code
removed.
bun run build clean.
1. /deadlines list ticking the complete-checkbox now goes through
window.confirm() before firing PATCH /api/deadlines/{id}/complete.
The deadline title is interpolated into the prompt so the user sees
what they're closing. Matches the existing window.confirm() pattern
used in projects-detail / admin-team / approvals-withdraw etc. —
no custom modal layer.
2. The cascade row "ändern" button in the deadline calculator stayed
in German on the EN side. data-i18n="deadlines.row.edit" was set
correctly but applyTranslations() only runs at page init and on
lang-toggle; the cascade re-renders on every state change without
re-hydrating, so the static "ändern" fallback in the HTML stuck.
Render the label via t() directly in the template — same pattern
the rest of the cascade uses, no hydration dependency.
Both i18n keys land on both DE and EN sides (deadlines.complete.confirm
+ existing deadlines.row.edit). bun run build clean, 2414 keys.
Sweep of frontend/src/* for the proceeding-code rename landed by
mig 096. Same scope as the Go sweep — comments + literal string
codes substituted, plus the visible additions:
- fristenrechner.tsx / verfahrensablauf.tsx UPC_TYPES gain
upc.ccr.cfi as a fourth UPC option ("Widerklage auf Nichtigkeit");
it surfaces in the picker and renders the determinator routing
notice from proceeding_mapping.ResolveCounterclaimRouting.
- i18n.ts deadlines.* keys renamed to mirror the new codes exactly
(`deadlines.upc.inf.cfi`, …). DE + EN sides in sync.
- frontend/src/client/fristenrechner.ts fristenrechnerCodeToCascadeSegment
rekeyed to new codes; upc.ccr.cfi shares the upc-inf kebab segment
because the event_categories slug taxonomy is not renamed and ccr
resolves to inf-rules anyway.
- client/views/verfahrensablauf-core.ts court-picker conditions
rewritten against the new codes.
Bun build clean (i18n-keys.ts regenerated from the canonical map).
m's UX bug (2026-05-17, paliad.de prod): clicking Genehmigen/Ablehnen/
Zurückziehen on a row the viewer can't act on alerted ("Eigengenehmigung
nicht zulässig.", "Sie haben nicht die erforderliche Rolle.") after the
POST round-trip. m's ask: "approval that i cannot grant should have the
'Genehmigen' button greyed out... that would be better than showing an
error when I try."
Backend (internal/services/approval_service.go):
- ApprovalRequestView gains viewer_can_approve + viewer_is_requester
booleans. Resolved server-side per caller — false on self-authored rows
(caller == requester), true when the eligibility predicate matches.
- Extract the eligibility EXISTS-block into approvalEligibilitySQL const
and reuse it in ListPendingForApprover (WHERE), PendingCountForUser
(WHERE), and the new viewer_can_approve SELECT expression. Single
source of truth for the gate, identical to canApprove.
- ListPendingForApprover, ListSubmittedByUser, and GetRequest all bind
$1 = callerID so the SELECT computes the flags inline (one query, no
N+1). GetRequest's signature grows a callerID arg; the handler passes
the authenticated user.
Frontend (frontend/src/client/views/shape-list.ts):
- ApprovalDetail picks up the two booleans (optional — falsy is safe:
it disables, never falsely enables).
- approvalActionBtn renders the button as before but flips
btn.disabled + sets a tooltip via disabledReasonFor: approve/reject
share the viewer_can_approve gate (self → self_approval tooltip;
unauthorized → not_authorized); revoke needs viewer_is_requester.
- All three buttons still render on every pending row so users see
what's possible — the disabled+tooltip combo explains what's not.
i18n + CSS:
- 3 new keys × DE/EN: approvals.disabled.{self_approval,
not_authorized,revoke_not_requester}.
- .inbox-row-action:disabled neutralises the .btn-primary/danger/
secondary variant via opacity + not-allowed + muted tokens.
Tests:
- internal/services/approval_service_test.go::TestApprovalService_ViewerFlags
is a 4-case table-driven live-DB test (skips without TEST_DATABASE_URL):
self-authored (false/true), eligible peer (true/false), non-eligible
viewer (false/false), global_admin (true/false). Also asserts the flags
on ListPendingForApprover + ListSubmittedByUser rows.
Defence-in-depth preserved: server still rejects illegal POSTs with the
same error contract, and the alert path stays in inbox.ts for the race
where state changes between render and click.
Two issues m hit and reported in one breath while adding a project:
1. **Internal error on POST /projects** (prod-only, surfaced at 10:23). Both
ProjectService.Create and CreateCounterclaim re-referenced the uuid
parameter `$1` as `$1::text` to fill the path placeholder. Postgres'
planner deduced conflicting types for `$1` (uuid in the id column,
text in the cast) and rejected the prepared statement with 42P08
"inconsistent types deduced for parameter". The path placeholder
value is irrelevant — paliad.projects_sync_path() (BEFORE INSERT
trigger from mig 018/021) always overwrites it from id and parent
path. Fix: replace `$1::text` with a literal '' in both INSERTs,
keeping the parameter list decoupled from the id column's type.
Same comment now anchors the rationale on both call sites.
2. **CM number length — 6 digits, not 7.** m's correction; mig 018's
`^[0-9]{7}$` CHECK on paliad.projects.client_number and
matter_number was wrong. Mig 094 snapshots affected rows to
paliad.projects_pre_094, NULL-s the 3 surviving 7-digit test
values (2 client_numbers, 1 matter_number), then swaps the legacy
`projekte_*_check` constraints from {7} to {6}. Frontend pattern,
maxLength, placeholder, labels, and i18n hint flipped from 7 → 6
on both DE and EN sides; format hint reads CCCCCC.MMMMMM now.
Dry-run against live DB (BEGIN..ROLLBACK):
- Fixed Create SQL: trigger populates path = id::text (36 chars). ✓
- Mig 094: 2 rows snapshotted, 0 clients/matters remain after clear,
0 rows violate the new 6-digit CHECK. ✓
go build, go test ./internal/..., bun run build all clean.
Closes the Determinator cascade redesign. Three intertwined pieces:
1. The mode row is gone — the `🔍 Direkt suchen` icon at the top of the
row stack now toggles an inline search overlay over the cascade
instead of routing to the legacy B2 surface. Results render into the
same `#fristen-b1-results` container the cascade uses, so users see
one consistent concept-card layout regardless of whether they
reached the rule via cascade narrowing or free-text search. ESC
inside the input clears it on the first press and collapses on the
second; "← Zurück zum Entscheidungsbaum" restores cascade + state.
Deep-link `?mode=filter` still routes to the legacy B2 panel for
backwards-compatible shared URLs but is no longer exposed in the
cascade UI.
2. Mobile responsive per design §7. Three breakpoints layer onto the
`.fristen-row` primitive: <640px (phone — chips full-width single
column, ändern permanently visible, answer wraps to its own line),
<768px (tablet — head wraps so ändern moves down, chips
single-column), <1024px (small desktop / large tablet — chips drop
to 2-column auto-fill). Active row autoscrolls into view on every
render with 60px headroom; the helper is a no-op when the row is
already visible so desktop doesn't jitter.
3. Auto-walk tooltip polish: 200ms fade-in + slide-down via an
is-entering transition state; mobile (<640px) flips the insertion
point so the tip lands below the prefilled row rather than above;
any chip pick or ändern click counts as user-engagement and
dismisses the tip (in addition to the explicit × button).
Refs: docs/design-determinator-row-cascade-2026-05-13.md §6 + §7 + §10 Slice 3.
Wires the project context into the Determinator row stack so a UPC INF
matter doesn't need to be hand-walked through five obvious cascade picks.
Auto-walk descends single-option chains as `is-prefilled` rows, the inbox
row vanishes for UPC matters (CMS implied), and the first prefilled row
carries the project reference inline ("aus Akte: HL-2024-001").
Backend: `internal/services/proceeding_mapping.go` adds
MapLitigationToFristenrechner — single source of truth for bridging the
litigation conceptual codes (INF / REV / APP / CCR / AMD / APM / OPP) onto
fristenrechner codes (UPC_INF / DE_INF / EPA_OPP / …). Ambiguous combos
(APP+DE, ZPO_CIVIL, AMD+DE) return ok=false; callers degrade to "no
narrowing" instead of guessing. Table-driven test covers every documented
mapping plus the ambiguous-degrade cases.
Frontend: `buildRowStack` filters cascade children by project context
along the proceeding axis (kebab segment lookup against the project's
fristenrechner code); auto-walks while filtered scope narrows to one;
caps depth via `cascadeAutoWalkStopAfter` after an "ändern" on a prefilled
row so the user lands at an active chip set without the auto-walk
re-engaging. Result panel narrows on the post-auto-walk effective slug,
not the URL slug. A one-time inline tooltip ("Diese Schritte ergeben sich
aus Ihrer Akte") surfaces when ≥2 rows render prefilled — dismissal flag
persists in localStorage.
Narrowing is purely additive: an Akte without a fristenrechner code
(11/11 live projects pre-Slice-5 were NULL) degrades to today's
forum-only behaviour. Slice 3 (mobile polish + search relocation) follows.
Refs: docs/design-determinator-row-cascade-2026-05-13.md §10 Slice 2 + §4 + §5.
Replace the four-layer Pathway B mess (mode radio + perspective chip strip
+ inbox chip strip + breadcrumb cascade) with a single `.fristen-row`
primitive rendered in a top-down stack. Every decision — mode, perspective,
inbox, cascade depth N — now uses the same shape (label · picked answer ·
inline "ändern") and three states (is-active / is-answered / is-prefilled).
The user finally sees their full decision path at a glance instead of
chasing breadcrumb crumbs after each drill. Click on any answered row (or
its ändern affordance) re-actives it; ändern on a cascade depth drops the
descendants (same drop-descendants semantic as today's breadcrumb-click).
Reset link and `🔍 Direkt suchen` escape-hatch live at the top of the stack
per design §6 Option B; the mode-toggle radio is gone, routing to
?mode=filter now flows through the mode row.
Visual-only refactor — narrowing engine (inboxFilterAllowsForums +
perspectiveAllowsParty) is unchanged. Slice 2 will add project-driven
prefills + auto-walk; Slice 3 covers mobile polish and search relocation.
Refs: docs/design-determinator-row-cascade-2026-05-13.md §10 Slice 1.
Phase 3 Slice 9 frontend cleanup. The backend's UIDeadline wire
shape stopped emitting (isMandatory, isOptional) in this slice;
the matching legacy-fallback branch in priorityRendering is now
dead code. Drops:
- CalculatedDeadline TS interface: isMandatory + isOptional
fields removed. `priority` is required (not optional) since
every backend response now populates it.
- priorityRendering(): collapsed to a clean switch on `priority`.
Unknown priority falls back to "render as mandatory" (safe
default; never silently drop a rule) — the legacy
(isMandatory, isOptional) inference is gone.
- Save-modal optional-badge rendering in fristenrechner.ts now
reads `dl.priority === "optional"` directly (was previously
`dl.priority === "optional" || dl.isOptional`).
- Timeline row's optional-badge rendering in
verfahrensablauf-core.ts switched from `!dl.isMandatory` to
`dl.priority === "optional"`. Slightly different semantic —
pre-Slice-9 the badge fired on every non-mandatory row
(recommended + optional + informational); post-Slice-9 only
on opt-in rules (RoP.151 pattern). Recommended + informational
are surfaced via their own rendering tier (notice card for
informational) so the badge change tightens the meaning.
Frontend build clean; no i18n keys removed (the priority labels
shipped in Slice 8 stay live).
Surfaces the Slice 11a admin API at /admin/rules so editors can drive
the rule lifecycle without curling. Three new pages, each gated by
adminGate on the route + sidebar reveal via /api/me:
/admin/rules — list page with filters (proceeding,
trigger event, lifecycle chips, fuzzy
search) and a second "Orphans" tab that
loads paliad.deadline_rule_backfill_orphans
via the new GET /admin/api/orphans
endpoint. Pick-chip on each candidate
fires the reason modal → POST resolve.
"+ Neue Regel" opens the same reason modal
with minimal required fields (name DE/EN
+ duration) and routes to the edit page
on success.
/admin/rules/{id}/edit — full form (37 columns grouped: identity /
proceeding / timing / party / display /
lifecycle / condition). Side panel hosts
the preview widget (trigger date + flags
→ GET .../preview, drafts only) and the
audit-log timeline (paginated, 20 per
page). Bottom action bar adapts to
lifecycle_state — save-draft + publish on
drafts, clone on published/archived,
archive on draft/published, restore on
archived. Every action opens the reason
modal with ≥10-char client-side guard per
Slice 11a edge case #4.
/admin/rules/export — minimal SQL preview + "Download as file"
/ "Copy to clipboard". Optional `since`
audit-id scopes the export window.
condition_expr ships with a raw JSON textarea + inline parse
validation; the tree-builder is out of scope for Slice 11b (raw JSON
is sufficient given the existing 172-row corpus and validates the
same grammar live). The dependency on document.querySelectorAll for
form binding follows the admin-event-types / admin-audit-log
playbook — no new component substrate needed.
Wiring:
- frontend/build.ts: 3 new entrypoints + 3 new HTML writes.
- frontend/src/admin.tsx: new "Regeln verwalten" card with ICON_TABLE.
- frontend/src/components/Sidebar.tsx: two new admin nav entries
(Regeln + Regel-Migrations).
- frontend/src/client/i18n.ts: 162 new keys (DE+EN), under
admin.rules.* and admin.rules.edit.* and admin.rules.export.*.
- frontend/src/styles/global.css: new admin-rules-* CSS block
appended (chips, pills, audit timeline, edit-grid, preview list,
orphan cards, export pre). Uses paliad's existing CSS tokens so
light/dark/auto themes inherit automatically.
Route registration:
- GET /admin/rules — list page shell
- GET /admin/rules/{id}/edit — edit page shell
- GET /admin/rules/export — export page shell
All routes adminGate + gateOnboarded, so non-admin users 404 before
the shell even loads. Backend audit and lifecycle invariants from
Slice 11a stay authoritative; the frontend never bypasses them.
Phase 3 Slice 8 frontend wire-shape swap. Save-modal pre-check logic
moves from the legacy (isMandatory, isOptional) pair to the unified
priority enum via a new priorityRendering helper in
verfahrensablauf-core.ts:
- mandatory → pre-checked, save button visible
- recommended → pre-checked, save button visible
- optional → pre-unchecked, save button visible (RoP.151 pattern)
- informational → NO save button — renders as a notice card with a
"Hinweis" / "Note" label, distinct visual tier (no checkbox).
The visible UX win of Phase 3: the 18 F/F filing rules
(Berufungserwiderung, Replik, Duplik, R.19, R.116 EPÜ, etc.)
currently render as 'recommended'; once editorial review flips
them to 'informational' via the rule editor (Slice 11), this
branch lights up and they stop offering a save action that
would auto-create deadlines users didn't ask for.
priorityRendering falls back to the legacy (isMandatory, isOptional)
pair semantic when priority is missing (pre-Slice-8 backend
responses), so the cutover is bidirectional-safe. After Slice 9
drops the legacy fields, the fallback branch becomes unreachable.
CalculatedDeadline TS interface gains:
- priority: optional 4-way union literal type
- conditionExpr: optional unknown (rule editor reads this; the
save-modal doesn't need to interpret it)
i18n keys added (DE + EN both):
- deadlines.priority.mandatory/recommended/optional/informational
- deadlines.priority.informational.notice_label (Hinweis / Note)
- project.instance_level.first/appeal/cassation/unset
- verlauf.spawn.chip + verlauf.spawn.cycle_warning (reserved for
the SmartTimeline spawn-chip work, deferred to a focused
follow-up so this slice doesn't balloon)
Frontend build clean (2225 i18n keys, 11 new). The instance_level
pill group on the project-edit form is intentionally NOT shipped
in this slice — the project-edit form is large and the pill is
self-contained UI; the data field is exposed via the API and a
follow-up slice (or the rule editor work) can wire the picker
without blocking the wire-shape swap.
Phase 3 Slice 5 frontend. loadProceedingTypes() in projects-detail.ts
now fetches /api/proceeding-types-db?category=fristenrechner so the
project edit picker only ever shows the 19 fristenrechner codes,
never the 7 legacy litigation codes (INF / REV / CCR / APM / APP /
AMD / ZPO_CIVIL).
The Fristenrechner calculator page + Verfahrensablauf page are NOT
touched — they still need the full proceeding_types catalog (the
litigation codes have rule trees the calculator can render, per
design §3.F: "litigation codes stay … reachable via cascade leaves").
Only the project-binding picker is restricted.
Defence-in-depth: even if a future fetch bypasses this filter, the
server-side service guard (ErrInvalidProceedingTypeCategory) and
the mig 088 DB trigger both reject the write. The picker filter is
the UX layer of the chain — invisible bad-shape inputs.
projects-new.ts has no proceeding-type field today (the form lives
on the edit page only); no change needed there.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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
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.
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.