Compare commits

..

22 Commits

Author SHA1 Message Date
mAi
cb44b3b8cc mAi: #117 + #118 - t-paliad-285/-286 UPC dmgs+pi court followup (mig 133)
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.
2026-05-25 17:25:19 +02:00
mAi
c4e9875ff4 Merge: t-paliad-276 — submission generator DE/EN language selector, post-rebase (mig 130) (m/paliad#108) 2026-05-25 17:04:23 +02:00
mAi
e4c694e01c mAi: #108 - t-paliad-276 submission generator language selector (DE/EN)
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.
2026-05-25 17:03:34 +02:00
mAi
5efb9f5098 Merge: t-paliad-281 — admin rules list 'undefined' proceeding name fix (m/paliad#113) 2026-05-25 16:54:39 +02:00
mAi
c6267e4e6d Merge: t-paliad-277 — submission party selector + import-from-project (mig 131) (m/paliad#109) 2026-05-25 16:53:50 +02:00
mAi
8e696487e0 Merge: t-paliad-279 — Verfahrensablauf form reorder, party-after-proceeding-type (m/paliad#111)
# Conflicts:
#	frontend/src/client/verfahrensablauf.ts
2026-05-25 16:53:32 +02:00
mAi
001542a3ce mAi: #113 - fix admin rules list 'undefined' proceeding name
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.
2026-05-25 16:52:07 +02:00
mAi
a6d0acbcb4 mAi: #111 - t-paliad-279 — Verfahrensablauf form reorder + project auto-fill chip
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.
2026-05-25 16:51:27 +02:00
mAi
96eab90044 Merge: t-paliad-280 — search input icon-text padding fix (m/paliad#112) 2026-05-25 16:51:26 +02:00
mAi
5348cb548f mAi: #112 - fix Fristenrechner Akte-picker icon overlap
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.
2026-05-25 16:50:05 +02:00
mAi
b1340e2be4 Merge: t-paliad-278 — date-range picker 3-column layout Past/NOW/Future (m/paliad#110) 2026-05-25 16:46:59 +02:00
mAi
1292aa575d Merge: t-paliad-265 — per-event-card choices Slice A+B (popover + CCR + projection engine, mig 129) (m/paliad#96) 2026-05-25 16:46:15 +02:00
mAi
87c200a47e feat(t-paliad-265): caret + popover + chip on Verfahrensablauf cards
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).
2026-05-25 16:45:39 +02:00
mAi
4f910e31ea mAi: #110 - t-paliad-278 — 3-column date-range picker (Past/NOW/Future, closeness-to-NOW sort)
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
2026-05-25 16:45:30 +02:00
mAi
bf60fc1400 feat(t-paliad-265): projection engine + HTTP handlers for per-card choices
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.
2026-05-25 16:45:21 +02:00
mAi
dc47ea7f43 feat(t-paliad-265): migration 129 + EventChoiceService (Slice A foundation)
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.
2026-05-25 16:45:07 +02:00
mAi
930771a898 Merge: t-paliad-275 — HL-formatted skeleton template with placeholders (m/paliad#107) 2026-05-25 16:37:34 +02:00
mAi
f2fbf93adf feat(submissions): HL-formatted skeleton template with placeholders (t-paliad-275)
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.
2026-05-25 16:35:38 +02:00
mAi
7368e7012b Merge: t-paliad-274 — bidirectional draft editor link + click-field-highlights (m/paliad#106) 2026-05-25 16:34:28 +02:00
mAi
d4df81e374 mAi: #106 - t-paliad-274 — bidirectional draft editor link + click-field-highlights
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.
2026-05-25 16:32:45 +02:00
mAi
169ace5d26 design(t-paliad-265): fold m's decisions into the design doc
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.
2026-05-25 16:27:30 +02:00
mAi
ac7bc27fb7 design(t-paliad-265): per-event-card optional choices on Verfahrensablauf
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.
2026-05-25 16:27:30 +02:00
41 changed files with 4150 additions and 255 deletions

View File

@@ -218,6 +218,8 @@ func main() {
// is captured into __meta of every export and printed in the
// embedded README.
Export: services.NewExportService(pool, branding.Name),
// t-paliad-265 / m/paliad#96 — per-event-card optional choices.
EventChoice: services.NewEventChoiceService(pool, projectSvc, users),
}
// t-paliad-246 Slice A — Backup Mode runner. Wired only when

View File

@@ -0,0 +1,492 @@
# Design — Per-event-card optional choices on the Verfahrensablauf timeline
**Author:** atlas (inventor)
**Date:** 2026-05-25
**Task:** t-paliad-265 (m/paliad#96)
**Branch:** `mai/atlas/inventor-per-event-card`
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
---
## 0. TL;DR
> **m's decisions landed 2026-05-25** — see §11. Persisted table, caret+popover, per-card-overrides-page-level, and m chose to bundle Slice A + Slice B into one coder shift (over the inventor (R) of "Slice A first"). All other picks matched inventor recommendations.
The Verfahrensablauf timeline today carries **two** projection knobs at the page level — `side` (who-we-are) and `appellant` (who-initiated). Both are **global** for the whole timeline. m wants three more knobs, but **per event card**, not page-level:
1. **Appellant per decision card** — if a decision is appealable, the user picks which side appealed (Claimant / Defendant / Both / None). Different decisions in the same timeline can have different appellants.
2. **Include Nichtigkeitswiderklage on Klageerwiderung** — toggling this on a single Klageerwiderung card flips on the existing `with_ccr` flag for everything downstream of that card.
3. **Skip an optional event** — for any rule marked `priority='optional'`, a per-card "don't consider for this case" toggle hides downstream consequences.
The flow these choices drive is **already there**`condition_expr` jsonb gates (`with_ccr`, `with_amend`, `with_cci`) plus the page-level appellant selector. What's missing is (a) **per-card** scope and (b) **per-project persistence**.
Recommendation: persist choices in a new `paliad.project_event_choices` table; expose them through a popover-on-caret affordance on the relevant cards only; map them into the existing `CalcOptions.Flags` + a new per-rule `Appellants` map at projection time. Two slices: **Slice A** (appellant-per-decision + skip-optional, narrow + bounded), **Slice B** (include-CCR-on-Klageerwiderung, requires per-card flag-scoping in the projection engine — bigger).
---
## 1. Premises verified live (before designing)
CLAUDE.md / memory / issue text can drift; the live system can't. Each load-bearing premise below was probed against the live DB or live source on 2026-05-25.
### Schema
- **Migration tracker at 127** (`paliad.paliad_schema_migrations`). Next migration: 128. No new table for `project_event_choices` exists today.
- **`paliad.deadline_rules` carries `condition_expr jsonb`** already. The flag-evaluation engine (`internal/services/fristenrechner.go:208 Calculate`, `evalConditionExpr` at line ~947) walks the jsonb tree and skips rules whose gate is unsatisfied. Today's gates are `{"flag":"with_ccr"}`, `{"flag":"with_amend"}`, `{"flag":"with_cci"}`, and `{"op":"and","args":[…]}` combinations.
- **`with_ccr` is the existing Nichtigkeitswiderklage gate.** Verified live: 7 upc.inf.cfi rules gate on it (`upc.inf.cfi.reply`, `…rejoin`, `…ccr`, `…def_to_ccr`, `…reply_def_ccr`, `…rejoin_reply_ccr`, plus `upc.inf.cfi.app_to_amend` which additionally requires `with_amend`).
- **`priority` column** has 4 values: `mandatory`, `recommended`, `optional`, `informational`. Live counts (deadline_rules table-wide): 230 mandatory / 18 recommended / 6 optional / (informational not in count, must be 0 or absent). The "skip optional" affordance keys off `priority='optional'`.
- **`event_type` discriminator** exists with values `filing`, `decision`, `hearing`. The "appellant-per-decision" affordance keys off `event_type='decision'`. Live: every decision rule has `primary_party='court'`.
- **`paliad.projects.our_side`** exists (column added before mig 112; values today include `claimant|defendant|applicant|appellant|respondent|third_party|other`). It is the broad project-level side axis t-paliad-257 / #88 hooked into.
- **NO `appellant` column on `paliad.projects`** — the appellant axis lives only in the URL query (`?appellant=claimant|defendant`) in `client/verfahrensablauf.ts:73-89`.
### Frontend
- `frontend/src/client/views/verfahrensablauf-core.ts` is the **shared rendering core** for both `/tools/verfahrensablauf` and `/tools/fristenrechner`. Per-card UI affordances added here surface on both pages automatically.
- `bucketDeadlinesIntoColumns(deadlines, {side, appellant})` (line 496) is the **pure routing primitive**; column placement is computed without DOM. Unit-tested in `verfahrensablauf-core.test.ts`.
- `deadlineCardHtml(dl, {showParty, editable, showNotes})` (line 254) is the **per-card renderer**. There is no per-card props channel for "choices" yet — that's the surface this design extends.
- `client/verfahrensablauf.ts` and `client/fristenrechner.ts` both manage `currentSide` + `currentAppellant` in-memory and round-trip them through the URL (`writeSideToURL` / `writeAppellantToURL`). The pattern is mature; this design mirrors it for the new state when state stays URL-bound, and lifts it into a server-persisted store when state stays per-project.
- `APPELLANT_AXIS_PROCEEDINGS` set (verfahrensablauf.ts:52-62) gates the page-level appellant selector to appeal-flavoured proceedings only. The per-card appellant affordance MUST NOT depend on this set — any first-instance decision is a potential appeal trigger (e.g. LG-Urteil → Berufung, BPatG-Entscheidung → BGH-Rechtsbeschwerde).
### Surfaces in scope
- **`/tools/verfahrensablauf`** — abstract browse, no project context. Per-card choices here are ephemeral (URL-bound) — there's no project to persist into.
- **`/tools/fristenrechner`** — concrete projection, optionally project-bound via `?project=<id>` (`currentStep1Context.kind === "project"`). When project-bound, per-card choices persist to `paliad.project_event_choices`. When unbound, URL only.
- **`/projects/{id}` Verlauf tab (SmartTimeline)** — separate widget (per `docs/design-smart-timeline-2026-05-08.md`); does **NOT** use `renderColumnsBody`. Per-card choices are NOT in scope for the SmartTimeline in v1 — the Verfahrensablauf core is.
### What is NOT premised
- The deadline_rules → procedural_events rename (#93) is **not assumed shipped**. This design uses `deadline_rules`/`rule_code` vocabulary throughout and flags the rename touch-points in §6.
- The per-card UI does NOT require new server-side priority/event_type semantics. Both `priority='optional'` and `event_type='decision'` exist on every row.
---
## 2. Vision + scope
m's vision (verbatim 2026-05-25 15:12):
> We still have no choice to say that a specific party appealed. We may need selections within the event cards on the timeline to change it? For example for a decision we could check Appeal by... or in Klageerwiderung we can chose to include a Nichtigkeitswiderklage. Or with any optional event we can select not to consider it (because someone decided not to file it).
### What changes
- A **caret affordance** (▾) appears on the right edge of cards that have at least one applicable choice-kind. Click → small popover with the choices. Cards without an applicable choice render unchanged.
- A **`choices_offered` jsonb column** on `paliad.deadline_rules` declares which choice-kinds each rule offers. Three kinds in v1:
- `appellant` — applicable to rules with `event_type='decision'` (no static list; engine decides).
- `include_ccr` — applicable to the single Klageerwiderung rule per proceeding (today: `upc.inf.cfi.def`, `de.inf.lg.erwidg`).
- `skip` — applicable to any rule with `priority='optional'`.
- A **new persistence table** `paliad.project_event_choices(project_id, rule_code, choice_kind, choice_value)` holds the user's choices. Per-project, audit-logged via `paliad.system_audit_log`.
- A **projection-time merge** turns the persisted choices into `CalcOptions.Flags` and a new `PerCardAppellants map[ruleCode]string` field, then re-runs the existing projection engine. No new flag types; `with_ccr` is the same `with_ccr`.
### What stays
- `bucketDeadlinesIntoColumns` and `renderColumnsBody` are extended (new opts), not replaced.
- `condition_expr` jsonb gating semantics are unchanged. Per-card `include_ccr` choice simply means "set `with_ccr` in the flag set for this projection" — same engine.
- Page-level `side` / `appellant` selectors stay. The per-card appellant choice is an **override layer** on top of the page-level appellant (Q4 below).
- URL-state plumbing (`?side=…`, `?appellant=…`) stays. The page-level URL params remain the only state for unbound `/tools/verfahrensablauf`.
### Out of scope (v1)
- Per-card choices on the SmartTimeline (project Verlauf tab). Deferred to a follow-up when SmartTimeline matures.
- Versioning of choices over time ("the appellant changed mid-case", "the CCR was withdrawn"). Choices are last-write-wins.
- Cross-project propagation of choices.
- Implementing the choice flow (coder task per slice; this is design-only).
- A "what-if scenarios" mode (saved named scenarios).
---
## 3. Data model
### 3.1 The new table
```sql
-- migration 128_project_event_choices.up.sql
CREATE TABLE paliad.project_event_choices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
rule_code text NOT NULL, -- e.g. "RoP.029.a" or "de.inf.lg.urteil"
choice_kind text NOT NULL, -- 'appellant' | 'include_ccr' | 'skip'
choice_value text NOT NULL, -- value namespace per kind (see §3.3)
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
-- One choice per (project, rule_code, kind). Re-pick is an UPDATE.
UNIQUE (project_id, rule_code, choice_kind)
);
CREATE INDEX project_event_choices_project_idx
ON paliad.project_event_choices (project_id);
-- RLS: same `paliad.can_see_project(project_id)` predicate as paliad.deadlines.
ALTER TABLE paliad.project_event_choices ENABLE ROW LEVEL SECURITY;
CREATE POLICY project_event_choices_select ON paliad.project_event_choices
FOR SELECT USING (paliad.can_see_project(project_id));
CREATE POLICY project_event_choices_mutate ON paliad.project_event_choices
FOR ALL USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
```
**Why this shape:**
- Tall not wide — adding a 4th choice-kind in slice C means one more allowed `choice_kind` value, no DDL.
- `rule_code` is the join key against `paliad.deadline_rules` (which already uses `rule_code` widely — `Calculate`, `AnchorOverrides`, the projection). Stable across rule renames provided the rename keeps the same `rule_code`.
- UNIQUE per `(project, rule_code, kind)` makes the choice idempotent — re-picking the appellant overwrites, doesn't accumulate.
- ON DELETE CASCADE follows the project — when a project is hard-deleted (rare; usually soft-status), the choices go with it.
### 3.2 The opt-in column on `paliad.deadline_rules`
```sql
-- migration 128_project_event_choices.up.sql (same migration)
ALTER TABLE paliad.deadline_rules
ADD COLUMN choices_offered jsonb;
-- Example seeded values (in the same migration's data-fix block):
--
-- upc.inf.cfi.def → '{"include_ccr": [true, false]}'
-- de.inf.lg.erwidg → '{"include_ccr": [true, false]}'
-- upc.inf.cfi.decision → '{"appellant": ["claimant", "defendant", "both", "none"]}'
-- de.inf.lg.urteil → '{"appellant": ["claimant", "defendant", "both", "none"]}'
-- (every event_type='decision' rule)
-- upc.inf.cfi.ccr (priority='optional') → '{"skip": [true, false]}'
-- (every priority='optional' rule)
```
**Alternative considered + rejected:** infer offering at projection-time from `(event_type, priority, submission_code)` heuristics. Rejected because:
- The Klageerwiderung rule is identified only by its `submission_code` slug. Tying the engine to a hardcoded slug list inside the projection service is brittle (mig 124 + future Wave-1 fixes rename slugs); declaring `choices_offered` in data lets the audit ship them without a code change.
- A `skip` toggle that's automatically derived from `priority='optional'` is consistent today but may diverge tomorrow (an optional rule we DON'T want skippable, or a non-optional rule we DO want skippable). The opt-in jsonb keeps the choice axis decoupled from `priority`.
### 3.3 Value namespaces per kind
| `choice_kind` | `choice_value` valid set | Default when no row exists |
|---|---|---|
| `appellant` | `"claimant"` / `"defendant"` / `"both"` / `"none"` | inherits page-level appellant (URL `?appellant=`), else `null` (treated as "not yet picked" — render appeal-deadlines greyed) |
| `include_ccr` | `"true"` / `"false"` | `"false"` (no CCR until user opts in — matches current default flag set) |
| `skip` | `"true"` / `"false"` | `"false"` (rule renders normally) |
Values are stored as `text` not `boolean` so the same column scales to multi-valued kinds (appellant has 4 values; future kinds may have N). Coercion lives in the service layer.
### 3.4 Audit trail
Every INSERT / UPDATE / DELETE on `project_event_choices` writes a row to `paliad.system_audit_log` (the standard sink mig 102 introduced) with `event_type='project_event_choice.set'` and the changed `(rule_code, kind, value)` in `metadata jsonb`. Pattern mirrors `paliad.deadlines.status_changed` audit rows.
---
## 4. Projection flow
The existing projection engine is a single Go function: `FristenrechnerService.Calculate(ctx, proceedingCode, triggerDateStr, opts CalcOptions)`. Two changes:
### 4.1 Extending `CalcOptions`
```go
type CalcOptions struct {
// ...existing fields...
Flags []string // <-- already exists
AnchorOverrides map[string]string // <-- already exists
// NEW — per-card overrides surfaced by the per-event-card choices.
// Keyed by deadline_rules.rule_code.
//
// PerCardAppellant: when a decision rule's rule_code is in this map,
// the appellant for downstream rules whose parent is THAT decision
// is set to the value here. Overrides any global Appellant.
//
// SkipRules: when a rule's rule_code is in this set, the rule is
// suppressed AND its descendants are suppressed. Same suppression
// path as a failed condition_expr gate.
//
// IncludeCCRFor: when a rule's rule_code is in this set, the with_ccr
// flag is treated as set in the flag context FROM that rule
// onward (i.e. for that rule's descendants). On v1 with a single
// Klageerwiderung-per-proceeding, this is equivalent to a project-
// wide with_ccr — but the per-card scope leaves room for future
// proceedings with multiple CCR entry points.
PerCardAppellant map[string]string // rule_code → "claimant"|"defendant"|"both"|"none"
SkipRules map[string]struct{} // set of rule_code
IncludeCCRFor map[string]struct{} // set of rule_code
}
```
The handler reads `project_event_choices` for the project (if project-bound) and folds them into these fields before calling `Calculate`. When called unbound (URL-only, `/tools/verfahrensablauf` without project), the maps come from URL params instead (see §5.2).
### 4.2 Three engine changes
1. **SkipRules suppression**: in the post-condition_expr filter pass (`Calculate` around line 333 where the gate is evaluated), additionally drop any rule whose `rule_code ∈ opts.SkipRules`. Also drop its descendants (existing `parent_id` walk already handles cascading; just add the new predicate to the keep/drop decision).
2. **IncludeCCRFor scope**: rather than threading a per-rule flag context (expensive change to engine), implement v1 as: **if any rule_code in IncludeCCRFor exists at all, append `"with_ccr"` to `opts.Flags`** before the gate-evaluation pass. This is correct for the v1 surface (Klageerwiderung is the only CCR-entry-point per proceeding) but loses the per-card scoping for multi-CCR cases. The full per-rule scope is **Slice B** (§7).
3. **PerCardAppellant routing**: when `bucketDeadlinesIntoColumns` collapses `party=both` rows in the appellant's column, today it consults the global `opts.appellant`. Extend to consult `PerCardAppellant[ruleCode]` first — if present, that drives the collapse for descendants of that decision. Out-of-band: this changes the projection contract subtly. We surface this as **server-computed metadata** on the response (`CalculatedDeadline.AppellantContext`) so the frontend bucketer doesn't need to know about parent-chain walks — the server already does the walk.
### 4.3 Wire shape
The `CalculatedDeadline` Go struct + TS mirror grow one optional field:
```go
type CalculatedDeadline struct {
// ...existing fields...
AppellantContext string `json:"appellantContext,omitempty"`
// "claimant" | "defendant" | "both" | "none" | "" (default).
// Filled by the projection from the user's per-decision choice.
// Frontend bucketer prefers this over the page-level appellant.
}
```
This keeps the bucketer logic local — no second pass needed.
---
## 5. UI / i18n
### 5.1 Caret + popover affordance
Each rendered card gets, when `choices_offered IS NOT NULL`, a `▾` caret on the right edge of the title line. Click → popover anchored to the caret. Popover renders one block per choice-kind the rule offers (typically one, occasionally two if a rule has both `appellant` and `skip` — none today; design holds for the future).
DOM-wise: `frontend/src/client/views/verfahrensablauf-core.ts` `deadlineCardHtml` grows a `choicesCaret` segment, and a sibling module `client/views/event-card-choices.ts` (new) owns the popover open/close + commit handler. The popover commits via `POST /api/projects/{id}/event-choices` with body `{rule_code, kind, value}`; the response is the updated choice row.
**Why a popover and not inline checkboxes:**
- Inline would put a checkbox on every decision card + every optional card. ~6 decision cards + ~6 optional cards on a typical UPC.INF.CFI projection is ~12 always-on widgets per timeline. Visual noise + scan cost.
- Popover defaults to hidden; the caret is a low-noise affordance. The selected choice surfaces as a small chip on the card title line ("Berufung: Beklagter") so the choice is glanceable without re-opening.
- Mobile + touch: the caret is a 24×24 tap target; the popover is keyboard-dismissable.
**Why not card-hover-reveal:** discoverability + touch failure (no hover on iOS).
### 5.2 URL fallback (no project context)
When `/tools/verfahrensablauf` is opened without a project (the abstract-browse case), per-card choices have no persistence layer. The popover still works, but commits update an **in-memory + URL** state instead:
```
?event_choices=RoP.029.a:appellant=defendant,upc.inf.cfi.ccr:skip=true
```
Compact CSV in one URL param. Read at page load, applied to `CalcOptions` via the same `PerCardAppellant` / `SkipRules` / `IncludeCCRFor` route. Shareable, ephemeral. Matches the existing `?side=` + `?appellant=` URL idiom.
### 5.3 Chip indicators
A card with a non-default choice gets a small chip next to the title:
- Appellant chosen: `Berufung: Beklagter` / `Appeal: Defendant`
- Include CCR: `mit Nichtigkeitswiderklage` / `with CCR`
- Skipped: card itself fades to 50% opacity, body adds class `timeline-item--skipped`, chip reads `übersprungen` / `skipped` with an undo arrow.
### 5.4 i18n keys (new)
```
choices.caret.title "Optionen für dieses Ereignis" "Options for this event"
choices.appellant.title "Berufung durch ..." "Appealed by ..."
choices.appellant.claimant "Klägerseite" "Claimant side"
choices.appellant.defendant "Beklagtenseite" "Defendant side"
choices.appellant.both "beide Parteien" "both parties"
choices.appellant.none "keine Berufung" "no appeal"
choices.include_ccr.title "Nichtigkeitswiderklage einbeziehen" "Include nullity counterclaim"
choices.skip.title "Für diese Akte überspringen" "Skip for this case"
choices.skipped.chip "übersprungen" "skipped"
choices.reset "Auswahl zurücksetzen" "Reset choice"
```
### 5.5 What's removed
The page-level appellant selector (URL `?appellant=`) stays for **non-decision proceedings** (the Appeal-CoA case where the appellant axis is the whole-timeline framing, not a per-decision choice). But for first-instance proceedings (UPC.INF, DE.INF.LG, etc.), the appellant axis migrates from page-level to per-decision card. The page-level selector hides when the proceeding has decision rules with `choices_offered.appellant` declared — which is the cleaner UX (one knob, in the right place).
---
## 6. Services + handlers (new surface)
### 6.1 Go service
```go
// internal/services/event_choice_service.go (new)
type EventChoiceService struct {
db *sqlx.DB
}
func (s *EventChoiceService) ListForProject(ctx context.Context, projectID uuid.UUID) ([]ProjectEventChoice, error)
func (s *EventChoiceService) Upsert(ctx context.Context, c ProjectEventChoice) error
func (s *EventChoiceService) Delete(ctx context.Context, projectID uuid.UUID, ruleCode, kind string) error
// Used by ProjectionService to fold choices into CalcOptions.
func (s *EventChoiceService) ToCalcOptions(choices []ProjectEventChoice) CalcOptionsAddendum
```
The `CalcOptionsAddendum` type wraps the three new map/set fields so the merge into the parent `CalcOptions` is one call from the projection handler.
### 6.2 HTTP routes
```
GET /api/projects/{id}/event-choices → []ProjectEventChoice
PUT /api/projects/{id}/event-choices → upsert one (body: {rule_code, kind, value})
DELETE /api/projects/{id}/event-choices/{rule_code}/{kind} → remove
```
All gated by `gateOnboarded` + visibilityPredicate (project-team membership).
### 6.3 Projection handler
The existing `POST /api/tools/fristenrechner` handler accepts `flags`, `anchorOverrides`, `priorityDate`, `courtId`. Extend the request shape:
```json
{
"proceedingType": "upc.inf.cfi",
"triggerDate": "2026-01-15",
"flags": ["with_ccr"],
"perCardChoices": [
{"rule_code": "RoP.029.a", "kind": "appellant", "value": "defendant"},
{"rule_code": "upc.inf.cfi.ccr", "kind": "skip", "value": "true"}
]
}
```
Or, when project-bound:
```json
{
"proceedingType": "upc.inf.cfi",
"triggerDate": "2026-01-15",
"projectId": "abc-123"
// server pulls perCardChoices from paliad.project_event_choices
}
```
The handler merges either source into `CalcOptions` and runs `Calculate`.
### 6.4 Touch points — files coder will edit
- **DB**: new migration `128_project_event_choices.up.sql` + `.down.sql`. Add `choices_offered` column + seed data.
- **Go**: `internal/services/event_choice_service.go` (new), `internal/services/fristenrechner.go` (extend `CalcOptions`, projection logic), `internal/handlers/event_choices.go` (new HTTP routes), `internal/handlers/fristenrechner.go` (request shape extension).
- **Models**: `internal/models/models.go``ProjectEventChoice` struct, `CalculatedDeadline.AppellantContext` field.
- **Frontend**: `frontend/src/client/views/verfahrensablauf-core.ts` (caret + chip in deadlineCardHtml), `frontend/src/client/views/event-card-choices.ts` (new popover module), `frontend/src/client/verfahrensablauf.ts` + `frontend/src/client/fristenrechner.ts` (URL-state plumbing for the unbound case; load project choices for the bound case).
- **i18n**: `frontend/src/client/i18n.ts` + `frontend/src/i18n-keys.ts` — new keys per §5.4.
- **Tests**: `internal/services/event_choice_service_test.go` (new), `internal/services/fristenrechner_test.go` (extend with PerCardAppellant + SkipRules cases), `frontend/src/client/views/verfahrensablauf-core.test.ts` (extend bucketing with `perCardAppellant` opt).
### 6.5 Coordination with #93 procedural-events rename
When #93 lands (and the rename ships), this design's `rule_code` references become `procedural_event.code` — same string namespace, cleaner name. Join points:
- `project_event_choices.rule_code``project_event_choices.procedural_event_code` (or stays as a generic string column if #93 keeps `rule_code` as the join key).
- `deadline_rules.choices_offered``procedural_events.choices_offered`.
If #93 ships first, this design's migration applies to `procedural_events` instead. The data shape (jsonb + new join table) is unaffected. If THIS ships first, #93 absorbs the column in its rename.
---
## 7. Slice plan
### Slice A — Appellant per decision + Skip optional event
Two choice-kinds, narrow + bounded, do not change the gate-evaluation engine.
- **DB**: migration 128 adds `project_event_choices` + `choices_offered`. Seed `choices_offered` on all `event_type='decision'` rules and all `priority='optional'` rules.
- **Service**: `EventChoiceService` CRUD; `CalcOptions.PerCardAppellant` + `CalcOptions.SkipRules`; `Calculate` extension to honour SkipRules suppression + AppellantContext metadata.
- **HTTP**: 3 new routes (GET / PUT / DELETE on project_event_choices); fristenrechner request extension.
- **Frontend**: caret + popover on decision cards + optional cards; chip indicators; URL-state for the unbound case; load-on-mount for the bound case.
- **Tests**: bucketing with PerCardAppellant; service CRUD; gate-suppression with SkipRules.
Ship this slice first. It validates the popover affordance + the persistence layer end-to-end without touching the flag-evaluation engine.
### Slice B — Include Nichtigkeitswiderklage on Klageerwiderung
Wires `IncludeCCRFor` through the flag-evaluation engine. v1 simplification (§4.2 #2) makes this **almost** a no-op for the engine — but the per-card scope semantics need a separate inventor pass to nail down whether the simplification holds for de.inf.lg's CCR analogue (Widerklage auf Nichtigkeit) and for any future proceedings with multiple CCR entry points.
- **DB**: add `include_ccr` to allowed `choice_kind` values + seed `choices_offered = '{"include_ccr": [true, false]}'` on the Klageerwiderung rows (`upc.inf.cfi.def`, `de.inf.lg.erwidg`).
- **Service**: `CalcOptions.IncludeCCRFor`; the "if non-empty, append with_ccr to Flags" simplification.
- **Frontend**: the include_ccr popover block (already designed; just enabling the row).
- **Cross-flow audit**: confirm that the existing 7 upc.inf.cfi cross-flow rules + de.inf.lg analogues fire correctly when with_ccr is set via the per-card path vs. the existing page-level flag checkbox. Existing checkbox stays in v1; deprecation is a Slice C decision.
### Bundling note (per m's Q4 decision 2026-05-25)
A + B ship together. The slice headings above remain as a logical breakdown for the coder to follow when sequencing commits inside the single shift; they are not separate PRs. See §11 Q4 for rationale.
### Slice C — Future choice-kinds
Open-ended; not designed here. Examples surfaced by the t-paliad-067 audit:
- "Bilateral hearing requested" toggle on hearing rules.
- "Cost orders requested" toggle on cost-related rules.
- "Stay applied" toggle on procedural events.
Each new kind = one new allowed `choice_kind` value + one seed row + one popover block. Schema-stable.
---
## 8. Risk assessment
- **Migration risk**: new table + new column, both additive. Down-migration drops table + column + reverts seed. No data loss path. Low risk.
- **Projection correctness**: PerCardAppellant changes the bucket routing for "both" rows in chains downstream of a decision card. The unit-tested `bucketDeadlinesIntoColumns` carries the existing appellant semantics; extending it without breaking the existing test suite means new tests, not changes to existing ones. Coder MUST add the new tests before changing the bucketer.
- **Flag-context vs per-rule-flag aliasing**: §4.2 #2 (Slice B) trades per-card precision for engine simplicity. Acceptable in v1 (Klageerwiderung is the only entry point per proceeding) but a known limitation. Document it in `internal/services/fristenrechner.go` doc comment so the next Wave-2 inventor doesn't think it's bug-free.
- **Page-level vs per-card appellant interaction**: when both are set, per-card wins for descendants of the decision the per-card was set on; page-level still drives descendants of decisions without a per-card pick. Could confuse a user. Mitigation: the page-level appellant selector hides for first-instance proceedings (per §5.5). For appeal proceedings, the selector stays — but those proceedings have a single root decision so the conflict surface is small.
- **Cross-proceeding consistency** (where #93's rename lives) — coordinate with the inventor on #93 if both ship in parallel.
---
## 9. Out of scope (recap)
- SmartTimeline (project Verlauf tab) per-card choices.
- Versioning / time-machine of choices.
- Cross-project propagation.
- Coder implementation (separate task per slice).
- A "saved scenarios" feature.
- Removal of the page-level `?appellant=` URL param for appeal proceedings.
---
## 10. Open questions for m
The following 4 questions need m's pick. Inventor recommendations marked **(R)**. After m answers via AskUserQuestion, the picks land in §11 below as the historical record.
### Q1 — State location
Where do per-card choices live?
- **(R) A. `paliad.project_event_choices` persisted (with URL override for what-if).** Per-case choices are real, not exploratory. Persist by default; what-if exploration handled later as a URL-override layer.
- B. URL query state only. Ephemeral, shareable, no persistence.
- C. Both from day one. Persisted default + URL-overridable for what-if scenarios.
### Q2 — Affordance
How do the choices surface on a card?
- **(R) A. Caret (▾) + popover on click.** Off-by-default visual, on-tap reveal. Selected choice surfaces as a chip on the card title.
- B. Inline checkbox/radio on every relevant card. Higher discoverability, more visual noise.
- C. Card-hover reveals the choices. Discoverability + touch issues.
### Q3 — Page-level appellant interaction
When a per-card appellant is set on a decision, what happens to the page-level `?appellant=` selector?
- **(R) A. Per-card overrides page-level for descendants of THAT decision.** Decisions without a per-card pick still use page-level. Most expressive.
- B. Per-card inherits page-level unless explicitly set. Less surprising default but loses the per-decision expressiveness.
### Q4 — Slice order
Which slice ships first?
- **(R) A. Slice A first (appellant per decision + skip optional).** Bounded, validates the popover + persistence layer without touching the flag-evaluation engine. Slice B (include-CCR) follows.
- B. Slice B first. Higher-impact user feature but requires the engine change.
- C. Bundle A + B in one coder shift. Slower to ship, lower per-coder load, but one less round trip.
---
## 11. m's decisions (2026-05-25)
- **Q1 (State location):** Persisted table — `paliad.project_event_choices` per §3.1. Matches inventor (R).
- **Q2 (Affordance):** Caret + popover with chip indicator on chosen cards per §5.1, §5.3. Matches inventor (R).
- **Q3 (Appellant layer):** Per-card overrides page-level for descendants of that decision. Page-level still drives decisions without a per-card pick. Matches inventor (R). Implementation: `CalculatedDeadline.AppellantContext` (§4.3) carries the per-decision pick down the parent chain so the bucketer reads one field.
- **Q4 (Slice order):** **Bundle Slice A + Slice B in one coder shift** (m picked over inventor (R) of "A first"). Reasoning: keeps the popover, persistence layer, AND the engine extension for `IncludeCCRFor` in one cohesive PR — coder + reviewer hold the full mental model once; one user-visible release; no half-shipped state where the caret exists on Klageerwiderung cards but the include-CCR pick doesn't yet wire through. Trade-off: larger PR. Mitigation: coder still organises commits per slice internally (separate test files, separate handler additions) so review can read them sequentially. See §7 slice plan — both slices implemented; ship as one.
### Coder-shift implications of Q4 bundling
- Migration 128 carries ALL three choice-kinds (`appellant`, `skip`, `include_ccr`) in the seed of `choices_offered`, plus the Klageerwiderung rows seeded with `{"include_ccr": [true, false]}`.
- `CalcOptions` gains all three new fields (`PerCardAppellant`, `SkipRules`, `IncludeCCRFor`) in the same Go change.
- The `IncludeCCRFor` v1 simplification (§4.2 #2 — "any non-empty set means append `with_ccr` to Flags") documents the per-card-scope limitation up front. Multi-CCR proceedings are a future expansion, not a v1 ship blocker.
- Frontend popover renders all three blocks the rule offers in one render path; coder cannot half-ship by leaving include_ccr's popover branch as a TODO.
- Tests cover the full matrix on the same branch.
---
## 12. Hard rules for the coder shift
- Migration is 128, not anything else. Verify against `paliad.paliad_schema_migrations` MAX before authoring.
- Tests added BEFORE projection-engine changes in fristenrechner.go (bucketer, gate, AppellantContext).
- `go build ./... && go test ./internal/... && cd frontend && bun run build` clean.
- No regression on `?side=` + `?appellant=` URL state.
- DE primary, EN secondary for all new i18n keys.
- Branch per slice: `mai/<coder>/event-card-choices-slice-a` etc.
---
## 13. Reporting
When ready, the coder reports completion with the URL of the test project that exercises the feature, a screenshot of the popover, and the deadline-rules SQL UPDATE counts for the seeded `choices_offered` rows. Standard slice-completion shape.

View File

@@ -51,7 +51,10 @@ interface Rule {
interface ProceedingType {
id: number;
code: string;
name_de: string;
// `name` is the German display name on the wire; the Go `ProceedingType`
// model serialises `db:"name"` as JSON key `name`. Don't reach for
// `name_de` — that field does not exist in this payload (m/paliad#113).
name: string;
name_en: string;
}
@@ -169,7 +172,8 @@ function fillProceedingSelect(selectId: string, list: ProceedingType[]) {
for (const pt of list) {
const opt = document.createElement("option");
opt.value = String(pt.id);
opt.textContent = `${pt.code} · ${getLang() === "en" ? pt.name_en : pt.name_de}`;
const name = getLang() === "en" ? pt.name_en : pt.name;
opt.textContent = name ? `${pt.code} · ${name}` : pt.code;
sel.appendChild(opt);
}
}

View File

@@ -29,7 +29,11 @@ interface Rule {
interface ProceedingType {
id: number;
code: string;
name_de: string;
// `name` is the German display name on the wire; the Go `ProceedingType`
// model serialises `db:"name"` as JSON key `name` (the schema treats DE
// as primary). EN lives in `name_en`. Don't reach for `name_de` — that
// field does not exist in this payload (cf. m/paliad#113).
name: string;
name_en: string;
category: string;
}
@@ -125,7 +129,12 @@ function proceedingLabel(id: number | null | undefined): string {
if (id == null) return "—";
const pt = proceedings.find((p) => p.id === id);
if (!pt) return `#${id}`;
const name = getLang() === "en" ? pt.name_en : pt.name_de;
const name = getLang() === "en" ? pt.name_en : pt.name;
// Guard against a proceeding row that's missing the active-language
// name (or against a stale field-name mismatch slipping back in).
// Show the code on its own rather than "code · undefined" — that
// literal string is the smell that surfaced this bug (m/paliad#113).
if (!name) return pt.code;
return `${pt.code} · ${name}`;
}
@@ -153,7 +162,8 @@ async function loadProceedings(): Promise<void> {
for (const pt of proceedings) {
const opt = document.createElement("option");
opt.value = String(pt.id);
opt.textContent = `${pt.code} · ${getLang() === "en" ? pt.name_en : pt.name_de}`;
const name = getLang() === "en" ? pt.name_en : pt.name;
opt.textContent = name ? `${pt.code} · ${name}` : pt.code;
sel.appendChild(opt);
}
}

View File

@@ -191,25 +191,37 @@ export function mountDateRangePicker(opts: MountOpts): PickerHandle {
function renderPanel(): void {
panel.replaceChildren();
// Three groups in a single row: past fan / ALLES centre / next fan.
const row = document.createElement("div");
row.className = "date-range-row";
// Three vertical columns: Past (closest→farthest top→bottom),
// NOW (Heute + Alles), Future (closest→farthest). The grid
// visualises time as space around NOW — each column's top is
// closest to the current moment, bottom is furthest away.
const grid = document.createElement("div");
grid.className = "date-range-grid";
const pastGroup = renderFan(
PAST_HORIZONS.filter((h) => presets.includes(h)),
// Past column: PAST_HORIZONS registry is outermost→innermost
// (past_all → past_1d); reverse for closeness-to-NOW ordering
// (past_1d at top, past_all at bottom).
const pastCol = renderColumn(
"past",
t("date_range.fan.past.label"),
[...PAST_HORIZONS].reverse().filter((h) => presets.includes(h)),
);
const centerGroup = renderCenter();
const nextGroup = renderFan(
NEXT_HORIZONS.filter((h) => presets.includes(h)),
"next",
const nowCol = renderNowColumn();
// Future column: NEXT_HORIZONS registry is already in closeness
// order (next_1d → next_all). next_1d moves to the NOW column as
// "Heute" (semantically just-today, single-day window), so the
// future column skips it.
const futureCol = renderColumn(
"future",
t("date_range.fan.future.label"),
NEXT_HORIZONS.filter((h) => h !== "next_1d" && presets.includes(h)),
);
if (pastGroup) row.appendChild(pastGroup);
if (centerGroup) row.appendChild(centerGroup);
if (nextGroup) row.appendChild(nextGroup);
if (pastCol) grid.appendChild(pastCol);
if (nowCol) grid.appendChild(nowCol);
if (futureCol) grid.appendChild(futureCol);
panel.appendChild(row);
panel.appendChild(grid);
// Custom-range section ("Anpassen"). Toggle button + collapsible
// date-pair editor below.
@@ -218,49 +230,57 @@ export function mountDateRangePicker(opts: MountOpts): PickerHandle {
}
}
function renderFan(horizons: readonly TimeHorizon[], side: "past" | "next"): HTMLElement | null {
function renderColumn(
side: "past" | "future",
heading: string,
horizons: readonly TimeHorizon[],
): HTMLElement | null {
if (horizons.length === 0) return null;
const group = document.createElement("div");
group.className = `date-range-fan date-range-fan--${side}`;
group.setAttribute("role", "group");
group.setAttribute("aria-label", side === "past"
? t("date_range.fan.past.label")
: t("date_range.fan.future.label"));
const col = document.createElement("div");
col.className = `date-range-col date-range-col--${side}`;
col.setAttribute("role", "group");
col.setAttribute("aria-label", heading);
const head = document.createElement("div");
head.className = "date-range-col-heading";
head.textContent = heading;
col.appendChild(head);
for (const h of horizons) {
group.appendChild(makeChip(h));
col.appendChild(makeChip(h));
}
return group;
return col;
}
function renderCenter(): HTMLElement | null {
if (!presets.includes("any")) return null;
const wrap = document.createElement("div");
wrap.className = "date-range-center";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "date-range-center-btn";
if (value.horizon === "any" || value.horizon === "all") {
btn.classList.add("date-range-center-btn--active");
}
btn.setAttribute("aria-pressed", String(value.horizon === "any" || value.horizon === "all"));
btn.dataset.testid = `${opts.surface}.date-range-chip.any`;
function renderNowColumn(): HTMLElement | null {
const showHeute = presets.includes("next_1d");
const showAlles = presets.includes("any");
if (!showHeute && !showAlles) return null;
const glyph = document.createElement("span");
glyph.className = "date-range-center-glyph";
const col = document.createElement("div");
col.className = "date-range-col date-range-col--now";
col.setAttribute("role", "group");
col.setAttribute("aria-label", t("date_range.center.label"));
const glyph = document.createElement("div");
glyph.className = "date-range-col-heading date-range-col-heading--glyph";
glyph.setAttribute("aria-hidden", "true");
glyph.textContent = "⌖"; // ⌖ POSITION INDICATOR
const label = document.createElement("span");
label.className = "date-range-center-label";
label.textContent = t("date_range.center.label");
btn.appendChild(glyph);
btn.appendChild(label);
col.appendChild(glyph);
btn.addEventListener("click", () => {
commit({ horizon: "any" }, /*closeAfter*/ true);
});
wrap.appendChild(btn);
return wrap;
if (showHeute) col.appendChild(makeChip("next_1d"));
if (showAlles) {
const allesChip = makeChip("any");
// Legacy "all" horizon also lights up Alles for back-compat
// with saved Custom Views that store the bidirectional-unbounded
// value (Q26 — parser preserves it, picker surfaces it here).
if (value.horizon === "all") {
allesChip.classList.add("agenda-chip-active");
allesChip.setAttribute("aria-pressed", "true");
}
col.appendChild(allesChip);
}
return col;
}
function makeChip(h: TimeHorizon): HTMLButtonElement {

View File

@@ -73,13 +73,16 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts):
type TimeHorizonValue = NonNullable<BarState["time"]>["horizon"];
// Default chip set when the surface doesn't override. Matches the
// forward-leaning bias of the legacy filter-bar default (the universal
// substrate is more often used for "what's coming up" than "what just
// happened") but now covers the full symmetric fan plus past_30d for
// quick recent-history lookups.
// Default chip set when the surface doesn't override. Mirrors m's
// 3-column picker spec (t-paliad-278): symmetric 7d/30d/90d/all fan
// per side, plus Heute (next_1d) + Alles (any) in the centre column,
// plus Anpassen. Surfaces with a tighter scope (project history is
// past-only) keep overriding via `timePresets`.
const DEFAULT_TIME_PRESETS: TimeHorizonValue[] = [
"past_30d", "past_7d", "any", "next_7d", "next_30d", "next_90d", "custom",
"past_7d", "past_30d", "past_90d", "past_all",
"next_1d", "any",
"next_7d", "next_30d", "next_90d", "next_all",
"custom",
];
function renderTimeAxis(ctx: AxisCtx, presetOverride?: TimeHorizonValue[]): HTMLElement {

View File

@@ -24,6 +24,12 @@ import {
renderTimelineBody,
wireDateEditClicks,
} from "./views/verfahrensablauf-core";
import {
attachEventCardChoices,
reseedChips,
type EventChoice,
type ChoiceKind,
} from "./views/event-card-choices";
let lastResponse: DeadlineResponse | null = null;
@@ -162,6 +168,13 @@ async function calculate() {
? courtPicker.value
: "";
// t-paliad-265 — when project-bound, the server pulls per-card
// choices from paliad.project_event_choices. The frontend has
// already pre-fetched them into perCardChoicesCache so chip
// indicators repaint in step with the calc; sending projectId here
// is the persistence path.
const projectIdForCalc = currentStep1Context.kind === "project" ? currentStep1Context.projectId : "";
const data = await calculateDeadlines({
proceedingType: selectedType,
triggerDate,
@@ -169,6 +182,7 @@ async function calculate() {
flags,
anchorOverrides: overrides,
courtId,
projectId: projectIdForCalc || undefined,
});
if (seq !== procCalcSeq) return;
if (!data) return;
@@ -439,6 +453,10 @@ function renderProcedureResults(data: DeadlineResponse) {
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + bodyHtml;
// t-paliad-265: rehydrate per-event-card chip indicators after the
// innerHTML rewrite. Safe to call before attachEventCardChoices() —
// it no-ops when no state was attached yet.
reseedChips(container);
printBtn.style.display = "block";
if (saveBtn) {
// Ad-hoc explore-mode has no project to save against — show the
@@ -461,6 +479,49 @@ function renderProcedureResults(data: DeadlineResponse) {
applyPendingFocus();
}
// initEventCardChoicesForFristenrechner attaches the per-event-card
// popover to the timeline container. The fristenrechner page is the
// project-bound surface: commits POST/DELETE to the persistence
// endpoint; the next calculate() pulls the fresh state from the
// server. (t-paliad-265)
async function initEventCardChoicesForFristenrechner(container: HTMLElement): Promise<void> {
// Load the current persisted state for the project context, if any.
const initial: EventChoice[] = [];
if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(currentStep1Context.projectId)}/event-choices`);
if (resp.ok) {
const rows = (await resp.json()) as EventChoice[];
for (const r of rows) initial.push(r);
}
} catch (e) {
console.error("event-choices: initial load failed", e);
}
}
attachEventCardChoices({
container,
initial,
commit: async (choice) => {
if (currentStep1Context.kind !== "project" || !currentStep1Context.projectId) return;
const resp = await fetch(`/api/projects/${encodeURIComponent(currentStep1Context.projectId)}/event-choices`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(choice),
});
if (!resp.ok) throw new Error(`event-choices PUT ${resp.status}`);
scheduleProcCalc(0);
},
remove: async (submissionCode, kind) => {
if (currentStep1Context.kind !== "project" || !currentStep1Context.projectId) return;
const url = `/api/projects/${encodeURIComponent(currentStep1Context.projectId)}/event-choices/${encodeURIComponent(submissionCode)}/${encodeURIComponent(kind)}`;
const resp = await fetch(url, { method: "DELETE" });
if (!resp.ok && resp.status !== 404) throw new Error(`event-choices DELETE ${resp.status}`);
scheduleProcCalc(0);
},
});
}
// onDateEditCommit is the click-to-edit callback handed to the shared
// wireDateEditClicks() helper: persist the per-rule override (empty value
// clears it) then recompute so downstream rules re-anchor.
@@ -648,6 +709,15 @@ document.addEventListener("DOMContentLoaded", () => {
const timelineContainer = document.getElementById("timeline-container");
if (timelineContainer) wireDateEditClicks(timelineContainer, onDateEditCommit);
// t-paliad-265 — per-event-card choices. Project-bound surface, so
// commits POST to /api/projects/{id}/event-choices. The popover
// module owns the popover; this page owns the recalc trigger. When
// there's no project context yet (Step 1 not picked), the popover
// still works but commits silently no-op (project_id missing).
if (timelineContainer) {
void initEventCardChoicesForFristenrechner(timelineContainer);
}
// Reset button
document.getElementById("reset-btn")!.addEventListener("click", reset);

View File

@@ -207,6 +207,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.step1": "Verfahrensart w\u00e4hlen",
"deadlines.step2": "Ausgangsdatum eingeben",
"deadlines.step2.perspective": "Perspektive und Datum",
"deadlines.step3": "Ergebnis",
"deadlines.upc": "UPC",
"deadlines.de": "Deutsche Gerichte",
@@ -306,6 +307,24 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.col.court": "Gericht",
"deadlines.col.opponent": "Gegnerseite",
"deadlines.col.both": "Beide Parteien",
// t-paliad-265 — per-event-card choice popover (Verfahrensablauf timeline)
"choices.caret.title": "Optionen für dieses Ereignis",
"choices.appellant.title": "Berufung durch …",
"choices.appellant.claimant": "Klägerseite",
"choices.appellant.defendant": "Beklagtenseite",
"choices.appellant.both": "beide Parteien",
"choices.appellant.none": "keine Berufung",
"choices.include_ccr.title": "Nichtigkeitswiderklage einbeziehen",
"choices.include_ccr.true": "Ja",
"choices.include_ccr.false": "Nein",
"choices.skip.title": "Für diese Akte überspringen",
"choices.skip.true": "Überspringen",
"choices.skip.false": "Einbeziehen",
"choices.skipped.chip": "übersprungen",
"choices.appellant.chip": "Berufung:",
"choices.include_ccr.chip": "mit Nichtigkeitswiderklage",
"choices.reset": "Auswahl zurücksetzen",
"choices.commit.error": "Konnte Auswahl nicht speichern",
// Trigger-event mode (PR-2 \u2014 youpc-parity)
"deadlines.mode.procedure": "Verfahrensablauf",
"deadlines.mode.event": "Was kommt nach\u2026",
@@ -421,6 +440,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.side.claimant": "Klägerseite",
"deadlines.side.defendant": "Beklagtenseite",
"deadlines.side.both": "Beide",
"deadlines.side.from_project": "Aus Akte:",
"deadlines.side.override": "Andere Seite wählen",
"deadlines.appellant.label": "Berufung durch:",
"deadlines.appellant.claimant": "Klägerseite",
"deadlines.appellant.defendant": "Beklagtenseite",
@@ -1477,6 +1498,11 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.import.button": "Aus Projekt importieren",
"submissions.draft.parties.title": "Parteien",
"submissions.draft.parties.hint": "Wählen Sie aus, welche Parteien im Schriftsatz genannt werden sollen.",
// t-paliad-276 — DE/EN language toggle on the draft editor.
"submissions.draft.language": "Sprache",
"submissions.draft.language.de": "DE",
"submissions.draft.language.en": "EN",
"submissions.draft.language.fallback_notice": "Fallback: universelles Skelett (keine sprachspezifische Vorlage).",
// t-paliad-240 — global Schriftsätze drafts index page.
"submissions.index.title": "Schriftsätze — Paliad",
"submissions.index.heading": "Schriftsätze",
@@ -3048,7 +3074,7 @@ const translations: Record<Lang, Record<string, string>> = {
// /admin/audit-log to the same component.
"date_range.button.label": "Zeitraum",
"date_range.button.label.custom_range": "Von {from} bis {to}",
"date_range.horizon.next_1d": "Morgen",
"date_range.horizon.next_1d": "Heute",
"date_range.horizon.next_7d": "Nächste 7 Tage",
"date_range.horizon.next_14d": "Nächste 14 Tage",
"date_range.horizon.next_30d": "Nächste 30 Tage",
@@ -3278,6 +3304,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.step1": "Select Proceeding Type",
"deadlines.step2": "Enter Trigger Date",
"deadlines.step2.perspective": "Perspective and Date",
"deadlines.step3": "Result",
"deadlines.upc": "UPC",
"deadlines.de": "German Courts",
@@ -3377,6 +3404,24 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.col.court": "Court",
"deadlines.col.opponent": "Opponent Side",
"deadlines.col.both": "Both parties",
// t-paliad-265 — per-event-card choice popover (Verfahrensablauf timeline)
"choices.caret.title": "Options for this event",
"choices.appellant.title": "Appeal by …",
"choices.appellant.claimant": "Claimant side",
"choices.appellant.defendant": "Defendant side",
"choices.appellant.both": "both parties",
"choices.appellant.none": "no appeal",
"choices.include_ccr.title": "Include nullity counterclaim",
"choices.include_ccr.true": "Yes",
"choices.include_ccr.false": "No",
"choices.skip.title": "Skip for this case",
"choices.skip.true": "Skip",
"choices.skip.false": "Include",
"choices.skipped.chip": "skipped",
"choices.appellant.chip": "Appeal:",
"choices.include_ccr.chip": "with nullity counterclaim",
"choices.reset": "Reset choice",
"choices.commit.error": "Could not save selection",
"deadlines.adjusted": "Adjusted",
"deadlines.adjusted.reason": "weekend/holiday",
"deadlines.adjusted.weekend": "weekend",
@@ -3499,6 +3544,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.side.claimant": "Claimant",
"deadlines.side.defendant": "Defendant",
"deadlines.side.both": "Both",
"deadlines.side.from_project": "From case:",
"deadlines.side.override": "Choose other side",
"deadlines.appellant.label": "Appeal filed by:",
"deadlines.appellant.claimant": "Claimant",
"deadlines.appellant.defendant": "Defendant",
@@ -4524,6 +4571,11 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.switcher.label": "Draft",
"submissions.draft.name.placeholder": "Name of this draft",
"submissions.draft.preview.title": "Preview",
// t-paliad-276 — DE/EN language toggle on the draft editor.
"submissions.draft.language": "Language",
"submissions.draft.language.de": "DE",
"submissions.draft.language.en": "EN",
"submissions.draft.language.fallback_notice": "Fallback: universal skeleton (no language-matched template).",
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
// t-paliad-277 — import-from-project + party-picker.
"submissions.draft.import.button": "Import from project",
@@ -6082,7 +6134,7 @@ const translations: Record<Lang, Record<string, string>> = {
// Date-range picker (t-paliad-248). See DE block above for details.
"date_range.button.label": "Time range",
"date_range.button.label.custom_range": "From {from} to {to}",
"date_range.horizon.next_1d": "Tomorrow",
"date_range.horizon.next_1d": "Today",
"date_range.horizon.next_7d": "Next 7 days",
"date_range.horizon.next_14d": "Next 14 days",
"date_range.horizon.next_30d": "Next 30 days",

View File

@@ -20,6 +20,9 @@ interface SubmissionDraftJSON {
submission_code: string;
user_id: string;
name: string;
// t-paliad-276 — per-draft output language ("de" or "en"). Drives the
// template-variant lookup and language-aware variable resolution.
language: string;
variables: Record<string, string>;
selected_parties: string[];
last_exported_at?: string | null;
@@ -56,6 +59,11 @@ interface SubmissionDraftView {
has_template: boolean;
template_missing?: boolean;
available_parties: AvailablePartyJSON[];
// t-paliad-276 — template-tier metadata used to surface the
// "Fallback: universelles Skelett" notice when the requested draft
// language has no per-firm language-matched template.
template_tier?: string;
language_fallback?: boolean;
}
interface SubmissionDraftListResponse {
@@ -411,7 +419,7 @@ async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
return resp.json();
}
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[] }): Promise<SubmissionDraftView> {
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string }): Promise<SubmissionDraftView> {
const p = state.parsed;
if (!p.draftID) throw new Error("no draft id");
if (state.inFlight) {
@@ -463,6 +471,8 @@ function paint(): void {
paintNameRow();
paintImportRow();
paintPartyPicker();
paintLanguageRow();
paintLanguageFallback();
paintVariables();
paintPreview();
}
@@ -703,6 +713,64 @@ function formatStamp(iso: string): string {
return d.toLocaleString(isEN() ? "en-GB" : "de-DE");
}
// paintLanguageRow syncs the DE/EN radio with the loaded draft's
// language. Switching the radio fires onLanguageChange which PATCHes
// the draft and lets the server return the freshly-resolved bag +
// preview HTML (so the lawyer sees the EN form names appear without a
// manual reload). t-paliad-276.
function paintLanguageRow(): void {
if (!state.view) return;
const lang = (state.view.draft.language || "de").toLowerCase();
const de = document.getElementById("submission-draft-language-de") as HTMLInputElement | null;
const en = document.getElementById("submission-draft-language-en") as HTMLInputElement | null;
if (de) {
de.checked = lang === "de";
de.onchange = () => { void onLanguageChange("de"); };
}
if (en) {
en.checked = lang === "en";
en.onchange = () => { void onLanguageChange("en"); };
}
}
// paintLanguageFallback shows / hides the "no language-matched
// template" notice. The server sets language_fallback=true when the
// resolved template tier doesn't match the draft's language
// (e.g. EN draft → DE per-code template, or no skeleton EN sibling).
function paintLanguageFallback(): void {
const el = document.getElementById("submission-draft-language-fallback");
if (!el) return;
const fallback = !!state.view?.language_fallback;
el.style.display = fallback ? "" : "none";
}
async function onLanguageChange(lang: "de" | "en"): Promise<void> {
if (!state.view) return;
if ((state.view.draft.language || "de").toLowerCase() === lang) return;
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
try {
const view = await patchDraft({ language: lang });
state.view = view;
// Repaint everything that depends on language: the DE/EN form
// values in the resolved bag, the localized rule name in the
// header, and the fallback notice.
paintHeader();
paintLanguageRow();
paintLanguageFallback();
paintVariables();
paintPreview();
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
} catch (err) {
if ((err as Error).name === "AbortError") return;
console.error("submission-draft language switch:", err);
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
// Revert the radio to the persisted value so the UI doesn't lie
// about which language is active.
paintLanguageRow();
}
}
function paintVariables(): void {
const host = document.getElementById("submission-draft-variables");
if (!host || !state.view) return;
@@ -751,10 +819,25 @@ function paintVariables(): void {
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
inp.addEventListener("input", () => onVarChange(inp));
// t-paliad-274 (B) — focus into a sidebar field highlights every
// matching .draft-var span in the preview (sticky while focused,
// clears on blur). Survives autosave repaints because paintVariables
// is called by flushAutosave and we re-bind every render.
inp.addEventListener("focusin", () => onVarFocusEnter(inp.dataset.var ?? ""));
inp.addEventListener("focusout", () => onVarFocusLeave(inp.dataset.var ?? ""));
});
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-reset").forEach((btn) => {
btn.addEventListener("click", () => onVarReset(btn.dataset.resetKey ?? ""));
});
// After repaint, re-apply the active highlight if a field is still
// focused (paintVariables runs after autosave; the same input regains
// focus via restoreVarFocus and would otherwise emit focusin too
// late for our handler — re-apply explicitly).
const active = document.activeElement;
if (isVarField(active)) {
const key = active.dataset.var;
if (key) applyPreviewActiveHighlight(key);
}
}
function paintPreview(): void {
@@ -762,6 +845,16 @@ function paintPreview(): void {
if (!host || !state.view) return;
host.innerHTML = state.view.preview_html ?? "";
wireDraftVars(host);
// t-paliad-274 (B) — preview HTML was just blown away by innerHTML,
// so any prior --active classes are gone. Re-apply for whichever
// sidebar field is currently focused (typing in a field triggers an
// autosave round-trip that ends in paintPreview, and the user should
// see the highlight stay put across that cycle).
const active = document.activeElement;
if (isVarField(active)) {
const key = active.dataset.var;
if (key) applyPreviewActiveHighlight(key);
}
}
// t-paliad-261 (B) — click a substituted variable in the preview to
@@ -836,6 +929,48 @@ function onDraftVarClick(key: string, ev: Event): void {
flashVarRow(input);
}
// t-paliad-274 (B) — sidebar-field-focus → preview-occurrence highlight.
// Reverse direction of the click-to-jump from #92: when the user focuses
// any .submission-draft-var-input, every matching .draft-var span in the
// preview gets the --active modifier; on blur (or focus shift to a
// different field), the previous key's highlights clear and the new
// key's apply. Sticky-while-focused, not a one-shot flash — the lawyer
// can scan the preview for "where does this variable land in my prose?"
// while the field stays focused.
function onVarFocusEnter(key: string): void {
if (!key) return;
// Clear any leftover highlight before applying the new one — covers
// the focus-shift-without-blur case (Tab between fields).
clearPreviewActiveHighlight();
applyPreviewActiveHighlight(key);
}
function onVarFocusLeave(_key: string): void {
// We don't need the key here — if focus moves to a different sidebar
// input, that input's focusin will re-call apply with the new key
// (after our clearPreviewActiveHighlight). If focus leaves the sidebar
// entirely, this clears.
clearPreviewActiveHighlight();
}
function applyPreviewActiveHighlight(key: string): void {
const host = document.getElementById("submission-draft-preview");
if (!host) return;
host.querySelectorAll<HTMLElement>(
`.draft-var[data-var="${cssEscape(key)}"]`,
).forEach((el) => {
el.classList.add("draft-var--active");
});
}
function clearPreviewActiveHighlight(): void {
const host = document.getElementById("submission-draft-preview");
if (!host) return;
host.querySelectorAll<HTMLElement>(".draft-var--active").forEach((el) => {
el.classList.remove("draft-var--active");
});
}
function flashVarRow(input: HTMLElement): void {
const row = input.closest<HTMLElement>(".submission-draft-var-row");
if (!row) return;

View File

@@ -21,6 +21,13 @@ import {
renderTimelineBody,
wireDateEditClicks,
} from "./views/verfahrensablauf-core";
import {
attachEventCardChoices,
reseedChips,
currentChoices,
type EventChoice,
type ChoiceKind,
} from "./views/event-card-choices";
let selectedType = "";
let lastResponse: DeadlineResponse | null = null;
@@ -38,6 +45,13 @@ let lastResponse: DeadlineResponse | null = null;
let currentSide: Side = null;
let currentAppellant: Side = null;
// Project-driven auto-fill state (t-paliad-279 / m/paliad#111). When the
// page is opened with ?project=<id> and that project has our_side set,
// the side row renders as a read-only chip instead of the radio cluster.
// The user can flip to free-pick via the "Andere Seite wählen" override
// link, which clears this flag (radio cluster takes over again).
let sidePrefilledFromProject = false;
// Proceedings where one party initiates and "both" rows are role-swap
// (i.e. either party files depending on who acted at the lower
// instance). For these proceedings the appellant selector is meaningful
@@ -98,6 +112,37 @@ function writeAppellantToURL(a: Side) {
const anchorOverrides = new Map<string, string>();
function clearAnchorOverrides() { anchorOverrides.clear(); }
// Per-event-card choices (t-paliad-265). Unbound on this page (no
// project context), so persistence is URL-only via `?event_choices=`.
// Format: comma-separated `submission_code:kind=value` tuples. Same
// idiom as `?side=` + `?appellant=`.
let perCardChoices: EventChoice[] = [];
function readChoicesFromURL(): EventChoice[] {
const raw = new URLSearchParams(window.location.search).get("event_choices");
if (!raw) return [];
const out: EventChoice[] = [];
for (const tuple of raw.split(",")) {
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
if (!m) continue;
const kind = m[2] as ChoiceKind;
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
}
return out;
}
function writeChoicesToURL(choices: EventChoice[]) {
const url = new URL(window.location.href);
if (choices.length === 0) {
url.searchParams.delete("event_choices");
} else {
const enc = choices.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`).join(",");
url.searchParams.set("event_choices", enc);
}
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
type ProcedureView = "timeline" | "columns";
let procedureView: ProcedureView = "columns";
@@ -210,6 +255,7 @@ async function doCalc() {
flags: readFlags(),
anchorOverrides: overrides,
courtId,
perCardChoices,
});
if (seq !== calcSeq) return;
if (!data) return;
@@ -302,6 +348,11 @@ function renderResults(data: DeadlineResponse) {
if (toggle) toggle.style.display = "";
syncTriggerEventLabel();
// t-paliad-265: rehydrate per-event-card chip indicators after every
// re-render so the popover-driven active state survives the
// innerHTML rewrite the timeline body just did.
reseedChips(container);
}
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
@@ -388,6 +439,125 @@ function syncRadioGroup(name: string, value: string) {
});
}
// Project context (t-paliad-279 / m/paliad#111). When the page is opened
// with ?project=<id> and the project carries an our_side value, the side
// row renders as a read-only chip with an "Andere Seite wählen" override
// link. The proceeding picker + appellant axis stay untouched — only the
// side selector pre-fills.
interface ProjectOurSide {
id: string;
our_side?:
| "claimant"
| "defendant"
| "applicant"
| "appellant"
| "respondent"
| "third_party"
| "other"
| null;
}
function readProjectFromURL(): string {
return new URLSearchParams(window.location.search).get("project") || "";
}
// ourSideToSide maps the project-level our_side enum (t-paliad-222) onto
// the side-selector's two-value axis. Active roles (claimant / applicant /
// appellant) collapse to "claimant"; reactive roles (defendant /
// respondent) collapse to "defendant"; everything else (third_party /
// other / NULL) returns null = no pre-fill. Mirrors fristenrechner.ts
// ourSideToPerspective() so projects render consistently across both
// surfaces.
function ourSideToSide(os: ProjectOurSide["our_side"] | undefined): Side {
switch (os) {
case "claimant":
case "applicant":
case "appellant":
return "claimant";
case "defendant":
case "respondent":
return "defendant";
default:
return null;
}
}
async function fetchProjectOurSide(projectID: string): Promise<ProjectOurSide | null> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}`, {
credentials: "same-origin",
});
if (!resp.ok) return null;
return (await resp.json()) as ProjectOurSide;
} catch {
return null;
}
}
function sideLabelI18n(s: Side): string {
if (s === "claimant") return t("deadlines.side.claimant");
if (s === "defendant") return t("deadlines.side.defendant");
return t("deadlines.side.both");
}
// renderSideChip swaps the radio cluster for a read-only chip showing
// the auto-filled side + an "Andere Seite wählen" override link. Called
// after fetchProjectOurSide resolves to a side. The override link clears
// the prefilled flag and swaps back to the radio cluster — the user can
// then pick any side freely.
function renderSideChip(side: Side) {
const cluster = document.getElementById("side-radio-cluster");
const chip = document.getElementById("side-chip");
const value = document.getElementById("side-chip-value");
if (!cluster || !chip || !value) return;
cluster.style.display = "none";
chip.style.display = "";
value.textContent = sideLabelI18n(side);
}
function showSideRadioCluster() {
const cluster = document.getElementById("side-radio-cluster");
const chip = document.getElementById("side-chip");
if (!cluster || !chip) return;
cluster.style.display = "";
chip.style.display = "none";
}
// applySidePrefill takes a project's our_side, maps it to the side axis,
// and locks the side row to a read-only chip if a mapping exists. URL
// wins — if ?side= is already explicit, the user (or shared link) has
// already chosen and we never overwrite. When we do prefill, write the
// derived side to the URL so reload + back/forward round-trip cleanly.
function applySidePrefill(os: ProjectOurSide["our_side"] | undefined) {
if (readSideFromURL() !== null) return;
const next = ourSideToSide(os);
if (next === null) return;
currentSide = next;
writeSideToURL(next);
syncRadioGroup("side", next);
sidePrefilledFromProject = true;
renderSideChip(next);
if (lastResponse) renderResults(lastResponse);
}
function clearSidePrefill() {
sidePrefilledFromProject = false;
showSideRadioCluster();
// Drop ?project= from the URL so a reload doesn't re-lock the side.
// ?side= stays — that's the user's last pick at this point.
const url = new URL(window.location.href);
url.searchParams.delete("project");
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
async function initProjectAutofill() {
const projectID = readProjectFromURL();
if (!projectID) return;
const project = await fetchProjectOurSide(projectID);
if (!project) return;
applySidePrefill(project.our_side);
}
function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
// Mirrors the events.ts pattern (body.events-view-*). The print
// stylesheet keys `body.verfahrensablauf-view-timeline` to
@@ -529,6 +699,44 @@ document.addEventListener("DOMContentLoaded", () => {
initViewToggle();
initPerspectiveControls();
// t-paliad-265 — per-event-card choices. Unbound surface, so commits
// mutate the in-memory list + URL, then trigger a recalc. The
// popover module owns the popover lifecycle; this page owns the
// recalc + URL plumbing.
perCardChoices = readChoicesFromURL();
const timelineEl = document.getElementById("timeline-container");
if (timelineEl) {
attachEventCardChoices({
container: timelineEl,
initial: perCardChoices,
commit: (choice) => {
perCardChoices = perCardChoices.filter(
(c) => !(c.submission_code === choice.submission_code && c.choice_kind === choice.choice_kind),
);
perCardChoices.push(choice);
writeChoicesToURL(perCardChoices);
scheduleCalc(0);
},
remove: (submissionCode, kind) => {
perCardChoices = perCardChoices.filter(
(c) => !(c.submission_code === submissionCode && c.choice_kind === kind),
);
writeChoicesToURL(perCardChoices);
scheduleCalc(0);
},
});
}
// t-paliad-279 — override link on the prefilled side chip — swaps back
// to the radio cluster and clears ?project= from the URL.
document.getElementById("side-chip-override")?.addEventListener("click", clearSidePrefill);
// Project autofill — runs after the radio cluster has its URL-driven
// state so we never clobber an explicit ?side= pick. Fire-and-forget;
// the chip swap happens once the project resolves.
void initProjectAutofill();
onLangChange(() => {
// Active-button name updates with language change (the data-i18n
// pass swaps the inner <strong>'s text). Re-collapse the summary
@@ -539,6 +747,12 @@ document.addEventListener("DOMContentLoaded", () => {
const summary = document.getElementById("proceeding-summary-name");
if (summary) summary.textContent = proceedingDisplayName(activeBtn);
}
// Side-chip label tracks language so a DE/EN flip while the chip is
// visible re-renders the inferred side in the active language.
if (sidePrefilledFromProject) {
const value = document.getElementById("side-chip-value");
if (value) value.textContent = sideLabelI18n(currentSide);
}
if (lastResponse) renderResults(lastResponse);
syncTriggerEventLabel();
});

View File

@@ -0,0 +1,292 @@
// Per-event-card choice popover + chip indicator (t-paliad-265 /
// m/paliad#96).
//
// The shared rendering core (verfahrensablauf-core.ts) emits a caret
// button on cards that carry a non-empty `choices_offered` declaration
// and an inert chip span next to the title. This module:
//
// 1. Wires a delegated click handler on the result container so the
// caret opens a popover with the offered choice-kinds.
// 2. Commits the user's pick — either by POSTing to the project-
// bound endpoint or by mutating the in-memory state for the
// unbound (no-project) case.
// 3. Rehydrates the chip on every render + after every commit so the
// glanceable indicator matches the active state.
//
// Two consumer pages — /tools/verfahrensablauf (unbound) and
// /tools/fristenrechner (project-bound) — both wire this module
// once at boot via attachEventCardChoices().
import { escAttr, escHtml } from "./verfahrensablauf-core";
import { t } from "../i18n";
export type ChoiceKind = "appellant" | "include_ccr" | "skip";
export interface EventChoice {
submission_code: string;
choice_kind: ChoiceKind;
choice_value: string;
}
// State surface — the page passes in callbacks that own persistence.
// commit / remove must trigger a recalc on the page side (the popover
// only owns its own visual state).
export interface EventCardChoicesOpts {
container: HTMLElement;
// Initial state: a list of choices. The page seeds this from the
// server response (project-bound) or from URL params (unbound).
initial: EventChoice[];
// commit gets called for an UPSERT. The page POSTs to the API (or
// mutates URL state) AND triggers a recalc.
commit: (choice: EventChoice) => Promise<void> | void;
// remove gets called when the user resets a choice.
remove: (submissionCode: string, kind: ChoiceKind) => Promise<void> | void;
}
// One mutable bag per attach() call. The current implementation is a
// single-page singleton — paginated views (admin tables) are not in
// scope. Last-write-wins on the in-memory state.
interface AttachedState {
opts: EventCardChoicesOpts;
// active: submission_code → kind → value. Rebuilt from `initial`
// on every reseed() call.
active: Map<string, Map<ChoiceKind, string>>;
popover: HTMLDivElement | null;
}
const states = new WeakMap<HTMLElement, AttachedState>();
// attachEventCardChoices wires the delegated click + popover lifecycle
// to the given container. Call once per page after mount; safe to call
// again with a fresh container.
export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
const state: AttachedState = {
opts,
active: new Map(),
popover: null,
};
for (const c of opts.initial) {
if (!state.active.has(c.submission_code)) {
state.active.set(c.submission_code, new Map());
}
state.active.get(c.submission_code)!.set(c.choice_kind, c.choice_value);
}
states.set(opts.container, state);
opts.container.addEventListener("click", (e) => {
const target = (e.target as HTMLElement | null)?.closest<HTMLElement>(".event-card-choices-caret");
if (target) {
e.stopPropagation();
openPopover(state, target);
return;
}
// Outside-click closes the popover.
if (state.popover && !state.popover.contains(e.target as Node)) {
closePopover(state);
}
});
// ESC also closes.
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && state.popover) {
closePopover(state);
}
});
// Repaint chips on every renderResults() call. The page is
// responsible for calling reseedChips() after re-render so the chip
// dom node (re-created by the renderer) picks the active state up.
reseedChips(opts.container);
}
// reseedChips walks every chip span in the container and re-renders
// its content from the active state map. Idempotent.
export function reseedChips(container: HTMLElement): void {
const state = states.get(container);
if (!state) return;
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
const code = chip.dataset.submissionCode || "";
const kinds = state.active.get(code);
if (!kinds || kinds.size === 0) {
chip.innerHTML = "";
chip.dataset.empty = "true";
return;
}
chip.dataset.empty = "false";
chip.innerHTML = renderChip(kinds);
});
// Skipped rows fade out via a class on the card-item ancestor.
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
const code = chip.dataset.submissionCode || "";
const skipped = state.active.get(code)?.get("skip") === "true";
const itemEl = chip.closest<HTMLElement>(".timeline-item, .fr-col-item");
if (itemEl) itemEl.classList.toggle("timeline-item--skipped", skipped);
});
}
function renderChip(kinds: Map<ChoiceKind, string>): string {
const parts: string[] = [];
if (kinds.get("skip") === "true") {
parts.push(`<span class="event-card-choices-chip-part event-card-choices-chip-part--skipped">${escHtml(t("choices.skipped.chip"))}</span>`);
}
const ap = kinds.get("appellant");
if (ap && ap !== "" ) {
let label = "";
switch (ap) {
case "claimant": label = t("choices.appellant.claimant"); break;
case "defendant": label = t("choices.appellant.defendant"); break;
case "both": label = t("choices.appellant.both"); break;
case "none": label = t("choices.appellant.none"); break;
}
if (label) {
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.appellant.chip"))} ${escHtml(label)}</span>`);
}
}
if (kinds.get("include_ccr") === "true") {
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.include_ccr.chip"))}</span>`);
}
return parts.join(" ");
}
function openPopover(state: AttachedState, caret: HTMLElement): void {
closePopover(state);
const code = caret.dataset.submissionCode || "";
if (!code) return;
let offered: Record<string, unknown> = {};
try {
offered = JSON.parse(caret.dataset.choicesOffered || "{}");
} catch {
return;
}
const pop = document.createElement("div");
pop.className = "event-card-choices-popover";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-label", t("choices.caret.title"));
const blocks: string[] = [];
if (Array.isArray(offered.appellant)) {
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
}
if (Array.isArray(offered.include_ccr)) {
blocks.push(renderToggleBlock(state, code, "include_ccr"));
}
if (Array.isArray(offered.skip)) {
blocks.push(renderToggleBlock(state, code, "skip"));
}
pop.innerHTML = blocks.join("");
document.body.appendChild(pop);
state.popover = pop;
positionPopover(pop, caret);
pop.addEventListener("click", async (e) => {
const btn = (e.target as HTMLElement | null)?.closest<HTMLButtonElement>("button[data-choice-action]");
if (!btn) return;
e.stopPropagation();
const kind = btn.dataset.choiceKind as ChoiceKind | undefined;
const value = btn.dataset.choiceValue || "";
const action = btn.dataset.choiceAction;
if (!kind) return;
try {
if (action === "set") {
await state.opts.commit({ submission_code: code, choice_kind: kind, choice_value: value });
if (!state.active.has(code)) state.active.set(code, new Map());
state.active.get(code)!.set(kind, value);
} else if (action === "clear") {
await state.opts.remove(code, kind);
state.active.get(code)?.delete(kind);
}
reseedChips(state.opts.container);
closePopover(state);
} catch (err) {
console.error("event card choice commit failed", err);
// Surface a soft inline error inside the popover; do NOT close.
const errEl = document.createElement("div");
errEl.className = "event-card-choices-error";
errEl.textContent = t("choices.commit.error");
pop.appendChild(errEl);
}
});
}
function renderAppellantBlock(state: AttachedState, code: string, values: unknown[]): string {
const current = state.active.get(code)?.get("appellant") || "";
const buttons = values
.filter((v): v is string => typeof v === "string")
.map((v) => {
const labelKey = `choices.appellant.${v}` as const;
const isActive = v === current;
return `<button type="button"
data-choice-action="set"
data-choice-kind="appellant"
data-choice-value="${escAttr(v)}"
class="event-card-choices-option${isActive ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
})
.join("");
const reset = current
? `<button type="button" data-choice-action="clear" data-choice-kind="appellant"
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
: "";
return `<div class="event-card-choices-block">
<div class="event-card-choices-title">${escHtml(t("choices.appellant.title"))}</div>
<div class="event-card-choices-options">${buttons}</div>
${reset}
</div>`;
}
function renderToggleBlock(state: AttachedState, code: string, kind: "include_ccr" | "skip"): string {
const current = state.active.get(code)?.get(kind) || "false";
const titleKey = kind === "include_ccr" ? "choices.include_ccr.title" : "choices.skip.title";
const trueKey = kind === "include_ccr" ? "choices.include_ccr.true" : "choices.skip.true";
const falseKey = kind === "include_ccr" ? "choices.include_ccr.false" : "choices.skip.false";
const opt = (v: "true" | "false", labelKey: string) => `<button type="button"
data-choice-action="set"
data-choice-kind="${kind}"
data-choice-value="${v}"
class="event-card-choices-option${v === current ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
const reset = state.active.get(code)?.has(kind)
? `<button type="button" data-choice-action="clear" data-choice-kind="${kind}"
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
: "";
return `<div class="event-card-choices-block">
<div class="event-card-choices-title">${escHtml(t(titleKey as any))}</div>
<div class="event-card-choices-options">
${opt("true", trueKey)}
${opt("false", falseKey)}
</div>
${reset}
</div>`;
}
function closePopover(state: AttachedState): void {
if (state.popover) {
state.popover.remove();
state.popover = null;
}
}
function positionPopover(pop: HTMLDivElement, caret: HTMLElement): void {
const rect = caret.getBoundingClientRect();
const scrollY = window.scrollY || document.documentElement.scrollTop;
const scrollX = window.scrollX || document.documentElement.scrollLeft;
pop.style.position = "absolute";
pop.style.top = `${rect.bottom + scrollY + 4}px`;
pop.style.left = `${Math.max(8, rect.right + scrollX - 240)}px`;
pop.style.zIndex = "1000";
}
// Returns the current in-memory choice list for the given container —
// used by the unbound /tools/verfahrensablauf page to keep the URL
// param in sync.
export function currentChoices(container: HTMLElement): EventChoice[] {
const state = states.get(container);
if (!state) return [];
const out: EventChoice[] = [];
state.active.forEach((kinds, code) => {
kinds.forEach((value, kind) => {
out.push({ submission_code: code, choice_kind: kind, choice_value: value });
});
});
return out;
}

View File

@@ -191,6 +191,48 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
expect(rows[0].court.map((d) => d.name)).toEqual(["C"]);
});
test("appellantContext overrides the page-level appellant for descendants (t-paliad-265)", () => {
// A per-decision pick stamps AppellantContext on descendants of
// that decision. The bucketer prefers it over the page-level
// appellant: if a "both" row carries appellantContext='defendant',
// it collapses to defendant's column regardless of the global
// appellant opt.
const dl: CalculatedDeadline = {
...both("Notice of Appeal", "2026-07-23"),
appellantContext: "defendant",
};
const rows = bucketDeadlinesIntoColumns([dl], { appellant: "claimant" });
expect(rows[0].ours).toHaveLength(0);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
});
test("appellantContext='claimant' + side='defendant' lands the row in opponent (claimant ≠ us)", () => {
// The user is on the defendant side; per-card pick says the
// claimant appealed. The "both" row collapses to the claimant's
// column, which after the side-swap is opponent (right).
const dl: CalculatedDeadline = {
...both("Notice of Appeal", "2026-07-23"),
appellantContext: "claimant",
};
const rows = bucketDeadlinesIntoColumns([dl], { side: "defendant", appellant: "defendant" });
expect(rows[0].ours).toHaveLength(0);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
});
test("appellantContext='both' or 'none' falls back to page-level mirror (t-paliad-265)", () => {
// 'both' and 'none' aren't side-collapse values — they're
// statements about who appealed but don't pick a column. The
// bucketer treats them as no override, so the page-level
// appellant (or default mirror) applies.
const both1: CalculatedDeadline = {
...both("Notice of Appeal", "2026-07-23"),
appellantContext: "both",
};
const rowsBoth = bucketDeadlinesIntoColumns([both1]);
expect(rowsBoth[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rowsBoth[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
});
test("unscheduled rows (no dueDate) trail dated rows, preserving declaration order", () => {
const rows = bucketDeadlinesIntoColumns([
partySpecific("court", "Oral Hearing", ""),

View File

@@ -61,6 +61,17 @@ export interface CalculatedDeadline {
// Frontend save-modal logic doesn't read this; the rule editor
// (Slice 11) is the consumer. Unknown shape on this side — pass-through.
conditionExpr?: unknown;
// choicesOffered (t-paliad-265): declares which per-card choice-kinds
// this rule offers on the Verfahrensablauf timeline. Object shape:
// { appellant?: string[], include_ccr?: [true,false], skip?: [true,false] }.
// null/undefined = no caret affordance.
choicesOffered?: Record<string, unknown>;
// appellantContext (t-paliad-265): the per-decision appellant pick
// that applies to descendants of the closest ancestor decision card
// with a per-card appellant set. Empty = no per-card override (the
// page-level appellant axis still applies in that case). The bucketer
// reads this in preference to the page-level appellant.
appellantContext?: string;
}
// priorityRendering returns the per-priority UX hints the save-modal
@@ -139,6 +150,16 @@ export interface CalcParams {
flags?: string[];
anchorOverrides?: Record<string, string>;
courtId?: string;
// t-paliad-265: per-event-card choices. Either pass `projectId` for
// server-side lookup against paliad.project_event_choices, OR pass
// an inline list (for the unbound /tools/verfahrensablauf surface).
// When both are supplied the inline list wins server-side.
projectId?: string;
perCardChoices?: Array<{
submission_code: string;
choice_kind: string;
choice_value: string;
}>;
}
const PARTY_CLASS: Record<string, string> = {
@@ -272,6 +293,18 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
? '<span class="optional-badge">optional</span>'
: "";
// t-paliad-265 — caret affordance + chip indicator when this rule
// offers per-card choices and the user has made a pick. The popover
// open/commit lifecycle lives in client/views/event-card-choices.ts;
// the data-* attributes here are the wire contract between the two.
const choicesHtml = dl.code !== "" && dl.choicesOffered && Object.keys(dl.choicesOffered).length > 0
? `<button type="button" class="event-card-choices-caret"
data-submission-code="${escAttr(dl.code)}"
data-choices-offered="${escAttr(JSON.stringify(dl.choicesOffered))}"
aria-label="${escAttr(t("choices.caret.title"))}"
title="${escAttr(t("choices.caret.title"))}">▾</button>`
: "";
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
const adjustedNote = dl.wasAdjusted
@@ -310,12 +343,22 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
</div>`
: "";
// Chip indicator surfaces the active per-card pick (t-paliad-265).
// The popover module rehydrates this on commit so it stays in sync.
const chipHtml = dl.code !== ""
? `<span class="event-card-choices-chip"
data-submission-code="${escAttr(dl.code)}"
data-empty="true"></span>`
: "";
return `<div class="timeline-item-header">
<span class="timeline-name">
${dlName}
${mandatoryBadge}
${chipHtml}
</span>
${dateStr}
${choicesHtml}
</div>
${meta}
${adjustedNote}
@@ -532,7 +575,15 @@ export function bucketDeadlinesIntoColumns(
row.court.push(dl);
break;
case "both":
if (appellantColumn !== null) {
// t-paliad-265: a per-card appellant set on a decision
// ancestor propagates as appellantContext on this rule. When
// present, it overrides the page-level appellant for the
// collapse decision on THIS row. Falls through to page-level
// when empty.
if (dl.appellantContext === "claimant" || dl.appellantContext === "defendant") {
const perCardCol = dl.appellantContext === "claimant" ? claimantColumn : defendantColumn;
row[perCardCol].push(dl);
} else if (appellantColumn !== null) {
// Role-swap collapse: appellant initiated → both → one row
// in appellant's column. Mirror suppressed.
row[appellantColumn].push(dl);
@@ -625,6 +676,10 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
? params.anchorOverrides
: undefined,
courtId: params.courtId || undefined,
projectId: params.projectId || undefined,
perCardChoices: params.perCardChoices && params.perCardChoices.length > 0
? params.perCardChoices
: undefined,
}),
});
if (!resp.ok) {

View File

@@ -1008,6 +1008,23 @@ export type I18nKey =
| "checklisten.tab.mine"
| "checklisten.tab.templates"
| "checklisten.title"
| "choices.appellant.both"
| "choices.appellant.chip"
| "choices.appellant.claimant"
| "choices.appellant.defendant"
| "choices.appellant.none"
| "choices.appellant.title"
| "choices.caret.title"
| "choices.commit.error"
| "choices.include_ccr.chip"
| "choices.include_ccr.false"
| "choices.include_ccr.title"
| "choices.include_ccr.true"
| "choices.reset"
| "choices.skip.false"
| "choices.skip.title"
| "choices.skip.true"
| "choices.skipped.chip"
| "common.cancel"
| "common.close"
| "common.forbidden"
@@ -1446,7 +1463,9 @@ export type I18nKey =
| "deadlines.side.both"
| "deadlines.side.claimant"
| "deadlines.side.defendant"
| "deadlines.side.from_project"
| "deadlines.side.label"
| "deadlines.side.override"
| "deadlines.source.caldav"
| "deadlines.source.fristenrechner"
| "deadlines.source.imported"
@@ -1477,6 +1496,7 @@ export type I18nKey =
| "deadlines.step2.happened.desc"
| "deadlines.step2.happened.title"
| "deadlines.step2.heading"
| "deadlines.step2.perspective"
| "deadlines.step3"
| "deadlines.step3a.back"
| "deadlines.step3a.draft.desc"
@@ -2600,6 +2620,10 @@ export type I18nKey =
| "submissions.draft.action.new"
| "submissions.draft.back"
| "submissions.draft.import.button"
| "submissions.draft.language"
| "submissions.draft.language.de"
| "submissions.draft.language.en"
| "submissions.draft.language.fallback_notice"
| "submissions.draft.loading"
| "submissions.draft.name.placeholder"
| "submissions.draft.notfound"

View File

@@ -3476,6 +3476,133 @@ input[type="range"]::-moz-range-thumb {
color: var(--status-amber-fg);
}
/* t-paliad-265 — per-event-card optional choices. The caret sits in
* the card header next to the date; the chip surfaces the active pick
* inline with the title; the popover is body-attached and positioned
* by the JS module. Skipped rows fade to 50% opacity. */
.event-card-choices-caret {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
margin-left: 0.4rem;
border: 1px solid var(--color-border, #d4d4d4);
border-radius: 4px;
background: transparent;
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
line-height: 1;
}
.event-card-choices-caret:hover,
.event-card-choices-caret:focus-visible {
background: var(--color-accent-bg, rgba(198, 244, 28, 0.18));
color: var(--color-text);
}
.event-card-choices-chip {
display: inline-flex;
gap: 0.3rem;
margin-left: 0.4rem;
}
.event-card-choices-chip[data-empty="true"] {
display: none;
}
.event-card-choices-chip-part {
font-size: 0.7rem;
font-weight: 500;
padding: 0.05rem 0.4rem;
border-radius: 99px;
background: var(--color-accent-bg, rgba(198, 244, 28, 0.22));
color: var(--color-text);
}
.event-card-choices-chip-part--skipped {
background: var(--color-bg-soft, #f1f1f1);
color: var(--color-text-muted);
text-decoration: line-through;
}
.timeline-item--skipped {
opacity: 0.55;
}
.event-card-choices-popover {
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #d4d4d4);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
padding: 0.6rem 0.7rem;
min-width: 240px;
max-width: 320px;
}
.event-card-choices-block + .event-card-choices-block {
margin-top: 0.7rem;
padding-top: 0.6rem;
border-top: 1px solid var(--color-border-soft, #ececec);
}
.event-card-choices-title {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text);
margin-bottom: 0.4rem;
}
.event-card-choices-options {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.event-card-choices-option {
font-size: 0.78rem;
padding: 0.25rem 0.55rem;
border-radius: 4px;
border: 1px solid var(--color-border, #d4d4d4);
background: var(--color-bg, #fff);
color: var(--color-text);
cursor: pointer;
}
.event-card-choices-option:hover,
.event-card-choices-option:focus-visible {
background: var(--color-bg-soft, #f1f1f1);
}
.event-card-choices-option--active {
background: var(--color-accent, #c6f41c);
border-color: var(--color-accent, #c6f41c);
color: var(--color-text);
font-weight: 600;
}
.event-card-choices-reset {
margin-top: 0.5rem;
font-size: 0.72rem;
background: transparent;
border: none;
color: var(--color-text-muted);
text-decoration: underline;
cursor: pointer;
padding: 0;
}
.event-card-choices-reset:hover {
color: var(--color-text);
}
.event-card-choices-error {
margin-top: 0.5rem;
font-size: 0.74rem;
color: var(--status-red-fg, #b00020);
}
.timeline-rule {
font-family: var(--font-mono);
font-size: 0.72rem;
@@ -3572,6 +3699,59 @@ input[type="range"]::-moz-range-thumb {
margin-bottom: 0;
}
/* Visual divider between the perspective block (side + appellant)
and the date / court / flag knobs below. t-paliad-279 reorder put
the most-defining inputs (side, appellant) at the top of step-2; the
divider keeps the date block from reading as a continuation of the
perspective rows. */
.verfahrensablauf-step2-divider {
height: 1px;
margin: 1rem 0;
background: var(--color-border, #e5e5e5);
border: 0;
}
/* Read-only auto-fill chip for #side-row. Renders when ?project=<id>
resolves a project whose our_side is set: shows the inferred side
with a small "Andere Seite wählen" override link that swaps the row
back to the radio cluster. t-paliad-279 / m/paliad#111. */
.side-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
border: 1px solid var(--color-border, #e5e5e5);
border-radius: 0.5rem;
background: var(--color-bg-subtle, #fafafa);
font-size: 0.95rem;
}
.side-chip-tag {
color: var(--color-text-muted, #666);
font-size: 0.85rem;
}
.side-chip-value {
color: var(--color-text, #222);
}
.side-chip-override {
margin-left: 0.3rem;
padding: 0.15rem 0.55rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 9999px;
background: var(--color-bg, #fff);
color: var(--color-text-muted, #555);
font-size: 0.8rem;
cursor: pointer;
transition: background 120ms, border-color 120ms;
}
.side-chip-override:hover {
background: var(--color-bg-subtle, #f4f4f4);
border-color: var(--color-text-muted, #aaa);
}
.side-chip-override:focus-visible {
outline: 2px solid var(--color-accent, #c6f41c);
outline-offset: 1px;
}
/* Compact note hint — sits in the timeline-meta line when the notes
toggle is off. Native browser tooltip via title= attribute carries
the full text on hover; tabindex=0 + aria-label make it
@@ -5774,6 +5954,40 @@ dialog.modal::backdrop {
color: var(--color-danger, #c00);
}
/* t-paliad-276 — DE/EN language toggle on the draft editor. Same look
as the rest of the sidebar mini-controls; muted label + inline radios
so it doesn't compete with the editor's primary inputs. */
.submission-draft-language-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.25rem 0 0.5rem 0;
font-size: 0.9em;
}
.submission-draft-language-label {
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.85em;
}
.submission-draft-language-option {
display: inline-flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
}
.submission-draft-language-fallback {
font-size: 0.85em;
color: var(--color-text-muted);
margin: 0 0 0.5rem 0;
padding: 0.4rem 0.6rem;
border-left: 2px solid var(--color-warning, #d4a017);
background: var(--color-warning-bg, rgba(212, 160, 23, 0.08));
}
.submission-draft-variables {
display: flex;
flex-direction: column;
@@ -5880,16 +6094,27 @@ dialog.modal::backdrop {
font-style: italic;
}
/* t-paliad-261 (B) — substituted variables in the preview are wrapped
in <span class="draft-var" data-var="…"> by the Go HTML renderer.
.draft-var by itself shows a subtle dotted underline so the lawyer
can SEE which text was filled in from a variable. .draft-var--has-input
(added client-side when a matching sidebar input exists) layers on
the clickable affordance — pointer cursor + brighter hover background.
Non-matching draft-vars (derived variables not exposed in the
sidebar) stay visually distinct but non-interactive. */
/* t-paliad-261 / t-paliad-274 — substituted variables in the preview
are wrapped in <span class="draft-var" data-var="…"> by the Go HTML
renderer for BOTH filled values and missing-marker text. The lawyer
can click any wrapped span and jump to the matching sidebar input;
conversely, focusing a sidebar input lights up every matching span in
the preview via .draft-var--active.
Visual contract:
.draft-var — invisible by default (prose stays clean
per t-paliad-274 m's request).
.draft-var--has-input — pointer cursor; dotted underline on
hover so the click affordance reveals
itself, plus a brighter lime tint.
.draft-var--active — sticky lime highlight applied while the
matching sidebar input is focused
(t-paliad-274 reverse direction).
[KEIN WERT: …] / [NO VALUE: …] markers carry their own warning
style via .submission-draft-var-marker on the sidebar hint; in the
preview they read as obvious gap text, so .draft-var itself doesn't
need an always-on visual to flag them. */
.draft-var {
background-color: rgba(198, 244, 28, 0.12);
border-radius: 2px;
padding: 0 2px;
box-decoration-break: clone;
@@ -5904,9 +6129,21 @@ dialog.modal::backdrop {
.draft-var--has-input:hover,
.draft-var--has-input:focus-visible {
background-color: rgba(198, 244, 28, 0.45);
text-decoration: underline dotted rgba(198, 244, 28, 0.85);
text-underline-offset: 2px;
outline: none;
}
/* t-paliad-274 (B) — sticky highlight while the matching sidebar input
is focused. Brighter than the hover tint so the user's eye lands on
every occurrence at once when they click into a field. Applies to ALL
.draft-var spans for that data-var, not just one. */
.draft-var--active,
.draft-var--has-input.draft-var--active {
background-color: rgba(198, 244, 28, 0.55);
box-shadow: 0 0 0 1px rgba(198, 244, 28, 0.85);
}
/* t-paliad-261 (B) — brief lime flash on the sidebar row after a
click-jump from the preview, so the user's eye lands on the right
input even after the smooth-scroll motion. Animation restarts on
@@ -8369,6 +8606,7 @@ input.rule-mode-custom {
}
.fristen-step1-search-row .fristen-search-icon {
position: static;
color: var(--color-muted, #666);
flex-shrink: 0;
}
@@ -17700,9 +17938,10 @@ dialog.quick-add-sheet::backdrop {
}
.date-range-panel {
/* Inherits .multi-panel positioning + border + shadow. Widen it so
the symmetric fan + the custom editor have room to breathe. */
width: 32rem;
/* Inherits .multi-panel positioning + border + shadow. Sized so the
3-column grid holds the widest chip text ("Ganze Vergangenheit")
without wrapping while staying within the viewport on tablets. */
width: 34rem;
max-width: calc(100vw - 1rem);
top: 100%;
left: 0;
@@ -17710,88 +17949,54 @@ dialog.quick-add-sheet::backdrop {
gap: 0.75rem;
}
.date-range-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: stretch;
.date-range-grid {
/* Past / NOW / Future as three equal vertical columns. Each column
is a top-aligned chip stack so closeness-to-NOW (closest at top,
farthest at bottom) reads spatially. */
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.75rem;
align-items: start;
}
.date-range-fan {
.date-range-col {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-content: flex-start;
flex: 1 1 12rem;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.date-range-fan--past {
/* Past fan: outermost chip (Ganze Vergangenheit) leftmost. */
justify-content: flex-end;
.date-range-col--now {
align-items: stretch;
}
.date-range-fan--next {
/* Future fan: innermost chip (Morgen / next_1d) leftmost. */
justify-content: flex-start;
}
.date-range-center {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
padding: 0 0.25rem;
}
.date-range-center-btn {
appearance: none;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
background: var(--color-surface-muted);
border: 1px solid var(--color-border);
border-radius: 0.6rem;
min-width: 4.5rem;
padding: 0.55rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.date-range-center-btn:hover {
background: var(--color-overlay-subtle);
border-color: var(--color-accent-light);
}
.date-range-center-btn:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.date-range-center-btn--active {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-dark);
}
.date-range-center-glyph {
font-size: 1.4rem;
line-height: 1;
}
.date-range-center-label {
.date-range-col-heading {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted, #71717a);
text-align: center;
padding-bottom: 0.15rem;
}
.date-range-col-heading--glyph {
font-size: 1.3rem;
line-height: 1;
letter-spacing: 0;
text-transform: none;
color: var(--color-text-muted, #71717a);
}
.date-range-chip {
/* .agenda-chip provides bg/border/radius/typography; this modifier
only tightens horizontal padding so more chips fit per row. */
padding: 0.3rem 0.65rem;
/* .agenda-chip provides bg/border/radius/typography; in the
3-column stack each chip fills its column so the closeness-to-NOW
ordering reads as a single vertical column rather than a ragged
row. */
padding: 0.35rem 0.65rem;
font-size: 0.8rem;
width: 100%;
text-align: center;
}
.date-range-chip--custom {
@@ -17878,17 +18083,14 @@ dialog.quick-add-sheet::backdrop {
color: var(--status-red-fg, #b91c1c);
}
/* Mobile: stack past / centre / next vertically so each fan gets
the full popover width. */
/* Mobile: stack the 3 columns vertically (one column per row),
preserving the closeness-to-NOW sort within each column. */
@media (max-width: 540px) {
.date-range-panel {
width: calc(100vw - 1rem);
}
.date-range-row {
flex-direction: column;
}
.date-range-fan--past,
.date-range-fan--next {
justify-content: flex-start;
.date-range-grid {
grid-template-columns: 1fr;
gap: 0.5rem;
}
}

View File

@@ -109,6 +109,47 @@ export function renderSubmissionDraft(): string {
</button>
</div>
{/* t-paliad-276 — output language toggle (DE/EN).
Hydrated by client/submission-draft.ts; switching
autosaves the draft and re-renders the preview. */}
<div
className="submission-draft-language-row"
id="submission-draft-language-row"
role="radiogroup"
aria-labelledby="submission-draft-language-label">
<span
id="submission-draft-language-label"
className="submission-draft-language-label"
data-i18n="submissions.draft.language">
Sprache
</span>
<label className="submission-draft-language-option">
<input
type="radio"
name="submission-draft-language"
value="de"
id="submission-draft-language-de"
/>
<span data-i18n="submissions.draft.language.de">DE</span>
</label>
<label className="submission-draft-language-option">
<input
type="radio"
name="submission-draft-language"
value="en"
id="submission-draft-language-en"
/>
<span data-i18n="submissions.draft.language.en">EN</span>
</label>
</div>
<p
className="submission-draft-language-fallback"
id="submission-draft-language-fallback"
style="display:none"
data-i18n="submissions.draft.language.fallback_notice">
Fallback: universelles Skelett (keine sprachspezifische Vorlage).
</p>
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
{/* t-paliad-277: "Aus Projekt importieren" + last-

View File

@@ -158,9 +158,79 @@ export function renderVerfahrensablauf(): string {
<div className="wizard-step" id="step-2" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">2</span>
<span data-i18n="deadlines.step2">Ausgangsdatum eingeben</span>
<span data-i18n="deadlines.step2.perspective">Perspektive und Datum</span>
</h3>
{/* Perspective strip (t-paliad-250 / m/paliad#81, reordered
in t-paliad-279 / m/paliad#111). Side defines whose
perspective the columns project; appellant collapses
party=both rows for role-swap proceedings (Appeal etc.).
Moved above .date-input-group because party-side is the
most-defining input after proceeding-type — without
side, the column labels can't pick "your filings". Both
selectors are URL-driven (?side= + ?appellant=) so the
perspective survives reload and is shareable.
When the page is opened with ?project=<id> and that
project's our_side is set, side-row renders as a
read-only chip with an "Andere Seite wählen" override
link — see client/verfahrensablauf.ts. */}
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
<div className="verfahrensablauf-perspective-row" id="side-row">
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
<div className="side-radio-cluster" id="side-radio-cluster">
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
<label className="fristen-view-option">
<input type="radio" name="side" value="claimant" />
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="defendant" />
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="" checked />
<span data-i18n="deadlines.side.both">Beide</span>
</label>
</div>
</div>
{/* Auto-fill chip — populated by the client when a
?project=<id> URL resolves a project with our_side
set. Hidden by default; the radio cluster above is
hidden whenever this chip is shown. */}
<div className="side-chip" id="side-chip" style="display:none">
<span className="side-chip-tag" data-i18n="deadlines.side.from_project">Aus Akte:</span>
<strong className="side-chip-value" id="side-chip-value">&mdash;</strong>
<button type="button" className="side-chip-override" id="side-chip-override"
data-i18n="deadlines.side.override">
Andere Seite w&auml;hlen
</button>
</div>
</div>
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
<label className="fristen-view-option">
<input type="radio" name="appellant" value="claimant" />
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appellant" value="defendant" />
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appellant" value="" checked />
<span data-i18n="deadlines.appellant.none"></span>
</label>
</div>
</div>
</div>
{/* Visual divider — keeps the perspective block (most-
defining inputs after proceeding-type) optically
separate from the date / court / flag knobs below. */}
<div className="verfahrensablauf-step2-divider" aria-hidden="true"></div>
<div className="date-input-group">
<div className="date-field-row">
{/* Read-only caption labelling the value <span>. Not a
@@ -210,53 +280,6 @@ export function renderVerfahrensablauf(): string {
Fristen berechnen
</button>
</div>
{/* Perspective strip (t-paliad-250 / m/paliad#81). Side
swaps the column LABELS so the user's own side is
proactive (= "your filings"). Appellant collapses
party=both rows to a single column when set — only
relevant for role-swap proceedings (Appeal etc.);
the row hides itself when the picked proceeding has
no appellant axis (see hasAppellantAxis() in the
client). Both selectors are URL-driven (?side= +
?appellant=) so the perspective survives reload
and is shareable. */}
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
<div className="verfahrensablauf-perspective-row" id="side-row">
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
<label className="fristen-view-option">
<input type="radio" name="side" value="claimant" />
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="defendant" />
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="" checked />
<span data-i18n="deadlines.side.both">Beide</span>
</label>
</div>
</div>
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
<label className="fristen-view-option">
<input type="radio" name="appellant" value="claimant" />
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appellant" value="defendant" />
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appellant" value="" checked />
<span data-i18n="deadlines.appellant.none"></span>
</label>
</div>
</div>
</div>
</div>
<div className="wizard-step" id="step-3" style="display:none">

View File

@@ -0,0 +1,11 @@
-- t-paliad-265 — drop per-event-card choices schema.
SELECT set_config(
'paliad.audit_reason',
'mig 129 down: drop project_event_choices + deadline_rules.choices_offered',
true);
DROP TABLE IF EXISTS paliad.project_event_choices;
ALTER TABLE paliad.deadline_rules
DROP COLUMN IF EXISTS choices_offered;

View File

@@ -0,0 +1,116 @@
-- t-paliad-265 / m/paliad#96 — per-event-card optional choices on the
-- Verfahrensablauf timeline.
--
-- Design: docs/design-event-card-choices-2026-05-25.md
-- Decisions: see §11 of the design doc.
--
-- Two schema changes:
--
-- 1. paliad.project_event_choices — new persistence table holding the
-- user's per-card picks scoped to a project. One row per
-- (project, submission_code, choice_kind). Re-picking is an UPDATE
-- (UNIQUE constraint enforces idempotence).
--
-- 2. paliad.deadline_rules.choices_offered jsonb — opt-in declaration
-- of which choice-kinds each rule offers. The projection engine
-- reads this to decide whether to render the caret affordance on
-- a card. Seeded for every event_type='decision' rule (appellant),
-- every priority='optional' rule (skip), and the two Klageerwiderung
-- rows (include_ccr).
--
-- NOTE on join key: the design doc named the join column "rule_code".
-- Live verification (2026-05-25 SELECT against paliad.deadline_rules)
-- showed `rule_code` is NULL on every decision row — it's the legal-
-- source citation column, not a stable identifier. The
-- AnchorOverrides plumbing in internal/services/fristenrechner.go
-- already keys on `submission_code` (UIDeadline.Code populates from
-- submission_code, lines 351-352), so we mirror that decision here:
-- the join column is `submission_code`. Same intent, correct field.
--
-- Idempotent: CREATE TABLE IF NOT EXISTS + ADD COLUMN IF NOT EXISTS +
-- UPDATEs guarded by WHERE choices_offered IS NULL so re-applying
-- against an already-seeded DB no-ops.
SELECT set_config(
'paliad.audit_reason',
'mig 129: add paliad.project_event_choices + deadline_rules.choices_offered for per-event-card optional choices (t-paliad-265 / m/paliad#96)',
true);
-- 1. The choice-storage table ----------------------------------------------
CREATE TABLE IF NOT EXISTS paliad.project_event_choices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
submission_code text NOT NULL,
choice_kind text NOT NULL CHECK (choice_kind IN ('appellant', 'include_ccr', 'skip')),
choice_value text NOT NULL,
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (project_id, submission_code, choice_kind)
);
CREATE INDEX IF NOT EXISTS project_event_choices_project_idx
ON paliad.project_event_choices (project_id);
ALTER TABLE paliad.project_event_choices ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS project_event_choices_select ON paliad.project_event_choices;
CREATE POLICY project_event_choices_select ON paliad.project_event_choices
FOR SELECT USING (paliad.can_see_project(project_id));
DROP POLICY IF EXISTS project_event_choices_mutate ON paliad.project_event_choices;
CREATE POLICY project_event_choices_mutate ON paliad.project_event_choices
FOR ALL
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
COMMENT ON TABLE paliad.project_event_choices IS
'Per-event-card user picks scoped to a project. choice_kind ∈ {appellant, include_ccr, skip}. '
'choice_value namespace per kind: appellant=claimant|defendant|both|none; include_ccr=true|false; '
'skip=true|false. Join key submission_code matches paliad.deadline_rules.submission_code (the same key '
'AnchorOverrides uses). UNIQUE(project,submission_code,kind) keeps re-picks idempotent. '
'Audit-logged via paliad.system_audit_log (event_type=project_event_choice.set).';
-- 2. The choices_offered opt-in column ------------------------------------
ALTER TABLE paliad.deadline_rules
ADD COLUMN IF NOT EXISTS choices_offered jsonb;
COMMENT ON COLUMN paliad.deadline_rules.choices_offered IS
'Declares which per-card choice-kinds this rule offers on the Verfahrensablauf timeline. '
'NULL = no caret affordance (default). Example shapes: '
'{"appellant": ["claimant","defendant","both","none"]} on decision rules, '
'{"skip": [true, false]} on optional rules, '
'{"include_ccr": [true, false]} on Klageerwiderung rules. '
'Engine and frontend read it; storing per-kind value lists keeps the contract self-describing.';
-- 3. Seed -----------------------------------------------------------------
-- 3a. Every published decision rule offers the appellant choice.
UPDATE paliad.deadline_rules
SET choices_offered = '{"appellant": ["claimant", "defendant", "both", "none"]}'::jsonb
WHERE event_type = 'decision'
AND lifecycle_state = 'published'
AND choices_offered IS NULL;
-- 3b. Every published optional rule offers the skip choice.
UPDATE paliad.deadline_rules
SET choices_offered = '{"skip": [true, false]}'::jsonb
WHERE priority = 'optional'
AND lifecycle_state = 'published'
AND choices_offered IS NULL;
-- 3c. Klageerwiderung rules offer the include_ccr choice. Two rows
-- today (upc.inf.cfi.sod + de.inf.lg.erwidg) — verified live
-- (2026-05-25 SELECT FROM paliad.deadline_rules WHERE name ILIKE
-- 'Klageerwiderung'); the UPC INF Klageerwiderung is `sod` (Statement
-- of Defence, R.24 RoP), not `def`. Slice B (Q4 bundle) is the
-- user-visible feature.
UPDATE paliad.deadline_rules
SET choices_offered = '{"include_ccr": [true, false]}'::jsonb
WHERE submission_code IN ('upc.inf.cfi.sod', 'de.inf.lg.erwidg')
AND lifecycle_state = 'published'
AND choices_offered IS NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE paliad.submission_drafts
DROP COLUMN IF EXISTS language;

View File

@@ -0,0 +1,17 @@
-- t-paliad-276 / m/paliad#108: per-draft output language for the
-- Submissions generator.
--
-- The submission editor lets the lawyer pick DE or EN per draft so the
-- generator selects the matching template variant + resolves language-
-- aware variables ({{procedural_event.name_de}} vs _en). Default is
-- 'de' to match the primary-language convention in CLAUDE.md and to
-- keep existing rows behaving exactly as before (every legacy draft
-- was implicitly DE; the resolved bag for those drafts is unchanged
-- under language='de').
ALTER TABLE paliad.submission_drafts
ADD COLUMN IF NOT EXISTS language text NOT NULL DEFAULT 'de'
CONSTRAINT submission_drafts_language_check CHECK (language IN ('de', 'en'));
COMMENT ON COLUMN paliad.submission_drafts.language IS
't-paliad-276: output language for the generated .docx. ''de'' or ''en''. Drives template variant selection ({code}.{lang}.docx fallback chain) and language-aware variable resolution.';

View File

@@ -0,0 +1,33 @@
-- Reverses mig 133. Removes the 5 new rules:
-- * upc.dmgs.cfi.interim
-- * upc.dmgs.cfi.oral
-- * upc.dmgs.cfi.decision
-- * upc.dmgs.cfi.appeal_spawn
-- * upc.pi.cfi.appeal_spawn
--
-- The audit_reason is required by the mig 079 trigger for DELETE;
-- set_config at top supplies it.
--
-- Idempotent — if a rule is already missing the DELETE matches zero
-- rows and the audit log records nothing extra.
SELECT set_config(
'paliad.audit_reason',
'mig 133 (down): revert UPC Damages tree-end rows and UPC PI appeal-spawn (t-paliad-285 / m/paliad#117 + t-paliad-286 / m/paliad#118)',
true);
-- Delete the spawn rows first so the parent_id reference goes away
-- before the parent decision row is removed.
DELETE FROM paliad.deadline_rules
WHERE submission_code IN (
'upc.dmgs.cfi.appeal_spawn',
'upc.pi.cfi.appeal_spawn')
AND lifecycle_state = 'published';
DELETE FROM paliad.deadline_rules
WHERE submission_code IN (
'upc.dmgs.cfi.interim',
'upc.dmgs.cfi.oral',
'upc.dmgs.cfi.decision')
AND proceeding_type_id = 17
AND lifecycle_state = 'published';

View File

@@ -0,0 +1,405 @@
-- t-paliad-285 (m/paliad#117) + t-paliad-286 (m/paliad#118) —
-- post-submission court followup for UPC Damages and appeal route
-- for UPC Provisional Measures.
--
-- m's 2026-05-25 report: the upc.dmgs.cfi proceeding stops at the
-- last party submission (rejoin) — no interim conference, no oral
-- hearing, no decision row, no appeal-spawn. The upc.pi.cfi
-- proceeding has its decision row (`pi.order`) but no spawn into
-- the appeal tree. Both gaps prevent the Verfahrensablauf timeline
-- from rendering the court phase plus any downstream appeal sub-
-- tree that atlas's #96 spawn-rendering mechanism is otherwise
-- ready to surface.
--
-- Two sections in one migration (slot 133 — knuth on 132, paliadin
-- coordinated):
--
-- A. UPC Damages tree-end rows (#117)
-- A1 upc.dmgs.cfi.interim UPC RoP R.105 court-set hearing
-- A2 upc.dmgs.cfi.oral UPC RoP R.118 / R.250 court-set hearing
-- A3 upc.dmgs.cfi.decision UPC RoP R.118 / R.144 court-set decision
-- A4 upc.dmgs.cfi.appeal_spawn UPC RoP R.220.1(a) / R.224.1(a) 2mo, spawn → upc.apl.merits (id=11)
--
-- B. UPC Provisional Measures appeal route (#118)
-- B1 upc.pi.cfi.appeal_spawn UPC RoP R.220.1(a) / R.224.1(a) 2mo, spawn → upc.apl.merits (id=11)
--
-- Source citations:
-- * docs/research-deadlines-completeness-2026-05-25.md
-- — §2.1 (upc.dmgs.cfi has only 4 rules: R.131.2 / R.137.2 / R.139)
-- — §D Damages table (R.144 tree-end row missing — listed
-- in Tier 4 as "cosmetic", upgraded to Tier-0 by m's
-- report once the wider follow-up gap was understood)
-- * docs/audit-upc-rop-deadlines-2026-05-08.md §D row R.144,
-- §F R.220.1(a) / R.224.1(a) (verified verbatim in youpc DB
-- under law_type=UPCRoP).
-- * UPC Rules of Procedure (consolidated):
-- R.105 — Interim conference (court fixes after written
-- procedure closes; same structural shape as the inf
-- interim conference, already modelled as `upc.inf.cfi.interim`).
-- R.118 — Decision after oral hearing; general rule for
-- deciding panels.
-- R.250 — Determination of damages decision; damages-
-- specific decision rule (chains R.144 indication →
-- damages award).
-- R.144 — Final decision on damages quantum (tree-end
-- anchor for §A3).
-- R.220.1(a) — Appeal lies from any final decision /
-- decision disposing of the case at first instance.
-- A PI order under R.211 disposes of the urgent question
-- and is therefore appealable on the main 2-month track
-- (not the 15-day order track of R.220.1(c), which covers
-- case-management and procedural orders requiring leave).
-- Curie's §F table confirms the main-track wiring for
-- decisions on merits / disposing orders.
-- R.224.1(a) — Statement of Appeal within 2 months of
-- service of the final decision; the deadline-notes text
-- mirrors mig 095's inf.appeal_spawn / rev.appeal_spawn.
-- R.224.2(a) — Statement of grounds within 4 months
-- (separate deadline in the spawned upc.apl.merits
-- proceeding; already present as upc.apl.merits.grounds).
--
-- Shape decisions (mirroring mig 012 / mig 095 conventions):
-- * Court-set rows (interim / oral / decision) carry
-- primary_party='court', event_type='hearing'|'decision',
-- duration_value=0, is_court_set=true, parent_id=NULL,
-- concept_id reuses the shared concepts already wired for
-- upc.inf.cfi (interim-conference / oral-hearing / decision).
-- * Spawn rows carry primary_party='both', is_spawn=true,
-- spawn_proceeding_type_id=11 (upc.apl.merits), spawn_label
-- identical to the merits spawn already in production. The
-- spawn row's parent_id is the spawning decision/order row
-- (so the audit log carries the trigger link).
-- * No condition_expr — m's F2.3 decision recorded in mig 095
-- §3: "the appeal deadline should always be triggered by a
-- decision … appeal is always a possibility." Visibility
-- filtering on the frontend hides appeals on projects where
-- no appeal is contemplated.
-- * sequence_order numbering follows the inf convention
-- (40=interim, 50=oral, 60=decision, 80=appeal_spawn) so the
-- Verfahrensablauf timeline orders consistently across
-- proceedings. For PI the existing pi.order sits at
-- sequence_order=3; the appeal_spawn lands at 10 (clear of
-- the writ phase, room for future court-phase rows).
--
-- Idempotency: every INSERT is gated by `WHERE NOT EXISTS (… same
-- submission_code, proceeding_type_id, lifecycle_state)`. Re-apply
-- against an already-migrated DB inserts zero rows and the audit
-- log carries no duplicate entries.
--
-- audit_reason set_config required at the top — the mig 079 trigger
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
-- on INSERT/UPDATE/DELETE without it.
SELECT set_config(
'paliad.audit_reason',
'mig 133: t-paliad-285 / m/paliad#117 + t-paliad-286 / m/paliad#118 — UPC Damages tree-end rows (interim conference R.105, oral hearing R.118/R.250, decision R.118/R.144, appeal-spawn R.220.1(a)) and UPC Provisional Measures appeal-spawn R.220.1(a); see docs/research-deadlines-completeness-2026-05-25.md §D and docs/audit-upc-rop-deadlines-2026-05-08.md §D/§F',
true);
-- =============================================================================
-- A. UPC Damages — court-phase tree end (m/paliad#117)
-- =============================================================================
-- A1. upc.dmgs.cfi.interim — Interim conference (UPC RoP R.105).
-- Court-set hearing fixed by the judge-rapporteur once the
-- written procedure closes. Identical shape to
-- upc.inf.cfi.interim; reuses the shared interim-conference
-- concept node.
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state,
concept_id)
SELECT
17,
NULL,
'upc.dmgs.cfi.interim',
'Zwischenverfahren',
'Interim Conference',
NULL,
'court',
'hearing',
0,
'months',
'after',
NULL,
'Termin vom Gericht bestimmt',
'Date set by the court',
40,
false,
NULL,
NULL,
true,
NULL,
false,
NULL,
'optional',
true,
'published',
'e5071152-d408-4455-b644-9e79d86fd538'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.dmgs.cfi.interim'
AND proceeding_type_id = 17
AND lifecycle_state = 'published');
-- A2. upc.dmgs.cfi.oral — Oral hearing (UPC RoP R.118 / R.250).
-- Court-set hearing after the interim conference / close of
-- written procedure. Same shape as upc.inf.cfi.oral; reuses
-- the shared oral-hearing concept node.
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state,
concept_id)
SELECT
17,
NULL,
'upc.dmgs.cfi.oral',
'Mündliche Verhandlung',
'Oral Hearing',
NULL,
'court',
'hearing',
0,
'months',
'after',
NULL,
NULL,
NULL,
50,
false,
NULL,
NULL,
true,
NULL,
false,
NULL,
'optional',
true,
'published',
'd6e5b793-dcf1-4d83-81ff-34f42dbb3693'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.dmgs.cfi.oral'
AND proceeding_type_id = 17
AND lifecycle_state = 'published');
-- A3. upc.dmgs.cfi.decision — Damages decision (UPC RoP R.118 /
-- R.144 / R.250). Court-set decision delivered after oral
-- hearing; closes the §3.1 audit gap (R.144 tree-end). Same
-- shape as upc.inf.cfi.decision; reuses the shared decision
-- concept node.
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state,
concept_id)
SELECT
17,
NULL,
'upc.dmgs.cfi.decision',
'Entscheidung',
'Decision',
NULL,
'court',
'decision',
0,
'months',
'after',
NULL,
NULL,
NULL,
60,
false,
NULL,
NULL,
true,
NULL,
false,
NULL,
'optional',
true,
'published',
'472fc32d-cc4f-4aa4-8ace-e422031812de'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.dmgs.cfi.decision'
AND proceeding_type_id = 17
AND lifecycle_state = 'published');
-- A4. upc.dmgs.cfi.appeal_spawn — Appeal against damages decision
-- (UPC RoP R.220.1(a), 2-month main track; grounds R.224.2(a)
-- run as a separate deadline in the spawned upc.apl.merits
-- proceeding). Parent points at the freshly-inserted
-- upc.dmgs.cfi.decision; the SELECT subquery resolves it
-- after A3 lands. Same shape as the mig 095 inf.appeal_spawn.
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state)
SELECT
17,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.dmgs.cfi.decision'
AND proceeding_type_id = 17
AND lifecycle_state = 'published'
AND is_active = true),
'upc.dmgs.cfi.appeal_spawn',
'Berufung gegen Schadensentscheidung',
'Appeal against damages decision',
'Berufung gegen die Entscheidung über die Schadensbemessung (R.118 / R.144). Statutarische Frist von 2 Monaten ab Zustellung der Entscheidung (R.224.1(a)); die Berufungsbegründung folgt mit 4 Monaten ab Zustellung (R.224.2(a), eigenständige Frist im Berufungsverfahren).',
'both',
'filing',
2,
'months',
'after',
'RoP.220.1.a',
'Innerhalb von 2 Monaten ab Zustellung der Schadensentscheidung Berufungsschrift einreichen (R.224.1(a)). Die Berufungsbegründung (R.224.2(a), 4 Monate) läuft als separate Frist im Berufungsverfahren.',
'Within 2 months of service of the damages decision lodge the Statement of appeal (R.224.1(a)). The Statement of grounds (R.224.2(a), 4 months) runs as an independent deadline in the appeal proceeding.',
80,
true,
11,
'Berufungsverfahren öffnen',
true,
'UPC.RoP.220.1',
false,
NULL,
'optional',
false,
'published'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.dmgs.cfi.appeal_spawn'
AND proceeding_type_id = 17
AND lifecycle_state = 'published');
-- =============================================================================
-- B. UPC Provisional Measures — appeal route (m/paliad#118)
-- =============================================================================
-- B1. upc.pi.cfi.appeal_spawn — Appeal against PI order (UPC RoP
-- R.220.1(a), 2-month main track). PI orders under R.211
-- dispose of the urgent question and are appealable on the
-- main 2-month track (R.220.1(a)/R.224.1(a)); the 15-day
-- order track of R.220.1(c) is for case-management /
-- procedural orders requiring leave and does not apply to
-- PI dispositions. Parent points at the existing
-- upc.pi.cfi.order (sequence_order=3) so the spawn fires
-- once the order is anchored on a project's timeline.
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state)
SELECT
10,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.order'
AND proceeding_type_id = 10
AND lifecycle_state = 'published'
AND is_active = true),
'upc.pi.cfi.appeal_spawn',
'Berufung gegen Anordnung',
'Appeal against PI order',
'Berufung gegen die einstweilige Anordnung nach R.211. Eine PI-Anordnung erledigt die einstweilige Streitfrage und wird wie eine Endentscheidung im Hauptverfahren behandelt: statutarische Frist von 2 Monaten ab Zustellung (R.224.1(a)); die Berufungsbegründung folgt mit 4 Monaten ab Zustellung (R.224.2(a), eigenständige Frist im Berufungsverfahren). Die 15-Tage-Spur nach R.220.1(c) / R.220.2 gilt für Verfahrensanordnungen mit Zulassung und ist hier nicht einschlägig.',
'both',
'filing',
2,
'months',
'after',
'RoP.220.1.a',
'Innerhalb von 2 Monaten ab Zustellung der PI-Anordnung Berufungsschrift einreichen (R.224.1(a)). Die Berufungsbegründung (R.224.2(a), 4 Monate) läuft als separate Frist im Berufungsverfahren.',
'Within 2 months of service of the PI order lodge the Statement of appeal (R.224.1(a)). The Statement of grounds (R.224.2(a), 4 months) runs as an independent deadline in the appeal proceeding.',
10,
true,
11,
'Berufungsverfahren öffnen',
true,
'UPC.RoP.220.1',
false,
NULL,
'optional',
false,
'published'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.appeal_spawn'
AND proceeding_type_id = 10
AND lifecycle_state = 'published');
-- =============================================================================
-- C. Post-insert verification — raise if any expected row is missing
-- (matches the mig 095 / 127 convention; protects against a future
-- re-shape of the table that silently drops one of the WHERE NOT
-- EXISTS predicates).
-- =============================================================================
DO $$
DECLARE
v_missing text;
BEGIN
SELECT string_agg(expected, ', ' ORDER BY expected)
INTO v_missing
FROM (VALUES
('upc.dmgs.cfi.interim'),
('upc.dmgs.cfi.oral'),
('upc.dmgs.cfi.decision'),
('upc.dmgs.cfi.appeal_spawn'),
('upc.pi.cfi.appeal_spawn')
) AS t(expected)
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules dr
WHERE dr.submission_code = t.expected
AND dr.lifecycle_state = 'published'
AND dr.is_active = true);
IF v_missing IS NOT NULL THEN
RAISE EXCEPTION
'mig 133: expected published rules missing after insert: %', v_missing;
END IF;
IF NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules dr
WHERE dr.submission_code = 'upc.dmgs.cfi.appeal_spawn'
AND dr.proceeding_type_id = 17
AND dr.spawn_proceeding_type_id = 11
AND dr.is_spawn = true
AND dr.parent_id IS NOT NULL
AND dr.lifecycle_state = 'published'
) THEN
RAISE EXCEPTION
'mig 133: upc.dmgs.cfi.appeal_spawn shape check failed (expected is_spawn=true, spawn_proceeding_type_id=11, parent_id set)';
END IF;
IF NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules dr
WHERE dr.submission_code = 'upc.pi.cfi.appeal_spawn'
AND dr.proceeding_type_id = 10
AND dr.spawn_proceeding_type_id = 11
AND dr.is_spawn = true
AND dr.parent_id IS NOT NULL
AND dr.lifecycle_state = 'published'
) THEN
RAISE EXCEPTION
'mig 133: upc.pi.cfi.appeal_spawn shape check failed (expected is_spawn=true, spawn_proceeding_type_id=11, parent_id set)';
END IF;
END $$;

View File

@@ -0,0 +1,113 @@
package handlers
// HTTP handlers for paliad.project_event_choices (t-paliad-265 / m/paliad#96).
//
// Three endpoints:
// GET /api/projects/{id}/event-choices → list
// PUT /api/projects/{id}/event-choices → upsert one
// DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}
//
// All three gated by visibility on the project (paliad.can_see_project)
// via EventChoiceService.
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/projects/{id}/event-choices
func handleListProjectEventChoices(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.eventChoice == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "event-choice service not configured"})
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
rows, err := dbSvc.eventChoice.ListForProject(r.Context(), uid, projectID)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// PUT /api/projects/{id}/event-choices — upsert one row.
func handlePutProjectEventChoice(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.eventChoice == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "event-choice service not configured"})
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
var input services.UpsertEventChoiceInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
row, err := dbSvc.eventChoice.Upsert(r.Context(), uid, projectID, input)
if err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
}
// DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}
func handleDeleteProjectEventChoice(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.eventChoice == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "event-choice service not configured"})
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
submissionCode := r.PathValue("submission_code")
choiceKind := r.PathValue("choice_kind")
if err := dbSvc.eventChoice.Delete(r.Context(), uid, projectID, submissionCode, choiceKind); err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -79,6 +79,38 @@ var fileRegistry = map[string]fileEntry{
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
},
// Firm-formatted skeleton (t-paliad-275). Carries the same 48-key
// placeholder bag as the universal _skeleton.docx, but additionally
// preserves every HL paragraph + character style from the HL Patents
// Style .dotm (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
// HLpat-Table-Recitals-*, HLpat-Signature, …) and the firm letterhead
// (header logo + firm-address footer). Slotted ahead of the universal
// skeleton in the fallback chain so any submission_code without a
// dedicated per-code template still renders as a real firm-branded
// Schriftsatz with variables substituted, rather than a plain skeleton.
// Generated via scripts/gen-hl-skeleton-template against the .dotm.
firmSkeletonSubmissionSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
DownloadName: branding.Name + " — Firm Schriftsatz-Skelett.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
},
// English skeleton variant (t-paliad-276). Sibling of
// `_skeleton.docx`; used when a draft's language='en' and no
// per-code EN template exists. If the file isn't authored yet in
// mWorkRepo, the Gitea fetch fails and resolveSubmissionTemplate
// falls through to the DE skeleton — visible to the user as the
// "Fallback: universelles Skelett" notice on the draft editor.
skeletonSubmissionENSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
DownloadName: branding.Name + " — Submission skeleton.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
},
}
// skeletonSubmissionSlug names the universal skeleton template inside
@@ -87,6 +119,19 @@ var fileRegistry = map[string]fileEntry{
// the same string the registry uses.
const skeletonSubmissionSlug = "submission/_skeleton.docx"
// firmSkeletonSubmissionSlug names the firm-formatted skeleton template
// inside the shared fileRegistry cache (t-paliad-275). Same placeholder
// surface as skeletonSubmissionSlug; carries HL paragraph + character
// styles from the source .dotm on top. Sits between the per-code
// template and the generic universal skeleton in the fallback chain so
// codes without a dedicated template still render with firm branding.
const firmSkeletonSubmissionSlug = "submission/_firm-skeleton.docx"
// skeletonSubmissionENSlug names the English skeleton variant used when
// a draft's language='en' and no per-code EN template exists
// (t-paliad-276). Same role as skeletonSubmissionSlug but in EN.
const skeletonSubmissionENSlug = "submission/_skeleton.en.docx"
// submissionTemplateRegistry maps a deadline-rule submission_code to a
// fileRegistry slug. Lookup order matches the cronus design fallback
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
@@ -96,14 +141,32 @@ const skeletonSubmissionSlug = "submission/_skeleton.docx"
// the file itself lives in mWorkRepo and is served through the shared
// Gitea proxy cache so refreshes are visible to all consumers in one
// place.
//
// t-paliad-276: codes that ship an EN sibling
// (e.g. `de.inf.lg.erwidg.en.docx`) also register it in
// submissionTemplateENRegistry; the language-aware lookup
// (resolveSubmissionTemplate(ctx, code, lang)) prefers the language-
// suffixed slug and falls back to the unsuffixed one when no per-firm
// EN variant exists.
var submissionTemplateRegistry = map[string]string{
"de.inf.lg.erwidg": "submission/de.inf.lg.erwidg.docx",
}
// submissionTemplateENRegistry maps a submission_code to the EN
// variant slug. Empty when no EN template has been authored — the
// lookup falls through to the unsuffixed (DE-baked) template and the
// editor surfaces the "Fallback: universelles Skelett" notice when
// even the skeleton has no EN sibling.
var submissionTemplateENRegistry = map[string]string{}
// fetchSubmissionTemplateBytes returns the per-submission_code template
// bytes (and provenance SHA) when one is registered. The bool result
// distinguishes "no per-code template registered" (callers fall back to
// HL Patents Style) from an upstream fetch error.
//
// Language-suffixed variants (t-paliad-276) are served via
// fetchSubmissionTemplateBytesForLang — this base function returns the
// unsuffixed registry entry only (the legacy DE-baked template).
func fetchSubmissionTemplateBytes(ctx context.Context, submissionCode string) ([]byte, string, bool, error) {
slug, ok := submissionTemplateRegistry[submissionCode]
if !ok {
@@ -209,6 +272,113 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
}
// fetchSubmissionTemplateBytesForLang returns the per-(code, lang)
// template bytes when a language-suffixed variant is registered. Used
// only for the EN variant today; DE goes through the unsuffixed
// fetchSubmissionTemplateBytes (which is the legacy / authoritative
// DE registry). t-paliad-276.
//
// Returned bool = "variant registered AND fetched OK". A registered
// variant whose file 404s on Gitea returns (nil, "", false, nil) so
// the caller falls through to the unsuffixed template, mirroring the
// behaviour for unregistered codes.
func fetchSubmissionTemplateBytesForLang(ctx context.Context, submissionCode, lang string) ([]byte, string, bool, error) {
if lang != "en" {
// Only EN has a separate registry today. DE goes through the
// unsuffixed path which is the authoritative DE template.
return nil, "", false, nil
}
slug, ok := submissionTemplateENRegistry[submissionCode]
if !ok {
return nil, "", false, nil
}
entry, ok := fileRegistry[slug]
if !ok {
return nil, "", false, fmt.Errorf("file proxy: submission template slug %q not registered", slug)
}
ce := getCacheEntry(slug)
ce.mu.RLock()
hasData := len(ce.data) > 0
needsCheck := time.Since(ce.lastChecked) >= checkInterval
ce.mu.RUnlock()
if !hasData {
if err := fileFetch(ce, entry); err != nil {
// Treat upstream miss as "variant unavailable" so the
// resolver falls through to the DE template instead of
// surfacing a 502.
log.Printf("file proxy: EN variant fetch failed for %s (%s): %v — falling through", submissionCode, slug, err)
return nil, "", false, nil
}
} else if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, "", false, nil
}
out := make([]byte, len(ce.data))
copy(out, ce.data)
_ = ctx
return out, ce.sha, true, nil
}
// fetchSubmissionSkeletonBytesForLang returns the cached skeleton
// template bytes for the requested language. EN falls back to DE when
// the EN skeleton hasn't been authored yet (t-paliad-276). Returned
// bool flags whether the bytes match the requested language — false
// means the resolver should communicate "fallback" to the UI.
func fetchSubmissionSkeletonBytesForLang(ctx context.Context, lang string) ([]byte, string, bool, error) {
if lang == "en" {
entry, ok := fileRegistry[skeletonSubmissionENSlug]
if ok {
ce := getCacheEntry(skeletonSubmissionENSlug)
ce.mu.RLock()
hasData := len(ce.data) > 0
needsCheck := time.Since(ce.lastChecked) >= checkInterval
ce.mu.RUnlock()
if !hasData {
if err := fileFetch(ce, entry); err == nil {
ce.mu.RLock()
if len(ce.data) > 0 {
out := make([]byte, len(ce.data))
copy(out, ce.data)
sha := ce.sha
ce.mu.RUnlock()
return out, sha, true, nil
}
ce.mu.RUnlock()
} else {
log.Printf("file proxy: EN skeleton fetch failed (%s): %v — falling back to DE", skeletonSubmissionENSlug, err)
}
} else {
if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
if len(ce.data) > 0 {
out := make([]byte, len(ce.data))
copy(out, ce.data)
sha := ce.sha
ce.mu.RUnlock()
return out, sha, true, nil
}
ce.mu.RUnlock()
}
}
}
// Fall through to the DE skeleton; bool=false flags that the
// returned bytes don't carry the requested language.
bytes, sha, err := fetchSubmissionSkeletonBytes(ctx)
if err != nil {
return nil, "", false, err
}
return bytes, sha, lang == "de", nil
}
// fetchSubmissionSkeletonBytes returns the cached universal skeleton
// template bytes plus its provenance SHA. Sits between the per-firm
// per-submission_code template (fetchSubmissionTemplateBytes) and the
@@ -219,11 +389,28 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
// call warms the cache synchronously from mWorkRepo via Gitea; later
// calls return immediately while a background refresh runs.
func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
entry, ok := fileRegistry[skeletonSubmissionSlug]
return fetchSubmissionTemplateSlug(ctx, skeletonSubmissionSlug)
}
// fetchFirmSkeletonBytes returns the cached firm-formatted skeleton
// template bytes (HL paragraph/character styles + 48-key placeholder
// bag) plus its provenance SHA. Sits between the per-code template and
// the generic universal skeleton in resolveSubmissionTemplate's
// fallback chain (t-paliad-275). Same stale-while-revalidate caching
// as the other Gitea-backed template parts.
func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
return fetchSubmissionTemplateSlug(ctx, firmSkeletonSubmissionSlug)
}
// fetchSubmissionTemplateSlug is the shared cache-aware fetcher used by
// the firm-skeleton and universal-skeleton accessors. Factored out so
// the two paths can't drift apart on caching semantics.
func fetchSubmissionTemplateSlug(ctx context.Context, slug string) ([]byte, string, error) {
entry, ok := fileRegistry[slug]
if !ok {
return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
return nil, "", fmt.Errorf("file proxy: %s not registered", slug)
}
ce := getCacheEntry(skeletonSubmissionSlug)
ce := getCacheEntry(slug)
ce.mu.RLock()
hasData := len(ce.data) > 0
@@ -241,7 +428,7 @@ func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", slug)
}
out := make([]byte, len(ce.data))
copy(out, ce.data)

View File

@@ -5,6 +5,9 @@ import (
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
)
@@ -51,6 +54,15 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
Flags []string `json:"flags,omitempty"`
AnchorOverrides map[string]string `json:"anchorOverrides,omitempty"`
CourtID string `json:"courtId,omitempty"`
// t-paliad-265: per-event-card choices. Two parallel inputs:
// - ProjectID lets the server pull persisted choices from
// paliad.project_event_choices (project-bound /tools/fristenrechner).
// - PerCardChoices lets the unbound /tools/verfahrensablauf
// send an inline-CSV-decoded list straight off the URL
// without persisting. When both are present the inline list
// wins (what-if exploration overrides the saved state).
ProjectID string `json:"projectId,omitempty"`
PerCardChoices []services.UpsertEventChoiceInput `json:"perCardChoices,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
@@ -61,11 +73,42 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
return
}
// Fold per-card choices into the CalcOptions addendum. The inline
// PerCardChoices wins over the persisted ProjectID lookup when both
// are non-empty.
var addendum services.CalcOptionsAddendum
if len(req.PerCardChoices) > 0 {
choices := make([]models.ProjectEventChoice, 0, len(req.PerCardChoices))
for _, c := range req.PerCardChoices {
choices = append(choices, models.ProjectEventChoice{
SubmissionCode: c.SubmissionCode,
ChoiceKind: c.ChoiceKind,
ChoiceValue: c.ChoiceValue,
})
}
addendum = services.ToCalcOptionsAddendum(choices)
} else if req.ProjectID != "" && dbSvc.eventChoice != nil {
if pid, err := uuid.Parse(req.ProjectID); err == nil {
if uid, ok := requireUser(w, r); ok {
if choices, err := dbSvc.eventChoice.ListForProject(r.Context(), uid, pid); err == nil {
addendum = services.ToCalcOptionsAddendum(choices)
}
// Visibility-filtered lookup: a non-visible project
// returns ErrNotVisible from ListForProject; in that
// case we project without per-card overlays rather
// than 404 — the timeline itself is non-PII data.
}
}
}
resp, err := dbSvc.fristenrechner.Calculate(r.Context(), req.ProceedingType, req.TriggerDate, services.CalcOptions{
PriorityDateStr: req.PriorityDate,
Flags: req.Flags,
AnchorOverrides: req.AnchorOverrides,
CourtID: req.CourtID,
PriorityDateStr: req.PriorityDate,
Flags: req.Flags,
AnchorOverrides: req.AnchorOverrides,
CourtID: req.CourtID,
PerCardAppellant: addendum.PerCardAppellant,
SkipRules: addendum.SkipRules,
IncludeCCRFor: addendum.IncludeCCRFor,
})
if err != nil {
if errors.Is(err, services.ErrUnknownProceedingType) {

View File

@@ -106,6 +106,10 @@ type Services struct {
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
SubmissionDraft *services.SubmissionDraftService
// t-paliad-265 / m/paliad#96 — per-event-card optional choices on
// the Verfahrensablauf timeline.
EventChoice *services.EventChoiceService
// Paliadin is wired when DATABASE_URL is set. The concrete backend
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
// (remote → mRiver via SSH) or local tmux availability. Stays nil
@@ -169,6 +173,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
export: svc.Export,
backup: svc.Backup,
submissionDraft: svc.SubmissionDraft,
eventChoice: svc.EventChoice,
}
}
@@ -394,6 +399,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/projects/{id}/partner-units", handleAttachPartnerUnit)
protected.HandleFunc("DELETE /api/projects/{id}/partner-units/{unit_id}", handleDetachPartnerUnit)
// t-paliad-265 — per-event-card choices on the Verfahrensablauf timeline.
protected.HandleFunc("GET /api/projects/{id}/event-choices", handleListProjectEventChoices)
protected.HandleFunc("PUT /api/projects/{id}/event-choices", handlePutProjectEventChoice)
protected.HandleFunc("DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}", handleDeleteProjectEventChoice)
// Partner units (structural partner-led units; legacy "Dezernate").
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit)

View File

@@ -68,6 +68,9 @@ type dbServices struct {
// t-paliad-238 — submission draft editor.
submissionDraft *services.SubmissionDraftService
// t-paliad-265 — per-event-card optional choices.
eventChoice *services.EventChoiceService
}
var dbSvc *dbServices

View File

@@ -68,6 +68,17 @@ type submissionDraftView struct {
Lang string `json:"lang"`
HasTemplate bool `json:"has_template"`
TemplateMissing bool `json:"template_missing,omitempty"`
// TemplateTier identifies which tier of resolveSubmissionTemplate
// produced the bytes — one of per_code_lang, per_code, skeleton_lang,
// skeleton, letterhead. Lets the editor distinguish a perfect
// per-firm match from a skeleton fallback. t-paliad-276.
TemplateTier string `json:"template_tier,omitempty"`
// LanguageFallback is true when the requested draft.language has no
// per-firm per-code template (e.g. EN draft falls back to the DE
// per-code template, or to the universal skeleton). UI surfaces a
// notice so the lawyer knows the rendered body lacks language-
// matched code-specific prose. t-paliad-276.
LanguageFallback bool `json:"language_fallback,omitempty"`
// AvailableParties is the project's full party roster (t-paliad-277)
// so the frontend can render the multi-select picker in one round-
// trip. Empty when the draft has no project attached.
@@ -89,6 +100,7 @@ type submissionDraftJSON struct {
SubmissionCode string `json:"submission_code"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
Language string `json:"language"`
Variables services.PlaceholderMap `json:"variables"`
SelectedParties []uuid.UUID `json:"selected_parties"`
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
@@ -119,6 +131,7 @@ type submissionDraftPatchInput struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
Language *string `json:"language,omitempty"`
}
// ─────────────────────────────────────────────────────────────────────
@@ -353,7 +366,12 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
patch := services.DraftPatch{Name: input.Name, Variables: input.Variables, SelectedParties: input.SelectedParties}
patch := services.DraftPatch{
Name: input.Name,
Variables: input.Variables,
SelectedParties: input.SelectedParties,
Language: input.Language,
}
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
if err != nil {
writeSubmissionDraftServiceError(w, err)
@@ -434,7 +452,7 @@ func handlePreviewSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
@@ -483,7 +501,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, tplSHA, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
@@ -686,6 +704,7 @@ func handleGetGlobalSubmissionDraft(w http.ResponseWriter, r *http.Request) {
type globalDraftPatchInput struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Language *string `json:"language,omitempty"`
// projectIDProvided is true when the JSON included the "project_id"
// key (regardless of value); needed to distinguish "no change" from
// "set to null". Set by the custom UnmarshalJSON below.
@@ -700,6 +719,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
type alias struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Language *string `json:"language,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
}
@@ -709,6 +729,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
}
g.Name = a.Name
g.Variables = a.Variables
g.Language = a.Language
g.ProjectID = a.ProjectID
g.SelectedParties = a.SelectedParties
// Detect whether "project_id" was present in the JSON object.
@@ -747,7 +768,12 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
patch := services.DraftPatch{Name: in.Name, Variables: in.Variables, SelectedParties: in.SelectedParties}
patch := services.DraftPatch{
Name: in.Name,
Variables: in.Variables,
SelectedParties: in.SelectedParties,
Language: in.Language,
}
if in.projectIDProvided {
pid := in.ProjectID // may be nil → detach
patch.ProjectID = &pid
@@ -864,7 +890,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, tplSHA, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
@@ -963,7 +989,7 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
view.Rule.LegalSourcePretty = merged["rule.legal_source_pretty"]
}
tplBytes, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
tplBytes, _, tier, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: template fetch for view (draft=%s): %v", d.ID, err)
view.TemplateMissing = true
@@ -971,6 +997,12 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
view.PreviewHTML = `<p class="preview-error">Vorlage konnte nicht geladen werden.</p>`
return view, nil
}
view.TemplateTier = string(tier)
// LanguageFallback signals "no per-firm template in the requested
// language" — the editor surfaces a notice so the lawyer knows the
// rendered body lacks code-specific prose. The per-code DE template
// counts as a fallback when the requested language is EN.
view.LanguageFallback = languageFallback(d.Language, tier)
html, err := dbSvc.submissionDraft.RenderPreview(ctx, d, tplBytes)
if err != nil {
return nil, err
@@ -979,41 +1011,101 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
return view, nil
}
// submissionTemplateTier enumerates which tier of the template
// fallback chain produced the bytes returned by resolveSubmissionTemplate.
// Used by the editor to surface "Fallback: universelles Skelett" when
// the requested (code, lang) didn't have a dedicated template.
type submissionTemplateTier string
const (
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
)
// resolveSubmissionTemplate returns the .docx bytes for the given
// submission code. Lookup order matches the cronus design fallback chain
// §8 plus the t-paliad-259 universal-skeleton slot:
// (submission_code, language). Merges t-paliad-275 (firm-skeleton tier)
// and t-paliad-276 (language-selector + EN skeleton tier). Lookup order:
//
// 1. per-firm per-submission_code template registered in
// submissionTemplateRegistry (e.g. de.inf.lg.erwidg.docx) — code-
// specific structure plus the full variable bag.
// 2. universal _skeleton.docx — same variable bag, no submission_code-
// specific prose. Catches every code without a dedicated template
// so the editor preview / generate flow still has variables to
// substitute instead of falling through to the bare letterhead.
// 3. universal HL Patents Style .dotm — macro-only letterhead, no
// placeholders. Final fallback when even the skeleton is unreachable
// (mWorkRepo outage etc.). Preserves the pre-t-paliad-259 behaviour
// for resilience.
// 1. per-firm per-(code, lang) template — most specific. e.g.
// `de.inf.lg.erwidg.en.docx` for EN drafts. t-paliad-276.
// 2. per-firm per-code (unsuffixed) template — DE-baked baseline. The
// legacy registry shape from before the language selector landed.
// 3. universal language-matched skeleton — `_skeleton.en.docx` for EN
// drafts. Skipped for DE drafts (steps 4+5 already cover DE).
// 4. firm-formatted skeleton — `_firm-skeleton.docx` (t-paliad-275).
// HL paragraph + character styles + letterhead, full placeholder
// bag. DE-flavored: counts as language_fallback=true for EN drafts.
// 5. universal _skeleton.docx — plain DE skeleton, no firm styles.
// Backstop when the firm skeleton is unreachable.
// 6. universal HL Patents Style .dotm — macro-only letterhead, no
// placeholders. Last-ditch when every skeleton tier is unreachable.
//
// The returned SHA is the cache entry's commit SHA so the export audit
// row can record provenance.
func resolveSubmissionTemplate(ctx context.Context, submissionCode string) ([]byte, string, error) {
if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
return nil, "", err
// The returned SHA pins the audit row's template provenance. The tier
// tells the editor whether the result language-matches the request so
// it can surface a "Fallback: universelles Skelett" notice.
func resolveSubmissionTemplate(ctx context.Context, submissionCode, lang string) ([]byte, string, submissionTemplateTier, error) {
if lang != "de" && lang != "en" {
lang = "de"
}
// 1. per-(code, lang)
if data, sha, found, err := fetchSubmissionTemplateBytesForLang(ctx, submissionCode, lang); err != nil {
return nil, "", "", err
} else if found {
return data, sha, nil
return data, sha, tplTierPerCodeLang, nil
}
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
return data, sha, nil
// 2. per-code (unsuffixed)
if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
return nil, "", "", err
} else if found {
return data, sha, tplTierPerCode, nil
}
// 3. language-matched skeleton — only meaningful for EN drafts; DE
// drafts fall through to the firm/universal DE skeletons below.
if lang == "en" {
if data, sha, langMatched, err := fetchSubmissionSkeletonBytesForLang(ctx, lang); err == nil && langMatched {
return data, sha, tplTierSkeletonLang, nil
}
}
// 4. firm-formatted skeleton (HL styles, DE prose). For DE drafts
// this is a first-class match; for EN drafts it counts as a
// language fallback (handled by languageFallback()).
if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil {
return data, sha, tplTierSkeleton, nil
} else {
log.Printf("submission_drafts: skeleton fetch failed for code=%s, falling back to HL Patents Style: %v", submissionCode, err)
log.Printf("submission_drafts: firm-skeleton fetch failed for code=%s lang=%s, falling back to universal skeleton: %v", submissionCode, lang, err)
}
// 5. universal plain DE skeleton.
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
return data, sha, tplTierSkeleton, nil
} else {
log.Printf("submission_drafts: skeleton fetch failed for code=%s lang=%s, falling back to HL Patents Style: %v", submissionCode, lang, err)
}
// 6. HL Patents Style letterhead (no placeholders, last-ditch).
bytes, err := fetchHLPatentsStyleBytes(ctx)
if err != nil {
return nil, "", err
return nil, "", "", err
}
sha := hlPatentsStyleSHA()
return bytes, sha, nil
return bytes, sha, tplTierLetterhead, nil
}
// languageFallback reports whether the resolved template tier failed
// to match the requested draft language. For an EN draft, anything
// other than per_code_lang or skeleton_lang is a fallback (per_code is
// the legacy DE-baked template, skeleton is the DE skeleton). For a DE
// draft, only `letterhead` counts as a fallback — the DE skeleton and
// per-code template are both first-class DE outputs. t-paliad-276.
func languageFallback(lang string, tier submissionTemplateTier) bool {
if tier == tplTierLetterhead {
return true
}
if strings.EqualFold(lang, "en") {
return tier != tplTierPerCodeLang && tier != tplTierSkeletonLang
}
return false
}
// hlPatentsStyleSHA reads the current cache SHA for the universal
@@ -1039,12 +1131,17 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
if selected == nil {
selected = []uuid.UUID{}
}
lang := d.Language
if lang == "" {
lang = "de"
}
return submissionDraftJSON{
ID: d.ID,
ProjectID: d.ProjectID,
SubmissionCode: d.SubmissionCode,
UserID: d.UserID,
Name: d.Name,
Language: lang,
Variables: vars,
SelectedParties: selected,
LastExportedAt: d.LastExportedAt,

View File

@@ -0,0 +1,43 @@
package handlers
// Regression tests for the template-tier → language-fallback mapping
// (t-paliad-276). The editor surfaces a "Fallback: universelles
// Skelett" notice when the requested draft language has no per-firm
// language-matched template — these tests pin which tier counts as a
// fallback for each language so the UI signal stays stable.
import "testing"
func TestLanguageFallback(t *testing.T) {
t.Parallel()
cases := []struct {
name string
lang string
tier submissionTemplateTier
want bool
}{
// DE drafts: every non-letterhead tier is a first-class match.
{"de_per_code_lang", "de", tplTierPerCodeLang, false},
{"de_per_code", "de", tplTierPerCode, false},
{"de_skeleton_lang", "de", tplTierSkeletonLang, false},
{"de_skeleton", "de", tplTierSkeleton, false},
{"de_letterhead", "de", tplTierLetterhead, true},
// EN drafts: per_code (DE-baked) and skeleton (DE-baked) both
// surface the fallback notice so the lawyer knows the rendered
// body lacks EN prose.
{"en_per_code_lang", "en", tplTierPerCodeLang, false},
{"en_per_code", "en", tplTierPerCode, true},
{"en_skeleton_lang", "en", tplTierSkeletonLang, false},
{"en_skeleton", "en", tplTierSkeleton, true},
{"en_letterhead", "en", tplTierLetterhead, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := languageFallback(c.lang, c.tier); got != c.want {
t.Errorf("languageFallback(%q, %q) = %v, want %v", c.lang, c.tier, got, c.want)
}
})
}
}

View File

@@ -304,14 +304,23 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
defer cancel()
tplBytes, _, err := resolveSubmissionTemplate(ctx, submissionCode)
// One-shot /generate has no draft row to pull `language` from —
// accept `?language=de|en` as an explicit override (t-paliad-276)
// and otherwise fall back to the user's UI language.
user, _ := dbSvc.users.GetByID(ctx, uid)
lang := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("language")))
if lang != "de" && lang != "en" {
lang = userLang(user)
}
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, submissionCode, lang)
if err != nil {
log.Printf("submissions: template fetch (project=%s code=%s): %v", projectID, submissionCode, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
return
}
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, tplBytes)
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, lang, tplBytes)
if err != nil {
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{

View File

@@ -682,6 +682,13 @@ type DeadlineRule struct {
// NULL while draft, set on publish, retained through archive.
// Distinct from UpdatedAt (moves on every edit).
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
// ChoicesOffered declares which per-event-card choice-kinds this
// rule offers on the Verfahrensablauf timeline (mig 129,
// t-paliad-265). NULL = no caret affordance (default). See the
// COMMENT on paliad.deadline_rules.choices_offered for the value
// shape. The engine and the frontend both read this column.
ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`
}
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
@@ -946,3 +953,24 @@ type ApprovalRequest struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// ProjectEventChoice is one per-event-card pick scoped to a project
// (t-paliad-265 / m/paliad#96). The join key SubmissionCode matches
// paliad.deadline_rules.submission_code — the same identifier the
// AnchorOverrides plumbing in fristenrechner.go already uses.
//
// ChoiceKind ∈ {appellant, include_ccr, skip}. ChoiceValue namespace
// per kind: appellant=claimant|defendant|both|none; include_ccr=true|false;
// skip=true|false. UNIQUE(project_id, submission_code, choice_kind)
// makes re-picks idempotent (Upsert path).
type ProjectEventChoice struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
SubmissionCode string `db:"submission_code" json:"submission_code"`
ChoiceKind string `db:"choice_kind" json:"choice_kind"`
ChoiceValue string `db:"choice_value" json:"choice_value"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedBy *uuid.UUID `db:"updated_by" json:"updated_by,omitempty"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -34,7 +34,8 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_active,
created_at, updated_at,
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
priority, is_court_set, lifecycle_state, draft_of, published_at`
priority, is_court_set, lifecycle_state, draft_of, published_at,
choices_offered`
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
category, default_color, sort_order, is_active`

View File

@@ -0,0 +1,272 @@
package services
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// EventChoiceService reads and writes paliad.project_event_choices —
// per-event-card user picks scoped to a project (t-paliad-265 /
// m/paliad#96). Three choice kinds today:
//
// appellant — claimant | defendant | both | none
// include_ccr — true | false
// skip — true | false
//
// Visibility follows paliad.can_see_project (via ProjectService.CanSee).
// Audits via paliad.system_audit_log with event_type=project_event_choice.set
// (insert/update) or .deleted (delete).
//
// The CRUD surface is intentionally tight: List for a project (one read),
// Upsert one (idempotent re-pick), Delete one (kind-scoped). The
// projection engine receives the choices via ToCalcOptionsAddendum,
// which folds them into CalcOptions before Calculate runs.
type EventChoiceService struct {
db *sqlx.DB
projects *ProjectService
users *UserService
}
func NewEventChoiceService(db *sqlx.DB, projects *ProjectService, users *UserService) *EventChoiceService {
return &EventChoiceService{db: db, projects: projects, users: users}
}
// Allowed choice kinds + per-kind value namespaces. Validated server-side
// before any write; the DB CHECK constraint catches the same shape but
// the early validation gives a friendlier error and short-circuits the
// transaction.
var (
allowedChoiceKinds = map[string]map[string]struct{}{
"appellant": {"claimant": {}, "defendant": {}, "both": {}, "none": {}},
"include_ccr": {"true": {}, "false": {}},
"skip": {"true": {}, "false": {}},
}
)
func validateChoice(kind, value string) error {
values, ok := allowedChoiceKinds[kind]
if !ok {
return fmt.Errorf("%w: unknown choice_kind %q", ErrInvalidInput, kind)
}
if _, ok := values[value]; !ok {
return fmt.Errorf("%w: invalid choice_value %q for kind %q", ErrInvalidInput, value, kind)
}
return nil
}
// ListForProject returns every choice row for the given project. Caller
// must hold visibility on the project.
func (s *EventChoiceService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.ProjectEventChoice, error) {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return nil, err
}
out := []models.ProjectEventChoice{}
err := s.db.SelectContext(ctx, &out,
`SELECT id, project_id, submission_code, choice_kind, choice_value,
created_by, created_at, updated_by, updated_at
FROM paliad.project_event_choices
WHERE project_id = $1
ORDER BY submission_code, choice_kind`, projectID)
if err != nil {
return nil, fmt.Errorf("list event choices: %w", err)
}
return out, nil
}
// UpsertInput is the body shape for an upsert.
type UpsertEventChoiceInput struct {
SubmissionCode string `json:"submission_code"`
ChoiceKind string `json:"choice_kind"`
ChoiceValue string `json:"choice_value"`
}
// Upsert inserts or updates one (project, submission_code, choice_kind)
// row. Audit-log row written in the same tx.
func (s *EventChoiceService) Upsert(ctx context.Context, userID, projectID uuid.UUID, input UpsertEventChoiceInput) (*models.ProjectEventChoice, error) {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return nil, err
}
if input.SubmissionCode == "" {
return nil, fmt.Errorf("%w: submission_code required", ErrInvalidInput)
}
if err := validateChoice(input.ChoiceKind, input.ChoiceValue); err != nil {
return nil, err
}
actorEmail, err := s.actorEmail(ctx, userID)
if err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason',
'project_event_choice.set ('||$1||','||$2||','||$3||')', true)`,
input.SubmissionCode, input.ChoiceKind, input.ChoiceValue); err != nil {
return nil, fmt.Errorf("set audit reason: %w", err)
}
var row models.ProjectEventChoice
err = tx.GetContext(ctx, &row,
`INSERT INTO paliad.project_event_choices
(project_id, submission_code, choice_kind, choice_value, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $5)
ON CONFLICT (project_id, submission_code, choice_kind)
DO UPDATE SET choice_value = EXCLUDED.choice_value,
updated_by = EXCLUDED.updated_by,
updated_at = now()
RETURNING id, project_id, submission_code, choice_kind, choice_value,
created_by, created_at, updated_by, updated_at`,
projectID, input.SubmissionCode, input.ChoiceKind, input.ChoiceValue, userID)
if err != nil {
return nil, fmt.Errorf("upsert event choice: %w", err)
}
if err := writeChoiceAudit(ctx, tx, "project_event_choice.set", userID, actorEmail, projectID, input.SubmissionCode, input.ChoiceKind, input.ChoiceValue); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit upsert: %w", err)
}
return &row, nil
}
// Delete removes the (project, submission_code, choice_kind) row.
// Returns ErrNotVisible if the project isn't visible OR the row didn't
// exist (no leak between the two).
func (s *EventChoiceService) Delete(ctx context.Context, userID, projectID uuid.UUID, submissionCode, choiceKind string) error {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return err
}
if submissionCode == "" || choiceKind == "" {
return fmt.Errorf("%w: submission_code + choice_kind required", ErrInvalidInput)
}
if _, ok := allowedChoiceKinds[choiceKind]; !ok {
return fmt.Errorf("%w: unknown choice_kind %q", ErrInvalidInput, choiceKind)
}
actorEmail, err := s.actorEmail(ctx, userID)
if err != nil {
return err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason',
'project_event_choice.deleted ('||$1||','||$2||')', true)`,
submissionCode, choiceKind); err != nil {
return fmt.Errorf("set audit reason: %w", err)
}
res, err := tx.ExecContext(ctx,
`DELETE FROM paliad.project_event_choices
WHERE project_id = $1 AND submission_code = $2 AND choice_kind = $3`,
projectID, submissionCode, choiceKind)
if err != nil {
return fmt.Errorf("delete event choice: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotVisible
}
if err := writeChoiceAudit(ctx, tx, "project_event_choice.deleted", userID, actorEmail, projectID, submissionCode, choiceKind, ""); err != nil {
return err
}
return tx.Commit()
}
// CalcOptionsAddendum is the per-card slice of CalcOptions, built from
// the persisted choices. ProjectionService folds these into the parent
// CalcOptions before Calculate runs.
type CalcOptionsAddendum struct {
PerCardAppellant map[string]string // submission_code → appellant value
SkipRules map[string]struct{} // set of submission_code
IncludeCCRFor map[string]struct{} // set of submission_code
}
// ToCalcOptionsAddendum converts a list of choices into the calc-options
// shape. Empty input yields an addendum whose maps are non-nil but empty
// so callers can use map indexing without nil checks.
func ToCalcOptionsAddendum(choices []models.ProjectEventChoice) CalcOptionsAddendum {
out := CalcOptionsAddendum{
PerCardAppellant: map[string]string{},
SkipRules: map[string]struct{}{},
IncludeCCRFor: map[string]struct{}{},
}
for _, c := range choices {
switch c.ChoiceKind {
case "appellant":
out.PerCardAppellant[c.SubmissionCode] = c.ChoiceValue
case "skip":
if c.ChoiceValue == "true" {
out.SkipRules[c.SubmissionCode] = struct{}{}
}
case "include_ccr":
if c.ChoiceValue == "true" {
out.IncludeCCRFor[c.SubmissionCode] = struct{}{}
}
}
}
return out
}
// writeChoiceAudit inserts a project-scoped row into paliad.system_audit_log
// with the choice details in metadata. Same shape as the data-export +
// checklist audit writers.
func writeChoiceAudit(ctx context.Context, tx *sqlx.Tx, eventType string, actorID uuid.UUID, actorEmail string, projectID uuid.UUID, submissionCode, choiceKind, choiceValue string) error {
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.system_audit_log
(event_type, actor_id, actor_email, scope, scope_root, metadata)
VALUES ($1, $2, $3, 'project', $4,
jsonb_build_object(
'submission_code', $5::text,
'choice_kind', $6::text,
'choice_value', $7::text
))`,
eventType, actorID, actorEmail, projectID, submissionCode, choiceKind, choiceValue); err != nil {
return fmt.Errorf("audit insert: %w", err)
}
return nil
}
func (s *EventChoiceService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
var email string
err := s.db.GetContext(ctx, &email,
`SELECT email FROM paliad.users WHERE id = $1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotVisible
}
if err != nil {
return "", fmt.Errorf("lookup actor: %w", err)
}
return email, nil
}
func (s *EventChoiceService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error {
visible, err := s.projects.CanSee(ctx, userID, projectID)
if err != nil {
return err
}
if !visible {
return ErrNotVisible
}
return nil
}

View File

@@ -0,0 +1,108 @@
package services
import (
"testing"
"mgit.msbls.de/m/paliad/internal/models"
)
// Unit tests for the pure helpers in event_choice_service.go. The CRUD
// path needs a live DB and lives in the integration suite.
func TestValidateChoice_Appellant(t *testing.T) {
for _, value := range []string{"claimant", "defendant", "both", "none"} {
if err := validateChoice("appellant", value); err != nil {
t.Errorf("appellant=%q should pass, got %v", value, err)
}
}
for _, bad := range []string{"", "applicant", "true", "claimaant"} {
if err := validateChoice("appellant", bad); err == nil {
t.Errorf("appellant=%q should fail validation", bad)
}
}
}
func TestValidateChoice_IncludeCCR(t *testing.T) {
for _, value := range []string{"true", "false"} {
if err := validateChoice("include_ccr", value); err != nil {
t.Errorf("include_ccr=%q should pass, got %v", value, err)
}
}
for _, bad := range []string{"", "yes", "1", "True"} {
if err := validateChoice("include_ccr", bad); err == nil {
t.Errorf("include_ccr=%q should fail validation", bad)
}
}
}
func TestValidateChoice_Skip(t *testing.T) {
for _, value := range []string{"true", "false"} {
if err := validateChoice("skip", value); err != nil {
t.Errorf("skip=%q should pass, got %v", value, err)
}
}
if err := validateChoice("skip", "maybe"); err == nil {
t.Errorf("skip=maybe should fail")
}
}
func TestValidateChoice_UnknownKind(t *testing.T) {
if err := validateChoice("not_a_kind", "true"); err == nil {
t.Errorf("unknown choice_kind should fail")
}
}
func TestToCalcOptionsAddendum_PerCardAppellant(t *testing.T) {
choices := []models.ProjectEventChoice{
{SubmissionCode: "upc.inf.cfi.decision", ChoiceKind: "appellant", ChoiceValue: "defendant"},
{SubmissionCode: "de.inf.lg.urteil", ChoiceKind: "appellant", ChoiceValue: "both"},
}
out := ToCalcOptionsAddendum(choices)
if out.PerCardAppellant["upc.inf.cfi.decision"] != "defendant" {
t.Errorf("appellant pick for upc.inf.cfi.decision = %q, want defendant", out.PerCardAppellant["upc.inf.cfi.decision"])
}
if out.PerCardAppellant["de.inf.lg.urteil"] != "both" {
t.Errorf("appellant pick for de.inf.lg.urteil = %q, want both", out.PerCardAppellant["de.inf.lg.urteil"])
}
if len(out.SkipRules) != 0 || len(out.IncludeCCRFor) != 0 {
t.Errorf("appellant-only input should not populate skip/include_ccr maps")
}
}
func TestToCalcOptionsAddendum_SkipRules(t *testing.T) {
choices := []models.ProjectEventChoice{
{SubmissionCode: "upc.inf.cfi.ccr", ChoiceKind: "skip", ChoiceValue: "true"},
{SubmissionCode: "upc.inf.cfi.prelim", ChoiceKind: "skip", ChoiceValue: "false"},
}
out := ToCalcOptionsAddendum(choices)
if _, ok := out.SkipRules["upc.inf.cfi.ccr"]; !ok {
t.Errorf("skip=true should populate SkipRules")
}
if _, ok := out.SkipRules["upc.inf.cfi.prelim"]; ok {
t.Errorf("skip=false should NOT populate SkipRules")
}
}
func TestToCalcOptionsAddendum_IncludeCCRFor(t *testing.T) {
choices := []models.ProjectEventChoice{
{SubmissionCode: "upc.inf.cfi.sod", ChoiceKind: "include_ccr", ChoiceValue: "true"},
{SubmissionCode: "de.inf.lg.erwidg", ChoiceKind: "include_ccr", ChoiceValue: "false"},
}
out := ToCalcOptionsAddendum(choices)
if _, ok := out.IncludeCCRFor["upc.inf.cfi.sod"]; !ok {
t.Errorf("include_ccr=true should populate IncludeCCRFor")
}
if _, ok := out.IncludeCCRFor["de.inf.lg.erwidg"]; ok {
t.Errorf("include_ccr=false should NOT populate IncludeCCRFor")
}
}
func TestToCalcOptionsAddendum_EmptyInput(t *testing.T) {
out := ToCalcOptionsAddendum(nil)
if out.PerCardAppellant == nil || out.SkipRules == nil || out.IncludeCCRFor == nil {
t.Errorf("empty input should still produce non-nil maps for safe indexing")
}
if len(out.PerCardAppellant) != 0 || len(out.SkipRules) != 0 || len(out.IncludeCCRFor) != 0 {
t.Errorf("empty input should produce empty maps")
}
}

View File

@@ -90,6 +90,18 @@ type UIDeadline struct {
// court itself.
IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"`
IsOverridden bool `json:"isOverridden,omitempty"`
// ChoicesOffered surfaces paliad.deadline_rules.choices_offered for
// the rule so the frontend knows whether to render the per-event-card
// caret affordance, and which choice-kinds to populate the popover
// with. NULL / empty for rules with no choices. (t-paliad-265)
ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"`
// AppellantContext is the per-decision appellant pick that applies
// to descendants of the closest ancestor decision card with a
// PerCardAppellant set. Empty when no per-card override is in
// effect (page-level ?appellant= still applies in that case).
// Frontend bucketer prefers this over the page-level appellant when
// non-empty. (t-paliad-265)
AppellantContext string `json:"appellantContext,omitempty"`
}
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
@@ -179,6 +191,29 @@ type CalcOptions struct {
// Empty / nil = no override (default). Overrides apply equally to
// the proceeding-tree and trigger-event branches.
RuleOverrides []models.DeadlineRule
// Per-event-card choice overlays (t-paliad-265 / m/paliad#96).
// Keyed by paliad.deadline_rules.submission_code — same key
// AnchorOverrides uses.
//
// - PerCardAppellant: maps a decision-card's submission_code to the
// user-picked appellant ("claimant"|"defendant"|"both"|"none").
// The engine walks the parent chain of each rule and stamps the
// resulting UIDeadline.AppellantContext from the closest ancestor
// decision with a pick. The frontend bucketer then prefers the
// per-rule context over the page-level appellant.
// - SkipRules: set of submission_code values whose rules (and any
// descendants) the user has opted out of for this projection.
// Same suppression path as a failed condition_expr gate.
// - IncludeCCRFor: set of submission_code values for rules where
// the user opted in to the include-CCR choice (Klageerwiderung
// cards). v1 simplification (design §4.2 #2): if non-empty,
// "with_ccr" is appended to the flag set before gate
// evaluation. Correct for single-CCR-entry-point proceedings
// (UPC INF + DE LG today). Multi-CCR scope is a future expansion.
PerCardAppellant map[string]string
SkipRules map[string]struct{}
IncludeCCRFor map[string]struct{}
}
// Calculate renders the full UI timeline for a proceeding type + trigger date.
@@ -233,6 +268,14 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
for _, f := range opts.Flags {
flagSet[f] = struct{}{}
}
// v1 simplification (design §4.2 #2, t-paliad-265): when any
// IncludeCCRFor entry exists, we treat with_ccr as set in the flag
// context. Correct for single-CCR-entry-point proceedings (UPC INF +
// DE LG today). Multi-CCR scope is a future expansion that would
// thread the include set through the gate evaluator per-rule.
if len(opts.IncludeCCRFor) > 0 {
flagSet["with_ccr"] = struct{}{}
}
// Parse anchor overrides up-front so a malformed date errors out
// before we start walking rules.
@@ -329,6 +372,21 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
courtSet := make(map[uuid.UUID]bool, len(rules))
deadlines := make([]UIDeadline, 0, len(rules))
// Per-event-card overlays (t-paliad-265). Empty/nil maps are safe
// for membership tests; the engine reads them but doesn't mutate.
skipRules := opts.SkipRules
perCardAppellant := opts.PerCardAppellant
// skippedIDs accumulates the set of rule UUIDs whose timeline entry
// the user has opted out of. Walking in sequence_order means a
// child rule's parent has already been classified — so descendant
// suppression is a one-pass parent_id lookup.
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
// appellantContext maps a rule UUID to the appellant value that
// applies to its descendants. A rule that has its own PerCardAppellant
// pick stamps itself with that value; a rule whose parent has a
// context inherits it.
appellantContext := make(map[uuid.UUID]string, len(rules))
for _, r := range rules {
// Phase-3 unified gate: evaluate condition_expr (jsonb).
// Suppression semantic preserved: when the gate fires false AND
@@ -341,12 +399,49 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
continue
}
// SkipRules suppression (t-paliad-265): the user has marked
// this rule (or one of its ancestors) as "don't consider for
// this case". Drop the row entirely AND record the rule ID so
// descendants suppress too.
if r.SubmissionCode != nil {
if _, skipped := skipRules[*r.SubmissionCode]; skipped {
skippedIDs[r.ID] = struct{}{}
continue
}
}
if r.ParentID != nil {
if _, parentSkipped := skippedIDs[*r.ParentID]; parentSkipped {
skippedIDs[r.ID] = struct{}{}
continue
}
}
// AppellantContext propagation. A rule with its own PerCardAppellant
// pick stamps its UUID with that value. Otherwise inherit from
// parent if the parent had a context.
var ctxVal string
if r.SubmissionCode != nil {
if v, ok := perCardAppellant[*r.SubmissionCode]; ok {
ctxVal = v
}
}
if ctxVal == "" && r.ParentID != nil {
if v, ok := appellantContext[*r.ParentID]; ok {
ctxVal = v
}
}
if ctxVal != "" {
appellantContext[r.ID] = ctxVal
}
d := UIDeadline{
RuleID: r.ID.String(),
Name: r.Name,
NameEN: r.NameEN,
Priority: r.Priority,
ConditionExpr: json.RawMessage(r.ConditionExpr),
RuleID: r.ID.String(),
Name: r.Name,
NameEN: r.NameEN,
Priority: r.Priority,
ConditionExpr: json.RawMessage(r.ConditionExpr),
AppellantContext: ctxVal,
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
}
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode

View File

@@ -0,0 +1,79 @@
package services
// Regression tests for the per-draft language column (t-paliad-276).
// The draft's `language` value drives both the placeholder-bag
// language pick (`procedural_event.name` → name_de vs name_en) and the
// template-variant lookup (`{code}.{lang}.docx` fallback chain). These
// tests pin the pure-function pieces — Build wiring needs DB fixtures
// and lives in the handler-layer smoke path.
import (
"strings"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
func TestNormalizeDraftLanguage(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want string
}{
{"de", "de"},
{"DE", "de"},
{" de ", "de"},
{"en", "en"},
{"EN", "en"},
{" en ", "en"},
{"fr", "de"}, // unknown collapses to de (the CHECK-allowed default)
{"", "de"},
{"english", "de"}, // strict — only the canonical two-letter code is accepted
}
for _, c := range cases {
if got := normalizeDraftLanguage(c.in); got != c.want {
t.Errorf("normalizeDraftLanguage(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// The placeholder bag picks the language-matched value for the
// canonical (procedural_event.name) and legacy (rule.name) keys based
// on the lang argument. This pins the wiring used by Build when a
// draft's language overrides the user's UI lang (t-paliad-276).
func TestAddRuleVars_LanguageSelectsMatchedName(t *testing.T) {
t.Parallel()
code := "de.inf.lg.erwidg"
rule := &models.DeadlineRule{
ID: uuid.New(),
SubmissionCode: &code,
Name: "Klageerwiderung",
NameEN: "Statement of Defence",
}
for _, lang := range []string{"de", "en"} {
bag := PlaceholderMap{}
addRuleVars(bag, rule, lang)
want := rule.Name
if strings.EqualFold(lang, "en") {
want = rule.NameEN
}
if got := bag["procedural_event.name"]; got != want {
t.Errorf("lang=%s: procedural_event.name = %q, want %q", lang, got, want)
}
if got := bag["rule.name"]; got != want {
t.Errorf("lang=%s: rule.name = %q, want %q (legacy alias must mirror canonical)", lang, got, want)
}
// The explicit *_de / *_en keys never change — both are always
// emitted so a template can pin one regardless of the draft's
// language. Regression guard against accidentally
// language-gating the explicit variants.
if bag["procedural_event.name_de"] != rule.Name {
t.Errorf("lang=%s: procedural_event.name_de = %q, want %q", lang, bag["procedural_event.name_de"], rule.Name)
}
if bag["procedural_event.name_en"] != rule.NameEN {
t.Errorf("lang=%s: procedural_event.name_en = %q, want %q", lang, bag["procedural_event.name_en"], rule.NameEN)
}
}
}

View File

@@ -43,11 +43,16 @@ import (
// parties / deadline state to resolve). All callers must check for nil
// before treating it as a uuid.
type SubmissionDraft struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
SubmissionCode string `db:"submission_code" json:"submission_code"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
ID uuid.UUID `db:"id" json:"id"`
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
SubmissionCode string `db:"submission_code" json:"submission_code"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
// Language is the output language for the generated .docx — 'de' or
// 'en'. Drives the template-variant lookup ({code}.{lang}.docx
// fallback chain) and language-aware variable resolution
// ({{procedural_event.name}} → name_de or name_en). t-paliad-276.
Language string `db:"language" json:"language"`
VariablesRaw []byte `db:"variables" json:"-"`
SelectedPartiesRaw pq.StringArray `db:"selected_parties" json:"-"`
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
@@ -108,6 +113,10 @@ type DraftPatch struct {
// the column; pass *p = nil or an empty slice to reset to "include
// every party on the project" (the backward-compat default).
SelectedParties *[]uuid.UUID
// Language sets the output language. Valid values: "de", "en".
// Anything else returns ErrInvalidInput. t-paliad-276.
Language *string
}
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
@@ -120,7 +129,7 @@ var ErrSubmissionDraftNameTaken = errors.New("submission draft: name already tak
// draftColumns is the canonical select list — kept in one place so
// every fetch stays in sync.
const draftColumns = `id, project_id, submission_code, user_id, name,
const draftColumns = `id, project_id, submission_code, user_id, name, language,
variables, selected_parties,
last_exported_at, last_exported_sha,
last_imported_at,
@@ -173,7 +182,7 @@ type DraftWithProject struct {
func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid.UUID) ([]DraftWithProject, error) {
var rows []DraftWithProject
err := s.db.SelectContext(ctx, &rows,
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name,
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name, d.language,
d.variables, d.selected_parties,
d.last_exported_at, d.last_exported_sha, d.last_imported_at,
d.created_at, d.updated_at,
@@ -280,13 +289,18 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
if err != nil {
return nil, err
}
// Seed the new draft's output language from the user's UI lang so
// the editor opens in the language the lawyer is already working in.
// Anything other than "en" normalizes to "de" — matches the DB CHECK
// constraint and the project's primary-language default.
draftLang := normalizeDraftLanguage(lang)
var d SubmissionDraft
err = s.db.GetContext(ctx, &d,
`INSERT INTO paliad.submission_drafts
(project_id, submission_code, user_id, name)
VALUES ($1, $2, $3, $4)
(project_id, submission_code, user_id, name, language)
VALUES ($1, $2, $3, $4, $5)
RETURNING `+draftColumns,
projectID, submissionCode, userID, name)
projectID, submissionCode, userID, name, draftLang)
if err != nil {
return nil, fmt.Errorf("create submission draft: %w", err)
}
@@ -422,6 +436,17 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
idx++
}
if patch.Language != nil {
newLang := strings.ToLower(strings.TrimSpace(*patch.Language))
if newLang != "de" && newLang != "en" {
return nil, ErrInvalidInput
}
setParts = append(setParts, fmt.Sprintf("language = $%d", idx))
args = append(args, newLang)
idx++
}
if len(setParts) == 0 {
return existing, nil
}
@@ -581,6 +606,10 @@ func (s *SubmissionDraftService) BuildRenderBag(ctx context.Context, draft *Subm
ProjectID: draft.ProjectID,
SubmissionCode: draft.SubmissionCode,
SelectedParties: draft.SelectedParties,
// The draft's language overrides the user's UI lang — the lawyer
// can author an EN draft in a DE-UI session and vice versa
// (t-paliad-276). Empty / unknown falls back to "de".
Lang: normalizeDraftLanguage(draft.Language),
})
if err != nil {
return nil, nil, err
@@ -635,12 +664,13 @@ func (s *SubmissionDraftService) Export(ctx context.Context, draft *SubmissionDr
// ProjectService.GetByID — callers get ErrNotFound on no-access.
// ErrSubmissionRuleNotFound surfaces when no published rule matches the
// requested submission_code.
func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, userID, projectID uuid.UUID, submissionCode string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
pid := projectID
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
UserID: userID,
ProjectID: &pid,
SubmissionCode: submissionCode,
Lang: normalizeDraftLanguage(lang),
})
if err != nil {
return nil, nil, err
@@ -698,6 +728,19 @@ func (d *SubmissionDraft) decodeSelectedParties() error {
return nil
}
// normalizeDraftLanguage maps any input to one of the two allowed
// language values for paliad.submission_drafts.language. Anything other
// than "en" (case-insensitive) collapses to "de" — matches the DB CHECK
// constraint, the project's primary-language default, and the seed
// behaviour for existing rows that came in before the column existed.
func normalizeDraftLanguage(lang string) string {
if strings.EqualFold(strings.TrimSpace(lang), "en") {
return "en"
}
return "de"
}
// Compile-time guard: ensure the *models.User reference in the import
// graph doesn't get optimised away by linters. The service doesn't
// dereference User directly — that happens in SubmissionVarsService —

View File

@@ -327,6 +327,40 @@ func TestRenderHTML_WrapsMissingMarker(t *testing.T) {
}
}
// TestRenderHTML_WrapsOverriddenValueSameAsResolved is the t-paliad-274
// regression: m's report on m/paliad#106 was that "When filled, the link
// disappears". The preview HTML must wrap an override value with the
// same <span class="draft-var"> as it would an unfilled placeholder, so
// the click-jump from preview→sidebar persists after the user types a
// value. There is no distinction at the renderer level between a value
// that came from the resolved bag (project / parties / deadline lookups)
// and a value the lawyer typed into the sidebar — both arrive in the
// same PlaceholderMap and both must be wrapped.
func TestRenderHTML_WrapsOverriddenValueSameAsResolved(t *testing.T) {
doc := `<w:document><w:body>` +
`<w:p><w:r><w:t>{{project.case_number}} / {{firm.name}}</w:t></w:r></w:p>` +
`</w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
// project.case_number is the typed-by-lawyer override.
// firm.name is the always-resolved value from the firm bag.
html, err := r.RenderHTML(tmpl, PlaceholderMap{
"project.case_number": "UPC_CFI_42/2026",
"firm.name": "HLC",
}, nil)
if err != nil {
t.Fatalf("render html: %v", err)
}
wantOverride := `<span class="draft-var" data-var="project.case_number">UPC_CFI_42/2026</span>`
if !strings.Contains(html, wantOverride) {
t.Errorf("expected overridden value wrapped in draft-var span (click-jump must persist after fill, t-paliad-274), got %q", html)
}
wantResolved := `<span class="draft-var" data-var="firm.name">HLC</span>`
if !strings.Contains(html, wantResolved) {
t.Errorf("expected resolved value still wrapped, got %q", html)
}
}
// TestRender_DocxOutputUnchangedByPreviewWrap asserts the hard rule from
// t-paliad-261: the .docx export path must NOT carry the preview-only
// draft-var sentinels or any draft-var span markup. Renders the same

View File

@@ -83,6 +83,13 @@ type SubmissionVarsContext struct {
ProjectID *uuid.UUID
SubmissionCode string
SelectedParties []uuid.UUID
// Lang pins the output language for this Build, overriding the
// caller's UI preference (user.Lang). When empty, Build falls back
// to user.Lang so existing callers (the format-only Slice 1 path)
// keep working unchanged. The draft editor passes the per-draft
// `language` column (t-paliad-276) so DE/EN can be picked
// independently of the UI session.
Lang string
}
// SubmissionVarsResult bundles the placeholder map with the lookup
@@ -132,7 +139,15 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
return nil, err
}
lang := user.Lang
// Per-call Lang override (t-paliad-276) wins over the user's UI
// language so the draft editor can render an EN .docx from a DE-UI
// session and vice versa. Falls back to the user pref when the
// caller didn't specify, preserving the format-only Slice 1
// behaviour.
lang := strings.ToLower(strings.TrimSpace(in.Lang))
if lang != "de" && lang != "en" {
lang = user.Lang
}
if lang == "" {
lang = "de"
}

View File

@@ -0,0 +1,450 @@
// HL-firm skeleton submission template generator (t-paliad-275).
//
// Reads HLC's "HL Patents Style" .dotm letterhead, strips its VBA
// macros and template-only artifacts, then emits a clean .docx that:
//
// 1. Preserves every HL paragraph + character style (HLpat-Heading-H1,
// HLpat-Body-B0, HLpat-Signature, HLpat-Table-Recitals-*, …) by
// keeping word/styles.xml, word/theme/*, word/numbering.xml,
// word/fontTable.xml, settings.xml, footnotes/endnotes from the
// source .dotm untouched.
// 2. Preserves the firm letterhead (logo header + firm-address footer)
// by keeping word/header[12].xml + word/footer[12].xml and the
// sectPr that references them.
// 3. Replaces word/document.xml with a Schriftsatz-shaped body that
// exercises every SubmissionVarsService placeholder (firm.*,
// today.*, user.*, project.*, parties.*, procedural_event.*, rule.*,
// deadline.*) — applying HL paragraph/character styles to each
// section so the rendered output reads as a real HL submission with
// variables substituted.
//
// Drop the output into HL/mWorkRepo at
//
// 6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx
//
// so paliad's submission generator picks it up via the fallback chain.
// Lookup order after this CL: per-firm per-code → _firm-skeleton.docx
// (THIS file — HL formatting + placeholders) → universal _skeleton.docx
// (generic skeleton from t-paliad-259) → bare HL Patents Style .dotm
// (no placeholders). See internal/handlers/submission_drafts.go
// resolveSubmissionTemplate.
//
// Why this is firm-specific: the .dotm carries HL-licensed fonts,
// HL-branded logo media, and HLpat-prefixed style IDs. The output lives
// under the firm-namespaced directory in mWorkRepo so a future firm gets
// its own equivalent file generated against its own .dotm.
//
// Run:
//
// go run ./scripts/gen-hl-skeleton-template \
// -in /tmp/hl-patents-style.dotm \
// -out /tmp/_firm-skeleton.docx
//
// Output is byte-stable across runs for a given input (zip mtimes
// pinned).
package main
import (
"archive/zip"
"bytes"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
)
func main() {
in := flag.String("in", "", "path to source HL Patents Style .dotm (required)")
out := flag.String("out", "_firm-skeleton.docx", "output .docx path")
flag.Parse()
if *in == "" {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: -in is required (path to HL Patents Style .dotm)")
os.Exit(2)
}
srcBytes, err := os.ReadFile(*in)
if err != nil {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: read source:", err)
os.Exit(1)
}
docx, err := buildDocx(srcBytes)
if err != nil {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template:", err)
os.Exit(1)
}
if err := os.WriteFile(*out, docx, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: write:", err)
os.Exit(1)
}
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
}
// fixedTime pins every zip entry's mtime so successive runs over the
// same .dotm produce byte-stable output. Useful for diffing the
// generated file in PR review.
var fixedTime = time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
// dropPaths lists zip entries removed during the .dotm → .docx
// conversion. VBA macros + their keymap binding + the template-only
// glossary parts and ribbon customizations are all dead weight (and
// some actively trigger Word's macro-security warning) — none of them
// add anything to a placeholder-rich Schriftsatz starter.
var dropPaths = map[string]bool{
"word/vbaProject.bin": true,
"word/vbaData.xml": true,
"word/customizations.xml": true,
"userCustomization/customUI.xml": true,
"customUI/customUI14.xml": true,
"word/glossary/document.xml": true,
"word/glossary/_rels/document.xml.rels": true,
"word/glossary/fontTable.xml": true,
"word/glossary/numbering.xml": true,
"word/glossary/settings.xml": true,
"word/glossary/styles.xml": true,
"word/glossary/webSettings.xml": true,
}
// rIdsToDrop names the document-rel ids whose targets are stripped
// from the package (vbaProject, customizations.xml, glossary). They
// must vanish from word/_rels/document.xml.rels so Word doesn't choke
// on a dangling reference.
var rIdsToDrop = map[string]bool{
"rId1": true, // vbaProject.bin
"rId2": true, // customizations.xml (keymap to VBA)
"rId21": true, // glossary/document.xml
}
func buildDocx(src []byte) ([]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
if err != nil {
return nil, fmt.Errorf("open source zip: %w", err)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, f := range zr.File {
name := f.Name
if dropPaths[name] {
continue
}
body, err := readZipEntry(f)
if err != nil {
return nil, fmt.Errorf("read %s: %w", name, err)
}
switch name {
case "[Content_Types].xml":
body = []byte(patchContentTypes(string(body)))
case "_rels/.rels":
body = []byte(patchRootRels(string(body)))
case "word/_rels/document.xml.rels":
body = []byte(patchDocumentRels(string(body)))
case "word/document.xml":
body = []byte(buildDocumentXML())
}
hdr := &zip.FileHeader{
Name: name,
Method: zip.Deflate,
Modified: fixedTime,
}
w, err := zw.CreateHeader(hdr)
if err != nil {
return nil, fmt.Errorf("create %s: %w", name, err)
}
if _, err := w.Write(body); err != nil {
return nil, fmt.Errorf("write %s: %w", name, err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("finalise zip: %w", err)
}
return buf.Bytes(), nil
}
func readZipEntry(f *zip.File) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
// patchContentTypes rewrites the macroEnabledTemplate part type to the
// regular wordprocessingml.document type (a .dotm carries the macro
// part type even on the body part), and removes Default/Override
// entries that target now-deleted parts (vba binary, customizations,
// glossary).
func patchContentTypes(in string) string {
out := in
out = strings.ReplaceAll(out,
`<Override PartName="/word/document.xml" ContentType="application/vnd.ms-word.template.macroEnabledTemplate.main+xml"/>`,
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>`)
removals := []string{
`<Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/>`,
`<Override PartName="/word/vbaData.xml" ContentType="application/vnd.ms-word.vbaData+xml"/>`,
`<Override PartName="/word/customizations.xml" ContentType="application/vnd.ms-word.keyMapCustomizations+xml"/>`,
`<Override PartName="/word/glossary/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml"/>`,
`<Override PartName="/word/glossary/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>`,
`<Override PartName="/word/glossary/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>`,
`<Override PartName="/word/glossary/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>`,
`<Override PartName="/word/glossary/webSettings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"/>`,
`<Override PartName="/word/glossary/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/>`,
}
for _, r := range removals {
out = strings.ReplaceAll(out, r, "")
}
return out
}
// patchRootRels drops the userCustomization (ribbon mini-tab) and the
// customUI14 extensibility relationships — both reference VBA-backed
// UI we don't ship.
func patchRootRels(in string) string {
out := in
out = stripRelByPrefix(out, `<Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2006/relationships/ui/userCustomization"`)
out = stripRelByPrefix(out, `<Relationship Id="Rf8f70ab1afd0469a" Type="http://schemas.microsoft.com/office/2007/relationships/ui/extensibility"`)
return out
}
// patchDocumentRels drops the document-level rels whose targets we
// stripped (vbaProject, customizations.xml, glossaryDocument).
func patchDocumentRels(in string) string {
out := in
for rid := range rIdsToDrop {
needle := `<Relationship Id="` + rid + `" `
out = stripRelByPrefix(out, needle)
}
return out
}
// stripRelByPrefix removes the full <Relationship .../> element whose
// open tag starts with the given prefix. Tolerates either a regular
// closing tag (</Relationship>) or the more common self-closing form.
func stripRelByPrefix(s, prefix string) string {
for {
start := strings.Index(s, prefix)
if start < 0 {
return s
}
// Find end of this element (next "/>"). The .dotm always uses the
// self-closing form for Relationship elements.
end := strings.Index(s[start:], "/>")
if end < 0 {
return s
}
s = s[:start] + s[start+end+2:]
}
}
// buildDocumentXML emits a Schriftsatz skeleton that exercises every
// SubmissionVarsService placeholder (the canonical 48-key v1 contract
// + the procedural_event.* canonical names + their rule.* legacy
// aliases). The structure mirrors a real DE/UPC submission — title
// block → court → rubrum → patent reference → submission title →
// legal grounds → Sachverhalt/Anträge/Rechtsausführungen/Beweis →
// signature → locale-variant verification footer.
//
// Each placeholder lives in its own <w:r> run so the renderer's pass-1
// (format-preserving single-run replace) catches every key. HL
// paragraph styles (HLpat-Heading-H1, HLpat-Header-Section, etc.) are
// applied via pStyle, character styles via rStyle.
//
// The sectPr at the bottom is copied verbatim from the source .dotm
// so the firm header/footer references (rId16=header1, rId17=footer1,
// rId18=header2 first-page, rId19=footer2 first-page) keep resolving
// after we replace the body. pgSz/pgMar/cols/docGrid match the .dotm
// exactly — a lawyer printing this gets the same A4 layout the .dotm
// produces.
func buildDocumentXML() string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">`)
b.WriteString(`<w:body>`)
skeletonBanner(&b)
heading(&b, "HLpat-Heading-H1", "{{firm.name}}")
body0(&b, "Bearbeiter: {{user.display_name}}")
body0(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
body0(&b, "Datum: {{today.long_de}} ({{today.iso}})")
body0(&b, "{{firm.signature_block}}")
headerSection(&b, "{{project.court}}")
body0(&b, "Aktenzeichen: {{project.case_number}}")
body0(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
body0(&b, "Instanz: {{project.instance_level}}")
headerSubsection(&b, "In der Sache")
recitalsParty(&b, "{{parties.claimant.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.claimant.representative}}")
recitalsRoles(&b, "— Klägerin / Patentinhaberin / Anmelderin —")
recitalsSequencer(&b, "gegen")
recitalsParty(&b, "{{parties.defendant.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.defendant.representative}}")
recitalsRoles(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —")
recitalsSequencer(&b, "sowie")
recitalsParty(&b, "{{parties.other.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.other.representative}}")
recitalsRoles(&b, "— Weitere Beteiligte —")
headerSubsection(&b, "Betreff")
body0(&b, "Streitpatent: {{project.patent_number}} (UPC-Schreibweise: {{project.patent_number_upc}})")
body0(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
body0(&b, "Projekttitel: {{project.title}}")
body0(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
body0(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
body0(&b, "Internes Aktenzeichen: {{project.reference}}")
heading(&b, "HLpat-Heading-H1", "{{procedural_event.name}}")
body0(&b, "(Schriftsatz-Code: {{procedural_event.code}})")
body0(&b, "Rechtsgrundlage: {{procedural_event.legal_source_pretty}} ({{procedural_event.legal_source}})")
body0(&b, "Typische Partei: {{procedural_event.primary_party}} · Schriftsatz-Typ: {{procedural_event.event_kind}}")
headerSubsection(&b, "Frist")
body0(&b, "Frist-Bezeichnung: {{deadline.title}}")
body0(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
body0(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
body0(&b, "Berechnet aus: {{deadline.computed_from}} · Quelle: {{deadline.source}}")
heading(&b, "HLpat-Heading-H2", "I. Sachverhalt")
body0(&b, "[Hier folgt der Sachverhalt. Diese Vorlage ist eine Skelett-Fassung — bitte gemäß Schriftsatz-Typ ({{procedural_event.name}}) ausformulieren.]")
heading(&b, "HLpat-Heading-H2", "II. Anträge")
requestsIntro(&b, "Es wird beantragt:")
requestsLevel1(&b, "[Antrag 1 — gemäß {{procedural_event.legal_source_pretty}}]")
requestsLevel1(&b, "[Antrag 2]")
heading(&b, "HLpat-Heading-H2", "III. Rechtsausführungen")
body0(&b, "[Hier folgen die Rechtsausführungen.]")
heading(&b, "HLpat-Heading-H2", "IV. Beweis")
evidenceOffering(&b, "Beweis: [Beweismittel — z. B. Anlage K1: {{project.patent_number}}]")
heading(&b, "HLpat-Heading-H2", "Schlussformel")
signature(&b, "{{today.long_de}}")
signature(&b, "")
signature(&b, "{{user.display_name}}")
signature(&b, "{{firm.name}}")
// Locale-aware verification block — exercises every EN/DE alias the
// variable bag carries plus the rule.* legacy aliases so a lawyer
// editing the template sees that both surfaces resolve. A real
// submission deletes this section after sanity-checking the render.
heading(&b, "HLpat-Heading-H3", "Locale-Varianten & Legacy-Aliase (SKELETON)")
body1(&b, "EN long date: {{today.long_en}} · Today (bare alias): {{today}}")
body1(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
body1(&b, "Proceeding (DE): {{project.proceeding.name_de}}")
body1(&b, "Deadline EN long: {{deadline.due_date_long_en}}")
body1(&b, "Procedural event name (DE): {{procedural_event.name_de}} · (EN): {{procedural_event.name_en}}")
body1(&b, "Rule legacy aliases — name: {{rule.name}}, name_de: {{rule.name_de}}, name_en: {{rule.name_en}}")
body1(&b, "Rule legacy aliases — code: {{rule.submission_code}}, legal_source: {{rule.legal_source}}, legal_source_pretty: {{rule.legal_source_pretty}}")
body1(&b, "Rule legacy aliases — primary_party: {{rule.primary_party}}, event_type: {{rule.event_type}}")
// sectPr — copied verbatim from the source .dotm. Keeps the firm
// letterhead header (rId16=header1.xml, rId18=header2.xml first-page)
// and the firm-address footer (rId17, rId19) on every printed page.
b.WriteString(sectPrXML)
b.WriteString(`</w:body></w:document>`)
return b.String()
}
// sectPrXML matches the source .dotm's section properties exactly so
// the firm header/footer refs and A4 page geometry round-trip.
const sectPrXML = `<w:sectPr><w:headerReference w:type="default" r:id="rId16"/><w:footerReference w:type="default" r:id="rId17"/><w:headerReference w:type="first" r:id="rId18"/><w:footerReference w:type="first" r:id="rId19"/><w:pgSz w:w="11906" w:h="16838" w:code="9"/><w:pgMar w:top="567" w:right="1418" w:bottom="567" w:left="1418" w:header="284" w:footer="284" w:gutter="0"/><w:cols w:space="720"/><w:titlePg/><w:docGrid w:linePitch="286"/></w:sectPr>`
func skeletonBanner(b *strings.Builder) {
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="HLpat-Heading-H1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">SKELETON — HL Patents Style mit Platzhaltern (nicht freigegeben)</w:t></w:r></w:p>`)
}
func heading(b *strings.Builder, style, text string) { styledPara(b, style, "", text) }
func headerSection(b *strings.Builder, text string) { styledPara(b, "HLpat-Header-Section", "", text) }
func headerSubsection(b *strings.Builder, text string) { styledPara(b, "HLpat-Header-Subsection", "", text) }
func body0(b *strings.Builder, text string) { styledPara(b, "HLpat-Body-B0", "", text) }
func body1(b *strings.Builder, text string) { styledPara(b, "HLpat-Body-B1", "", text) }
func recitalsParty(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-Party", "", text) }
func recitalsPartyDetails(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-PartyDetails", "", text) }
func recitalsRoles(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-PartyRoles", "", text) }
func recitalsSequencer(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-Sequencers", "", text) }
func requestsIntro(b *strings.Builder, text string) { styledPara(b, "HLpat-Requests-Intro", "", text) }
func requestsLevel1(b *strings.Builder, text string) { styledPara(b, "HLpat-Requests-Level1", "", text) }
func evidenceOffering(b *strings.Builder, text string) { styledPara(b, "HLpat-EvidenceOffering", "", text) }
func signature(b *strings.Builder, text string) { styledPara(b, "HLpat-Signature", "", text) }
// styledPara writes one paragraph with the given pStyle (paragraph
// style id) and optional rStyle (character style applied to every run).
// Empty style ids drop the corresponding wrapper. Placeholders inside
// `text` are split into their own runs so the renderer's pass-1
// single-run replace catches each one independently.
func styledPara(b *strings.Builder, pStyle, rStyle, text string) {
b.WriteString(`<w:p>`)
if pStyle != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="`)
b.WriteString(pStyle)
b.WriteString(`"/></w:pPr>`)
}
for _, seg := range splitOnPlaceholders(text) {
b.WriteString(`<w:r>`)
if rStyle != "" {
b.WriteString(`<w:rPr><w:rStyle w:val="`)
b.WriteString(rStyle)
b.WriteString(`"/></w:rPr>`)
}
b.WriteString(`<w:t xml:space="preserve">`)
b.WriteString(xmlEscape(seg))
b.WriteString(`</w:t></w:r>`)
}
b.WriteString(`</w:p>`)
}
func splitOnPlaceholders(s string) []string {
if s == "" {
return []string{""}
}
var out []string
for {
open := strings.Index(s, "{{")
if open < 0 {
out = append(out, s)
return out
}
close := strings.Index(s[open:], "}}")
if close < 0 {
out = append(out, s)
return out
}
end := open + close + 2
if open > 0 {
out = append(out, s[:open])
}
out = append(out, s[open:end])
s = s[end:]
if s == "" {
return out
}
}
}
func xmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}