Optional events anchored on the same trigger (e.g. the four
post-Entscheidung rules in upc.inf.cfi) used to render in catalog
sequence_order, so a 2-month rule (R.118.4 Folgeentscheidungen)
would precede a 1-month rule (R.151 Kostenentscheidung) chained
off the same decision. Now the calculator does a post-evaluation
permutation pass that sorts consecutive same-parent rows by
duration ascending — days < weeks < months < years, ties broken
by duration_value then submission_code.
Different trigger groups keep their proceeding-sequence position
— the walk only ever permutes rows that already share a parent.
Root rules (no parent) are never sorted against each other.
Court-set / conditional rows whose date isn't in the duration
ladder sort LAST within their group.
Verified order against m's report: R.151 cost_app + R.353
rectification (1-month tier) now render before R.220.1
appeal_spawn + R.118.4 cons_orders (2-month tier).
Issue: m/paliad#128
m/paliad#125 — concern A (horizontal scroll) and concern B (compact
event-card UX).
Concern A: the inline "Wieder einblenden" chip from t-paliad-290 pushed
hidden cards past their column width on 375/414/768, causing horizontal
page scroll. Fix: drop the chip entirely; surface the un-hide as a
prominent "Wieder einblenden" entry inside the caret popover (matches
the m's "actions live in the caret menu" framing). The card title row
now also wraps + shrinks (flex-wrap + min-width:0 + overflow-wrap)
so no inline child can ever blow the row width.
Concern B (the bigger UX): cards now speak m's "cut the tree of
possibilities" vocabulary via iconified state markers in the title row:
- Optional event → ⊙ (timeline-state-icon--optional)
- Hidden by user → 👁⃠ (timeline-state-icon--hidden)
- Conditional anchor → already covered by the "abhängig von <parent>"
chip on the date column (t-paliad-289); no duplicate marker.
- CCR-included / appellant picks → already on the per-card chip.
The legacy `.optional-badge` text chip and `.event-card-choices-unhide`
inline chip are gone — both replaced by the icon language + popover
entry.
Renderer wires the unhide path with two contracts:
- data-is-hidden="1" on the caret button when isHidden=true, so the
popover knows to render the prominent unhide block on top.
- Defensive fallback: if a rule's choices_offered was edited away
after the user had already saved skip=true (so isHidden=true but
choicesOffered is empty), the renderer synthesizes {skip:[true,
false]} so the popover still has an un-hide path.
CSS:
- .timeline-item min-height 4rem → 2.75rem (less vertical air).
- .timeline-content padding-bottom 1rem → 0.6rem (tighter gutter).
- .timeline-item-header gains flex-wrap + min-width:0.
- .timeline-name gains min-width:0 + overflow-wrap:anywhere
(long German compounds wrap mid-word instead of overflowing).
- New: .timeline-state-icon[--optional|--hidden] icon-style markers.
- New: .event-card-choices-unhide-btn — prominent full-width lime
pill inside the popover, midnight-text in both themes (matches
the active-option pin from m/paliad#123).
i18n:
- state.optional.tooltip — "Optionales Ereignis" / "Optional event"
- state.hidden.tooltip — "Ausgeblendet — über Optionen-Menü wieder
einblenden" / "Hidden — restore via the options menu"
- choices.unhide.chip kept (now used as the popover button label).
Tests: 27 → 29 tests in verfahrensablauf-core.test.ts. Old isHidden
inline-chip cases replaced by state-icon + caret-data-is-hidden
contract cases. Added defensive-fallback case for the synthesized
skip offer. Added regression guard that the legacy
.event-card-choices-unhide class is no longer emitted. Added
optional-priority → ⊙ icon contract pair.
Hard rules respected:
- Title + date + Rule citation unchanged (m likes these).
- Click-to-edit on date span (.frist-date-edit) untouched.
- Conditional rendering (t-paliad-289 chip + dotted border) untouched.
- Per-card actions (skip, appellant pick, include-CCR, unhide) all
reachable via the caret popover.
go build ./... && go test ./internal/... && cd frontend && bun run
build && bun test — all green (181 tests).
Rules anchored on uncertain triggers (R.109 backward-anchor without
oral-hearing date; R.118(4) without validity decision; R.262(2)
without recorded Vertraulichkeitsantrag) previously rendered concrete
dates fabricated off the trigger date. Add IsConditional projection
flag so the SmartTimeline + Verfahrensablauf surfaces "abhängig von
<parent>" instead of a misleading date.
Backend (fristenrechner.go):
- Add IsConditional + ParentRuleCode/Name/NameEN to UIDeadline.
- Pre-pass populates courtSet from rule.is_court_set=true BEFORE the
main loop, so order-of-evaluation in sequence_order no longer matters
for the parent-court-set check. Fixes R.109(1) "Antrag auf
Simultanübersetzung" (sequence_order=45 < Mündliche Verhandlung's
sequence_order=50): the timing='before' backward arithmetic was
computing 1 month before the trigger date because the court-set
parent hadn't been classified yet.
- Set IsConditional=true on every IsCourtSetIndirect branch (catches
R.109 backward + R.118(4) cons_orders chain off the decision).
- Set IsConditional=true for priority='optional' + primary_party='both'
rules whose data-model parent is the trigger anchor (covers R.262(2)
confidentiality_response: the data anchors on SoC, but the real
trigger is the opposing party's confidentiality motion which may
never happen). Suppressed by IsOverridden so user anchors win.
Backend (projection_service.go):
- Add IsConditional to TimelineEvent + propagate from UIDeadline.
- New Status="conditional" for projected rows; clears Date, populates
DependsOnRuleCode/Name from UIDeadline.ParentRule* so the row
carries the "abhängig von <parent>" payload even when the parent
has no computed date for annotateDependsOn to discover.
Frontend (verfahrensablauf-core.ts + CSS + i18n):
- CalculatedDeadline gains isConditional + parentRule* fields.
- deadlineCardHtml renders "abhängig von <parent>" chip with
click-to-edit affordance in place of the date column when
isConditional=true. IsConditional wins over IsCourtSet for the
date column (they overlap; "abhängig von <parent>" names the
specific blocker).
- .timeline-item--conditional / .fr-col-item--conditional CSS:
dotted border + faded text so the conditional state reads at glance.
- Replaced escHtml's DOM-backed implementation with a pure-JS regex
escape so the module is testable in bun test without jsdom (the
old form forced fixtures to leave several fields empty just to
avoid the DOM dependency).
Tests:
- TestApplyLookaheadCap_ConditionalRowsPassThrough: pure-function lock
that conditional rows pass through applyLookaheadCap untouched
(don't count against ProjectedTotal/Shown, don't get capped).
- TestUIDeadline_IsConditional_UncertainAnchors (TEST_DATABASE_URL):
asserts R.109(1)/(4), R.118(4) chain, and R.262(2) all render
IsConditional=true with empty DueDate + populated ParentRule*; SoD
stays non-conditional; override on the oral hearing flips R.109(1)
back to concrete date.
- 4 new bun tests for the conditional rendering branches in
deadlineCardHtml.
UX path verified by tests + manual review of the live rule corpus:
opening a UPC inf project without oral-hearing date now surfaces
R.109(1) + R.109(4) as conditional; recording the Vertraulichkeitsantrag
(anchoring R.262(2) via the existing "Datum setzen" flow) flips it
back to a concrete date.
go build / go test / bun test / bun run build all clean.
Six surfaces paired a lime background with var(--color-text), which
flips to cream in dark mode and collapses contrast on the high-luminance
brand lime. Switch them to var(--color-accent-dark) — the design token
already defined to stay midnight in both themes as the WCAG-AA fg on
lime.
Affected:
- .event-card-choices-option--active (Berufung durch … popover —
m's primary report on m/paliad#123)
- .fristen-row.is-active .fristen-row-num
- .form-hint-badge
- .paliadin-widget-send-btn
- .smart-timeline-anchor-submit
- .admin-rules-chip.active
Lime hue and non-active states untouched.
Refs: m/paliad#123
Restructures the submission-draft sidebar per m's m/paliad#119 review.
Three changes on the variable form (Part B):
- VARIABLE_GROUPS collapses into four lawyer-facing sections: Mandant
& Verfahren (firm.* + project.* + procedural_event.*), Parteien
(manual {{parties.<role>.*}} overrides), Frist (the now-internal
deadline.* block, COLLAPSED by default since the skeletons no
longer render it), Sonstiges (today.* / user.* trim).
- Group sections are click-to-collapse via a sticky state map; the
Frist + Parteien-override sections open closed so the visible form
stays tight on first load.
- The legacy {{rule.*}} aliases drop off the sidebar — still resolved
by SubmissionVarsService for old templates, no longer surfaced as
override rows (they cluttered the form and the canonical
procedural_event.* names cover the same ground).
Multi-party + Add Party (Part C):
- The party picker now renders all three role buckets (claimants /
defendants / others) even when empty, so the lawyer can populate via
Add Party. The block is hidden only when no project is attached.
- Each side gets a "+ Partei hinzufügen (Klägerseite / Beklagtenseite
/ Weitere Parteien)" button that opens an inline panel with two
tabs:
- Manual entry — name, role (pre-filled from side), representative.
Submits to POST /api/projects/{id}/parties, creating a real
paliad.parties row that immediately surfaces in available_parties.
- Aus DB übernehmen — debounced (200ms) search against the new
GET /api/parties/search endpoint. Returns hits across every
visible project with project_title + reference for context.
Already-on-this-project rows are filtered out client-side. Picking
a hit clones name/role/representative into a fresh row on the
current project — the simplest semantics that survives the
paliad.parties.project_id NOT NULL contract while honouring m's
"no manual re-typing" requirement.
- Newly-added parties land in selected_parties immediately so the new
party is rendered in the next preview round-trip without an extra
click. Implicit-"all" default is preserved (empty selected_parties
still means "every party on the project, including this new one").
- Search-result repaints reach only into the <ul>, not the whole
picker — keeps focus + selection on the search input across
keystrokes.
CSS:
- Collapsible-section caret rotation, busy/disabled form states, tab
highlights, DB-picker result rows with project chip + hover, all
inherit the existing lime-tint accent so the new affordances look
native to the editor.
TSX:
- Comment update on the parties block; no structural change. The
bilingual hint copy in i18n.ts now nudges towards Add Party.
Adds PartyService.Search returning paliad.parties rows from every
project the caller can see, matched by case-insensitive substring on
name or representative. Wired via GET /api/parties/search?q=... — used
by the submission-draft Add-Party panel's "Aus DB übernehmen" tab.
Visibility flows through the same visibilityPredicatePositional helper
every project-scoped read uses; invisible projects' parties never
surface. Capped at 25 hits per call (no pagination — typical lookup is
"the party I'm thinking of by name", not a browse).
Result shape carries project_title + project_reference so the picker
can disambiguate identically-named parties across cases.
Per m's m/paliad#119 report: the {{deadline.*}} block was leaking
internal/admin context (Frist-Bezeichnung, Fälligkeit, "berechnet aus",
Quelle) into court-bound submissions. The dedicated Frist heading and
its 4 body lines are removed from both gen-skeleton-submission-template
(_skeleton.docx) and gen-hl-skeleton-template (_firm-skeleton.docx).
The {{deadline.due_date_long_en}} reference in the locale-aware
verification footer is also dropped. {{deadline.*}} placeholders stay
resolvable in SubmissionVarsService — a custom template can still pick
them up — but the default skeletons no longer render them in the body.
Regenerated .docx files uploaded to HL/mWorkRepo:
- 6 - material/Templates/Word/Paliad/HLC/_skeleton.docx → d0ecc0e
- 6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx → 25954c9
m/paliad#122. atlas's #96 Slice A added per-card 'Überspringen' but no
un-skip path — hidden cards just disappeared from the timeline. This
adds the missing return path:
- CalcOptions.IncludeHidden (default false) tells the calculator to
re-surface skipRules entries as faded rows instead of dropping them.
When true, the rule renders with UIDeadline.IsHidden=true and the
descendant-suppression cascade is bypassed so children compute their
dates off the un-suppressed parent.
- UIResponse.HiddenCount always reflects the projection's hide count
(gate-passed rules whose submission_code is in skipRules) so the
"Ausgeblendete (N)" badge stays accurate regardless of toggle state.
- /tools/verfahrensablauf gets a "Ausgeblendete anzeigen" checkbox next
to the perspective + appellant selectors. URL-driven (?show_hidden=1)
so the state is shareable and survives reload. The row hides itself
on projections with zero hidden cards.
- Hidden cards render via .timeline-item--hidden / .fr-col-item--hidden
(opacity 0.55 + dotted border, mirroring the existing
--skipped fade) and carry an inline "Wieder einblenden" chip. Clicking
the chip removes the skip choice via the page's existing
attachEventCardChoices remove callback (URL state + recalc included)
and runs through a new delegated handler in event-card-choices.ts.
- 3 new i18n keys (DE+EN): choices.show_hidden.label,
choices.show_hidden.count, choices.unhide.chip.
The skip-choice storage shape (paliad.project_event_choices, atlas's
table) is unchanged — un-hide is just a delete of the skip row.
Tests: 3 new bun-test cases pin the chip contract (emits on isHidden=
true with submission_code, suppressed otherwise); go test ./internal/...
+ bun run build clean.
The Verfahrensablauf side selector offered Klägerseite / Beklagtenseite /
Beide. 'Beide' is legally impossible (no party is on both sides) — the
state being modelled is "perspective not yet picked", not "both sides".
Rename the chip to 'Nicht festgelegt' (DE) / 'Undefined' (EN) without
changing the underlying state value or projection behaviour.
- frontend/src/verfahrensablauf.tsx: chip label flips to
deadlines.side.undefined; add inline hint chip
"Wählen Sie eine Seite, um die Spalten zu fokussieren." next to the
radio cluster, shown only while no side is picked.
- frontend/src/client/verfahrensablauf.ts: sideLabelI18n() returns the
new key for null; syncSideHintVisibility() toggles hint display from
initPerspectiveControls, the side-radio change handler, and
showSideRadioCluster (chip→radio override path).
- frontend/src/client/i18n.ts: rename deadlines.side.both →
deadlines.side.undefined (DE: Nicht festgelegt, EN: Undefined); add
deadlines.side.hint in both languages.
- frontend/src/i18n-keys.ts: rename in the union, keep alphabetical
order.
- frontend/src/styles/global.css: .side-radio-cluster becomes inline-flex
so the hint sits next to the toggle; .side-hint styled muted+italic.
URL backward-compat: ?side=both is already silently treated as null by
readSideFromURL (only accepts claimant|defendant) — same column
behaviour as before, no migration needed. projects.field.our_side.both
is a different concept (a project being a multi-party participant) and
stays untouched.
Tests: 17/17 in verfahrensablauf-core.test.ts still pass; the
"default (no opts) mirrors 'both' rules into ours AND opponent" case
already covers the unchanged null-side projection. Go build + tests
clean. Frontend build clean (i18n scan: 2901 keys, data-i18n
attributes clean).
m/paliad#120
The bar's chip clicks POST a payload shaped as `predicates: {<source>:
<per-source>}` — flat, one entry per data source. Go declared
`Predicates map[DataSource]Predicates` — a doubled-nested wrapper where
each map value was itself a Predicates struct with named per-source
fields. The JSON shape Go expected was
`{"deadline": {"deadline": {"status": [...]}}}`; the shape the bar
emitted was `{"deadline": {"status": [...]}}`. Go silently unmarshalled
the bar's payload as `Predicates{}` (all source fields nil), so every
chip click on /views/any was a server-side no-op — the regression in
#115.
The latent contract bug was present since t-paliad-144 A1 (b516201) but
only surfaced now: /inbox uses the InboxSystemView's code-resident
predicates (built in Go directly, doubled shape works) and saved views
never carried predicates in the DB, so chip-click overlays were the
only path that exercised the wire-format wrong way. /views/any made
that path visible because all four sources need narrowing.
Fix: align Go to the flat shape the frontend already emits.
- FilterSpec.Predicates: `map[DataSource]Predicates` → `*Predicates`.
- All `spec.Predicates[SourceX]` access sites in view_service.go +
approvalStatusMatches + allowed* helpers + system_views literals
+ tests rewritten to `spec.Predicates.X` with a nil-spec.Predicates
guard.
- Frontend FilterSpec.predicates type tightened from
`Partial<Record<DataSource, Predicates>>` (which silently allowed
the wrong runtime write) to `Predicates`.
Regression coverage:
- `filter_spec_predicates_test.go` (new, Go) pins three contracts:
the bar's exact wire payload unmarshals into a non-nil per-source
predicate; marshalling a Go-constructed spec produces the same flat
shape; the "Erledigt" chip's request narrows to completed deadlines.
- `compute-effective.test.ts` (new, bun:test) pins 12 chip-overlay
cases for /views/any (every axis the saved view's sources expose).
Build hygiene:
- `go build ./...` clean.
- `go test ./... -count 1` clean (existing inbox + filter_spec tests
updated for the new struct shape; new tests pass).
- `cd frontend && bun run build` clean.
- `cd frontend && bun test src/` — 169 pass, 0 fail.
No migration: paliad.user_views.filter_spec jsonb rows live with
`predicates: {}` or no predicates field; both unmarshal as nil
*Predicates under the new type, identical to the no-narrowing behaviour
the old map type produced for the same rows.
Adds .gitea/workflows/test.yaml that gates every push on `go build`,
`bun run build`, `go vet`, the migration coordination check, and the
role-split end-to-end migration smoke. On push to main + green, calls
Dokploy's compose.deploy API and polls /health/ready until 200.
t-paliad-282 / m/paliad#114. Design: docs/design-cicd-pre-deploy-gate-2026-05-25.md
(inventor shift on mai/cronus/inventor-ci-cd-pre).
Catches all three of today's outage classes:
brunel (~13:20) slot collision -> TestMigrations_NoDuplicateSlot
hermes (~16:05) dropped-col refs -> TestBootSmoke
mig 129 (~14:56) 42501 ownership -> TestMigrations_EndToEndAsAppRole
Snapshot approach. internal/db/testdata/prod-snapshot.sql is a pg_dump
of youpc-supabase paliad schema + applied_migrations rows. CI restores
this into a fresh `supabase/postgres:15.8.1.060` (same image, same role
topology as prod) and runs ApplyMigrations as the `postgres` role
(which is NOT a superuser on supabase/postgres, matching prod). Existing
migrations are skipped (already in applied_migrations); only NEW migs
from the PR run end-to-end. This sidesteps the fresh-DB idempotence
debt in some historical migrations (mig 037 missing pg_trgm, mig 051
inner COMMIT) — those are tracked separately and don't block the gate.
Sub-changes:
- internal/handlers/handlers.go — new /health/ready endpoint distinct
from /healthz. /healthz stays liveness (process alive, no DB); /ready
is readiness (DB pool pings within 2 s). Returns 503 when svc or pool
is nil (DB-less deploys are intentionally not-ready). svc.Pool added
to handlers.Services, wired in cmd/server/main.go.
- internal/db/migrate_test.go — TestMigrations_NoDuplicateSlot (pure
unit, catches brunel) and TestMigrations_EndToEndAsAppRole (snapshot-
gated, catches the 42501 class).
- cmd/server/main_smoke_test.go — TestBootSmoke now also asserts
/health/ready returns 503 with a nil svc. New TestHealthReady_Live
asserts 200 against a live pool.
- internal/db/migrations/024_rename_department_columns.up.sql and
027_rename_to_partner_units.up.sql — ALTER INDEX / ALTER POLICY
exception handlers now catch undefined_object OR undefined_table OR
duplicate_object. Old handler only caught undefined_object; Postgres
raises undefined_table when source object never existed, and
duplicate_object when destination already exists. The expanded
handlers make these migrations truly idempotent across all plausible
starting states.
- Makefile — verify-mig-app, test-frontend, refresh-snapshot targets.
refresh-snapshot pg_dumps youpc-supabase prod (needs PALIAD_PROD_DATABASE_URL),
strips pg16 \restrict commands for pg15 restore compat, and filters
applied_migrations rows to this branch's max on-disk version.
- internal/db/testdata/README.md — explains the snapshot's purpose,
refresh procedure, and how to verify locally.
- docs/cicd-runner-setup-2026-05-25.md — one-time admin steps for
registering a Gitea Actions runner on mriver and wiring DOKPLOY_TOKEN
as a repo secret. Documents soft-launch plan per m's Q11.4 (keep
Dokploy's autoDeploy=true webhook alive for one week, disable after
the workflow has gated 5 successful deploys).
Build clean. Full go test ./internal/... ./cmd/... green without
TEST_DATABASE_URL. With TEST_DATABASE_URL + TEST_APP_DATABASE_URL set
to a supabase/postgres scratch + snapshot restored:
TestMigrations_NoDuplicateSlot, TestMigrations_EndToEndAsAppRole,
TestBootSmoke, TestHealthReady_Live all pass. Live-DB service tests in
internal/services/* fail under supabase/postgres 15.8 with a 42P08
parameter-binding error (unrelated to Slice A — tracked as a follow-up).
Add 12 Tier 1 procedural deadline rules from curie's audit §10
(docs/research-deadlines-completeness-2026-05-25.md), backfill the
UPC R.104/R.105 Interim Conference citation on upc.inf.cfi.interim
(m/paliad#116 / m's 2026-05-25 report), and fold in the audit Q6
cleanup of the 40 _archived_litigation.* rows.
New rules:
T1.1 upc.inf.cfi.cmo_review 15d / R.333.2
T1.2 upc.inf.cfi.confidentiality_response 14d / R.262.2 (trigger 25)
T1.3 upc.apl.order.grounds_orders 15d / R.224.2(b)
T1.4 upc.apl.order.response_orders 15d / R.235.2
T1.5 upc.inf.cfi.cons_orders 2mo / R.118.4
T1.6 upc.inf.cfi.rectification 1mo / R.353
T1.7 upc.pi.cfi.deficiency 14d / R.207.6(a)
T1.8 upc.pi.cfi.merits_start 31d OR 20wd (max) / R.213 + R.198.1
T1.9 upc.inf.cfi.translation_request 1mo BEFORE oral / R.109.1
T1.10 upc.inf.cfi.interpreter_cost 2wk BEFORE oral / R.109.4
T1.11 upc.inf.cfi.translations_lodge 2wk / R.109.5 (trigger 113)
T1.12 upc.pi.cfi.response UPDATE: re-anchor on .app, court-set
T1.8 uses Wave 2 Slice A primitives (mig 128: working_days unit +
combine_op='max'). T1.9/T1.10 use timing='before' with the
backward-snap path in deadline_calculator.go.
Also drops the deadline_rule_audit.rule_id FK constraint. The mig 079
audit trigger had a latent bug — it could not log DELETEs because the
FK rejected the post-delete INSERT (count(*) WHERE action='delete'
was 0 across the entire history). Audit tables are append-only
history and should not FK-constrain on live entity tables; before_json
preserves the full row state. Unblocking this also unblocks the §13b
Q6 cleanup.
Verified on Supabase: 13 rows present in post-fix shape, all
assertions in the DO-block pass, audit log now records 11 creates +
2 updates + 40 deletes for this migration.
Adds the post-submission court phase to upc.dmgs.cfi and the appeal
route to upc.pi.cfi. The Verfahrensablauf timeline currently stops at
the last party submission (dmgs.rejoin / pi.order); without these rows
the interim conference / oral hearing / decision / appeal sub-tree
never renders, even though atlas's #96 spawn mechanism is in place.
Migration 133 (single slot, coordinated with knuth's #116 on 132):
Section A — UPC Damages tree end (#117):
- upc.dmgs.cfi.interim court-set, R.105
- upc.dmgs.cfi.oral court-set, R.118 / R.250
- upc.dmgs.cfi.decision court-set, R.118 / R.144
- upc.dmgs.cfi.appeal_spawn 2mo, R.220.1(a) / R.224.1(a), spawn → upc.apl.merits
Section B — UPC PI appeal route (#118):
- upc.pi.cfi.appeal_spawn 2mo, R.220.1(a) / R.224.1(a), spawn → upc.apl.merits
PI orders under R.211 dispose of the urgent question and ride the
main 2-month track; the 15-day R.220.1(c) order track does not apply.
Same shape as mig 095 inf.appeal_spawn and the upc.inf.cfi
interim/oral/decision rows from mig 012. Court-set rows reuse the
shared interim-conference / oral-hearing / decision concepts.
Citations: docs/research-deadlines-completeness-2026-05-25.md §D + Tier 4 (R.144), docs/audit-upc-rop-deadlines-2026-05-08.md §D R.144 + §F R.220.1(a)/R.224.1(a). Per-row RoP citation in the migration header.
Idempotent INSERT NOT EXISTS guards per row + post-insert DO block that RAISEs EXCEPTION if any expected row is missing or the spawn shape (is_spawn / spawn_proceeding_type_id / parent_id) is wrong.
go build ./... clean, go test ./internal/... clean, bun run build clean.
Per-draft `language` column drives the .docx output language for the
submission generator. The lawyer picks DE or EN on the draft editor's
sidebar; the generator selects the language-matched template variant
(falling back through {code}.{lang} → {code} → _skeleton.{lang} →
_skeleton → letterhead) and resolves language-aware variables
({{procedural_event.name}} → name_de vs name_en).
Schema (mig 130 — bumped from 129 to deconflict with atlas's #96):
- paliad.submission_drafts.language text NOT NULL DEFAULT 'de'
CHECK IN ('de','en'). Existing rows inherit 'de' via the default,
preserving every legacy draft's behaviour byte-for-byte.
Backend (Go):
- SubmissionVarsContext.Lang overrides the user's UI lang. Build()
uses it when set; falls back to user.Lang otherwise — Slice 1's
format-only /generate path keeps working unchanged.
- SubmissionDraftService.BuildRenderBag now threads draft.Language
through. Create/EnsureLatest seed from the UI lang (DE default).
- DraftPatch.Language landed; Update validates and rejects values
outside {de,en}. Project-scoped + global PATCH endpoints both
surface the field.
- resolveSubmissionTemplate(ctx, code, lang) replaces the lang-less
predecessor. Returns the matched tier (per_code_lang / per_code /
skeleton_lang / skeleton / letterhead) so the editor knows whether
to surface the "Fallback: universelles Skelett" notice.
- fileRegistry registers the EN skeleton sibling (`_skeleton.en.docx`)
alongside the DE one; per-code EN variants land in a parallel
submissionTemplateENRegistry (empty for now — EN templates land per
HLC authoring). 404s from Gitea fall through silently.
- /api/projects/{id}/submissions/{code}/generate accepts
`?language=de|en` query override (one-shot path, no draft row to
pull the column from); defaults to the user's UI lang.
Frontend (TS/JSX):
- DE/EN radio above the variables list in the draft editor sidebar.
Switching the radio PATCHes `language` and the server returns the
freshly-resolved bag + preview HTML so the lawyer sees EN values
immediately.
- Fallback notice ("Fallback: universelles Skelett (keine
sprachspezifische Vorlage)") shows when the resolved tier doesn't
match the requested language.
- 4 new i18n keys (DE + EN) + CSS for the toggle.
Tests:
- normalizeDraftLanguage covers DE/EN/case/whitespace/unknown.
- addRuleVars language-pick test pins procedural_event.name and the
rule.name alias to the language-matched value.
- languageFallback truth table covers all 10 (lang × tier) combos.
Build hygiene: go build/vet/test clean; bun run build clean.
ProceedingType TS interface in admin-rules-list.ts and admin-rules-edit.ts
declared `name_de` but the Go ProceedingType model serialises `db:"name"`
as JSON key `name` (DE is the primary on the wire). Result: `pt.name_de`
was undefined for every row, so `${pt.code} · ${pt.name_de}` produced the
literal "upc.apl.cost · undefined" in the list (and the same in proceeding
selects of the edit page).
Frontend-only fix:
- Rename the field to `name` to match the API contract.
- Guard the label builder: if the active-language name is missing, fall
back to just the proceeding code rather than rendering "code · " (or
worse, the original "code · undefined" string).
Other admin pages that fetch /api/proceeding-types-db (deadlines-new,
deadlines-detail, project-form, fristenrechner) already read `pt.name`
correctly, so the bug was scoped to these two files. TriggerEvent's
`name_de` field is real and stays untouched.
Multi-select party picker on the dedicated submission draft editor —
lawyer picks which of the project's parties to mention in this
specific submission. Adds the t-paliad-277 variable-bag multi-party
shape ({{parties.claimants}}, {{parties.claimant.0.name}}) while
keeping the legacy flat aliases ({{parties.claimant.name}}) for every
existing .docx template authored before the rename.
Surfaces an explicit "Aus Projekt importieren" button + last-imported
timestamp at the top of the variable sidebar so the lawyer can re-pull
project-derived variables (project.*, parties.*, deadline.*,
procedural_event.*, rule.*) when the project data drifts away from the
saved draft overrides. firm.*, today.*, user.* overrides survive the
import — those values aren't sourced from the project record.
Schema: mig 131 adds two columns to paliad.submission_drafts:
- selected_parties uuid[] DEFAULT '{}'::uuid[]
Empty = include every party (legacy default).
Non-empty = restrict to the subset, grouped by role at substitution.
- last_imported_at timestamptz NULL
Bumped each "Aus Projekt importieren" click; surfaced in UI.
Backend:
- SubmissionVarsContext gains SelectedParties — filterPartiesBySelection
restricts the resolved bag before role bucketing.
- addPartyVars emits THREE coexisting forms per role: comma-joined
(parties.claimants), indexed (parties.claimant.0.name), and flat
legacy (parties.claimant.name → first selected claimant). Flat
aliases are kept forever per the issue's backward-compat contract.
- SubmissionDraftService.ImportFromProject strips overrides for
project-derived prefixes and bumps last_imported_at; rejects
project-less drafts (nothing to import from).
- New endpoint POST /api/submission-drafts/{id}/import-from-project.
- DraftPatch + PATCH handlers accept selected_parties.
- submissionDraftView now ships available_parties so the editor can
render the picker without an extra round-trip.
Frontend:
- submission-draft.tsx: new import-row + parties block in the sidebar.
- client/submission-draft.ts: paintImportRow / paintPartyPicker /
onPartySelectionChange / onImportFromProject; group parties by
role bucket (claimant / defendant / other) with DE+EN role-string
matching to mirror the backend bucketing.
- 3 new i18n keys (DE+EN): import.button, parties.title, parties.hint.
- CSS for the picker + import row in global.css.
Tests: 6 new unit tests in submission_vars_parties_test.go covering
the multi-party bag emission, German role-string bucketing, flat-alias
first-of-role resolution, empty-selection-means-all default, non-empty
restriction, and the isProjectDerivedKey policy that powers the
import path.
Build hygiene: go build/vet clean; go test -short ./internal/... pass;
bun run build clean (2876 i18n keys, scan clean).
Reorder Verfahrensablauf 'Browse a proceeding' so the user-input flow
matches the importance hierarchy: proceeding-type → side → appellant →
date / court / flags. Side was previously below the date input; it is
the most-defining input after proceeding-type, so it belongs above.
- frontend/src/verfahrensablauf.tsx: move .verfahrensablauf-perspective
block above .date-input-group inside step-2. Wrap the side radio
cluster in #side-radio-cluster and add a sibling #side-chip (hidden by
default) that the client swaps in when a project pre-fills the side.
Add a 1px divider between perspective and date-input groups. Update
step-2 heading from "Ausgangsdatum eingeben" → "Perspektive und Datum"
to honestly describe both controls now under the heading.
- frontend/src/client/verfahrensablauf.ts: read ?project=<id> on init,
fetch /api/projects/<id>, map our_side onto the side axis (mirrors
fristenrechner.ts ourSideToPerspective: claimant/applicant/appellant
→ claimant, defendant/respondent → defendant, else null) and render
the side row as a read-only chip + "Andere Seite wählen" override
link. The chip respects ?side= as an explicit user pick — URL wins
over project auto-fill, same precedence as fristenrechner. Override
swaps back to the radio cluster and drops ?project= from the URL.
Side-chip label is language-aware via onLangChange.
- frontend/src/styles/global.css: .verfahrensablauf-step2-divider
(1px hr between perspective and date blocks); .side-chip / -tag /
-value / -override styles mirror .proceeding-summary's chip look so
the two read as the same visual family.
- frontend/src/client/i18n.ts + i18n-keys.ts: 3 new keys
(deadlines.step2.perspective, deadlines.side.from_project,
deadlines.side.override) in DE + EN.
URL state stays backward-compatible: ?side= and ?appellant= survive
the reorder unchanged. Adding ?project= opts in to auto-fill; without
it the page behaves identically to before.
No backend / projection logic change.
The Akte-picker (Step 1) wraps its magnifying-glass icon + input in a
flexbox row (`.fristen-step1-search-row`) with `gap: 0.5rem`, expecting
the icon to participate in the flex layout. But the shared
`.fristen-search-icon` rule (used by the B2 search input) sets
`position: absolute; left: 0.875rem;` — and the step1-scoped override
only tweaked color + flex-shrink without resetting `position`.
Result: the icon was absolutely-positioned out of the flex flow and
overlapped the input text (since `.fristen-akte-search` has no
padding-left). Resetting `position: static` for the step1 context lets
flexbox + gap handle the spacing naturally — same pattern as
`.fristen-row-search-panel-input-wrap`, which already works.
Audited other search inputs with leading magnifying-glass icons:
- `.glossar-search` (Glossary, Courts, Links, Team, AdminTeam,
AdminEventTypes) — wrap `.glossar-search-wrap` is `position: relative`,
input has `padding: 0.65rem 4.5rem 0.65rem 2.5rem`. Fine.
- `.projects-search-input` (/projects index) — wrap is
`position: relative`, input has `padding: 0.5rem 0.75rem 0.5rem 2.4rem`.
Fine.
- `.fristen-search-input` (Fristenrechner B2) — wrap `.fristen-search-row`
is `position: relative`, input has
`padding: 0.75rem 2.5rem 0.75rem 2.6rem`. Fine.
- `.fristen-row-search-panel-input` (Fristenrechner row-search panel) —
pure flex layout with `gap`, icon non-positioned. Fine.
- `.sidebar-search-input` (global sidebar search) — pure flex layout.
Fine.
- Other search inputs (`event-search-input`, `event-type-search`,
`submissions-new-search`, submissions index) have no leading icon.
N/A.
m/paliad#96 — frontend wiring of the per-event-card choice flow on
both consumer surfaces.
Shared rendering core (verfahrensablauf-core.ts):
- CalculatedDeadline gains choicesOffered + appellantContext (mirror
the new server fields).
- deadlineCardHtml emits a ▾ caret next to the date when a rule
carries a non-empty choicesOffered, plus an inert chip span next to
the title that the popover module rehydrates after every render.
- bucketDeadlinesIntoColumns prefers appellantContext over the
page-level appellant for "both" rows when the per-card context is
set to claimant or defendant. "both" / "none" / "" all fall back to
the existing collapse logic. New test cases cover all three paths.
- CalcParams + calculateDeadlines pass projectId / perCardChoices
through to the backend.
New module (client/views/event-card-choices.ts):
- attachEventCardChoices wires a delegated click handler on the
result container; the caret opens a body-anchored popover with one
block per choice-kind the rule offers (appellant: 4 radio-style
buttons; include_ccr + skip: 2-way toggle).
- Active picks render as small chips on the card title; reseedChips()
repaints them after every renderResults() innerHTML rewrite.
- Skipped rows fade to 55% opacity via the timeline-item--skipped
class.
Page wiring:
- /tools/verfahrensablauf (unbound): commits mutate an in-memory list
+ the ?event_choices= URL param, then schedule a recalc. Shareable
via link, no persistence — same idiom as ?side= / ?appellant=.
- /tools/fristenrechner (project-bound): commits POST/DELETE to
/api/projects/{id}/event-choices. The next calculate() call sends
projectId so the server folds the persisted choices in.
i18n: 17 new keys under choices.* (DE primary + EN secondary). Caret
title, appellant/include_ccr/skip block titles + value labels, chip
labels, reset action, commit error toast.
CSS: caret, popover, options, chip parts, skipped-row fade.
Tests: 3 new bucketer cases covering AppellantContext propagation
(157 frontend tests pass).
Restructures atlas's #79 horizontal row into 3 vertical columns: Past
(left), NOW (middle), Future (right). Each column sorts by closeness
to NOW (closest at top, farthest at bottom) — the picker now reads as
a spatial map of time around the current moment instead of a flat
horizontal fan.
Layout
Vergangenheit ⌖ Zukunft
Letzte 7 Tage Heute Nächste 7 Tage
Letzte 30 Tage Alles Nächste 30 Tage
Letzte 90 Tage Nächste 90 Tage
Ganze Vergangenheit Ganze Zukunft
Changes
- date-range-picker.ts — renderPanel builds .date-range-grid with
three vertical .date-range-col children. Past column iterates
PAST_HORIZONS reversed (past_1d → past_all top-to-bottom). NOW
column hosts next_1d ("Heute") + any ("Alles") plus a ⌖ glyph
header. Future column iterates NEXT_HORIZONS minus next_1d (which
moved to NOW). Legacy "all" horizon still lights up the Alles chip
for saved-Custom-View back-compat.
- global.css — replace .date-range-row/.date-range-fan/.date-range-
center{,-btn,-glyph,-label} with .date-range-grid + .date-range-col
+ .date-range-col-heading. Chips stretch to 100% column width for a
clean vertical stack. Panel widened from 32rem to 34rem so "Ganze
Vergangenheit" never wraps. Mobile (max-width 540px) collapses the
grid to a single column, preserving in-column sort.
- i18n.ts — next_1d label fixed from "Morgen"/"Tomorrow" to "Heute"/
"Today". next_1d's bounds are [today, tomorrow) = single-day today,
so the prior label was semantically wrong; renaming aligns the
label with the bounds and matches m's "Heute" spec for the NOW
column.
- axes.ts — DEFAULT_TIME_PRESETS updated to match m's spec (4 past +
Heute + Alles + 4 future + custom). projects-detail.ts continues
to override via timePresets for its past-only Verlauf surface.
12 horizon values in the union remain unchanged — PAST_HORIZONS /
NEXT_HORIZONS registries and parseURL still accept past_1d / past_14d
/ next_14d for back-compat with saved URLs; the default picker UI
just no longer surfaces chips for them. Surfaces that want the
finer granularity can opt back in via timePresets.
Verification
- bun test src/client/date-range-picker-pure.test.ts — 38 pass
- bun run build — i18n + branding + bundle clean
- go build ./... — clean
- go test ./internal/... — pass
m/paliad#96 — slice A engine + slice B engine wired together (per
m's Q4 bundling decision in §11 of the design doc).
Engine (internal/services/fristenrechner.go):
- CalcOptions gains PerCardAppellant map, SkipRules set, IncludeCCRFor
set. All three keyed by paliad.deadline_rules.submission_code (same
key AnchorOverrides uses).
- UIDeadline gains AppellantContext (per-decision pick that propagates
to descendants via parent_id chain) + ChoicesOffered (passes the
jsonb through to the frontend so the caret renders).
- Calculate honours all three:
* IncludeCCRFor non-empty → append with_ccr to flag set before gate
evaluation (v1 simplification documented in CalcOptions comment;
correct for single-CCR-entry-point proceedings).
* SkipRules suppression via submission_code match AND parent_id
cascade (descendants suppress too — one-pass walk in sequence_order).
* AppellantContext: each rule with its own per-card pick stamps its
UUID; descendants inherit via parent_id lookup; "" = no override.
HTTP:
- /api/projects/{id}/event-choices GET / PUT / DELETE — full CRUD
with visibility gate, audit-logged via paliad.system_audit_log.
- POST /api/tools/fristenrechner accepts either projectId (server
pulls choices from project_event_choices) OR inline perCardChoices
(unbound /tools/verfahrensablauf surface). Inline wins when both.
Services wiring:
- EventChoiceService instantiated in cmd/server/main.go; threaded into
handlers.dbServices.eventChoice.
m/paliad#96 — per-event-card optional choices on the Verfahrensablauf
timeline. This commit lands the schema + service layer.
Migration 129:
- paliad.project_event_choices table (project_id, submission_code,
choice_kind ∈ {appellant, include_ccr, skip}, choice_value) with
UNIQUE(project_id, submission_code, choice_kind) for idempotent
re-pick, RLS via paliad.can_see_project.
- paliad.deadline_rules.choices_offered jsonb — opt-in declaration of
which choice-kinds each rule offers. Seeded for every decision rule
(appellant), every priority='optional' rule (skip), and the two
Klageerwiderung rules (upc.inf.cfi.sod + de.inf.lg.erwidg) with
include_ccr.
Live verification before authoring:
- rule_code is NULL on every decision row → submission_code is the
join key (matches AnchorOverrides plumbing in fristenrechner.go).
- upc.inf.cfi.sod is the UPC Klageerwiderung, not upc.inf.cfi.def
(rejected the design doc's first guess; SELECT name ILIKE
'Klageerwiderung' confirmed).
Go service:
- models.ProjectEventChoice + DeadlineRule.ChoicesOffered.
- EventChoiceService: ListForProject / Upsert (with audit-log row to
paliad.system_audit_log) / Delete. Pure-helper ToCalcOptionsAddendum
+ per-kind value validation + unit tests.
Design: docs/design-event-card-choices-2026-05-25.md §3 + §6.
Adds a firm-formatted Schriftsatz skeleton between the per-submission_code
template and the generic universal skeleton in the fallback chain. Carries
every HL paragraph + character style from the HL Patents Style .dotm
(HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
HLpat-Table-Recitals-Party/Details/Roles/Sequencers, HLpat-Signature,
HLpat-Requests-Intro/Level1, HLpat-EvidenceOffering, …) and the firm
letterhead (header logo + firm-address footer), plus the full 48-key
SubmissionVarsService placeholder bag exercised in a real Schriftsatz
layout (rubrum → Betreff → Anträge → Sachverhalt → Rechtsausführungen →
Beweis → Schlussformel) with a locale-aware verification footer covering
every DE/EN alias and the rule.* legacy keys.
Resolved fallback chain after this CL:
1. per-firm per-submission_code template (submissionTemplateRegistry)
2. _firm-skeleton.docx — HL styles + placeholders (NEW)
3. universal _skeleton.docx — placeholders only
4. HL Patents Style.dotm — letterhead only
scripts/gen-hl-skeleton-template/main.go reads the source .dotm,
strips VBA macros + ribbon customizations + glossary parts, patches
[Content_Types].xml and the document rels, and replaces document.xml
with HL-styled paragraphs containing the placeholders. Keeps styles.xml,
theme/, header[12].xml, footer[12].xml, numbering.xml, settings.xml,
fontTable.xml, and media untouched so the firm typography survives.
Template uploaded to HL/mWorkRepo at
6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx
(commit 0a41b45, blob SHA 07f7547d).
Verified end-to-end against the in-house renderer with a 48-key sample
project: every placeholder substitutes cleanly, no orphan {{ markers,
no VBA / glossary / customUI leftovers, header/footer rIds resolve.
Extension of #92 (m/paliad/issues/106). Two related polish fixes for the
submission draft editor's preview ↔ sidebar wiring.
Concern A — link persists after fill (regression coverage + UX visibility)
Audited the Go renderer: substituteInTextNodes / substituteAcrossRuns
already pass both filled and missing values through htmlPreviewWrapper,
so the <span class="draft-var" data-var="…"> wrapping is present for
every substituted placeholder regardless of source (resolved bag,
lawyer override, missing marker). What looked broken to m was a
visibility problem: the always-on rgba(198, 244, 28, 0.12) tint is
imperceptible against the serif preview prose, so a filled value
reads as plain text and the user concludes "the link is gone".
Added TestRenderHTML_WrapsOverriddenValueSameAsResolved that pins the
invariant explicitly — an override (project.case_number = "UPC_CFI_
42/2026") and a resolved value (firm.name = "HLC") both end up in
matching draft-var spans. Locks future refactors out of dropping the
wrap on either path.
CSS rewrite per m's "prose stays clean when not interacting" guidance
(issue body): drop the always-on background; on hover of a
--has-input span, layer a dotted-underline + brighter lime tint so
the click affordance reveals itself. Missing markers carry their own
[KEIN WERT: …] / [NO VALUE: …] gap-text and don't need extra visual.
Concern B — sidebar-field-focus → preview-occurrence highlight (new)
Reverse direction of the click-to-jump from #92. focusin on any
.submission-draft-var-input applies .draft-var--active to every
matching span in the preview; focusout (or focus shift via Tab)
clears them. Sticky-while-focused, not a one-shot flash — the lawyer
can scan "where does this variable land in my prose?" while the
field stays focused.
New CSS class .draft-var--active uses a brighter lime + box-shadow
ring so all occurrences pop at once. Handlers are wired in
paintVariables and re-applied at the end of both paintVariables AND
paintPreview because:
- paintVariables runs after autosave and re-creates inputs via
innerHTML, so the focusin listener attached to the old input is
gone; restoreVarFocus puts focus back programmatically without
firing focusin again. We re-apply explicitly to bridge.
- paintPreview blows away the preview HTML on every autosave, so
any prior --active class is gone too. Re-apply based on the
currently-focused sidebar input.
Files
internal/services/submission_merge_test.go — new regression test
frontend/src/client/submission-draft.ts — focus handlers + re-apply
frontend/src/styles/global.css — draft-var rewrite, --active
Hard rules
- .docx export path unchanged (Render passes nil wrap, covered by
existing TestRender_DocxOutputUnchangedByPreviewWrap).
- Both directions survive autosave-driven preview re-renders (see
paintPreview re-apply + paintVariables re-apply).
- go build ./... && go test ./internal/... && bun run build all clean.
Four open questions answered via AskUserQuestion (2026-05-25):
Q1 State location → persisted table (matches (R))
Q2 Affordance → caret + popover (matches (R))
Q3 Appellant layer → per-card overrides (matches (R))
Q4 Slice order → bundle A + B (over (R) of "A first")
Q4 captured with rationale: cohesive PR, single user-visible release,
no half-shipped state where the include-CCR popover would exist
without the engine wire-through. Coder still organises commits per
slice internally; one branch, one ship.
Draft inventor design for m/paliad#96 — per-card affordances driving
projection state: appellant per decision, include-CCR on Klageerwiderung,
skip optional events.
Persisted choices in new paliad.project_event_choices table; opt-in
declared via choices_offered jsonb on paliad.deadline_rules. Caret +
popover affordance; chip indicators on cards with non-default picks.
Two-slice plan: A=appellant+skip (engine-stable), B=include-CCR.
m's decisions section to be filled after the AskUserQuestion round.
Implements three Tier 3 primitives from curie's bulletproof completeness
audit (docs/research-deadlines-completeness-2026-05-25.md §10 T3.1, T3.2,
T3.5), per m's 2026-05-25 15:29 steer to build the full primitives
instead of documenting workarounds.
Primitive 1 — duration_unit='working_days':
Calculator walks day-by-day skipping weekends + court holidays via
HolidayService.IsNonWorkingDay. Event day is not counted; result is
always a working day for the (country, regime). Unlocks T1.8/T1.9
modeling and the R.198 / R.213 alt leg.
Primitive 2 — combine_op='max' (and 'min'):
When alt_duration_value + alt_duration_unit + combine_op are set, the
calculator evaluates both legs and picks the later (max) or earlier
(min) of the two adjusted end dates. The DB already had two rules
shaped this way ('31d OR 20wd, whichever is longer' — R.198 / R.213);
the calculator was silently dropping the alt leg.
Primitive 5 — timing='before' backward snap-to-working-day:
For backward rules (R.109.1: 1 month before oral hearing; R.109.4:
2 weeks before) the calculator now snaps to the PRECEDING working day
when the computed cut-off lands on a weekend/holiday. Forward snap
(the prior behavior) would push the cut-off past the statutory limit
and miss the deadline. Adds HolidayService.AdjustForNonWorkingDays-
Backward as the symmetric counterpart of AdjustForNonWorkingDays.
Migration 128 — DB schema:
Adds CHECK constraints on deadline_rules.duration_unit and
alt_duration_unit pinning the allowed set to days/weeks/months/
working_days. Live data audited and passes (no rows excluded).
Tests (12 new + 1 flipped):
- 5 working_days cases: forward over weekend, 20wd anchored on Fri,
across Karfreitag/Ostermontag, across year boundary, backward
from Friday, anchored on Saturday.
- 2 backward snap cases: Sun → preceding Fri; cluster Sun → Sat →
Karfreitag → Thu.
- 4 combine_op cases: max with primary winning, max with alt winning
over Christmas+Neujahr cluster, min with primary winning, NULL-alt
short-circuit.
- TestCalculateEndDate_BeforeTiming renamed and flipped from forward
(Sun → Mon, the prior wrong behavior) to backward (Sun → Fri).
No regression on existing rules: every pre-existing days/weeks/months
'after' rule still computes the same date. Frontend build + full
go test ./internal/... clean.
Slot 128 assigned per next-available convention (mig 127 = Wave 0
Tier-0 fixes, mig 128 = Wave 2 Tier-3 Slice A primitives).