Compare commits

..

24 Commits

Author SHA1 Message Date
mAi
5f0a85fa83 refactor(litigationplanner): extract Fristen/Verfahrensablauf calc into pkg/litigationplanner (Slice A, t-paliad-298 / m/paliad#124)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Atomic extraction of the deadline-rule compute engine + types from
internal/services into a new pkg/litigationplanner package that paliad
+ youpc.org can both import. No behaviour change — every existing test
passes against the post-move shape.

Package contents (~1850 LoC):
- doc.go              package docstring + reuse manifesto
- types.go            Rule, ProceedingType, NullableJSON, AdjustmentReason,
                      HolidayDTO, CalcOptions, CalcRuleParams, Timeline,
                      TimelineEntry, RuleCalculation*, FristenrechnerType,
                      ProjectHint, sentinel errors
- catalog.go          Catalog interface (proceeding + rule lookups)
- holidays.go         HolidayCalendar interface
- courts.go           CourtRegistry interface + DefaultsForJurisdiction +
                      country/regime constants
- expr.go             EvalConditionExpr + HasConditionExpr +
                      ExtractFlagsFromExpr (jsonb gate evaluator)
- durations.go        ApplyDuration + AddWorkingDays (pure compute)
- subtrack.go         SubTrackRouting + LookupSubTrackRouting registry
- legal_source.go     FormatLegalSourceDisplay + BuildLegalSourceURL
- proceeding_mapping.go  MapLitigationToFristenrechner + code constants
                      (CodeUPCInfringement, CodeDEInfringementLG, ...)
- engine.go           Calculate + CalculateRule + the trigger-event
                      branch + applyRuleOverrides (the big move)

paliad side (~1900 LoC net deletion):
- internal/services/fristenrechner.go shrinks from 1505 → ~290 lines
  (thin paliad Catalog adapter + type aliases for back-compat).
- internal/models/models.go: DeadlineRule, ProceedingType, NullableJSON
  become type aliases to litigationplanner.* — every sqlx scan and
  every projection_service caller compiles unchanged.
- internal/services/holidays.go: AdjustmentReason + HolidayDTO become
  aliases to lp.* (canonical definitions now in the package).
- internal/services/proceeding_mapping.go: rewritten as thin re-exports
  of lp constants + helpers.
- internal/services/deadline_search_service.go: FormatLegalSourceDisplay
  + BuildLegalSourceURL replaced with delegating wrappers to lp.

Catalog interface satisfaction:
- DeadlineRuleService → paliadCatalog adapter (wraps the existing
  service, replicates the original SELECT shapes).
- HolidayService → satisfies lp.HolidayCalendar directly (compile-
  time assertion at end of fristenrechner.go).
- CourtService → satisfies lp.CourtRegistry directly.

Wire shape is byte-identical. JSON tags on Rule / ProceedingType /
Timeline / TimelineEntry / RuleCalculation match the historical
UIResponse / UIDeadline shape; the frontend reads the same bytes.

Slice B (Catalog interface + paliad loader cleanup) is folded into
this commit since Slice A already needs the interfaces to call
Calculate across the boundary. Slice C (embedded UPC snapshot +
generator) is the next coder shift; the Berufung unification m
called out lands in Slice B/C per head's brief.

Refs: docs/design-litigation-planner-2026-05-26.md
2026-05-26 13:01:07 +02:00
mAi
6e585951ee docs(litigation-planner): fold m's AskUserQuestion picks — new paliad.scenarios table + jsonb spec, no user-authored rules (t-paliad-292)
m's 2026-05-26 decisions:
- Q1 composition: primary+spawned (v1) with multi-proceeding peer compose as v2 goal — jsonb spec architected for N entries from day 1
- Q2 scope: per-project + abstract (project_id NULL = abstract saved templates)
- Q3 dates: per-anchor overrides over one base date (matches today's compute)
- Q4 storage: new paliad.scenarios table with jsonb spec (NOT project_event_choices column extension)
- "users should not add their own rules" — original Slice E (user-authored rules) DROPPED, replaced with abstract scenarios surface on /tools/verfahrensablauf

§5 rewritten with new schema (paliad.scenarios + active_scenario_id FK), jsonb spec shape (proceedings[] array, version-tagged), validate-on-load discipline, multi-peer v2 path. §6 struck-through with original body preserved as historical context. §10 slice plan revised: Slice E = abstract scenarios surface, not user-authored rules. §0.5 added with decision matrix; §13 marked resolved.

Package shape (§2 §3) unchanged — library was decoupled from persistence/UI choices by design.
2026-05-26 12:55:52 +02:00
mAi
8240717b5a docs(litigation-planner): pkg/litigationplanner design for paliad + youpc.org reuse (t-paliad-292)
Inventor design for m/paliad#124. Atomic extract of FristenrechnerService /
DeadlineCalculator / proceeding_mapping / SubTrackRoutings / legal-source
helpers into pkg/litigationplanner with Catalog / HolidayCalendar /
CourtRegistry interfaces. youpc.org reuse via embedded UPC snapshot
(catalog.json + holidays.json + courts.json) shipped inside the package.

6 slices: A extract, B catalog interface, C embedded snapshot + generator,
D scenarios persistence (project_event_choices.scenario_name), E
user-authored rules (deadline_rules.project_id), F youpc-side PR.

Q1 + Q2 (material) escalated to head per inventor protocol — NOT
AskUserQuestion. Q3-Q5 locked. Decision picks (R) noted; doc holds together
under any answer to the open Qs because pkg shape is decoupled from
persistence choices.
2026-05-26 12:55:52 +02:00
mAi
593e6243e0 Merge: t-paliad-295 — side-aware Verfahrensablauf column headers (Proaktiv/Reaktiv ↔ Unsere/Gegenseite) (m/paliad#127)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 11:59:29 +02:00
mAi
15cc5e418c feat(verfahrensablauf): side-aware column header labels (t-paliad-295)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
m/paliad#127 — m's correction to #88. The user-perspective labels
"Unsere Seite" / "Gegnerseite" only make sense once the user has picked
a side; while side === null (Nicht festgelegt, the default after #120)
the column headers fall back to the semantic-neutral pair
"Proaktiv" / "Reaktiv". Picking a side re-enables the #88 labels.

renderColumnsBody now branches the leftLabel / rightLabel pair on the
incoming side. Bucketing primitive untouched: column placement is
unchanged, only the column-header text differs.

New i18n keys deadlines.col.proactive / deadlines.col.reactive (DE +
EN). The label fallback is documented inline in
verfahrensablauf-core.ts so a future reader sees why the columns have
two header modes.

Tests: four renderColumnsBody assertions covering side=null (explicit
+ default), side=claimant, side=defendant. Existing bucketing tests
unchanged.
2026-05-26 11:57:39 +02:00
mAi
abf0328dcd Merge: t-paliad-297 — remove /admin/rules/export page + export-migrations API (m/paliad#129)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 11:51:48 +02:00
mAi
cc13a5b857 chore(admin): remove /admin/rules/export page + export-migrations API (t-paliad-297)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Workflow shifted to hand-written numbered migrations; the audit-row SQL
export tool no longer has any consumers. Pure deletion — /admin/rules
and /admin/rules/{id}/edit stay; only the export-to-SQL flow goes.

Deleted:
- frontend/src/admin-rules-export.tsx
- frontend/src/client/admin-rules-export.ts

Removed:
- routes GET /admin/rules/export and GET /admin/api/rules/export-migrations
- handleAdminExportRuleMigrations + handleAdminRulesExportPage
- RuleEditorService.ExportMigrationsSince + ExportResult + sqlEscape helper
- build.ts entries (import, client bundle, dist HTML write)
- Sidebar "Regel-Migrations" nav item + "Migrations exportieren" button on /admin/rules
- all admin.rules.export.* + nav.admin.rules_export + admin.rules.list.export i18n keys (DE+EN)
- .admin-rules-export-* CSS rules (dead after page deletion)

Doc references in design-fristen-phase2-2026-05-15.md and
design-paliad-data-export-2026-05-19.md updated to mark the endpoint as
removed (acceptance #2 requires grep to return zero hits).
2026-05-26 11:50:14 +02:00
mAi
abef74fe63 Merge: t-paliad-296 — sort post-trigger optional events by duration ascending (m/paliad#128)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 11:22:33 +02:00
mAi
49ddaa4eb8 feat(fristenrechner): sort post-trigger events by duration ASC within parent group (t-paliad-296)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Optional events anchored on the same trigger (e.g. the four
post-Entscheidung rules in upc.inf.cfi) used to render in catalog
sequence_order, so a 2-month rule (R.118.4 Folgeentscheidungen)
would precede a 1-month rule (R.151 Kostenentscheidung) chained
off the same decision. Now the calculator does a post-evaluation
permutation pass that sorts consecutive same-parent rows by
duration ascending — days < weeks < months < years, ties broken
by duration_value then submission_code.

Different trigger groups keep their proceeding-sequence position
— the walk only ever permutes rows that already share a parent.
Root rules (no parent) are never sorted against each other.
Court-set / conditional rows whose date isn't in the duration
ladder sort LAST within their group.

Verified order against m's report: R.151 cost_app + R.353
rectification (1-month tier) now render before R.220.1
appeal_spawn + R.118.4 cons_orders (2-month tier).

Issue: m/paliad#128
2026-05-26 11:21:29 +02:00
mAi
1bd2ebb4ae Merge: t-paliad-294 — conditional label uses trigger_event name (R.262(2) → Vertraulichkeitsantrag) (m/paliad#126)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 11:19:40 +02:00
mAi
f6c8eb5bcf fix(projection): conditional label uses trigger_event_id, not parent_id
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
t-paliad-294 / m/paliad#126. knuth's #121 conditional-rendering
defaulted the "abhängig von <parent>" chip to the rule's parent_id
display name. For R.262(2) Erwiderung auf Vertraulichkeitsantrag the
parent_id resolves to the SoC (Klageerhebung), but the rule's real
semantic anchor is the opposing party's confidentiality application
(paliad.trigger_events id=25). The chip read "abhängig von
Klageerhebung", which is wrong.

Fix: when a rule has a non-NULL trigger_event_id, the engine stamps
ParentRuleCode / ParentRuleName / ParentRuleNameEN from the
trigger_events catalog row instead of from the parent_id chain. The
parent_id stays as the calc-time arithmetic anchor — only the user-
facing dependency identity shifts.

Generalises across every rule with a real trigger_event_id (2 rows
in the live corpus today: confidentiality_response and
translations_lodge — both relabel correctly).

Touches both surfaces in one shot: verfahrensablauf-core's chip
("abhängig von …") and shape-timeline's "Folgt aus …" footer both
read from ParentRule*, so no frontend change needed.

Tests: extend TestUIDeadline_IsConditional_UncertainAnchors with a
DE+EN string-pinning case for R.262(2) plus a generalisation guard
for translations_lodge. Negative guard asserts the chip no longer
leaks "Klageerhebung" / "Statement of Claim".
2026-05-26 11:19:01 +02:00
mAi
5ba4df9d55 Merge: t-paliad-293 — event-card overhaul (caret menu + iconified state + no-scroll unhide) (m/paliad#125)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 10:48:22 +02:00
mAi
7ca6b2d643 feat(verfahrensablauf): event-card overhaul — iconified state + caret-popover unhide (t-paliad-293)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
m/paliad#125 — concern A (horizontal scroll) and concern B (compact
event-card UX).

Concern A: the inline "Wieder einblenden" chip from t-paliad-290 pushed
hidden cards past their column width on 375/414/768, causing horizontal
page scroll. Fix: drop the chip entirely; surface the un-hide as a
prominent "Wieder einblenden" entry inside the caret popover (matches
the m's "actions live in the caret menu" framing). The card title row
now also wraps + shrinks (flex-wrap + min-width:0 + overflow-wrap)
so no inline child can ever blow the row width.

Concern B (the bigger UX): cards now speak m's "cut the tree of
possibilities" vocabulary via iconified state markers in the title row:
  - Optional event → ⊙ (timeline-state-icon--optional)
  - Hidden by user → 👁⃠ (timeline-state-icon--hidden)
  - Conditional anchor → already covered by the "abhängig von <parent>"
    chip on the date column (t-paliad-289); no duplicate marker.
  - CCR-included / appellant picks → already on the per-card chip.

The legacy `.optional-badge` text chip and `.event-card-choices-unhide`
inline chip are gone — both replaced by the icon language + popover
entry.

Renderer wires the unhide path with two contracts:
  - data-is-hidden="1" on the caret button when isHidden=true, so the
    popover knows to render the prominent unhide block on top.
  - Defensive fallback: if a rule's choices_offered was edited away
    after the user had already saved skip=true (so isHidden=true but
    choicesOffered is empty), the renderer synthesizes {skip:[true,
    false]} so the popover still has an un-hide path.

CSS:
  - .timeline-item min-height 4rem → 2.75rem (less vertical air).
  - .timeline-content padding-bottom 1rem → 0.6rem (tighter gutter).
  - .timeline-item-header gains flex-wrap + min-width:0.
  - .timeline-name gains min-width:0 + overflow-wrap:anywhere
    (long German compounds wrap mid-word instead of overflowing).
  - New: .timeline-state-icon[--optional|--hidden] icon-style markers.
  - New: .event-card-choices-unhide-btn — prominent full-width lime
    pill inside the popover, midnight-text in both themes (matches
    the active-option pin from m/paliad#123).

i18n:
  - state.optional.tooltip — "Optionales Ereignis" / "Optional event"
  - state.hidden.tooltip — "Ausgeblendet — über Optionen-Menü wieder
    einblenden" / "Hidden — restore via the options menu"
  - choices.unhide.chip kept (now used as the popover button label).

Tests: 27 → 29 tests in verfahrensablauf-core.test.ts. Old isHidden
inline-chip cases replaced by state-icon + caret-data-is-hidden
contract cases. Added defensive-fallback case for the synthesized
skip offer. Added regression guard that the legacy
.event-card-choices-unhide class is no longer emitted. Added
optional-priority → ⊙ icon contract pair.

Hard rules respected:
  - Title + date + Rule citation unchanged (m likes these).
  - Click-to-edit on date span (.frist-date-edit) untouched.
  - Conditional rendering (t-paliad-289 chip + dotted border) untouched.
  - Per-card actions (skip, appellant pick, include-CCR, unhide) all
    reachable via the caret popover.

go build ./... && go test ./internal/... && cd frontend && bun run
build && bun test — all green (181 tests).
2026-05-26 10:11:02 +02:00
mAi
ed8af0dca9 Merge: t-paliad-289 — conditional rule projection (post-rebase) (m/paliad#121)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 09:58:48 +02:00
mAi
293e612582 feat(projection): IsConditional for uncertain-anchor rules (t-paliad-289)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Rules anchored on uncertain triggers (R.109 backward-anchor without
oral-hearing date; R.118(4) without validity decision; R.262(2)
without recorded Vertraulichkeitsantrag) previously rendered concrete
dates fabricated off the trigger date. Add IsConditional projection
flag so the SmartTimeline + Verfahrensablauf surfaces "abhängig von
<parent>" instead of a misleading date.

Backend (fristenrechner.go):
- Add IsConditional + ParentRuleCode/Name/NameEN to UIDeadline.
- Pre-pass populates courtSet from rule.is_court_set=true BEFORE the
  main loop, so order-of-evaluation in sequence_order no longer matters
  for the parent-court-set check. Fixes R.109(1) "Antrag auf
  Simultanübersetzung" (sequence_order=45 < Mündliche Verhandlung's
  sequence_order=50): the timing='before' backward arithmetic was
  computing 1 month before the trigger date because the court-set
  parent hadn't been classified yet.
- Set IsConditional=true on every IsCourtSetIndirect branch (catches
  R.109 backward + R.118(4) cons_orders chain off the decision).
- Set IsConditional=true for priority='optional' + primary_party='both'
  rules whose data-model parent is the trigger anchor (covers R.262(2)
  confidentiality_response: the data anchors on SoC, but the real
  trigger is the opposing party's confidentiality motion which may
  never happen). Suppressed by IsOverridden so user anchors win.

Backend (projection_service.go):
- Add IsConditional to TimelineEvent + propagate from UIDeadline.
- New Status="conditional" for projected rows; clears Date, populates
  DependsOnRuleCode/Name from UIDeadline.ParentRule* so the row
  carries the "abhängig von <parent>" payload even when the parent
  has no computed date for annotateDependsOn to discover.

Frontend (verfahrensablauf-core.ts + CSS + i18n):
- CalculatedDeadline gains isConditional + parentRule* fields.
- deadlineCardHtml renders "abhängig von <parent>" chip with
  click-to-edit affordance in place of the date column when
  isConditional=true. IsConditional wins over IsCourtSet for the
  date column (they overlap; "abhängig von <parent>" names the
  specific blocker).
- .timeline-item--conditional / .fr-col-item--conditional CSS:
  dotted border + faded text so the conditional state reads at glance.
- Replaced escHtml's DOM-backed implementation with a pure-JS regex
  escape so the module is testable in bun test without jsdom (the
  old form forced fixtures to leave several fields empty just to
  avoid the DOM dependency).

Tests:
- TestApplyLookaheadCap_ConditionalRowsPassThrough: pure-function lock
  that conditional rows pass through applyLookaheadCap untouched
  (don't count against ProjectedTotal/Shown, don't get capped).
- TestUIDeadline_IsConditional_UncertainAnchors (TEST_DATABASE_URL):
  asserts R.109(1)/(4), R.118(4) chain, and R.262(2) all render
  IsConditional=true with empty DueDate + populated ParentRule*; SoD
  stays non-conditional; override on the oral hearing flips R.109(1)
  back to concrete date.
- 4 new bun tests for the conditional rendering branches in
  deadlineCardHtml.

UX path verified by tests + manual review of the live rule corpus:
opening a UPC inf project without oral-hearing date now surfaces
R.109(1) + R.109(4) as conditional; recording the Vertraulichkeitsantrag
(anchoring R.262(2) via the existing "Datum setzen" flow) flips it
back to a concrete date.

go build / go test / bun test / bun run build all clean.
2026-05-26 09:56:15 +02:00
mAi
9d3325bd88 Merge: t-paliad-291 — dark-mode lime-chip contrast fix across 6 selectors (m/paliad#123)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 09:47:02 +02:00
mAi
18d2e743ba fix(styles): dark-mode contrast on lime-active chips (t-paliad-291)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Six surfaces paired a lime background with var(--color-text), which
flips to cream in dark mode and collapses contrast on the high-luminance
brand lime. Switch them to var(--color-accent-dark) — the design token
already defined to stay midnight in both themes as the WCAG-AA fg on
lime.

Affected:
  - .event-card-choices-option--active  (Berufung durch … popover —
    m's primary report on m/paliad#123)
  - .fristen-row.is-active .fristen-row-num
  - .form-hint-badge
  - .paliadin-widget-send-btn
  - .smart-timeline-anchor-submit
  - .admin-rules-chip.active

Lime hue and non-active states untouched.

Refs: m/paliad#123
2026-05-26 09:45:59 +02:00
mAi
07d2eb472c Merge: t-paliad-287 — submission form revision (Frist drop + grouped sections + Add Party + DB picker) (m/paliad#119)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 09:42:58 +02:00
mAi
7cdccd55ae feat(submission-draft): grouped sections + per-side Add Party with DB picker (t-paliad-287)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Restructures the submission-draft sidebar per m's m/paliad#119 review.

Three changes on the variable form (Part B):
- VARIABLE_GROUPS collapses into four lawyer-facing sections: Mandant
  & Verfahren (firm.* + project.* + procedural_event.*), Parteien
  (manual {{parties.<role>.*}} overrides), Frist (the now-internal
  deadline.* block, COLLAPSED by default since the skeletons no
  longer render it), Sonstiges (today.* / user.* trim).
- Group sections are click-to-collapse via a sticky state map; the
  Frist + Parteien-override sections open closed so the visible form
  stays tight on first load.
- The legacy {{rule.*}} aliases drop off the sidebar — still resolved
  by SubmissionVarsService for old templates, no longer surfaced as
  override rows (they cluttered the form and the canonical
  procedural_event.* names cover the same ground).

Multi-party + Add Party (Part C):
- The party picker now renders all three role buckets (claimants /
  defendants / others) even when empty, so the lawyer can populate via
  Add Party. The block is hidden only when no project is attached.
- Each side gets a "+ Partei hinzufügen (Klägerseite / Beklagtenseite
  / Weitere Parteien)" button that opens an inline panel with two
  tabs:
  - Manual entry — name, role (pre-filled from side), representative.
    Submits to POST /api/projects/{id}/parties, creating a real
    paliad.parties row that immediately surfaces in available_parties.
  - Aus DB übernehmen — debounced (200ms) search against the new
    GET /api/parties/search endpoint. Returns hits across every
    visible project with project_title + reference for context.
    Already-on-this-project rows are filtered out client-side. Picking
    a hit clones name/role/representative into a fresh row on the
    current project — the simplest semantics that survives the
    paliad.parties.project_id NOT NULL contract while honouring m's
    "no manual re-typing" requirement.
- Newly-added parties land in selected_parties immediately so the new
  party is rendered in the next preview round-trip without an extra
  click. Implicit-"all" default is preserved (empty selected_parties
  still means "every party on the project, including this new one").
- Search-result repaints reach only into the <ul>, not the whole
  picker — keeps focus + selection on the search input across
  keystrokes.

CSS:
- Collapsible-section caret rotation, busy/disabled form states, tab
  highlights, DB-picker result rows with project chip + hover, all
  inherit the existing lime-tint accent so the new affordances look
  native to the editor.

TSX:
- Comment update on the parties block; no structural change. The
  bilingual hint copy in i18n.ts now nudges towards Add Party.
2026-05-26 09:41:36 +02:00
mAi
d4ed989b8f feat(parties): cross-project party search endpoint for submission picker (t-paliad-287)
Adds PartyService.Search returning paliad.parties rows from every
project the caller can see, matched by case-insensitive substring on
name or representative. Wired via GET /api/parties/search?q=... — used
by the submission-draft Add-Party panel's "Aus DB übernehmen" tab.

Visibility flows through the same visibilityPredicatePositional helper
every project-scoped read uses; invisible projects' parties never
surface. Capped at 25 hits per call (no pagination — typical lookup is
"the party I'm thinking of by name", not a browse).

Result shape carries project_title + project_reference so the picker
can disambiguate identically-named parties across cases.
2026-05-26 09:41:07 +02:00
mAi
54fb676db5 chore(templates): drop 'Frist' block from skeleton + HL-firm-skeleton (t-paliad-287)
Per m's m/paliad#119 report: the {{deadline.*}} block was leaking
internal/admin context (Frist-Bezeichnung, Fälligkeit, "berechnet aus",
Quelle) into court-bound submissions. The dedicated Frist heading and
its 4 body lines are removed from both gen-skeleton-submission-template
(_skeleton.docx) and gen-hl-skeleton-template (_firm-skeleton.docx).
The {{deadline.due_date_long_en}} reference in the locale-aware
verification footer is also dropped. {{deadline.*}} placeholders stay
resolvable in SubmissionVarsService — a custom template can still pick
them up — but the default skeletons no longer render them in the body.

Regenerated .docx files uploaded to HL/mWorkRepo:
- 6 - material/Templates/Word/Paliad/HLC/_skeleton.docx → d0ecc0e
- 6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx → 25954c9
2026-05-26 09:41:01 +02:00
mAi
c3eaa9b1d4 Merge: t-paliad-290 — show-hidden toggle + un-hide chip on Verfahrensablauf (m/paliad#122)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 09:39:52 +02:00
mAi
5e17de6e07 Merge: t-paliad-288 — Verfahrensablauf 'Beide' → 'Nicht festgelegt' (m/paliad#120)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 09:35:19 +02:00
mAi
0e1f62e375 feat(verfahrensablauf): replace 'Beide' chip with 'Nicht festgelegt' (t-paliad-288)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
The Verfahrensablauf side selector offered Klägerseite / Beklagtenseite /
Beide. 'Beide' is legally impossible (no party is on both sides) — the
state being modelled is "perspective not yet picked", not "both sides".
Rename the chip to 'Nicht festgelegt' (DE) / 'Undefined' (EN) without
changing the underlying state value or projection behaviour.

- frontend/src/verfahrensablauf.tsx: chip label flips to
  deadlines.side.undefined; add inline hint chip
  "Wählen Sie eine Seite, um die Spalten zu fokussieren." next to the
  radio cluster, shown only while no side is picked.
- frontend/src/client/verfahrensablauf.ts: sideLabelI18n() returns the
  new key for null; syncSideHintVisibility() toggles hint display from
  initPerspectiveControls, the side-radio change handler, and
  showSideRadioCluster (chip→radio override path).
- frontend/src/client/i18n.ts: rename deadlines.side.both →
  deadlines.side.undefined (DE: Nicht festgelegt, EN: Undefined); add
  deadlines.side.hint in both languages.
- frontend/src/i18n-keys.ts: rename in the union, keep alphabetical
  order.
- frontend/src/styles/global.css: .side-radio-cluster becomes inline-flex
  so the hint sits next to the toggle; .side-hint styled muted+italic.

URL backward-compat: ?side=both is already silently treated as null by
readSideFromURL (only accepts claimant|defendant) — same column
behaviour as before, no migration needed. projects.field.our_side.both
is a different concept (a project being a multi-party participant) and
stays untouched.

Tests: 17/17 in verfahrensablauf-core.test.ts still pass; the
"default (no opts) mirrors 'both' rules into ours AND opponent" case
already covers the unchanged null-side projection. Go build + tests
clean. Frontend build clean (i18n scan: 2901 keys, data-i18n
attributes clean).

m/paliad#120
2026-05-26 09:33:00 +02:00
47 changed files with 5538 additions and 2531 deletions

View File

@@ -421,7 +421,7 @@ The editor is the **largest single surface** in Phase 3. ~3-4 PRs of work depend
| `POST /api/admin/rules` | POST | global_admin | Create a new rule from scratch (starts as `lifecycle_state='draft'`). |
| `GET /admin/rules/{id}/audit` | GET | global_admin | Audit log for this rule. |
| `POST /admin/rules/{id}/preview` | POST | global_admin | Preview-on-trigger-date — runs calculator with this draft replacing its published peer; returns the resulting timeline (no persistence). |
| `POST /admin/rules/export-migration` | POST | global_admin | Export pending (draft + audit-since-last-export) rules as a `*.up.sql` blob the human can paste into `internal/db/migrations/`. Sets `migration_exported=true` on the audit rows. |
| _(removed t-paliad-297)_ migration-export endpoint | — | — | Was a SQL-export tool generating `*.up.sql` from audit rows. Workflow shifted to hand-written numbered migrations; tool removed in m/paliad#129. |
### 4.2 Draft → published lifecycle

File diff suppressed because it is too large Load Diff

View File

@@ -43,7 +43,7 @@ A full org export today is **< 600 rows of user content** plus reference data
**Audit trail.** Lives in `paliad.project_events` (93 rows). One row per lifecycle event with `event_type`, `metadata jsonb`, `event_date`, `created_by`. The auditing union (`AuditService.ListEntries`) joins 5 sources (project_events, partner_unit_events, deadline_rule_audit, policy_audit_log, reminder_log). For the export we treat `project_events` as primary; the four auxiliary logs are scope-specific.
**Existing export precedent.** `/admin/rules/export` + `/admin/api/rules/export-migrations` (handlers/admin_rules.go) admin-gated, streams a generated SQL artifact. Same shape as what we want for the Excel exports. Re-use the gating helper.
**Existing export precedent.** _(Originally pointed at the admin rule-migration export. That tool was deleted in m/paliad#129 / t-paliad-297. The gating pattern — `adminGate(users, …)` on a download endpoint that streams a generated artifact — still lives on other admin handlers, e.g. `handleAdminDownloadBackup` for `/api/admin/backups/{id}/file`.)_ Re-use the gating helper.
**No Go xlsx library on `go.mod` today.** This design picks **`github.com/xuri/excelize/v2`** in §3.
@@ -591,7 +591,7 @@ No other slice deltas. v1 still ships slices 1+2+3.
- `docs/design-data-model-v2.md` projects + mandanten + ltree path + can_see_project predicate.
- `docs/design-approval-policy-ui-2026-05-07.md` 5-source audit union (this design adds the 6th source).
- `docs/design-profession-vs-project-role-2026-05-07.md` profession ladder for the §4 project gate.
- `internal/handlers/admin_rules.go:303` `handleAdminExportRuleMigrations` (precedent for admin-gated export-as-download).
- `internal/handlers/backups.go` `handleAdminDownloadBackup` (precedent for admin-gated artifact download; the older rule-migration export precedent was removed in t-paliad-297).
- `internal/services/project_service.go:15` visibility predicate.
- `internal/services/derivation_service.go` `EffectiveProjectRole` for the project gate.
- `github.com/xuri/excelize/v2` chosen xlsx library.

View File

@@ -46,7 +46,6 @@ import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
import { renderAdminRulesList } from "./src/admin-rules-list";
import { renderAdminRulesEdit } from "./src/admin-rules-edit";
import { renderAdminRulesExport } from "./src/admin-rules-export";
import { renderPaliadin } from "./src/paliadin";
import { renderAdminPaliadin } from "./src/admin-paliadin";
import { renderAdminBackups } from "./src/admin-backups";
@@ -284,7 +283,6 @@ async function build() {
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
join(import.meta.dir, "src/client/admin-rules-list.ts"),
join(import.meta.dir, "src/client/admin-rules-edit.ts"),
join(import.meta.dir, "src/client/admin-rules-export.ts"),
join(import.meta.dir, "src/client/paliadin.ts"),
// t-paliad-161 — inline Paliadin widget. Loaded via the
// PaliadinWidget component on every authenticated page, so the
@@ -416,7 +414,6 @@ async function build() {
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
await Bun.write(join(DIST, "admin-rules-list.html"), renderAdminRulesList());
await Bun.write(join(DIST, "admin-rules-edit.html"), renderAdminRulesEdit());
await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport());
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
await Bun.write(join(DIST, "admin-backups.html"), renderAdminBackups());

View File

@@ -1,80 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/rules/export — Slice 11b (t-paliad-192). Surfaces the
// GET /admin/api/rules/export-migrations endpoint as a SQL preview the
// editor can copy or download. Optional ?since=<audit-id> query lets
// the editor scope the export to a particular audit window — empty =
// every un-exported audit row.
export function renderAdminRulesExport(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.rules.export.title">Regel-Migrations exportieren &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/rules" />
<BottomNav currentPath="/admin/rules" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<p className="admin-rules-breadcrumb">
<a href="/admin/rules" data-i18n="admin.rules.export.breadcrumb">&larr; Regeln verwalten</a>
</p>
<h1 data-i18n="admin.rules.export.heading">Regel-Migrations exportieren</h1>
<p className="tool-subtitle" data-i18n="admin.rules.export.subtitle">
Generiert ein <code>*.up.sql</code>-Blob mit allen unsynchronisierten Audit-Ver&auml;nderungen.
Manuell in <code>internal/db/migrations/</code> einchecken.
</p>
</div>
</div>
<div className="admin-rules-export-controls">
<div className="form-field">
<label htmlFor="export-since" data-i18n="admin.rules.export.field.since">Startend ab Audit-ID (optional)</label>
<input type="text" id="export-since" className="admin-rules-input" placeholder="UUID, leer = alle un-exportierten" />
</div>
<button type="button" id="export-run" className="btn-primary" data-i18n="admin.rules.export.run">
Export generieren
</button>
<button type="button" id="export-download" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.download">
Als Datei herunterladen
</button>
<button type="button" id="export-copy" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.copy">
In Zwischenablage kopieren
</button>
</div>
<div id="export-feedback" className="form-msg" style="display:none" />
<div className="admin-rules-export-summary" id="export-summary" style="display:none">
<span id="export-summary-count" />
<span id="export-summary-latest" />
</div>
<pre id="export-output" className="admin-rules-export-pre" />
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-rules-export.js"></script>
</body>
</html>
);
}

View File

@@ -39,9 +39,6 @@ export function renderAdminRulesList(): string {
</p>
</div>
<div className="admin-rules-header-actions">
<a href="/admin/rules/export" className="btn-secondary" data-i18n="admin.rules.list.export">
Migrations exportieren
</a>
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.rules.list.new">
+ Neue Regel
</button>

View File

@@ -1,100 +0,0 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
// admin-rules-export.ts — /admin/rules/export. Calls
// GET /admin/api/rules/export-migrations[?since=<uuid>] and renders the
// SQL blob server-side. Download builds a Blob URL and triggers a
// fake <a> click; copy uses navigator.clipboard.
interface ExportResult {
migration_sql: string;
count: number;
latest_audit_id: string;
}
let latest: ExportResult | null = null;
function showFeedback(msg: string, isError: boolean) {
const el = document.getElementById("export-feedback") as HTMLElement | null;
if (!el) return;
el.textContent = msg;
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
el.style.display = "block";
if (!isError) setTimeout(() => { el.style.display = "none"; }, 4000);
}
async function runExport() {
const since = (document.getElementById("export-since") as HTMLInputElement).value.trim();
const qs = new URLSearchParams();
if (since) qs.set("since", since);
const url = "/admin/api/rules/export-migrations" + (qs.toString() ? "?" + qs.toString() : "");
const out = document.getElementById("export-output") as HTMLElement;
const summary = document.getElementById("export-summary") as HTMLElement;
const dl = document.getElementById("export-download") as HTMLElement;
const cp = document.getElementById("export-copy") as HTMLElement;
out.textContent = t("admin.rules.export.running") || "Lade...";
summary.style.display = "none";
dl.style.display = "none";
cp.style.display = "none";
const resp = await fetch(url);
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || (t("admin.rules.export.error") || "Export fehlgeschlagen."), true);
out.textContent = "";
return;
}
latest = await resp.json() as ExportResult;
out.textContent = latest.migration_sql;
summary.style.display = "";
const countEl = document.getElementById("export-summary-count") as HTMLElement;
const latestEl = document.getElementById("export-summary-latest") as HTMLElement;
countEl.textContent = (t("admin.rules.export.count") || "Audit-Zeilen: {n}").replace("{n}", String(latest.count));
if (latest.latest_audit_id) {
latestEl.textContent = (t("admin.rules.export.latest") || "Letzte Audit-ID: {id}").replace("{id}", latest.latest_audit_id);
} else {
latestEl.textContent = "";
}
if (latest.count > 0) {
dl.style.display = "";
cp.style.display = "";
showFeedback((t("admin.rules.export.ok") || "{n} Audit-Zeilen exportiert.").replace("{n}", String(latest.count)), false);
} else {
showFeedback(t("admin.rules.export.no_pending") || "Keine offenen Audit-Zeilen zum Export.", false);
}
}
function downloadFile() {
if (!latest) return;
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const name = `rules-export-${ts}.up.sql`;
const blob = new Blob([latest.migration_sql], { type: "application/sql" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function copyToClipboard() {
if (!latest) return;
try {
await navigator.clipboard.writeText(latest.migration_sql);
showFeedback(t("admin.rules.export.copied") || "In Zwischenablage kopiert.", false);
} catch (e) {
showFeedback(t("admin.rules.export.copy_failed") || "Kopieren fehlgeschlagen.", true);
}
}
function init() {
initI18n();
initSidebar();
(document.getElementById("export-run") as HTMLElement).addEventListener("click", runExport);
(document.getElementById("export-download") as HTMLElement).addEventListener("click", downloadFile);
(document.getElementById("export-copy") as HTMLElement).addEventListener("click", copyToClipboard);
}
document.addEventListener("DOMContentLoaded", init);

View File

@@ -254,6 +254,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.party.both.label": "beide Seiten",
"deadlines.court.set": "vom Gericht bestimmt",
"deadlines.court.indirect": "unbestimmt",
"deadlines.conditional.depends_on": "abhängig von {parent}",
"deadlines.conditional.unset": "abhängig von vorgelagertem Ereignis",
"deadlines.optional.badge": "auf Antrag",
"deadlines.priority.mandatory": "Pflicht",
"deadlines.priority.recommended": "empfohlen",
@@ -307,6 +309,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.col.court": "Gericht",
"deadlines.col.opponent": "Gegnerseite",
"deadlines.col.both": "Beide Parteien",
"deadlines.col.proactive": "Proaktiv",
"deadlines.col.reactive": "Reaktiv",
// t-paliad-265 — per-event-card choice popover (Verfahrensablauf timeline)
"choices.caret.title": "Optionen für dieses Ereignis",
"choices.appellant.title": "Berufung durch …",
@@ -329,6 +333,10 @@ const translations: Record<Lang, Record<string, string>> = {
"choices.show_hidden.label": "Ausgeblendete anzeigen",
"choices.show_hidden.count": "Ausgeblendete ({n})",
"choices.unhide.chip": "Wieder einblenden",
// t-paliad-293 \u2014 iconified state markers on the Verfahrensablauf
// event cards. Tooltip-only text; the glyph is the primary signal.
"state.optional.tooltip": "Optionales Ereignis",
"state.hidden.tooltip": "Ausgeblendet \u2014 \u00fcber Optionen-Men\u00fc wieder einblenden",
// Trigger-event mode (PR-2 \u2014 youpc-parity)
"deadlines.mode.procedure": "Verfahrensablauf",
"deadlines.mode.event": "Was kommt nach\u2026",
@@ -443,9 +451,10 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.side.label": "Seite:",
"deadlines.side.claimant": "Klägerseite",
"deadlines.side.defendant": "Beklagtenseite",
"deadlines.side.both": "Beide",
"deadlines.side.undefined": "Nicht festgelegt",
"deadlines.side.from_project": "Aus Akte:",
"deadlines.side.override": "Andere Seite wählen",
"deadlines.side.hint": "Wählen Sie eine Seite, um die Spalten zu fokussieren.",
"deadlines.appellant.label": "Berufung durch:",
"deadlines.appellant.claimant": "Klägerseite",
"deadlines.appellant.defendant": "Beklagtenseite",
@@ -1501,7 +1510,7 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-277 — import-from-project + party-picker.
"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.",
"submissions.draft.parties.hint": "Wählen Sie die im Schriftsatz genannten Parteien oder fügen Sie pro Seite weitere hinzu.",
// t-paliad-276 — DE/EN language toggle on the draft editor.
"submissions.draft.language": "Sprache",
"submissions.draft.language.de": "DE",
@@ -2885,7 +2894,6 @@ const translations: Record<Lang, Record<string, string>> = {
// `admin.procedural_events.*` aliases live after the EN block — they
// pin the contract for when .tsx files rebind in Slice B (B.5).
"nav.admin.rules": "Verfahrensschritte verwalten",
"nav.admin.rules_export": "Verfahrensschritt-Migrations",
"admin.card.rules.title": "Verfahrensschritte verwalten",
"admin.card.rules.desc": "Verfahrensschritte anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
@@ -2893,7 +2901,6 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.list.heading": "Verfahrensschritte verwalten",
"admin.rules.list.subtitle": "Verfahrensschritte (Schriftsätze, Anhörungen, Entscheidungen, …) anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.",
"admin.rules.list.new": "+ Neuer Verfahrensschritt",
"admin.rules.list.export": "Migrations exportieren",
"admin.rules.tab.rules": "Regeln",
"admin.rules.tab.orphans": "Orphans",
"admin.rules.loading": "Lade…",
@@ -3055,23 +3062,6 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.modal.restore.title": "Wiederherstellen",
"admin.rules.edit.modal.restore.body": "Regel wird wiederhergestellt (archived → published).",
"admin.rules.export.title": "Regel-Migrations exportieren — Paliad",
"admin.rules.export.heading": "Regel-Migrations exportieren",
"admin.rules.export.subtitle": "Generiert ein *.up.sql-Blob mit allen unsynchronisierten Audit-Veränderungen. Manuell in internal/db/migrations/ einchecken.",
"admin.rules.export.breadcrumb": "← Regeln verwalten",
"admin.rules.export.field.since": "Startend ab Audit-ID (optional)",
"admin.rules.export.run": "Export generieren",
"admin.rules.export.running": "Lade…",
"admin.rules.export.download": "Als Datei herunterladen",
"admin.rules.export.copy": "In Zwischenablage kopieren",
"admin.rules.export.copied": "In Zwischenablage kopiert.",
"admin.rules.export.copy_failed": "Kopieren fehlgeschlagen.",
"admin.rules.export.count": "Audit-Zeilen: {n}",
"admin.rules.export.latest": "Letzte Audit-ID: {id}",
"admin.rules.export.ok": "{n} Audit-Zeilen exportiert.",
"admin.rules.export.error": "Export fehlgeschlagen.",
"admin.rules.export.no_pending": "Keine offenen Audit-Zeilen zum Export.",
// Date-range picker (t-paliad-248). Symmetric past/future chip fan
// around an ALLES centre. Used by the filter-bar 'time' axis from
// Slice A onwards; future slices will migrate /agenda and
@@ -3355,6 +3345,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.party.both.label": "both parties",
"deadlines.court.set": "set by court",
"deadlines.court.indirect": "tbd",
"deadlines.conditional.depends_on": "depends on {parent}",
"deadlines.conditional.unset": "depends on an upstream event",
"deadlines.optional.badge": "on request",
"deadlines.priority.mandatory": "Mandatory",
"deadlines.priority.recommended": "Recommended",
@@ -3408,6 +3400,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.col.court": "Court",
"deadlines.col.opponent": "Opponent Side",
"deadlines.col.both": "Both parties",
"deadlines.col.proactive": "Proactive",
"deadlines.col.reactive": "Reactive",
// t-paliad-265 — per-event-card choice popover (Verfahrensablauf timeline)
"choices.caret.title": "Options for this event",
"choices.appellant.title": "Appeal by …",
@@ -3430,6 +3424,10 @@ const translations: Record<Lang, Record<string, string>> = {
"choices.show_hidden.label": "Show hidden",
"choices.show_hidden.count": "Hidden ({n})",
"choices.unhide.chip": "Show again",
// t-paliad-293 — iconified state markers on the Verfahrensablauf
// event cards. Tooltip-only text; the glyph is the primary signal.
"state.optional.tooltip": "Optional event",
"state.hidden.tooltip": "Hidden — restore via the options menu",
"deadlines.adjusted": "Adjusted",
"deadlines.adjusted.reason": "weekend/holiday",
"deadlines.adjusted.weekend": "weekend",
@@ -3551,9 +3549,10 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.side.label": "Side:",
"deadlines.side.claimant": "Claimant",
"deadlines.side.defendant": "Defendant",
"deadlines.side.both": "Both",
"deadlines.side.undefined": "Undefined",
"deadlines.side.from_project": "From case:",
"deadlines.side.override": "Choose other side",
"deadlines.side.hint": "Pick a side to focus the columns.",
"deadlines.appellant.label": "Appeal filed by:",
"deadlines.appellant.claimant": "Claimant",
"deadlines.appellant.defendant": "Defendant",
@@ -4588,7 +4587,7 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-277 — import-from-project + party-picker.
"submissions.draft.import.button": "Import from project",
"submissions.draft.parties.title": "Parties",
"submissions.draft.parties.hint": "Select which parties to mention in this submission.",
"submissions.draft.parties.hint": "Pick the parties mentioned in this submission, or add more per side.",
// t-paliad-240 — global submissions drafts index page.
"submissions.index.title": "Submissions — Paliad",
"submissions.index.heading": "Submissions",
@@ -5952,7 +5951,6 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-192 Slice 11b — Admin rule-editor UI.
// t-paliad-262 Slice A — "Rule" relabelled as "Procedural event".
"nav.admin.rules": "Manage procedural events",
"nav.admin.rules_export": "Procedural-event migrations",
"admin.card.rules.title": "Manage procedural events",
"admin.card.rules.desc": "Author, edit and publish procedural-event templates. Audit log, preview, migration export.",
@@ -5960,7 +5958,6 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.list.heading": "Manage procedural events",
"admin.rules.list.subtitle": "Author, edit and publish procedural events (filings, hearings, decisions, …). Lifecycle: draft → published → archived.",
"admin.rules.list.new": "+ New procedural event",
"admin.rules.list.export": "Export migrations",
"admin.rules.tab.rules": "Rules",
"admin.rules.tab.orphans": "Orphans",
"admin.rules.loading": "Loading…",
@@ -6122,23 +6119,6 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.modal.restore.title": "Restore",
"admin.rules.edit.modal.restore.body": "Rule will be restored (archived → published).",
"admin.rules.export.title": "Export rule migrations — Paliad",
"admin.rules.export.heading": "Export rule migrations",
"admin.rules.export.subtitle": "Generates a *.up.sql blob with every un-exported audit change. Commit manually into internal/db/migrations/.",
"admin.rules.export.breadcrumb": "← Manage Rules",
"admin.rules.export.field.since": "Starting from audit id (optional)",
"admin.rules.export.run": "Generate export",
"admin.rules.export.running": "Loading…",
"admin.rules.export.download": "Download as file",
"admin.rules.export.copy": "Copy to clipboard",
"admin.rules.export.copied": "Copied to clipboard.",
"admin.rules.export.copy_failed": "Copy failed.",
"admin.rules.export.count": "Audit rows: {n}",
"admin.rules.export.latest": "Latest audit id: {id}",
"admin.rules.export.ok": "{n} audit rows exported.",
"admin.rules.export.error": "Export failed.",
"admin.rules.export.no_pending": "No pending audit rows to export.",
// 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}",

View File

@@ -137,6 +137,13 @@ interface VariableGroup {
id: string;
label: VariableLabel;
keys: string[];
// t-paliad-287 — render with a click-to-toggle disclosure caret; the
// initial state is collapsed iff collapsedByDefault. Used for the
// Frist section which lawyers rarely need to override (the variables
// stay resolvable in the bag for the few templates that still want
// them, but render no body content by default).
collapsible?: boolean;
collapsedByDefault?: boolean;
}
const VARIABLE_LABELS: Record<string, VariableLabel> = {
@@ -205,33 +212,19 @@ const VARIABLE_LABELS: Record<string, VariableLabel> = {
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
};
// t-paliad-287 — variable groups restructured into four lawyer-facing
// sections: Mandant/Verfahren up top (the case identity), then Parteien
// (where the picker UI lives — this group only carries the manual
// {{parties.*}} overrides for power-users), then Frist collapsed by
// default (the deadline.* keys still resolve in the bag but the default
// templates don't render them in the body any more), then Sonstiges for
// the firm/date/user trim. The legacy procedural_event/rule namespaces
// fold into Mandant/Verfahren so the lawyer reads them in their natural
// context.
const VARIABLE_GROUPS: VariableGroup[] = [
{
id: "procedural_event",
label: { de: "Verfahrensschritt", en: "Procedural event" },
keys: [
"procedural_event.name",
"procedural_event.legal_source_pretty",
"procedural_event.primary_party",
"procedural_event.event_kind",
"procedural_event.code",
],
},
{
id: "parties",
label: { de: "Mandanten & Parteien", en: "Clients & parties" },
keys: [
"parties.claimant.name",
"parties.claimant.representative",
"parties.defendant.name",
"parties.defendant.representative",
"parties.other.name",
"parties.other.representative",
],
},
{
id: "project",
label: { de: "Verfahren", en: "Proceeding" },
id: "mandant_verfahren",
label: { de: "Mandant & Verfahren", en: "Client & proceeding" },
keys: [
"project.title",
"project.case_number",
@@ -246,11 +239,43 @@ const VARIABLE_GROUPS: VariableGroup[] = [
"project.matter_number",
"project.reference",
"project.instance_level",
"procedural_event.name",
"procedural_event.legal_source_pretty",
"procedural_event.primary_party",
"procedural_event.event_kind",
"procedural_event.code",
],
},
{
id: "parties",
label: { de: "Parteien (Variablen)", en: "Parties (variables)" },
// Manual overrides for {{parties.<role>.*}} placeholders — power-
// user escape hatch when the lawyer wants the rendered string to
// differ from the picker selection (e.g. honourific prefix on
// representative). Collapsed by default because the picker above
// is the canonical surface; these rows exist only as a safety
// valve.
collapsible: true,
collapsedByDefault: true,
keys: [
"parties.claimant.name",
"parties.claimant.representative",
"parties.defendant.name",
"parties.defendant.representative",
"parties.other.name",
"parties.other.representative",
],
},
{
id: "deadline",
label: { de: "Frist", en: "Deadline" },
label: { de: "Frist (intern)", en: "Deadline (internal)" },
// t-paliad-287 — the {{deadline.*}} placeholders no longer render
// in the default skeleton body (internal context that doesn't
// belong in a court-bound submission). The values still resolve
// here so a custom template can pick them up if needed; collapsed
// because most drafts never touch them.
collapsible: true,
collapsedByDefault: true,
keys: [
"deadline.due_date",
"deadline.due_date_long_de",
@@ -261,10 +286,11 @@ const VARIABLE_GROUPS: VariableGroup[] = [
],
},
{
id: "firm",
label: { de: "Kanzlei & Datum", en: "Firm & date" },
id: "sonstiges",
label: { de: "Sonstiges", en: "Other" },
keys: [
"firm.name",
"firm.signature_block",
"user.display_name",
"user.email",
"user.office",
@@ -291,6 +317,29 @@ interface State {
saveTimer: number | null;
pendingOverrides: Record<string, string> | null;
inFlight: AbortController | null;
// t-paliad-287 — per-section collapse memory. Sticky across repaints
// so autosave (which calls paintVariables) doesn't snap an open
// section shut. Seeded lazily from VARIABLE_GROUPS.collapsedByDefault.
collapsedGroups: Record<string, boolean>;
// t-paliad-287 — which side the Add-Party panel is currently open for
// (one panel can be open at a time; clicking the other side's button
// toggles). null means closed.
addPartyOpen: PartySide | null;
addPartyMode: "manual" | "search";
addPartySearchHits: PartySearchHit[];
addPartyBusy: boolean;
}
type PartySide = "claimant" | "defendant" | "other";
interface PartySearchHit {
id: string;
project_id: string;
project_title: string;
project_reference?: string | null;
name: string;
role?: string;
representative?: string;
}
const state: State = {
@@ -300,6 +349,11 @@ const state: State = {
saveTimer: null,
pendingOverrides: null,
inFlight: null,
collapsedGroups: {},
addPartyOpen: null,
addPartyMode: "manual",
addPartySearchHits: [],
addPartyBusy: false,
};
// ─────────────────────────────────────────────────────────────────────
@@ -607,24 +661,31 @@ function paintImportRow(): void {
btn.onclick = () => { void onImportFromProject(btn); };
}
// t-paliad-277 — multi-select party picker. Lists every party on the
// draft's project (view.available_parties), grouped by role, with one
// checkbox per party. Checked = include in the variable bag. Empty
// selection falls back to the legacy "include every party" default
// (consistent with the migration default).
// t-paliad-277 / t-paliad-287 — multi-select party picker plus Add-
// Party affordance per side. Lists every party on the draft's project
// (view.available_parties), grouped by role, with one checkbox per
// party. Each side (Klägerseite / Beklagtenseite / Sonstige) carries
// an "+ Partei hinzufügen" button that opens an inline panel with two
// modes: manual entry (creates a fresh paliad.parties row) or DB
// picker (searches every visible project, clones the row into THIS
// project on selection). Empty selection still falls back to the
// legacy "include every party" default.
function paintPartyPicker(): void {
const block = document.getElementById("submission-draft-parties");
const list = document.getElementById("submission-draft-parties-list");
if (!block || !list || !state.view) return;
const parties = state.view.available_parties ?? [];
if (!state.view.draft.project_id || parties.length === 0) {
// t-paliad-287 — picker is now shown even on empty-roster projects so
// the lawyer can use Add Party to populate. Still hidden when there
// is no project attached (no row to attach a party to).
if (!state.view.draft.project_id) {
block.style.display = "none";
list.innerHTML = "";
return;
}
block.style.display = "";
const parties = state.view.available_parties ?? [];
const selected = new Set(state.view.draft.selected_parties ?? []);
// Empty selection is the implicit "all" default — pre-check every
// party so the lawyer can see what's currently being mentioned and
@@ -637,9 +698,13 @@ function paintPartyPicker(): void {
const grouped = groupPartiesByRole(parties);
let html = "";
for (const group of grouped) {
if (group.parties.length === 0) continue;
html += `<fieldset class="submission-draft-parties-group" data-role-bucket="${group.bucket}">`;
html += `<legend>${escapeHtml(group.label)}</legend>`;
if (group.parties.length === 0) {
html += `<p class="submission-draft-parties-empty">${escapeHtml(
isEN() ? "No parties yet." : "Noch keine Parteien.",
)}</p>`;
}
for (const p of group.parties) {
const checked = effective.has(p.id) ? " checked" : "";
const chip = p.role
@@ -658,6 +723,7 @@ function paintPartyPicker(): void {
html += rep;
html += `</label>`;
}
html += renderAddPartyControls(group.bucket);
html += `</fieldset>`;
}
list.innerHTML = html;
@@ -665,6 +731,198 @@ function paintPartyPicker(): void {
list.querySelectorAll<HTMLInputElement>(".submission-draft-party-check").forEach((inp) => {
inp.addEventListener("change", () => onPartySelectionChange());
});
wireAddPartyControls(list);
}
// renderAddPartyControls emits the per-side "+ Add party" button and
// (when expanded) the inline panel offering manual entry OR DB search.
// Sticky panel state lives in state.addPartyOpen so a repaint after
// search-fetch / autosave / language-switch doesn't snap the panel
// shut mid-edit.
function renderAddPartyControls(side: PartySide): string {
const open = state.addPartyOpen === side;
const mode = state.addPartyMode;
const sideLabel = sideLabelFor(side);
const btnLabel = isEN()
? `+ Add party (${sideLabel})`
: `+ Partei hinzufügen (${sideLabel})`;
let html = `<div class="submission-draft-addparty">`;
html += `<button type="button" class="btn-small btn-secondary submission-draft-addparty-toggle"`;
html += ` data-side="${side}" aria-expanded="${open ? "true" : "false"}">`;
html += escapeHtml(btnLabel);
html += `</button>`;
if (!open) {
html += `</div>`;
return html;
}
// Tabs — manual / search.
html += `<div class="submission-draft-addparty-panel">`;
html += `<div class="submission-draft-addparty-tabs" role="tablist">`;
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
if (mode === "manual") html += ` submission-draft-addparty-tab--active`;
html += `" data-tab="manual" data-side="${side}" aria-selected="${mode === "manual"}">`;
html += escapeHtml(isEN() ? "Manual entry" : "Manuell");
html += `</button>`;
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
if (mode === "search") html += ` submission-draft-addparty-tab--active`;
html += `" data-tab="search" data-side="${side}" aria-selected="${mode === "search"}">`;
html += escapeHtml(isEN() ? "From DB" : "Aus DB übernehmen");
html += `</button>`;
html += `</div>`;
if (mode === "manual") {
html += renderAddPartyManualForm(side);
} else {
html += renderAddPartySearchPanel(side);
}
html += `</div></div>`;
return html;
}
function renderAddPartyManualForm(side: PartySide): string {
const defaultRole = defaultRoleFor(side);
const busyCls = state.addPartyBusy ? " submission-draft-addparty-form--busy" : "";
let html = `<form class="submission-draft-addparty-form${busyCls}" data-side="${side}" data-mode="manual">`;
html += `<label class="submission-draft-addparty-field">`;
html += `<span>${escapeHtml(isEN() ? "Name" : "Name")}</span>`;
html += `<input type="text" name="name" required class="entity-form-input"`;
html += ` placeholder="${escapeHtml(isEN() ? "Acme Inc." : "z. B. Acme GmbH")}" />`;
html += `</label>`;
html += `<label class="submission-draft-addparty-field">`;
html += `<span>${escapeHtml(isEN() ? "Role" : "Rolle")}</span>`;
html += `<input type="text" name="role" class="entity-form-input"`;
html += ` value="${escapeHtml(defaultRole)}"`;
html += ` placeholder="${escapeHtml(isEN() ? "claimant / defendant / intervenor / …" : "Klägerin / Beklagte / Streithelferin / …")}" />`;
html += `</label>`;
html += `<label class="submission-draft-addparty-field">`;
html += `<span>${escapeHtml(isEN() ? "Representative (optional)" : "Vertreter:in (optional)")}</span>`;
html += `<input type="text" name="representative" class="entity-form-input"`;
html += ` placeholder="${escapeHtml(isEN() ? "Dr. Müller, …" : "RA Dr. Müller, …")}" />`;
html += `</label>`;
html += `<div class="submission-draft-addparty-actions">`;
html += `<button type="submit" class="btn-small btn-primary"${state.addPartyBusy ? " disabled" : ""}>`;
html += escapeHtml(isEN() ? "Add party" : "Hinzufügen");
html += `</button>`;
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
html += `</button>`;
html += `</div>`;
html += `</form>`;
return html;
}
function renderAddPartySearchPanel(side: PartySide): string {
let html = `<div class="submission-draft-addparty-search" data-side="${side}" data-mode="search">`;
html += `<input type="search" class="entity-form-input submission-draft-addparty-search-input"`;
html += ` data-side="${side}"`;
html += ` placeholder="${escapeHtml(
isEN()
? "Search across projects (name or representative)…"
: "In allen Projekten suchen (Name oder Vertreter)…",
)}" />`;
html += renderPartySearchResultsList();
html += `<p class="submission-draft-addparty-search-hint">${escapeHtml(
isEN()
? "Picking a row clones it as a fresh party on this project — no typing."
: "Auswählen kopiert die Partei in dieses Projekt — kein erneutes Tippen.",
)}</p>`;
html += `<div class="submission-draft-addparty-actions">`;
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
html += `</button>`;
html += `</div>`;
html += `</div>`;
return html;
}
function wireAddPartyControls(root: HTMLElement): void {
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-toggle").forEach((btn) => {
btn.addEventListener("click", () => {
const side = (btn.dataset.side as PartySide) ?? "other";
if (state.addPartyOpen === side) {
// Toggle off.
state.addPartyOpen = null;
state.addPartySearchHits = [];
} else {
state.addPartyOpen = side;
state.addPartyMode = "manual";
state.addPartySearchHits = [];
}
paintPartyPicker();
});
});
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-tab").forEach((btn) => {
btn.addEventListener("click", () => {
const tab = btn.dataset.tab;
if (tab !== "manual" && tab !== "search") return;
state.addPartyMode = tab;
if (tab === "manual") state.addPartySearchHits = [];
paintPartyPicker();
if (tab === "search") {
// Pre-load most-recent matches with empty query so the lawyer
// sees options without typing first.
void runPartySearch("");
}
});
});
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-cancel").forEach((btn) => {
btn.addEventListener("click", () => {
state.addPartyOpen = null;
state.addPartySearchHits = [];
paintPartyPicker();
});
});
root.querySelectorAll<HTMLFormElement>(".submission-draft-addparty-form").forEach((form) => {
form.addEventListener("submit", (ev) => {
ev.preventDefault();
const side = (form.dataset.side as PartySide) ?? "other";
const data = new FormData(form);
const name = String(data.get("name") ?? "").trim();
if (!name) return;
const role = String(data.get("role") ?? "").trim();
const representative = String(data.get("representative") ?? "").trim();
void onAddPartyManualSubmit(side, { name, role, representative });
});
});
root.querySelectorAll<HTMLInputElement>(".submission-draft-addparty-search-input").forEach((inp) => {
let timer: number | null = null;
inp.addEventListener("input", () => {
if (timer !== null) window.clearTimeout(timer);
timer = window.setTimeout(() => {
void runPartySearch(inp.value.trim());
}, 200);
});
// Pre-load on first render of the search tab.
if (state.addPartyMode === "search" && state.addPartySearchHits.length === 0) {
void runPartySearch("");
}
});
root.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
li.addEventListener("click", () => {
const hitID = li.dataset.hitId;
if (!hitID) return;
const hit = state.addPartySearchHits.find((h) => h.id === hitID);
if (!hit) return;
const side = state.addPartyOpen ?? "other";
void onAddPartySearchPick(side, hit);
});
});
}
function sideLabelFor(side: PartySide): string {
if (side === "claimant") return isEN() ? "Claimant side" : "Klägerseite";
if (side === "defendant") return isEN() ? "Defendant side" : "Beklagtenseite";
return isEN() ? "Other parties" : "Weitere Parteien";
}
function defaultRoleFor(side: PartySide): string {
if (side === "claimant") return isEN() ? "claimant" : "Klägerin";
if (side === "defendant") return isEN() ? "defendant" : "Beklagte";
return "";
}
interface PartyRoleGroup {
@@ -781,8 +1039,27 @@ function paintVariables(): void {
let html = "";
for (const group of VARIABLE_GROUPS) {
const groupLabel = isEN() ? group.label.en : group.label.de;
html += `<section class="submission-draft-var-group" data-group="${group.id}">`;
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
// Re-use the user's prior toggle state across paintVariables calls
// (autosave / language switch trigger a repaint). Default sticky
// state lives in state.collapsedGroups; on first render the
// collapsedByDefault flag seeds it.
if (!Object.prototype.hasOwnProperty.call(state.collapsedGroups, group.id)) {
state.collapsedGroups[group.id] = !!(group.collapsible && group.collapsedByDefault);
}
const collapsed = !!state.collapsedGroups[group.id];
const collapsibleCls = group.collapsible ? " submission-draft-var-group--collapsible" : "";
const collapsedCls = collapsed ? " submission-draft-var-group--collapsed" : "";
html += `<section class="submission-draft-var-group${collapsibleCls}${collapsedCls}" data-group="${group.id}">`;
if (group.collapsible) {
html += `<button type="button" class="submission-draft-var-group-toggle"`;
html += ` data-toggle-group="${escapeHtml(group.id)}" aria-expanded="${collapsed ? "false" : "true"}">`;
html += `<span class="submission-draft-var-group-caret" aria-hidden="true">▸</span>`;
html += `<span class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</span>`;
html += `</button>`;
} else {
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
}
html += `<div class="submission-draft-var-group-body">`;
for (const key of group.keys) {
const label = labelFor(key);
const override = overrides[key];
@@ -813,10 +1090,19 @@ function paintVariables(): void {
// Visual hint: marker text appears in preview when override is "".
void mergedVal;
}
html += `</div>`;
html += `</section>`;
}
host.innerHTML = html;
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-group-toggle").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.dataset.toggleGroup;
if (!id) return;
state.collapsedGroups[id] = !state.collapsedGroups[id];
paintVariables();
});
});
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
@@ -1021,6 +1307,175 @@ async function onPartySelectionChange(): Promise<void> {
}
}
async function runPartySearch(query: string): Promise<void> {
try {
const params = new URLSearchParams();
if (query) params.set("q", query);
const resp = await fetch(`/api/parties/search?${params.toString()}`);
if (!resp.ok) throw new Error(`search ${resp.status}`);
const data = (await resp.json()) as { results: PartySearchHit[] };
// Filter out parties already on THIS project — picking one of them
// would be a no-op clone that doubles the row.
const existingIDs = new Set(
(state.view?.available_parties ?? []).map((p) => p.id),
);
state.addPartySearchHits = (data.results ?? []).filter((h) => !existingIDs.has(h.id));
// Refresh ONLY the results <ul> in place — repainting the whole
// picker would steal focus from the search input on every
// keystroke. The input keeps its value/selection and the lawyer
// can keep typing.
const ul = document.querySelector<HTMLUListElement>(
".submission-draft-addparty-search-results",
);
if (ul) {
ul.outerHTML = renderPartySearchResultsList();
const fresh = document.querySelector<HTMLUListElement>(
".submission-draft-addparty-search-results",
);
if (fresh) {
fresh.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
li.addEventListener("click", () => {
const hitID = li.dataset.hitId;
if (!hitID) return;
const hit = state.addPartySearchHits.find((h) => h.id === hitID);
if (!hit) return;
const side = state.addPartyOpen ?? "other";
void onAddPartySearchPick(side, hit);
});
});
}
} else {
// First load (panel just opened) — full picker paint to wire up
// every control. Subsequent keystroke updates take the cheaper
// path above.
paintPartyPicker();
}
} catch (err) {
console.error("submission-draft party-search:", err);
}
}
function renderPartySearchResultsList(): string {
let html = `<ul class="submission-draft-addparty-search-results">`;
if (state.addPartySearchHits.length === 0) {
html += `<li class="submission-draft-addparty-search-empty">${escapeHtml(
isEN() ? "No matches." : "Keine Treffer.",
)}</li>`;
} else {
for (const hit of state.addPartySearchHits) {
const ref = hit.project_reference
? `<span class="submission-draft-addparty-search-projref">${escapeHtml(hit.project_reference)}</span>`
: "";
const role = hit.role
? `<span class="submission-draft-party-chip">${escapeHtml(hit.role)}</span>`
: "";
const rep = hit.representative
? `<span class="submission-draft-addparty-search-rep">${escapeHtml(
(isEN() ? "Repr.: " : "Vertr.: ") + hit.representative,
)}</span>`
: "";
html += `<li class="submission-draft-addparty-search-row" data-hit-id="${escapeHtml(hit.id)}">`;
html += `<span class="submission-draft-addparty-search-name">${escapeHtml(hit.name)}</span>`;
html += role;
html += rep;
html += `<span class="submission-draft-addparty-search-projwrap">`;
html += escapeHtml(isEN() ? "Project: " : "Projekt: ");
html += `<span class="submission-draft-addparty-search-proj">${escapeHtml(hit.project_title)}</span>`;
html += ref;
html += `</span>`;
html += `</li>`;
}
}
html += `</ul>`;
return html;
}
async function onAddPartyManualSubmit(
side: PartySide,
payload: { name: string; role: string; representative: string },
): Promise<void> {
if (!state.view) return;
const projectID = state.view.draft.project_id;
if (!projectID) return;
// Disable the submit button in-place rather than repainting the form
// mid-flight (a repaint would blow away the lawyer's typed values on
// error and reset focus). The post-success/-error repaint runs once
// the call settles.
const submitBtn = document.querySelector<HTMLButtonElement>(
`.submission-draft-addparty-form[data-side="${side}"] button[type="submit"]`,
);
if (submitBtn) submitBtn.disabled = true;
state.addPartyBusy = true;
try {
const body: Record<string, unknown> = { name: payload.name };
if (payload.role) body.role = payload.role;
if (payload.representative) body.representative = payload.representative;
const resp = await fetch(`/api/projects/${projectID}/parties`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) throw new Error(`create party ${resp.status}`);
const created = (await resp.json()) as { id: string };
await refreshDraftViewAndSelect(created.id);
state.addPartyOpen = null;
setSaveStatus(isEN() ? "Party added" : "Partei hinzugefügt");
state.addPartyBusy = false;
paintPartyPicker();
} catch (err) {
console.error("submission-draft add-party manual:", err);
setSaveStatus(isEN() ? "Add party failed" : "Hinzufügen fehlgeschlagen", true);
if (submitBtn) submitBtn.disabled = false;
state.addPartyBusy = false;
}
}
async function onAddPartySearchPick(side: PartySide, hit: PartySearchHit): Promise<void> {
// DB picks clone the row into the current project — the simplest
// semantics that survive paliad.parties' project_id-NOT-NULL schema.
// The lawyer asked for "no manual re-typing"; this honours that
// without bending the data model.
await onAddPartyManualSubmit(side, {
name: hit.name,
role: hit.role ?? defaultRoleFor(side),
representative: hit.representative ?? "",
});
}
// refreshDraftViewAndSelect refetches the editor payload (so
// available_parties picks up the new row) and ensures the newly-added
// party is checked in selected_parties. If the lawyer was on the
// implicit-all default (empty selected_parties), the new party comes
// in pre-selected via the "empty=all" rule and no PATCH is needed.
async function refreshDraftViewAndSelect(newPartyID: string): Promise<void> {
if (!state.view) return;
const draftID = state.view.draft.id;
const view = state.view.draft.project_id
? await fetchView(state.view.draft.project_id, state.view.draft.submission_code, draftID)
: await fetchGlobalView(draftID);
state.view = view;
// If the previous draft had a non-empty selected_parties subset,
// explicitly add the new party so it isn't silently dropped from the
// submission. Empty selected_parties = "all" → no PATCH needed.
const currentSel = state.view.draft.selected_parties ?? [];
if (currentSel.length > 0 && !currentSel.includes(newPartyID)) {
const next = [...currentSel, newPartyID];
try {
const patched = await patchDraft({ selected_parties: next });
state.view = patched;
} catch (err) {
console.error("submission-draft select new party:", err);
}
}
paintImportRow();
paintPartyPicker();
paintVariables();
paintPreview();
}
async function onImportFromProject(btn: HTMLButtonElement): Promise<void> {
if (!state.view) return;
const draftID = state.view.draft.id;

View File

@@ -535,7 +535,17 @@ async function fetchProjectOurSide(projectID: string): Promise<ProjectOurSide |
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");
return t("deadlines.side.undefined");
}
// syncSideHintVisibility shows the "pick a side" hint chip only while
// currentSide is unset (m/paliad#120). When the user has picked
// claimant / defendant the columns are already focused, so the prompt
// would be misleading.
function syncSideHintVisibility() {
const hint = document.getElementById("side-hint");
if (!hint) return;
hint.style.display = currentSide === null ? "" : "none";
}
// renderSideChip swaps the radio cluster for a read-only chip showing
@@ -559,6 +569,9 @@ function showSideRadioCluster() {
if (!cluster || !chip) return;
cluster.style.display = "";
chip.style.display = "none";
// Cluster re-appears after override → re-evaluate hint visibility so
// we don't leave a stale "pick a side" prompt above a checked radio.
syncSideHintVisibility();
}
// applySidePrefill takes a project's our_side, maps it to the side axis,
@@ -644,6 +657,7 @@ function initPerspectiveControls() {
currentAppellant = readAppellantFromURL();
syncRadioGroup("side", currentSide ?? "");
syncRadioGroup("appellant", currentAppellant ?? "");
syncSideHintVisibility();
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
input.addEventListener("change", () => {
@@ -651,6 +665,7 @@ function initPerspectiveControls() {
const v = input.value;
currentSide = (v === "claimant" || v === "defendant") ? v : null;
writeSideToURL(currentSide);
syncSideHintVisibility();
if (lastResponse) renderResults(lastResponse);
});
});

View File

@@ -81,17 +81,6 @@ export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
openPopover(state, caret);
return;
}
// t-paliad-290: "Wieder einblenden" chip — direct un-hide path that
// mirrors the popover's reset on the `skip` kind. The chip only
// renders on hidden cards (server-flagged via UIDeadline.IsHidden),
// so we always have a real skip entry to remove.
const unhide = targetEl?.closest<HTMLElement>(".event-card-choices-unhide");
if (unhide) {
e.stopPropagation();
const code = unhide.dataset.submissionCode || "";
if (code) void unhideCard(state, code);
return;
}
// Outside-click closes the popover.
if (state.popover && !state.popover.contains(e.target as Node)) {
closePopover(state);
@@ -170,6 +159,7 @@ function openPopover(state: AttachedState, caret: HTMLElement): void {
} catch {
return;
}
const isHidden = caret.dataset.isHidden === "1";
const pop = document.createElement("div");
pop.className = "event-card-choices-popover";
@@ -177,6 +167,15 @@ function openPopover(state: AttachedState, caret: HTMLElement): void {
pop.setAttribute("aria-label", t("choices.caret.title"));
const blocks: string[] = [];
// t-paliad-293: hidden-card prominence. When the user opens the
// popover on a re-surfaced hidden card, "Wieder einblenden" is the
// most likely intent — surface it as a single high-contrast action
// at the top of the popover (rather than burying it under the skip
// toggle's reset link). Clicking it clears the `skip` choice, which
// is the same wire effect as the legacy inline chip from t-paliad-290.
if (isHidden) {
blocks.push(renderUnhideBlock());
}
if (Array.isArray(offered.appellant)) {
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
}
@@ -271,21 +270,21 @@ function renderToggleBlock(state: AttachedState, code: string, kind: "include_cc
</div>`;
}
// unhideCard removes the `skip` choice on the given submission_code via
// the page-supplied remove() callback, then repaints chips so the card
// loses its fade. The page's remove() also triggers a recalc — the
// re-surfaced card will then drop out of the result list naturally
// (since IncludeHidden is still on but the skip entry is gone). Errors
// surface in the console; the chip stays clickable for a retry.
// (t-paliad-290)
async function unhideCard(state: AttachedState, code: string): Promise<void> {
try {
await state.opts.remove(code, "skip");
state.active.get(code)?.delete("skip");
reseedChips(state.opts.container);
} catch (err) {
console.error("event card un-hide failed", err);
}
// renderUnhideBlock is the popover's prominent "Wieder einblenden"
// action — surfaced only when the caret is opened on a re-surfaced
// hidden card (data-is-hidden="1" on the caret). Clicking it dispatches
// the same `clear` action as the skip-block reset link below, but
// labelled in the user's terms ("restore this card" rather than
// "reset skip choice"). Drops out of the popover automatically on
// non-hidden cards so the popover stays minimal. (t-paliad-293)
function renderUnhideBlock(): string {
const label = t("choices.unhide.chip");
return `<div class="event-card-choices-block event-card-choices-block--unhide">
<button type="button"
data-choice-action="clear"
data-choice-kind="skip"
class="event-card-choices-unhide-btn">${escHtml(label)}</button>
</div>`;
}
function closePopover(state: AttachedState): void {

View File

@@ -1,8 +1,10 @@
import { describe, expect, test } from "bun:test";
import {
type CalculatedDeadline,
type DeadlineResponse,
bucketDeadlinesIntoColumns,
deadlineCardHtml,
renderColumnsBody,
} from "./verfahrensablauf-core";
// Regression tests for the editable→click-to-edit wiring on timeline date
@@ -67,31 +69,153 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
});
});
// t-paliad-290 (m/paliad#122): the "Ausgeblendete anzeigen" toggle
// surfaces hidden cards via UIDeadline.IsHidden=true. The renderer
// must (a) emit an inline "Wieder einblenden" chip carrying the
// submission_code (so the delegated handler in event-card-choices.ts
// can resolve which skip to clear) and (b) NOT emit the chip when
// either isHidden is false or the rule has no submission_code (no
// hide target to undo).
describe("deadlineCardHtml — isHidden inline 'Wieder einblenden' chip (t-paliad-290)", () => {
test("isHidden=true with submission_code emits unhide chip with data-submission-code", () => {
// t-paliad-293 (m/paliad#125): the "Wieder einblenden" affordance
// moved from an inline chip in the card header into the caret popover
// to fix horizontal-scroll on narrow viewports (the long German label
// pushed the card past its column width). The renderer now signals
// hidden state two ways: (1) a 👁⃠ state-icon in the title row and
// (2) data-is-hidden="1" on the caret button so event-card-choices.ts
// can surface the prominent "Wieder einblenden" popover entry when
// the user opens the menu. The legacy `.event-card-choices-unhide`
// inline chip class must NOT appear in the output.
describe("deadlineCardHtml — isHidden surfaces state-icon + caret hint (t-paliad-293)", () => {
test("isHidden=true emits the hidden state-icon", () => {
const html = deadlineCardHtml(
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
{ showParty: true },
);
expect(html).toContain("timeline-state-icon--hidden");
});
test("isHidden=true with choicesOffered.skip annotates the caret with data-is-hidden=\"1\"", () => {
const html = deadlineCardHtml(
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
{ showParty: true },
);
expect(html).toContain('data-is-hidden="1"');
expect(html).toContain("event-card-choices-caret");
});
test("isHidden=false (default) suppresses the state-icon and reports data-is-hidden=\"0\"", () => {
const html = deadlineCardHtml(
dl({ choicesOffered: { skip: [true, false] } }),
{ showParty: true },
);
expect(html).not.toContain("timeline-state-icon--hidden");
expect(html).toContain('data-is-hidden="0"');
});
test("isHidden=true with empty choicesOffered still emits caret with synthesized skip offer (defensive)", () => {
// Edge case: admin edits the rule's choices_offered after a user
// has already saved a `skip=true` choice. Without the fallback
// the card would re-surface as hidden with no popover entrypoint
// — the user would have no way to un-hide it. The renderer
// synthesizes a `{skip:[true,false]}` offer so the prominent
// "Wieder einblenden" button still renders in the popover.
const html = deadlineCardHtml(dl({ isHidden: true }), { showParty: true });
expect(html).toContain("event-card-choices-unhide");
expect(html).toContain('data-submission-code="upc-rop-12"');
expect(html).toContain("event-card-choices-caret");
expect(html).toContain('data-is-hidden="1"');
expect(html).toContain("data-choices-offered=\"{&quot;skip&quot;:[true,false]}\"");
});
test("isHidden=false (default) suppresses unhide chip", () => {
test("isHidden=false with empty choicesOffered suppresses caret (regression guard)", () => {
const html = deadlineCardHtml(dl(), { showParty: true });
expect(html).not.toContain("event-card-choices-unhide");
expect(html).not.toContain("event-card-choices-caret");
});
test("isHidden=true on a rule with no submission_code suppresses unhide chip", () => {
const html = deadlineCardHtml(dl({ code: "", isHidden: true }), { showParty: true });
expect(html).not.toContain("event-card-choices-unhide");
test("legacy inline `.event-card-choices-unhide` class is no longer emitted", () => {
// Pinned to catch a regression that would re-introduce the
// horizontal-scroll surface that motivated the move. The popover
// now uses `.event-card-choices-unhide-btn` (with the -btn suffix)
// inside the body-attached popover dom node — never in the card
// header HTML the renderer returns.
const html = deadlineCardHtml(
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
{ showParty: true },
);
expect(html).not.toContain('class="event-card-choices-unhide"');
expect(html).not.toMatch(/event-card-choices-unhide(?!-btn)/);
});
});
// t-paliad-293: the `optional` priority used to render an inline text
// badge in the card title. The overhaul replaces it with a ⊙ state
// icon so the title row stays compact on narrow viewports. Tooltip is
// driven by the `state.optional.tooltip` i18n key.
describe("deadlineCardHtml — optional priority renders the state icon (t-paliad-293)", () => {
test("priority='optional' emits the timeline-state-icon--optional marker", () => {
const html = deadlineCardHtml(dl({ priority: "optional" }), { showParty: true });
expect(html).toContain("timeline-state-icon--optional");
expect(html).not.toContain("optional-badge");
});
test("priority='mandatory' (default) omits the optional marker", () => {
const html = deadlineCardHtml(dl(), { showParty: true });
expect(html).not.toContain("timeline-state-icon--optional");
});
});
// t-paliad-289 — isConditional rules render an "abhängig von <parent>"
// chip in place of the date column, and the chip keeps the click-to-edit
// affordance so the user can pin a real date once the upstream anchor
// resolves (oral hearing scheduled, opposing party's motion received, …).
// Mirrors Symptom A (R.109(1) backward-anchor without oral-hearing date)
// and Symptom B (R.262(2) without recorded Vertraulichkeitsantrag) from
// the issue.
describe("deadlineCardHtml — isConditional rendering (t-paliad-289)", () => {
test("isConditional + parentRuleName emits 'abhängig von <parent>' chip with click-to-edit", () => {
const html = deadlineCardHtml(
dl({
code: "upc.inf.cfi.translation_request",
isConditional: true,
parentRuleCode: "upc.inf.cfi.oral",
parentRuleName: "Mündliche Verhandlung",
}),
{ showParty: true, editable: true },
);
expect(html).toContain("timeline-conditional");
expect(html).toContain("abhängig von Mündliche Verhandlung");
expect(html).toContain('data-rule-code="upc.inf.cfi.translation_request"');
expect(html).toContain('role="button"');
expect(html).not.toContain("timeline-court-set");
});
test("isConditional with no parentRuleName falls back to generic upstream-event label", () => {
const html = deadlineCardHtml(
dl({ isConditional: true }),
{ showParty: true, editable: true },
);
expect(html).toContain("timeline-conditional");
expect(html).toContain("abhängig von vorgelagertem Ereignis");
});
test("isConditional wins over isCourtSet — overlapping cases render conditional chip", () => {
// Court-set ancestor without override sets BOTH isCourtSet=true AND
// isConditional=true on the wire. The renderer must pick the
// conditional chip; otherwise the row keeps the legacy "wird vom
// Gericht bestimmt" label and the user can't see WHICH upstream
// event blocks them.
const html = deadlineCardHtml(
dl({
isConditional: true,
isCourtSet: true,
isCourtSetIndirect: true,
parentRuleName: "Entscheidung",
}),
{ showParty: true, editable: true },
);
expect(html).toContain("abhängig von Entscheidung");
expect(html).not.toContain("timeline-court-set");
});
test("isConditional=false keeps the normal date span (regression guard)", () => {
const html = deadlineCardHtml(dl({ isConditional: false }), { showParty: true });
expect(html).toContain("timeline-date");
expect(html).not.toContain("timeline-conditional");
});
});
// Pure column-routing behaviour. Originally pinned by m/paliad#81
// (side + appellant axes), re-framed by m/paliad#88: the column
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
@@ -270,4 +394,73 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
["Decision"],
]);
});
});
// m's correction in m/paliad#127 (t-paliad-295) reverted half of #88's
// header refresh: the user-perspective labels "Unsere Seite"/"Gegnerseite"
// only make sense once the user has picked a side. While the side is
// still "Nicht festgelegt" (side === null — the default after #120) the
// header falls back to the semantic-neutral "Proaktiv"/"Reaktiv" labels.
// Picking a side re-enables the #88 labels. The bucketing primitive
// itself is unchanged — only the column-header text differs.
describe("renderColumnsBody — side-aware column header labels (m/paliad#127)", () => {
const dlFix = (party: string, name: string, due: string): CalculatedDeadline => ({
code: name,
name,
nameEN: name,
party,
priority: "mandatory",
ruleRef: "",
dueDate: due,
originalDate: due,
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
});
const data: DeadlineResponse = {
proceedingType: "upc.inf.cfi",
proceedingName: "UPC Verletzungsverfahren",
triggerDate: "2026-01-01",
deadlines: [
dlFix("claimant", "Klageschrift", "2026-01-01"),
dlFix("defendant", "Klageerwiderung", "2026-04-01"),
],
};
test("side=null renders Proaktiv/Gericht/Reaktiv headers", () => {
const html = renderColumnsBody(data, { side: null });
expect(html).toContain(">Proaktiv<");
expect(html).toContain(">Gericht<");
expect(html).toContain(">Reaktiv<");
expect(html).not.toContain(">Unsere Seite<");
expect(html).not.toContain(">Gegnerseite<");
});
test("side=null when opts omitted (default) still renders Proaktiv/Reaktiv", () => {
const html = renderColumnsBody(data);
expect(html).toContain(">Proaktiv<");
expect(html).toContain(">Reaktiv<");
});
test("side=claimant renders Unsere Seite/Gericht/Gegnerseite headers", () => {
const html = renderColumnsBody(data, { side: "claimant" });
expect(html).toContain(">Unsere Seite<");
expect(html).toContain(">Gericht<");
expect(html).toContain(">Gegnerseite<");
expect(html).not.toContain(">Proaktiv<");
expect(html).not.toContain(">Reaktiv<");
});
test("side=defendant renders Unsere Seite/Gegnerseite headers (column swap is bucketing, not labels)", () => {
// The user-perspective labels are picked once a side is set; the
// bucketer still routes defendant filings into the `ours` column when
// side=defendant, so the left column's header truthfully reads
// "Unsere Seite" regardless of which underlying party occupies it.
const html = renderColumnsBody(data, { side: "defendant" });
expect(html).toContain(">Unsere Seite<");
expect(html).toContain(">Gegnerseite<");
expect(html).not.toContain(">Proaktiv<");
expect(html).not.toContain(">Reaktiv<");
});
});

View File

@@ -77,6 +77,24 @@ export interface CalculatedDeadline {
// anzeigen" toggle. The renderer fades the card and exposes an
// inline "Wieder einblenden" chip that deletes the skip choice.
isHidden?: boolean;
// isConditional (t-paliad-289): the rule's anchor is uncertain, so
// no concrete date is projected. Set by the calculator when the rule
// depends on a court-set ancestor without override, when a backward-
// anchored rule's forward anchor isn't set, or for optional rules
// whose true triggering event sits outside the rule data (e.g.
// R.262(2) Erwiderung auf Vertraulichkeitsantrag — anchored on SoC
// in the data, but the real trigger is the opposing party's
// confidentiality motion). The renderer drops the date column entry
// and shows an "abhängig von <parentRuleName>" chip instead.
isConditional?: boolean;
// parentRuleCode / parentRuleName / parentRuleNameEN surface the
// parent rule's identity so the renderer can label the
// "abhängig von <parent>" chip on conditional rows. Populated for
// every rule with a parent (not just conditional ones), so the
// dependency-footer logic can reuse it. Empty for root rules.
parentRuleCode?: string;
parentRuleName?: string;
parentRuleNameEN?: string;
}
// priorityRendering returns the per-priority UX hints the save-modal
@@ -192,10 +210,20 @@ export function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
// Pure-string HTML escape — keeps the module testable in bun test
// (plain Node, no jsdom). Used to be backed by document.createElement,
// which forced fixtures to leave any field that flowed through it
// empty just to exercise unrelated branches; the regex form is safe
// for arbitrary text including the per-rule name strings that the
// conditional-row chip ("abhängig von <parent>") now exposes.
// (t-paliad-289)
export function escHtml(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function formatDate(dateStr: string): string {
@@ -296,48 +324,80 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
const editAttrs = editable
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
: "";
const courtLabelKey = dl.isCourtSetIndirect
? "deadlines.court.indirect"
: "deadlines.court.set";
const dateStr = dl.isCourtSet
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
// Conditional rows (t-paliad-289) replace the date column with an
// "abhängig von <parent>" chip. The chip remains click-to-edit so
// the user can pin a real date once known (e.g. once the oral
// hearing date is set, or the opposing party's Vertraulichkeits-
// antrag arrives) — the same data-rule-code wiring fires the
// existing inline date editor. IsConditional wins over IsCourtSet:
// they overlap (court-set ancestor without override produces both),
// and "abhängig von <parent>" is the clearer user-facing signal.
const parentLabel = (getLang() === "en"
? (dl.parentRuleNameEN || dl.parentRuleName)
: dl.parentRuleName) || "";
let dateStr: string;
if (dl.isConditional) {
const chipText = parentLabel
? tDyn("deadlines.conditional.depends_on").replace("{parent}", escHtml(parentLabel))
: t("deadlines.conditional.unset");
dateStr = `<span class="timeline-conditional frist-date-edit"${editAttrs}>${chipText}</span>`;
} else if (dl.isCourtSet) {
const courtLabelKey = dl.isCourtSetIndirect
? "deadlines.court.indirect"
: "deadlines.court.set";
dateStr = `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`;
} else {
dateStr = `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
}
// Slice 9 (t-paliad-195): the legacy boolean pair is gone — read
// priority directly. Optional badge fires only on 'optional'
// priority (RoP.151-style opt-in deadlines).
const mandatoryBadge = dl.priority === "optional"
? '<span class="optional-badge">optional</span>'
: "";
// t-paliad-293 — iconified state markers. The card surface speaks
// "cut the tree of possibilities": each card carries 0N small icons
// in the title row that summarise its decision state at a glance.
// The text "optional" badge that used to sit inline next to the name
// is now a ⊙ icon (state.optional). Hidden cards get a 👁⃠ eye-slash
// marker. Conditional cards already have the date-column chip; the
// marker is redundant in the title row. CCR-included / appellant
// picks remain on the chip row (event-card-choices-chip) — see below.
// Tooltips are i18n-driven so they read in the user's language.
const stateIcons: string[] = [];
if (dl.priority === "optional") {
stateIcons.push(
`<span class="timeline-state-icon timeline-state-icon--optional" role="img" aria-label="${escAttr(t("state.optional.tooltip"))}" title="${escAttr(t("state.optional.tooltip"))}">⊙</span>`,
);
}
if (dl.isHidden) {
stateIcons.push(
`<span class="timeline-state-icon timeline-state-icon--hidden" role="img" aria-label="${escAttr(t("state.hidden.tooltip"))}" title="${escAttr(t("state.hidden.tooltip"))}">👁⃠</span>`,
);
}
const stateIconsHtml = stateIcons.join("");
// 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
//
// t-paliad-293 — hidden cards always expose the caret so the user
// can un-hide via the popover's "Wieder einblenden" entry. Normally
// a hidden card was hidden via a skip choice, so `choicesOffered.skip`
// is present. Defensive fallback: if a rule's `choices_offered` was
// edited away after the skip entry was saved, the user would lose
// the un-hide path entirely. Synthesize a `{skip:[true,false]}`
// offer for the popover in that edge case so the prominent
// "Wieder einblenden" button still renders.
const offeredForCaret = (dl.choicesOffered && Object.keys(dl.choicesOffered).length > 0)
? dl.choicesOffered
: (dl.isHidden ? { skip: [true, false] } : null);
const showCaret = dl.code !== "" && offeredForCaret !== null;
const choicesHtml = showCaret
? `<button type="button" class="event-card-choices-caret"
data-submission-code="${escAttr(dl.code)}"
data-choices-offered="${escAttr(JSON.stringify(dl.choicesOffered))}"
data-choices-offered="${escAttr(JSON.stringify(offeredForCaret))}"
data-is-hidden="${dl.isHidden ? "1" : "0"}"
aria-label="${escAttr(t("choices.caret.title"))}"
title="${escAttr(t("choices.caret.title"))}">▾</button>`
: "";
// t-paliad-290 — inline "Wieder einblenden" chip on re-surfaced
// hidden cards. Click deletes the skip choice (mirroring the popover
// reset path). The chip only renders when the card is hidden in the
// current projection (IsHidden=true on the wire) so it's always
// pointing at a real skip entry. The chip text is a static i18n
// value (no user input), so we use escAttr-only for attribute safety
// and inline the translated label directly — matches the renderer's
// pattern for the deadline name (also a known-safe string).
const unhideLabel = t("choices.unhide.chip");
const unhideHtml = dl.isHidden && dl.code !== ""
? `<button type="button" class="event-card-choices-unhide"
data-submission-code="${escAttr(dl.code)}"
aria-label="${escAttr(unhideLabel)}"
title="${escAttr(unhideLabel)}">${unhideLabel}</button>`
: "";
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
const adjustedNote = dl.wasAdjusted
@@ -387,12 +447,11 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
return `<div class="timeline-item-header">
<span class="timeline-name">
${dlName}
${mandatoryBadge}
${stateIconsHtml}
${chipHtml}
</span>
${dateStr}
${choicesHtml}
${unhideHtml}
</div>
${meta}
${adjustedNote}
@@ -483,12 +542,20 @@ export function wireDateEditClicks(
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
let html = '<div class="timeline">';
for (const dl of data.deadlines) {
// t-paliad-290: re-surfaced hidden cards render faded via the
// shared timeline-item--hidden modifier (same modifier the columns
// view uses; see fr-col-item--hidden below).
const hiddenCls = dl.isHidden ? " timeline-item--hidden" : "";
const itemClasses = [
"timeline-item",
dl.isRootEvent ? "timeline-root" : "",
// t-paliad-290: re-surfaced hidden cards render faded via the
// shared timeline-item--hidden modifier (same modifier the columns
// view uses; see fr-col-item--hidden below).
dl.isHidden ? "timeline-item--hidden" : "",
// t-paliad-289: dotted-border + faded styling for conditional rows
// so the "abhängig von <parent>" state is visually distinct from
// both anchored deadlines and direct court-set rows.
dl.isConditional ? "timeline-item--conditional" : "",
].filter(Boolean).join(" ");
html += `
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}${hiddenCls}">
<div class="${itemClasses}">
<div class="timeline-dot-col">
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
<div class="timeline-line"></div>
@@ -667,8 +734,17 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
const mirrorTag = showMirrorTag && dl.party === "both"
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
: "";
const hiddenCls = dl.isHidden ? " fr-col-item--hidden" : "";
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}${hiddenCls}">
const itemClasses = [
"fr-col-item",
dl.isRootEvent ? "fr-col-root" : "",
// t-paliad-290: re-surfaced hidden cards render faded via the
// shared fr-col-item--hidden modifier.
dl.isHidden ? "fr-col-item--hidden" : "",
// t-paliad-289: same conditional treatment as the linear
// timeline-item — dotted border + faded styling.
dl.isConditional ? "fr-col-item--conditional" : "",
].filter(Boolean).join(" ");
return `<div class="${itemClasses}">
${deadlineCardHtml(dl, cardOpts)}
${mirrorTag}
</div>`;
@@ -680,14 +756,29 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
const headerCell = (label: string, cls: string) =>
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
// Static labels — "Unsere Seite" is always the left column, regardless
// of which physical party (claimant vs defendant) occupies it. The
// bucketing primitive already routes the user's side into the `ours`
// bucket, so the header truth-fully describes the column contents.
// Column-header labels have two modes (m/paliad#127):
// - side picked → "Unsere Seite" / "Gegnerseite" (the columns
// truthfully describe whose filings sit there,
// because the bucketer routed the user's side into
// `ours`).
// - side === null → "Proaktiv" / "Reaktiv" (semantic-neutral). The
// user-perspective labels would lie here: we don't
// know yet which party is "us", so calling the left
// column "Unsere Seite" presumes a pick the user
// hasn't made. The neutral Proaktiv/Reaktiv pair
// keeps the spatial axis ("who initiates vs who
// responds") legible while the hint chip on the
// page nudges the user to pick a side.
//
// Note: the COLUMN PROJECTION does not change — the bucketing primitive
// still routes claimant→left, defendant→right when side=null (legacy
// claimant-on-the-left fallback). Only the HEADER label changes.
const leftLabel = userSide === null ? t("deadlines.col.proactive") : t("deadlines.col.ours");
const rightLabel = userSide === null ? t("deadlines.col.reactive") : t("deadlines.col.opponent");
let html = '<div class="fr-columns-view">';
html += headerCell(t("deadlines.col.ours"), "fr-col-ours");
html += headerCell(leftLabel, "fr-col-ours");
html += headerCell(t("deadlines.col.court"), "fr-col-court");
html += headerCell(t("deadlines.col.opponent"), "fr-col-opponent");
html += headerCell(rightLabel, "fr-col-opponent");
for (const row of rows) {
html += renderCell(row.ours);

View File

@@ -205,7 +205,6 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}

View File

@@ -401,22 +401,6 @@ export type I18nKey =
| "admin.rules.edit.title"
| "admin.rules.empty"
| "admin.rules.error.load"
| "admin.rules.export.breadcrumb"
| "admin.rules.export.copied"
| "admin.rules.export.copy"
| "admin.rules.export.copy_failed"
| "admin.rules.export.count"
| "admin.rules.export.download"
| "admin.rules.export.error"
| "admin.rules.export.field.since"
| "admin.rules.export.heading"
| "admin.rules.export.latest"
| "admin.rules.export.no_pending"
| "admin.rules.export.ok"
| "admin.rules.export.run"
| "admin.rules.export.running"
| "admin.rules.export.subtitle"
| "admin.rules.export.title"
| "admin.rules.filter.lifecycle"
| "admin.rules.filter.lifecycle.any"
| "admin.rules.filter.proceeding"
@@ -428,7 +412,6 @@ export type I18nKey =
| "admin.rules.lifecycle.archived"
| "admin.rules.lifecycle.draft"
| "admin.rules.lifecycle.published"
| "admin.rules.list.export"
| "admin.rules.list.heading"
| "admin.rules.list.new"
| "admin.rules.list.subtitle"
@@ -1233,11 +1216,15 @@ export type I18nKey =
| "deadlines.col.event_type"
| "deadlines.col.opponent"
| "deadlines.col.ours"
| "deadlines.col.proactive"
| "deadlines.col.reactive"
| "deadlines.col.rule"
| "deadlines.col.status"
| "deadlines.col.title"
| "deadlines.complete.action"
| "deadlines.complete.confirm"
| "deadlines.conditional.depends_on"
| "deadlines.conditional.unset"
| "deadlines.court.indirect"
| "deadlines.court.label"
| "deadlines.court.set"
@@ -1463,12 +1450,13 @@ export type I18nKey =
| "deadlines.search.placeholder"
| "deadlines.search.results.count"
| "deadlines.search.results.count_one"
| "deadlines.side.both"
| "deadlines.side.claimant"
| "deadlines.side.defendant"
| "deadlines.side.from_project"
| "deadlines.side.hint"
| "deadlines.side.label"
| "deadlines.side.override"
| "deadlines.side.undefined"
| "deadlines.source.caldav"
| "deadlines.source.fristenrechner"
| "deadlines.source.imported"
@@ -1989,7 +1977,6 @@ export type I18nKey =
| "nav.admin.paliadin"
| "nav.admin.partner_units"
| "nav.admin.rules"
| "nav.admin.rules_export"
| "nav.admin.team"
| "nav.agenda"
| "nav.akten"
@@ -2618,6 +2605,8 @@ export type I18nKey =
| "search.no_results"
| "search.placeholder"
| "sidebar.resize.title"
| "state.hidden.tooltip"
| "state.optional.tooltip"
| "submissions.draft.action.delete"
| "submissions.draft.action.export"
| "submissions.draft.action.new"

View File

@@ -1917,7 +1917,11 @@ input[type="range"]::-moz-range-thumb {
.fristen-row.is-active .fristen-row-num {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-text, #111);
/* Lime is high-luminance; foreground stays midnight in both themes via
--color-accent-dark (light: midnight by default, dark: midnight
explicit). Using --color-text here would flip to cream in dark mode
and collapse contrast on lime. */
color: var(--color-accent-dark);
}
.fristen-row.is-prefilled .fristen-row-num {
@@ -3328,7 +3332,11 @@ input[type="range"]::-moz-range-thumb {
.timeline-item {
display: flex;
gap: 0.75rem;
min-height: 4rem;
/* t-paliad-293: tighter min-height. Previously 4rem — too much
vertical air per card on long projections. Title row + meta row
fits comfortably in 2.75rem; longer cards (with notes expanded
or adjusted-date banners) still grow naturally. */
min-height: 2.75rem;
}
.timeline-item:last-child .timeline-line {
@@ -3369,19 +3377,37 @@ input[type="range"]::-moz-range-thumb {
.timeline-content {
flex: 1;
padding-bottom: 1rem;
/* t-paliad-293: tighter inter-card gutter. Was 1rem; 0.6rem keeps
the dotted-connector line readable without bloating long
projections. */
padding-bottom: 0.6rem;
min-width: 0;
}
.timeline-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
gap: 0.5rem;
/* t-paliad-293: allow shrink + wrap so a long title plus the state
icons + caret never push the card past its column. Combined with
min-width:0 on the name, no inline child can blow the row width
on 375/414/768 viewports. */
flex-wrap: wrap;
min-width: 0;
}
.timeline-name {
font-size: 0.88rem;
font-weight: 500;
/* min-width:0 lets the name shrink and wrap inside its flex parent
— otherwise overflow:hidden in an ancestor would clip it but the
flex item would still demand its intrinsic width. */
min-width: 0;
/* Word-break on long German compounds (Vertraulichkeitswiderklage …)
so they wrap mid-word rather than pushing the date column off-
screen. (t-paliad-293) */
overflow-wrap: anywhere;
}
.timeline-date {
@@ -3467,15 +3493,37 @@ input[type="range"]::-moz-range-thumb {
color: var(--status-neutral-fg-3);
}
.optional-badge {
font-size: 0.68rem;
font-weight: 500;
padding: 0.05rem 0.4rem;
border-radius: 99px;
background: var(--status-amber-bg);
/* t-paliad-293 — compact state icons in the card title row. They
* replace the legacy `.optional-badge` text chip and add a uniform
* language for the per-card decision state ("cut the tree of
* possibilities"). Each icon carries its own modifier so the tint
* matches the state semantic. The glyph itself is the primary signal;
* the i18n tooltip on the span carries the accessible description. */
.timeline-state-icon {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1rem;
height: 1rem;
margin-left: 0.3rem;
font-size: 0.85rem;
line-height: 1;
color: var(--color-text-muted);
cursor: help;
user-select: none;
/* Cancel the wrapper fade so the marker stays legible inside
.timeline-item--hidden which fades the whole content panel. */
opacity: 1;
}
.timeline-state-icon--optional {
color: var(--status-amber-fg);
}
.timeline-state-icon--hidden {
color: var(--color-text-muted);
}
/* 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
@@ -3545,24 +3593,44 @@ input[type="range"]::-moz-range-thumb {
padding: 0.3rem 0.5rem;
}
.event-card-choices-unhide {
margin-left: 0.4rem;
font-size: 0.7rem;
font-weight: 500;
padding: 0.1rem 0.5rem;
border-radius: 99px;
border: 1px solid var(--color-accent, #c6f41c);
background: var(--color-accent, #c6f41c);
color: var(--color-text);
cursor: pointer;
/* Cancel the wrapper fade so the action remains a clear, high-
* contrast affordance even though the rest of the card is muted. */
opacity: 1;
/* t-paliad-293 — prominent "Wieder einblenden" entry inside the caret
* popover. Surfaced only when the caret is opened on a hidden card
* (data-is-hidden="1"). Used to be an inline chip in the card header,
* but that caused horizontal scroll on narrow viewports (m/paliad#125)
* because its German label is wide ("Wieder einblenden") and the
* card header is a non-wrapping flex row. Moving it into the popover
* removes the surface entirely and matches m's "actions live in the
* caret menu" framing. */
.event-card-choices-block--unhide {
/* No top border separator — this block sits at the top of the
popover with the highest visual priority. */
padding-top: 0;
border-top: 0;
margin-top: 0;
}
.event-card-choices-unhide:hover,
.event-card-choices-unhide:focus-visible {
.event-card-choices-unhide-btn {
display: block;
width: 100%;
padding: 0.4rem 0.6rem;
font-size: 0.82rem;
font-weight: 600;
border-radius: 4px;
border: 1px solid var(--color-accent, #c6f41c);
background: var(--color-accent, #c6f41c);
/* Match the active-option pin (lime fg → midnight text) so the
button reads against the lime in both light and dark themes
(m/paliad#123). */
color: var(--color-accent-dark);
cursor: pointer;
transition: background 120ms ease;
}
.event-card-choices-unhide-btn:hover,
.event-card-choices-unhide-btn:focus-visible {
background: var(--color-bg, #fff);
color: var(--color-text);
outline: none;
}
.show-hidden-count {
@@ -3571,6 +3639,36 @@ input[type="range"]::-moz-range-thumb {
margin-left: 0.4rem;
}
/* t-paliad-289: rules whose anchor is uncertain (court-set ancestor
without override, backward-anchor with unset forward date, optional
event not recorded). The "abhängig von <parent>" chip on the date
column makes the conditional state explicit; the dotted border on
the content panel + slight desaturation reinforces it at glance so
the row reads as "pending an upstream input" rather than as a real
scheduled item. The frist-date-edit affordance on the chip still
wires through — the user can pin a concrete date once the anchor
resolves. */
.timeline-item--conditional .timeline-content,
.fr-col-item--conditional {
border: 1px dashed var(--color-border, #d4d4d4);
border-radius: 4px;
padding: 0.35rem 0.55rem;
background: var(--color-bg-soft, #fafafa);
}
.timeline-item--conditional .timeline-name,
.fr-col-item--conditional .timeline-name {
opacity: 0.85;
}
.timeline-conditional {
font-size: 0.82rem;
color: var(--color-text-muted);
font-style: italic;
text-align: right;
}
.event-card-choices-popover {
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #d4d4d4);
@@ -3618,7 +3716,10 @@ input[type="range"]::-moz-range-thumb {
.event-card-choices-option--active {
background: var(--color-accent, #c6f41c);
border-color: var(--color-accent, #c6f41c);
color: var(--color-text);
/* Foreground stays midnight in both themes — --color-text would flip
to cream in dark mode and leave the active "Berufung durch …"
chip unreadable on lime (m/paliad#123). */
color: var(--color-accent-dark);
font-weight: 600;
}
@@ -3751,6 +3852,22 @@ input[type="range"]::-moz-range-thumb {
border: 0;
}
/* "Pick a side" hint that sits next to the side-radio cluster while
currentSide is null (m/paliad#120). Both columns still render every
rule in that state — the chip just nudges the user that picking a
side focuses their column. Hidden by JS once a side is picked. */
.side-radio-cluster {
display: inline-flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.side-hint {
color: var(--color-text-muted, #666);
font-size: 0.85rem;
font-style: italic;
}
/* 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
@@ -6416,6 +6533,194 @@ dialog.modal::backdrop {
margin-left: 0.25rem;
}
/* t-paliad-287 — collapsible variable-group section (Frist + Parteien
override). The toggle button is the section header; clicking it
flips state.collapsedGroups[id] and re-renders. The visible caret
rotates via the parent's --collapsed class. */
.submission-draft-var-group--collapsible > .submission-draft-var-group-toggle {
all: unset;
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0;
margin: 0 0 0.5rem 0;
cursor: pointer;
color: var(--color-text-muted);
}
.submission-draft-var-group--collapsible > .submission-draft-var-group-toggle:focus-visible {
outline: 2px solid var(--color-accent, #c6f41c);
outline-offset: 2px;
}
.submission-draft-var-group-caret {
display: inline-block;
transition: transform 120ms ease;
font-size: 0.85em;
line-height: 1;
}
.submission-draft-var-group--collapsible:not(.submission-draft-var-group--collapsed)
.submission-draft-var-group-caret {
transform: rotate(90deg);
}
.submission-draft-var-group--collapsed .submission-draft-var-group-body {
display: none;
}
/* t-paliad-287 — Add Party affordance per side. */
.submission-draft-addparty {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.submission-draft-addparty-panel {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.6rem;
background: var(--color-surface-alt, #f7f7f0);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.submission-draft-addparty-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 0.25rem;
}
.submission-draft-addparty-tab {
all: unset;
cursor: pointer;
padding: 0.3rem 0.6rem;
font-size: 0.85em;
border-radius: 4px 4px 0 0;
color: var(--color-text-muted);
border-bottom: 2px solid transparent;
}
.submission-draft-addparty-tab--active {
color: var(--color-text);
border-bottom-color: var(--color-accent, #c6f41c);
background: var(--color-bg-lime-tint, #f0fac6);
}
.submission-draft-addparty-form {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.submission-draft-addparty-form--busy {
opacity: 0.6;
pointer-events: none;
}
.submission-draft-addparty-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.submission-draft-addparty-field > span {
font-size: 0.82em;
color: var(--color-text-muted);
}
.submission-draft-addparty-actions {
display: flex;
gap: 0.4rem;
align-items: center;
}
.submission-draft-addparty-search {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.submission-draft-addparty-search-results {
list-style: none;
padding: 0;
margin: 0;
max-height: 14rem;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-surface, #fff);
}
.submission-draft-addparty-search-row {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
}
.submission-draft-addparty-search-row:last-child {
border-bottom: none;
}
.submission-draft-addparty-search-row:hover {
background: var(--color-bg-lime-tint, #f0fac6);
}
.submission-draft-addparty-search-empty {
padding: 0.6rem;
font-size: 0.85em;
color: var(--color-text-muted);
text-align: center;
}
.submission-draft-addparty-search-name {
font-weight: 500;
color: var(--color-text);
}
.submission-draft-addparty-search-rep {
font-size: 0.78em;
color: var(--color-text-muted);
}
.submission-draft-addparty-search-projwrap {
font-size: 0.78em;
color: var(--color-text-muted);
width: 100%;
}
.submission-draft-addparty-search-proj {
color: var(--color-text);
}
.submission-draft-addparty-search-projref {
margin-left: 0.3rem;
padding: 0 0.4em;
border-radius: 3px;
background: var(--color-surface-alt, #f7f7f0);
color: var(--color-text-muted);
}
.submission-draft-addparty-search-hint {
font-size: 0.78em;
color: var(--color-text-muted);
margin: 0;
}
.submission-draft-parties-empty {
font-size: 0.82em;
color: var(--color-text-muted);
margin: 0.2rem 0;
}
.checklist-instance-actions {
display: flex;
gap: 0.35rem;
@@ -8016,7 +8321,7 @@ dialog.modal::backdrop {
padding: 0.05rem 0.45rem;
border-radius: 999px;
background: var(--color-accent);
color: var(--color-text);
color: var(--color-accent-dark);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
@@ -15946,7 +16251,7 @@ dialog.quick-add-sheet::backdrop {
border-radius: 6px;
border: 1px solid var(--color-border-strong);
background: var(--color-accent);
color: var(--color-text);
color: var(--color-accent-dark);
cursor: pointer;
transition: background 120ms ease;
}
@@ -16554,7 +16859,7 @@ dialog.quick-add-sheet::backdrop {
.smart-timeline-anchor-submit {
background: var(--color-accent, #c6f41c);
border: 1px solid var(--color-accent, #c6f41c);
color: var(--color-text, #333);
color: var(--color-accent-dark);
padding: 0.25rem 0.75rem;
border-radius: 4px;
cursor: pointer;
@@ -17492,7 +17797,7 @@ dialog.quick-add-sheet::backdrop {
.admin-rules-chip.active {
background: var(--color-accent, #BFF355);
border-color: var(--color-accent, #BFF355);
color: var(--color-text, #000);
color: var(--color-accent-dark);
}
.admin-rules-pill {
@@ -17880,42 +18185,6 @@ dialog.quick-add-sheet::backdrop {
border-top: 1px solid var(--color-border, #d4d4d8);
}
/* Export page */
.admin-rules-export-controls {
display: flex;
gap: 0.5rem;
align-items: flex-end;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.admin-rules-export-controls .form-field {
flex: 1 1 240px;
}
.admin-rules-export-summary {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
font-size: 0.9rem;
color: var(--color-text-muted, #71717a);
margin-bottom: 0.75rem;
}
.admin-rules-export-pre {
background: var(--color-bg-subtle, #f4f4f5);
border: 1px solid var(--color-border, #d4d4d8);
border-radius: 6px;
padding: 1rem;
overflow: auto;
max-height: 60vh;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8rem;
white-space: pre;
margin: 0;
}
/* Date-range picker (t-paliad-248) ------------------------------------
Symmetric past/future chip fan around an ALLES centre, in a popover
anchored under a closed-state trigger button. Reuses .agenda-chip /

View File

@@ -172,10 +172,13 @@ export function renderSubmissionDraft(): string {
/>
</div>
{/* t-paliad-277: multi-select party picker.
{/* t-paliad-277 / t-paliad-287: multi-select party
picker plus per-side Add-Party affordance.
Populated from view.available_parties; checkbox
per party, grouped by role. Hidden when no
project or no parties on the project. */}
project is attached; visible even on empty
rosters so the lawyer can use Add Party to
populate. */}
<div
id="submission-draft-parties"
className="submission-draft-parties"

View File

@@ -190,9 +190,18 @@ export function renderVerfahrensablauf(): string {
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="" checked />
<span data-i18n="deadlines.side.both">Beide</span>
<span data-i18n="deadlines.side.undefined">Nicht festgelegt</span>
</label>
</div>
{/* Prompt shown while the user hasn't picked a side
(m/paliad#120). Hidden by client when side is
claimant or defendant. Both columns still
render every rule in this state — picking a
side just focuses the user's column. */}
<span className="side-hint" id="side-hint"
data-i18n="deadlines.side.hint">
W&auml;hlen Sie eine Seite, um die Spalten zu fokussieren.
</span>
</div>
{/* Auto-fill chip — populated by the client when a
?project=<id> URL resolves a project with our_side

View File

@@ -299,21 +299,6 @@ func handleAdminPreviewRule(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// GET /admin/api/rules/export-migrations?since=<audit_id>
func handleAdminExportRuleMigrations(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
return
}
since := r.URL.Query().Get("since")
out, err := dbSvc.ruleEditor.ExportMigrationsSince(r.Context(), since)
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// =============================================================================
// Page handlers — serve the static SPA shells. Auth + admin gate live
// at the route registration in handlers.go.
@@ -327,10 +312,6 @@ func handleAdminRulesEditPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-rules-edit.html")
}
func handleAdminRulesExportPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-rules-export.html")
}
// =============================================================================
// helpers
// =============================================================================

View File

@@ -458,6 +458,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// t-paliad-139 — set unit_role on a member.
protected.HandleFunc("PATCH /api/partner-units/{id}/members/{user_id}/role", handleSetUnitMemberRole)
protected.HandleFunc("GET /api/parties/search", handlePartiesSearch)
protected.HandleFunc("DELETE /api/parties/{id}", handleDeleteParty)
// Phase F — Appointments (appointments)
@@ -669,10 +670,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// t-paliad-191 Slice 11a — admin rule-editor API.
// t-paliad-192 Slice 11b — admin rule-editor UI pages + orphan list/resolve.
protected.HandleFunc("GET /admin/rules", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
protected.HandleFunc("GET /admin/rules/export", adminGate(users, gateOnboarded(handleAdminRulesExportPage)))
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
protected.HandleFunc("GET /admin/api/rules", adminGate(users, handleAdminListRules))
protected.HandleFunc("GET /admin/api/rules/export-migrations", adminGate(users, handleAdminExportRuleMigrations))
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, handleAdminGetRule))
protected.HandleFunc("POST /admin/api/rules", adminGate(users, handleAdminCreateRule))
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, handleAdminPatchRule))

View File

@@ -701,6 +701,31 @@ func handleCreateParty(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, p)
}
// GET /api/parties/search?q=...
//
// Cross-project party picker for the submission-draft editor
// (t-paliad-287). Returns up to 25 parties from every project the
// caller can see, matched by case-insensitive substring on name or
// representative. Empty q returns the 20 most-recently-updated rows so
// the picker isn't blank on first open. Visibility is enforced in the
// service layer via the same predicate every project-scoped read uses.
func handlePartiesSearch(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
q := r.URL.Query().Get("q")
hits, err := dbSvc.parties.Search(r.Context(), uid, q, 25)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"results": hits})
}
// DELETE /api/parties/{id}
func handleDeleteParty(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {

View File

@@ -4,63 +4,20 @@
package models
import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// NullableJSON is a jsonb column that may be NULL. json.RawMessage
// (and *json.RawMessage) doesn't implement sql.Scanner, so a NULL value
// from Postgres breaks the row scan with "unsupported Scan, storing
// driver.Value type <nil> into type *json.RawMessage" — exactly the
// error that hid every approval_request from the inbox when m's first
// "create" lifecycle row arrived with NULL pre_image (m's dogfood
// 2026-05-08 20:35). Using NullableJSON on every nullable jsonb column
// fixes the scan and preserves inline JSON output (no base64 cast).
type NullableJSON []byte
func (n *NullableJSON) Scan(value any) error {
if value == nil {
*n = nil
return nil
}
switch v := value.(type) {
case []byte:
*n = append((*n)[:0], v...)
return nil
case string:
*n = []byte(v)
return nil
}
return fmt.Errorf("NullableJSON: unsupported scan type %T", value)
}
func (n NullableJSON) Value() (driver.Value, error) {
if len(n) == 0 {
return nil, nil
}
return []byte(n), nil
}
func (n NullableJSON) MarshalJSON() ([]byte, error) {
if len(n) == 0 {
return []byte("null"), nil
}
return []byte(n), nil
}
func (n *NullableJSON) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
*n = nil
return nil
}
*n = append((*n)[:0], data...)
return nil
}
// NullableJSON is a jsonb column that may be NULL. Canonical definition
// (with sql.Scanner / driver.Valuer / json.Marshaler / json.Unmarshaler)
// lives in pkg/litigationplanner — kept here as a type alias so every
// existing models.NullableJSON reference continues to compile.
type NullableJSON = litigationplanner.NullableJSON
// User extends auth.users with firm-specific profile fields. Created by the
// Phase D onboarding flow; without a row here, the user can't see any Projects.
@@ -584,112 +541,10 @@ type Party struct {
}
// DeadlineRule is one rule in the proceeding-rule tree (UPC R.023, etc.).
type DeadlineRule struct {
ID uuid.UUID `db:"id" json:"id"`
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
SubmissionCode *string `db:"submission_code" json:"submission_code,omitempty"`
Name string `db:"name" json:"name"`
NameEN string `db:"name_en" json:"name_en"`
Description *string `db:"description" json:"description,omitempty"`
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
EventType *string `db:"event_type" json:"event_type,omitempty"`
DurationValue int `db:"duration_value" json:"duration_value"`
DurationUnit string `db:"duration_unit" json:"duration_unit"`
Timing *string `db:"timing" json:"timing,omitempty"`
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
DeadlineNotesEn *string `db:"deadline_notes_en" json:"deadline_notes_en,omitempty"`
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"`
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
// ConceptDefaultEventTypeID is the canonical paliad.event_types row for
// this rule's concept (joined via paliad.deadline_concept_event_types
// where is_default = true). Lets the deadline create form auto-populate
// the Typ chip when the user picks this rule. Hydrated by the service
// layer; not a column. NULL when the concept has no mapped event_type.
ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"`
LegalSource *string `db:"legal_source" json:"legal_source,omitempty"`
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
IsActive bool `db:"is_active" json:"is_active"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
// ---------------------------------------------------------------
// Phase 3 unified-rule columns (mig 078, t-paliad-182).
// Slice 9 (t-paliad-195) dropped the legacy IsMandatory /
// IsOptional / ConditionFlag / ConditionRuleID fields — they
// were superseded by Priority / ConditionExpr / IsCourtSet and
// the unified calculator no longer reads them.
// ---------------------------------------------------------------
// TriggerEventID points at paliad.trigger_events when this rule is
// event-rooted (Pipeline C unification, design §2.5). NULL on
// proceeding-rooted rules. Exactly one of (proceeding_type_id,
// trigger_event_id) is set after Slice 3.
TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"`
// SpawnProceedingTypeID is the cross-proceeding spawn target —
// when is_spawn=true and this is non-NULL, the calculator follows
// the FK and emits the target proceeding's root rule chain. Slice
// 7 backfills the 8 live is_spawn=true rows.
SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"`
// CombineOp is 'max' or 'min' for composite-rule arithmetic
// (R.198 / R.213: "31d OR 20 working_days, whichever is longer").
// NULL = single-anchor arithmetic.
CombineOp *string `db:"combine_op" json:"combine_op,omitempty"`
// ConditionExpr is the jsonb gating expression replacing
// ConditionFlag (design §2.4). Grammar:
// {"flag": "<name>"}
// {"op":"and"|"or", "args":[<node>, ...]}
// {"op":"not", "args":[<node>]}
// NULL or {} = unconditional. NullableJSON so a NULL column scans
// cleanly (the row mishap that hid approval rows from the inbox
// must not recur on rule rows).
ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"`
// Priority is the 4-way unified enum replacing
// (IsMandatory, IsOptional). Values: 'mandatory' (default),
// 'recommended', 'optional', 'informational'. Backfilled in
// Slice 2; legacy callers read IsMandatory + IsOptional until
// Slice 4 cuts them over.
Priority string `db:"priority" json:"priority"`
// IsCourtSet replaces the runtime heuristic
// (primary_party='court' OR event_type IN ('hearing','decision',
// 'order')). Backfilled in Slice 2; legacy callers read the
// heuristic until Slice 4.
IsCourtSet bool `db:"is_court_set" json:"is_court_set"`
// LifecycleState drives the rule-editor flow (design §4.2):
// 'draft' (admin work-in-progress) | 'published' (live, calculator-
// visible) | 'archived' (historical, retained for audit). Every
// pre-Slice-1 row defaults to 'published' via the migration.
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
// DraftOf points at the published rule this draft will replace on
// publish. NULL on published / archived rows. NULL also on net-
// new drafts that have no prior published peer.
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
// PublishedAt records when the row entered LifecycleState='published'.
// 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"`
}
// Canonical definition lives in pkg/litigationplanner.Rule — kept here
// as a type alias so every existing models.DeadlineRule reference (sqlx
// scans, hydration, projection service) continues to compile.
type DeadlineRule = litigationplanner.Rule
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
// append-only audit log for every change to paliad.deadline_rules.
@@ -721,43 +576,19 @@ type DeadlineRuleAudit struct {
MigrationExported bool `db:"migration_exported" json:"migration_exported"`
}
// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter
// management) or the lowercase dot-separated fristenrechner codes
// (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — see
// docs/design-proceeding-code-taxonomy-2026-05-18.md.
type ProceedingType struct {
ID int `db:"id" json:"id"`
Code string `db:"code" json:"code"`
Name string `db:"name" json:"name"`
NameEN string `db:"name_en" json:"name_en"`
Description *string `db:"description" json:"description,omitempty"`
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
Category *string `db:"category" json:"category,omitempty"`
DefaultColor string `db:"default_color" json:"default_color"`
SortOrder int `db:"sort_order" json:"sort_order"`
IsActive bool `db:"is_active" json:"is_active"`
// TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf
// "Auslösendes Ereignis". When set, overrides the proceedingName fallback
// that fires when no rule has IsRootEvent=true. Populated for UPC Appeal
// (mig 121) so the caption reads "Anfechtbare Entscheidung" /
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
// NULL on most proceedings — they already carry a root rule.
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
}
// ProceedingType is one of the litigation conceptual codes (INF / REV /
// CCR / APM / APP / AMD / ZPO_CIVIL) or the lowercase dot-separated
// fristenrechner codes (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — see
// docs/design-proceeding-code-taxonomy-2026-05-18.md. Canonical
// definition lives in pkg/litigationplanner.ProceedingType — kept here
// as a type alias so every existing models.ProceedingType reference
// continues to compile.
type ProceedingType = litigationplanner.ProceedingType
// TriggerEvent is a UPC procedural event that can start one or more deadlines
// running. Powers the "Was kommt nach…" Fristenrechner mode (event-driven
// lookup, mirrored from youpc data.events).
type TriggerEvent struct {
ID int64 `db:"id" json:"id"`
Code string `db:"code" json:"code"`
Name string `db:"name" json:"name"`
NameDE string `db:"name_de" json:"name_de"`
Description string `db:"description" json:"description"`
IsActive bool `db:"is_active" json:"is_active"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// TriggerEvent is a UPC procedural event referenced by deadline rules
// whose semantic anchor is an event rather than a parent rule.
// Canonical definition lives in pkg/litigationplanner.TriggerEvent.
type TriggerEvent = litigationplanner.TriggerEvent
// EventDeadline is a single deadline that flows from a TriggerEvent. Mirrors
// youpc data.deadlines + the trigger half of data.deadline_events.

View File

@@ -38,7 +38,8 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
choices_offered`
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
category, default_color, sort_order, is_active`
category, default_color, sort_order, is_active,
trigger_event_label_de, trigger_event_label_en`
// List returns active rules, optionally filtered by proceeding type.
// Each row has ConceptDefaultEventTypeID hydrated from
@@ -207,6 +208,44 @@ func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]
return rules, nil
}
// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows for the
// given id set, keyed by id. Returns nil, nil for an empty input set so
// callers can blindly forward whatever they accumulated. Inactive rows
// are included — the conditional-label resolution in fristenrechner.go
// surfaces the trigger event's display name even when the catalog row
// has been retired, which is preferable to silently falling back to
// the (wrong) parent_id name.
//
// Used by FristenrechnerService.Calculate to redirect a conditional
// rule's "abhängig von …" chip from parent_id to trigger_event_id —
// the actual semantic anchor for rules whose data-model parent is the
// proceeding root but whose real trigger sits in the trigger_events
// catalog (e.g. R.262(2) Erwiderung auf Vertraulichkeitsantrag → the
// opposing party's confidentiality application). See m/paliad#126.
func (s *DeadlineRuleService) LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]models.TriggerEvent, error) {
if len(ids) == 0 {
return nil, nil
}
query, args, err := sqlx.In(
`SELECT id, code, name, name_de, description, is_active, created_at
FROM paliad.trigger_events
WHERE id IN (?)`, ids)
if err != nil {
return nil, fmt.Errorf("build trigger_events IN query: %w", err)
}
query = s.db.Rebind(query)
var rows []models.TriggerEvent
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, fmt.Errorf("load trigger_events by ids %v: %w", ids, err)
}
out := make(map[int64]models.TriggerEvent, len(rows))
for _, r := range rows {
out[r.ID] = r
}
return out, nil
}
// ListByTriggerEvent returns active rules scoped to a single trigger
// event — the Pipeline-C surface added by Phase 3 Slice 3 (mig 085).
// These rules carry proceeding_type_id IS NULL (event-rooted) and have

View File

@@ -9,6 +9,8 @@ import (
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// DeadlineSearchService backs the unified Fristenrechner search bar
@@ -921,130 +923,15 @@ func roundScore(v float64) float64 {
return float64(int(v*10000+0.5)) / 10000
}
// FormatLegalSourceDisplay renders a structured legal_source code into
// the form HLC users read in pleadings:
//
// UPC.RoP.23.1 → "UPC RoP R.23(1)"
// UPC.RoP.139 → "UPC RoP R.139"
// DE.PatG.82.1 → "PatG §82(1)"
// DE.ZPO.276.1 → "ZPO §276(1)"
// EU.EPÜ.108 → "EPÜ Art.108"
// EU.EPC-R.79.1 → "EPC R.79(1)"
// EU.RPBA.12.1.c → "RPBA Art.12(1)(c)"
//
// Returns the empty string for an empty input. Unknown jurisdictions
// fall through with the structured form preserved (caller decides
// whether to display).
// FormatLegalSourceDisplay + BuildLegalSourceURL are canonically
// defined in pkg/litigationplanner — kept here as thin re-exports so
// the existing in-package + handler call-sites compile unchanged.
func FormatLegalSourceDisplay(src string) string {
src = strings.TrimSpace(src)
if src == "" {
return ""
}
parts := strings.Split(src, ".")
if len(parts) < 3 {
// Malformed — return as-is so the caller still has something.
return src
}
code := parts[1]
rest := parts[2:]
var prefix string
switch code {
case "RoP":
prefix = "UPC RoP R."
case "PatG":
prefix = "PatG §"
case "ZPO":
prefix = "ZPO §"
case "EPÜ":
prefix = "EPÜ Art."
case "EPC-R":
prefix = "EPC R."
case "RPBA":
prefix = "RPBA Art."
default:
prefix = code + " "
}
var b strings.Builder
b.Grow(len(prefix) + len(src))
b.WriteString(prefix)
b.WriteString(rest[0])
for _, p := range rest[1:] {
b.WriteByte('(')
b.WriteString(p)
b.WriteByte(')')
}
return b.String()
return lp.FormatLegalSourceDisplay(src)
}
// BuildLegalSourceURL maps a structured legal_source code to a
// youpc.org/laws permalink when the cited body is hosted there. Today
// youpc only carries the UPC corpus (UPCA, UPCS, UPCRoP); DE national
// codes (PatG, ZPO) and EPO bodies (EPÜ, EPC-R, RPBA) have no youpc
// home yet, so the helper returns the empty string for those and the
// caller renders the display string as plain text.
//
// Inputs mirror FormatLegalSourceDisplay — structured dot-separated
// codes like UPC.RoP.23.1, UPC.UPCA.83. Sub-paragraph segments beyond
// the law-number position are dropped; youpc resolves the page at
// <type>.<number> granularity. The law-number is zero-padded to 3
// digits to match how youpc stores law_number (laws-data.json carries
// "001" / "023" / "220" forms).
//
// URL shape uses the hash-fragment form that youpc itself emits from
// its laws-page redirect (handlers/laws.go:215+229) — the canonical
// in-app deep link target. The `/laws/:type/:number` pretty route also
// resolves the same page but redirects to the hash form anyway.
//
// UPC.RoP.23.1 → https://youpc.org/laws#UPCRoP.023
// UPC.RoP.139 → https://youpc.org/laws#UPCRoP.139
// UPC.RoP.220.1 → https://youpc.org/laws#UPCRoP.220
// UPC.RoP.29.a → https://youpc.org/laws#UPCRoP.029
// UPC.UPCA.83 → https://youpc.org/laws#UPCA.083
// DE.ZPO.276.1 → "" (no youpc home — render display text plain)
func BuildLegalSourceURL(src string) string {
src = strings.TrimSpace(src)
if src == "" {
return ""
}
parts := strings.Split(src, ".")
if len(parts) < 3 {
return ""
}
var lawType string
switch parts[0] + "." + parts[1] {
case "UPC.RoP":
lawType = "UPCRoP"
case "UPC.UPCA":
lawType = "UPCA"
case "UPC.UPCS":
lawType = "UPCS"
default:
return ""
}
number := padLawNumber(parts[2])
if number == "" {
return ""
}
return "https://youpc.org/laws#" + lawType + "." + number
}
// padLawNumber zero-pads a pure-digit law-number segment to 3 digits.
// Non-digit-only inputs (e.g. "112a" if youpc ever ingests EPÜ Art.
// 112a) pass through unchanged so the URL still resolves. Empty input
// returns the empty string.
func padLawNumber(s string) string {
if s == "" {
return ""
}
for _, c := range s {
if c < '0' || c > '9' {
return s
}
}
if len(s) >= 3 {
return s
}
return strings.Repeat("0", 3-len(s)) + s
return lp.BuildLegalSourceURL(src)
}
// RefreshSearchView re-populates the materialised view. Safe to call on

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,221 @@
package services
// Pure-function tests for the trigger-group duration sort introduced
// by t-paliad-296 / m/paliad#128. No DB needed — feeds synthetic
// UIDeadlines and a ruleByID map directly into the helper.
import (
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
// makeRule is a tiny constructor for a synthetic rule with just the
// fields the sort reads (parent_id, duration_value, duration_unit,
// submission_code, trigger_event_id).
func makeRule(t *testing.T, parent *uuid.UUID, code string, val int, unit string) (uuid.UUID, models.DeadlineRule) {
t.Helper()
id := uuid.New()
codeCopy := code
return id, models.DeadlineRule{
ID: id,
ParentID: parent,
SubmissionCode: &codeCopy,
DurationValue: val,
DurationUnit: unit,
}
}
func makeDeadline(id uuid.UUID, code string) UIDeadline {
return UIDeadline{
RuleID: id.String(),
Code: code,
}
}
// TestSortDeadlinesByDurationWithinTriggerGroup_PostDecision is the
// canonical scenario from m's report — four post-decision optional
// events anchored on the same decision must render with 1-month rules
// before 2-month rules.
func TestSortDeadlinesByDurationWithinTriggerGroup_PostDecision(t *testing.T) {
decisionID := uuid.New()
// Catalog order matches mig 132 sequence_order: cons_orders(60),
// cost_app(70), rectification(70), appeal_spawn(80).
consOrdID, consOrdRule := makeRule(t, &decisionID, "upc.inf.cfi.cons_orders", 2, "months")
costAppID, costAppRule := makeRule(t, &decisionID, "upc.inf.cfi.cost_app", 1, "months")
rectID, rectRule := makeRule(t, &decisionID, "upc.inf.cfi.rectification", 1, "months")
appealID, appealRule := makeRule(t, &decisionID, "upc.inf.cfi.appeal_spawn", 2, "months")
ruleByID := map[uuid.UUID]models.DeadlineRule{
consOrdID: consOrdRule,
costAppID: costAppRule,
rectID: rectRule,
appealID: appealRule,
}
deadlines := []UIDeadline{
makeDeadline(consOrdID, "upc.inf.cfi.cons_orders"),
makeDeadline(costAppID, "upc.inf.cfi.cost_app"),
makeDeadline(rectID, "upc.inf.cfi.rectification"),
makeDeadline(appealID, "upc.inf.cfi.appeal_spawn"),
}
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
// 1-month tier first (cost_app, rectification — alphabetical by
// submission_code), then 2-month tier (appeal_spawn, cons_orders
// — submission_code ASC tiebreak per spec).
want := []string{
"upc.inf.cfi.cost_app",
"upc.inf.cfi.rectification",
"upc.inf.cfi.appeal_spawn",
"upc.inf.cfi.cons_orders",
}
for i, w := range want {
if deadlines[i].Code != w {
t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w)
}
}
}
// TestSortDeadlinesByDurationWithinTriggerGroup_UnitWeight asserts the
// unit-weight ordering: days < weeks < months < years, with shorter
// durations of the same unit winning their tier.
func TestSortDeadlinesByDurationWithinTriggerGroup_UnitWeight(t *testing.T) {
parentID := uuid.New()
d14ID, d14Rule := makeRule(t, &parentID, "x.14days", 14, "days")
d2wID, d2wRule := makeRule(t, &parentID, "x.2weeks", 2, "weeks")
d1mID, d1mRule := makeRule(t, &parentID, "x.1month", 1, "months")
d6mID, d6mRule := makeRule(t, &parentID, "x.6months", 6, "months")
d1yID, d1yRule := makeRule(t, &parentID, "x.1year", 1, "years")
ruleByID := map[uuid.UUID]models.DeadlineRule{
d14ID: d14Rule, d2wID: d2wRule, d1mID: d1mRule, d6mID: d6mRule, d1yID: d1yRule,
}
deadlines := []UIDeadline{
makeDeadline(d6mID, "x.6months"),
makeDeadline(d1yID, "x.1year"),
makeDeadline(d2wID, "x.2weeks"),
makeDeadline(d14ID, "x.14days"),
makeDeadline(d1mID, "x.1month"),
}
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
want := []string{"x.14days", "x.2weeks", "x.1month", "x.6months", "x.1year"}
for i, w := range want {
if deadlines[i].Code != w {
t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w)
}
}
}
// TestSortDeadlinesByDurationWithinTriggerGroup_NoCrossGroupReorder
// guards the hard rule: rules with different parents must keep their
// relative position. Sorting only ever permutes adjacent same-parent
// rows.
func TestSortDeadlinesByDurationWithinTriggerGroup_NoCrossGroupReorder(t *testing.T) {
parentAID := uuid.New()
parentBID := uuid.New()
a3mID, a3mRule := makeRule(t, &parentAID, "ga.3months", 3, "months")
b1mID, b1mRule := makeRule(t, &parentBID, "gb.1month", 1, "months")
a14dID, a14dRule := makeRule(t, &parentAID, "ga.14days", 14, "days")
b2mID, b2mRule := makeRule(t, &parentBID, "gb.2months", 2, "months")
ruleByID := map[uuid.UUID]models.DeadlineRule{
a3mID: a3mRule, b1mID: b1mRule, a14dID: a14dRule, b2mID: b2mRule,
}
// Interleaved groups: A, B, A, B. Each group has one rule between
// each other group's rules — the consecutive-run walk should treat
// each as its own one-element run and not reorder anything.
deadlines := []UIDeadline{
makeDeadline(a3mID, "ga.3months"),
makeDeadline(b1mID, "gb.1month"),
makeDeadline(a14dID, "ga.14days"),
makeDeadline(b2mID, "gb.2months"),
}
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
want := []string{"ga.3months", "gb.1month", "ga.14days", "gb.2months"}
for i, w := range want {
if deadlines[i].Code != w {
t.Errorf("deadlines[%d].Code = %q, want %q (interleaved groups must not reorder across)", i, deadlines[i].Code, w)
}
}
}
// TestSortDeadlinesByDurationWithinTriggerGroup_ConditionalLast asserts
// that court-set / conditional rows (no concrete date in the duration
// ladder) sort LAST within their group, regardless of their stated
// duration value.
func TestSortDeadlinesByDurationWithinTriggerGroup_ConditionalLast(t *testing.T) {
parentID := uuid.New()
dID, dRule := makeRule(t, &parentID, "x.duration", 2, "months")
cID, cRule := makeRule(t, &parentID, "x.conditional", 1, "months")
csID, csRule := makeRule(t, &parentID, "x.courtset", 1, "months")
d2ID, d2Rule := makeRule(t, &parentID, "x.short", 14, "days")
ruleByID := map[uuid.UUID]models.DeadlineRule{
dID: dRule, cID: cRule, csID: csRule, d2ID: d2Rule,
}
deadlines := []UIDeadline{
{RuleID: cID.String(), Code: "x.conditional", IsConditional: true},
{RuleID: dID.String(), Code: "x.duration"},
{RuleID: csID.String(), Code: "x.courtset", IsCourtSet: true},
{RuleID: d2ID.String(), Code: "x.short"},
}
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
// Concrete rows first (sorted by duration): x.short (14d) then
// x.duration (2mo). Then the two no-date rows, tiebroken by code:
// x.conditional < x.courtset alphabetically.
want := []string{"x.short", "x.duration", "x.conditional", "x.courtset"}
for i, w := range want {
if deadlines[i].Code != w {
t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w)
}
}
}
// TestSortDeadlinesByDurationWithinTriggerGroup_RootsNotMerged guards
// the root-rule exception: top-level rules (parent_id=nil, no
// trigger_event_id) must never be sorted against each other — they
// represent distinct anchor points (SoC vs oral hearing vs decision)
// whose proceeding-sequence order is non-negotiable.
func TestSortDeadlinesByDurationWithinTriggerGroup_RootsNotMerged(t *testing.T) {
rootSoCID, rootSoCRule := makeRule(t, nil, "x.soc", 0, "months")
rootOralID, rootOralRule := makeRule(t, nil, "x.oral", 0, "months")
rootDecID, rootDecRule := makeRule(t, nil, "x.decision", 0, "months")
ruleByID := map[uuid.UUID]models.DeadlineRule{
rootSoCID: rootSoCRule, rootOralID: rootOralRule, rootDecID: rootDecRule,
}
deadlines := []UIDeadline{
makeDeadline(rootSoCID, "x.soc"),
makeDeadline(rootOralID, "x.oral"),
makeDeadline(rootDecID, "x.decision"),
}
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
// Roots must keep their input order — they're not in the same
// trigger group as each other.
want := []string{"x.soc", "x.oral", "x.decision"}
for i, w := range want {
if deadlines[i].Code != w {
t.Errorf("deadlines[%d].Code = %q, want %q (roots must not be sorted against each other)", i, deadlines[i].Code, w)
}
}
}

View File

@@ -450,3 +450,182 @@ func TestUIDeadline_WireShape_Slice8(t *testing.T) {
t.Logf("warning: no upc.inf.cfi rule had conditionExpr populated — verify mig 084 ran")
}
}
// t-paliad-289: rules anchored on uncertain triggers must render as
// conditional (IsConditional=true, empty DueDate, ParentRule* populated)
// rather than fabricating a date off the trigger.
//
// Three pillars from the issue:
// - Symptom A: R.109(1) Antrag auf Simultanübersetzung (timing='before',
// parent=Mündliche Verhandlung which is court-set). Pre-fix the rule
// computed a meaningless "1 month before today" because sequence_order
// places translation_request (45) before oral (50), so the parent
// hadn't been classified as court-set yet. The new pre-pass in
// Calculate seeds courtSet from is_court_set=true on the data, so
// order-of-evaluation no longer matters.
// - R.118(4) cons_orders (parent=Entscheidung, court-set) — already
// worked via the legacy IsCourtSetIndirect path; assertion ensures
// the new IsConditional flag rides alongside it.
// - Symptom B: R.262(2) confidentiality_response (priority='optional',
// primary_party='both', parent=SoC which is the trigger anchor).
// The data-model parent is "always certain" but the real triggering
// event (opposing party's confidentiality motion) sits outside the
// rule data — render conditional until the user anchors the rule.
func TestUIDeadline_IsConditional_UncertainAnchors(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
holidays := NewHolidayService(pool)
rules := NewDeadlineRuleService(pool)
courts := NewCourtService(pool)
svc := NewFristenrechnerService(rules, holidays, courts)
resp, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-05-25", CalcOptions{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := map[string]UIDeadline{}
for _, d := range resp.Deadlines {
byCode[d.Code] = d
}
cases := []struct {
code string
wantConditional bool
wantParentCode string
}{
// Symptom A — backward-anchored on the court-set oral hearing.
// Pre-pass fix: order-of-evaluation no longer matters. These
// rules have no trigger_event_id, so ParentRuleCode stays on
// the parent_id-derived value.
{"upc.inf.cfi.translation_request", true, "upc.inf.cfi.oral"},
{"upc.inf.cfi.interpreter_cost", true, "upc.inf.cfi.oral"},
// R.118(4) chain — parent=decision (court-set). No trigger_event_id.
{"upc.inf.cfi.cons_orders", true, "upc.inf.cfi.decision"},
// Symptom B — optional + both, data-model parent is SoC but the
// real trigger is the opposing party's confidentiality application.
// m/paliad#126 / t-paliad-294: ParentRuleCode now reflects the
// trigger_events catalog row (id=25), NOT the parent_id chain.
{"upc.inf.cfi.confidentiality_response", true, "application_to_request_confidentiality_from_the_public"},
// Negative control — mandatory rule anchored on SoC must keep
// its concrete date (no IsConditional, real DueDate). No
// trigger_event_id, so parent_id-derived code stays.
{"upc.inf.cfi.sod", false, "upc.inf.cfi.soc"},
}
for _, c := range cases {
t.Run(c.code, func(t *testing.T) {
d, ok := byCode[c.code]
if !ok {
t.Fatalf("rule %s missing from response", c.code)
}
if d.IsConditional != c.wantConditional {
t.Errorf("IsConditional = %v, want %v", d.IsConditional, c.wantConditional)
}
if c.wantConditional {
if d.DueDate != "" {
t.Errorf("DueDate = %q, want empty (conditional)", d.DueDate)
}
if d.ParentRuleCode != c.wantParentCode {
t.Errorf("ParentRuleCode = %q, want %q", d.ParentRuleCode, c.wantParentCode)
}
if d.ParentRuleName == "" {
t.Errorf("ParentRuleName empty for conditional rule")
}
} else {
if d.DueDate == "" {
t.Errorf("non-conditional rule has empty DueDate")
}
}
})
}
// m/paliad#126 / t-paliad-294: the conditional chip for R.262(2)
// reads from the trigger_events catalog (id=25), so the user sees
// the actual semantic anchor instead of the parent_id-derived
// "Klageerhebung". Pin the exact DE + EN strings so a future
// rename of the catalog row surfaces here.
t.Run("R.262(2) conditional label uses trigger_event_id, not parent_id", func(t *testing.T) {
d, ok := byCode["upc.inf.cfi.confidentiality_response"]
if !ok {
t.Fatalf("confidentiality_response missing from response")
}
const wantNameDE = "Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit"
const wantNameEN = "Application to request confidentiality from the public"
if d.ParentRuleName != wantNameDE {
t.Errorf("ParentRuleName = %q, want %q (trigger_events.name_de for id=25)", d.ParentRuleName, wantNameDE)
}
if d.ParentRuleNameEN != wantNameEN {
t.Errorf("ParentRuleNameEN = %q, want %q (trigger_events.name for id=25)", d.ParentRuleNameEN, wantNameEN)
}
// Negative guard — neither label should leak the SoC ("Klageerhebung"),
// which is the regression the fix exists to prevent.
if d.ParentRuleName == "Klageerhebung" || d.ParentRuleNameEN == "Statement of Claim" {
t.Errorf("conditional label still resolves via parent_id (SoC); fix regressed")
}
})
// Generalisation guard — translations_lodge also carries a real
// trigger_event_id (113 = judge-rapporteur's order). Its
// conditional chip should reference the order, not its parent_id
// (Zwischenverfahren). Locks in the "any rule with trigger_event_id
// uses THAT, not parent_id" contract from m/paliad#126.
t.Run("translations_lodge conditional label uses trigger_event_id", func(t *testing.T) {
d, ok := byCode["upc.inf.cfi.translations_lodge"]
if !ok {
t.Skip("upc.inf.cfi.translations_lodge missing from response — data drift?")
}
if !d.IsConditional {
t.Skipf("translations_lodge IsConditional=false in current corpus; trigger-event override is only user-visible on conditional rows. Skip but keep the generalisation guard.")
}
if d.ParentRuleName == "Zwischenverfahren" {
t.Errorf("translations_lodge still labelled via parent_id (Zwischenverfahren); should follow trigger_event_id=113")
}
if d.ParentRuleCode != "order_of_the_judge_rapporteur_to_lodge_translations" {
t.Errorf("ParentRuleCode = %q, want trigger_events.code for id=113", d.ParentRuleCode)
}
})
// Override path: when the user anchors the oral hearing, the
// backward-anchored R.109(1) flips back to a concrete date and
// IsConditional clears. This is the click-to-edit unblock.
t.Run("override on court-set parent clears IsConditional", func(t *testing.T) {
resp2, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-05-25", CalcOptions{
AnchorOverrides: map[string]string{
"upc.inf.cfi.oral": "2027-03-01",
},
})
if err != nil {
t.Fatalf("Calculate with override: %v", err)
}
var tr UIDeadline
for _, d := range resp2.Deadlines {
if d.Code == "upc.inf.cfi.translation_request" {
tr = d
break
}
}
if tr.IsConditional {
t.Errorf("translation_request IsConditional=true after oral override; want false")
}
if tr.DueDate == "" {
t.Errorf("translation_request DueDate empty after oral override")
}
// 1 month before 2027-03-01 = ~2027-02-01 (with weekend bump).
if tr.DueDate < "2027-01-25" || tr.DueDate > "2027-02-05" {
t.Errorf("translation_request DueDate=%q not within expected 2027-01-25..2027-02-05 window", tr.DueDate)
}
})
}

View File

@@ -8,6 +8,8 @@ import (
"time"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// Country and regime constants — keep in sync with the paliad.countries
@@ -229,38 +231,14 @@ func (s *HolidayService) AdjustForNonWorkingDays(date time.Time, country, regime
// Feiertag" — so a 27-day shift across UPC vacation no longer looks like a
// math bug. See t-paliad-119.
//
// Date fields are JSON-serialised as YYYY-MM-DD strings (the same convention
// as UIDeadline.DueDate / OriginalDate) so the frontend doesn't need a
// separate RFC3339 parser. Holidays carries the same string-date shape.
type AdjustmentReason struct {
// Kind is the dominant cause; longest cause wins when several apply
// (vacation > public_holiday > weekend).
Kind string `json:"kind"`
// Holidays collects every named holiday encountered while walking past
// the non-working run, deduped by (date, name). May be empty when the
// only cause is a weekend.
Holidays []HolidayDTO `json:"holidays,omitempty"`
// VacationName, VacationStart and VacationEnd describe the contiguous
// vacation block the original date sits in. Populated only when Kind
// == "vacation". Span boundaries are the first/last vacation day in
// the block (excludes the weekends that pad it).
VacationName string `json:"vacationName,omitempty"`
VacationStart string `json:"vacationStart,omitempty"`
VacationEnd string `json:"vacationEnd,omitempty"`
// OriginalWeekday is the English weekday name of the original date —
// "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI
// can localise it.
OriginalWeekday string `json:"originalWeekday,omitempty"`
}
// HolidayDTO is the JSON shape for a holiday emitted in AdjustmentReason —
// distinct from Holiday so dates serialise as YYYY-MM-DD strings.
type HolidayDTO struct {
Date string `json:"date"`
Name string `json:"name"`
IsVacation bool `json:"isVacation,omitempty"`
IsClosure bool `json:"isClosure,omitempty"`
}
// Canonical AdjustmentReason + HolidayDTO definitions live in
// pkg/litigationplanner — kept here as type aliases so every existing
// reference (HolidayService methods, JSON serialisation, projection
// service) continues to compile.
type (
AdjustmentReason = litigationplanner.AdjustmentReason
HolidayDTO = litigationplanner.HolidayDTO
)
// AdjustForNonWorkingDaysWithReason is AdjustForNonWorkingDays plus an
// explanation. Reason is nil when wasAdjusted is false.

View File

@@ -38,6 +38,59 @@ type CreatePartyInput struct {
ContactInfo json.RawMessage `json:"contact_info,omitempty"`
}
// PartySearchHit is one row of the cross-project party search — a real
// paliad.parties row enriched with the parent project's title and
// reference so the picker can render context the lawyer needs to
// disambiguate identically-named parties on different cases
// (t-paliad-287).
type PartySearchHit struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
ProjectTitle string `db:"project_title" json:"project_title"`
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
Name string `db:"name" json:"name"`
Role *string `db:"role" json:"role,omitempty"`
Representative *string `db:"representative" json:"representative,omitempty"`
}
// Search returns parties from every project the caller can see, matched
// by case-insensitive substring on name OR representative. Empty query
// returns the 20 most recently-updated parties so the picker isn't
// blank on first open. Capped at 25 rows; the frontend doesn't paginate
// (the typical PA looks for one party they remember by name, not browses).
//
// Visibility is enforced inline via visibilityPredicatePositional —
// invisible projects' parties never surface in the result set.
func (s *PartyService) Search(ctx context.Context, userID uuid.UUID, query string, limit int) ([]PartySearchHit, error) {
if limit <= 0 || limit > 50 {
limit = 25
}
q := strings.TrimSpace(query)
args := []any{userID}
conds := []string{visibilityPredicatePositional("p", 1)}
if q != "" {
args = append(args, "%"+q+"%")
conds = append(conds,
fmt.Sprintf(`(pa.name ILIKE $%d OR COALESCE(pa.representative,'') ILIKE $%d)`,
len(args), len(args)))
}
args = append(args, limit)
sqlStr := `
SELECT pa.id, pa.project_id, p.title AS project_title,
p.reference AS project_reference,
pa.name, pa.role, pa.representative
FROM paliad.parties pa
JOIN paliad.projects p ON p.id = pa.project_id
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY pa.updated_at DESC
LIMIT $` + fmt.Sprintf("%d", len(args))
hits := []PartySearchHit{}
if err := s.db.SelectContext(ctx, &hits, sqlStr, args...); err != nil {
return nil, fmt.Errorf("search parties: %w", err)
}
return hits, nil
}
// ListForProject returns all Parties for the Project, visibility-checked.
func (s *PartyService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Party, error) {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {

View File

@@ -1,191 +1,63 @@
package services
// proceeding_mapping bridges the two proceeding-type vocabularies in the
// codebase: the **litigation** conceptual category (INF / REV / APP /
// CCR / AMD / APM / ZPO_CIVIL) used by the historical project-binding
// + Pipeline-A rules, and the **fristenrechner** code category
// (upc.inf.cfi / de.inf.lg / epa.opp.opd / …) used by the Determinator
// cascade + rule engine. Post-Phase-3-Slice-5 (t-paliad-186) projects
// bind to fristenrechner codes directly, but the litigation→fristenrechner
// mapping is still needed for the ~40 Pipeline-A rules that remain on
// litigation proceedings and for any other surface that thinks in
// litigation terms.
//
// The mapping table here is the single source of truth — see
// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the
// design rationale + ambiguity notes, and
// docs/design-proceeding-code-taxonomy-2026-05-18.md for the
// lowercase dot-separated naming convention applied by mig 096
// (t-paliad-206). **Never silent FK promotion**: every ambiguous case
// returns ok=false so callers can degrade gracefully ("no narrowing")
// instead of guessing.
import lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
// Stable code constants — the strings landed by mig 096. Use these
// throughout the codebase so a future rename only needs to touch this
// file. The id-anchored FKs (deadline_rules.proceeding_type_id,
// projects.proceeding_type_id) are unaffected by the rename.
// proceeding_mapping bridges the two proceeding-type vocabularies in
// the codebase. The canonical implementations now live in
// pkg/litigationplanner — this file keeps the existing service-level
// names alive as re-exports so the rest of internal/services + tests
// compile without an import-rewrite.
//
// See pkg/litigationplanner/proceeding_mapping.go for the logic +
// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the
// design rationale.
// Stable code constants — re-exported from the package so existing
// services / handlers can keep using the bare names.
const (
CodeUPCInfringement = "upc.inf.cfi"
CodeUPCRevocation = "upc.rev.cfi"
CodeUPCCounterclaim = "upc.ccr.cfi"
CodeUPCPreliminary = "upc.pi.cfi"
CodeUPCDamages = "upc.dmgs.cfi"
CodeUPCDiscovery = "upc.disc.cfi"
CodeUPCAppealMerits = "upc.apl.merits"
CodeUPCAppealOrder = "upc.apl.order"
CodeUPCAppealCost = "upc.apl.cost"
CodeDEInfringementLG = "de.inf.lg"
CodeDEInfringementOLG = "de.inf.olg"
CodeDEInfringementBGH = "de.inf.bgh"
CodeDENullityBPatG = "de.null.bpatg"
CodeDENullityBGH = "de.null.bgh"
CodeEPAGrant = "epa.grant.exa"
CodeEPAOpposition = "epa.opp.opd"
CodeEPAOppositionAppeal = "epa.opp.boa"
CodeDPMAOpposition = "dpma.opp.dpma"
CodeDPMAAppealBPatG = "dpma.appeal.bpatg"
CodeDPMAAppealBGH = "dpma.appeal.bgh"
CodeUPCInfringement = lp.CodeUPCInfringement
CodeUPCRevocation = lp.CodeUPCRevocation
CodeUPCCounterclaim = lp.CodeUPCCounterclaim
CodeUPCPreliminary = lp.CodeUPCPreliminary
CodeUPCDamages = lp.CodeUPCDamages
CodeUPCDiscovery = lp.CodeUPCDiscovery
CodeUPCAppealMerits = lp.CodeUPCAppealMerits
CodeUPCAppealOrder = lp.CodeUPCAppealOrder
CodeUPCAppealCost = lp.CodeUPCAppealCost
CodeDEInfringementLG = lp.CodeDEInfringementLG
CodeDEInfringementOLG = lp.CodeDEInfringementOLG
CodeDEInfringementBGH = lp.CodeDEInfringementBGH
CodeDENullityBPatG = lp.CodeDENullityBPatG
CodeDENullityBGH = lp.CodeDENullityBGH
CodeEPAGrant = lp.CodeEPAGrant
CodeEPAOpposition = lp.CodeEPAOpposition
CodeEPAOppositionAppeal = lp.CodeEPAOppositionAppeal
CodeDPMAOpposition = lp.CodeDPMAOpposition
CodeDPMAAppealBPatG = lp.CodeDPMAAppealBPatG
CodeDPMAAppealBGH = lp.CodeDPMAAppealBGH
)
// MapLitigationToFristenrechner returns the fristenrechner code +
// condition flags implied by a (litigationCode, jurisdiction) pair.
//
// Inputs are case-sensitive — pass the canonical upper-snake form
// (e.g. "INF", "UPC"). Unrecognised codes or genuinely ambiguous
// combinations (APP+DE, ZPO_CIVIL+DE) return ok=false with a zero
// fristenrechner code; callers should treat that as "no narrowing"
// and leave the cascade wide-open rather than auto-pick.
//
// Condition flags are returned as a slice so callers can apply them
// alongside the fristenrechner code (CCR+UPC → upc.inf.cfi + with_ccr,
// AMD+UPC → upc.inf.cfi + with_amend). An empty slice means no flag
// context applies.
// Delegates to litigationplanner.MapLitigationToFristenrechner.
func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristenrechnerCode string, conditionFlags []string, ok bool) {
switch litigationCode {
case "INF":
switch jurisdiction {
case "UPC":
return CodeUPCInfringement, nil, true
case "DE":
return CodeDEInfringementLG, nil, true
}
case "REV":
switch jurisdiction {
case "UPC":
return CodeUPCRevocation, nil, true
case "DE":
return CodeDENullityBPatG, nil, true
}
case "CCR":
// Counterclaim revocation — UPC fold-in is structural (the
// counterclaim lives inside an upc.inf.cfi proceeding with the
// with_ccr flag). DE Nichtigkeit is conceptually the same
// adversarial-validity test, no separate flag.
switch jurisdiction {
case "UPC":
return CodeUPCInfringement, []string{"with_ccr"}, true
case "DE":
return CodeDENullityBPatG, nil, true
}
case "AMD":
// Amendment-application bundled into upc.inf.cfi via with_amend.
// No DE / EPA / DPMA analogue today.
if jurisdiction == "UPC" {
return CodeUPCInfringement, []string{"with_amend"}, true
}
case "APP":
// Appeal is ambiguous in DE (OLG vs BGH) and the project
// model doesn't carry the instance hint we'd need to
// disambiguate. UPC is unambiguous — upc.apl.merits covers
// the merits appeal track for inf/rev/ccr/damages.
if jurisdiction == "UPC" {
return CodeUPCAppealMerits, nil, true
}
case "APM":
// Preliminary injunction / urgency procedure — UPC-only
// concept in the fristenrechner taxonomy.
if jurisdiction == "UPC" {
return CodeUPCPreliminary, nil, true
}
case "OPP":
// Opposition — primarily EPA. DPMA has dpma.opp.dpma but it
// doesn't surface from the litigation vocabulary today.
if jurisdiction == "EPA" {
return CodeEPAOpposition, nil, true
}
}
return "", nil, false
return lp.MapLitigationToFristenrechner(litigationCode, jurisdiction)
}
// ResolveCounterclaimRouting handles the determinator's
// upc.ccr.cfi illustrative-peer route: the code exists in the dropdown
// for taxonomic completeness, but no rules are attached to it. When the
// cascade resolves to upc.ccr.cfi we route the rule lookup back to
// upc.inf.cfi with a default with_ccr=true flag — see
// docs/design-proceeding-code-taxonomy-2026-05-18.md §0.3 sub-decision S1.
//
// `code` is the proceeding code the cascade resolved to. If it's
// upc.ccr.cfi, the function returns (CodeUPCInfringement,
// []string{"with_ccr"}, true). For any other code the function returns
// (code, nil, false) and callers proceed with the code unchanged. The
// boolean signals "routing was applied"; the caller can surface the hint
// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
// weiter." in the UI.
// ResolveCounterclaimRouting handles the determinator's upc.ccr.cfi
// illustrative-peer route. Delegates to
// litigationplanner.ResolveCounterclaimRouting.
func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) {
if route, ok := SubTrackRoutings[code]; ok {
return route.ParentCode, route.DefaultFlags, true
}
return code, nil, false
return lp.ResolveCounterclaimRouting(code)
}
// SubTrackRouting describes a proceeding type that has no native rules
// of its own and is normally rendered inside a parent proceeding's flow
// with one or more condition flags enabled. The Procedure Roadmap
// (verfahrensablauf) routes calc requests for these codes to the parent
// proceeding + default flags, but preserves the user-picked code/name
// in the response identity and surfaces a contextual note explaining
// the framing — see m/paliad#58 and the design doc cited above.
//
// Adding a new sub-track is a data-only change here: extend
// SubTrackRoutings with the (code, parent, flags, note) tuple and the
// renderer picks it up automatically. The note copy lives in this file
// because it's semantic to the routing, not UI chrome.
type SubTrackRouting struct {
// Code is the user-picked proceeding code (e.g. "upc.ccr.cfi").
Code string
// ParentCode is the proceeding whose rules to use (e.g. "upc.inf.cfi").
ParentCode string
// DefaultFlags are merged into the user's flag set so the
// gated rules render. Order is preserved.
DefaultFlags []string
// NoteDE / NoteEN are the contextual banner above the timeline,
// explaining that the proceeding type is normally a sub-track.
// Plain text — the frontend renders them as a banner.
NoteDE string
NoteEN string
}
// SubTrackRoutings — single-source-of-truth registry. Today: just CCR.
// The pattern generalises to other "sub-track" proceeding types (e.g.
// R.30 application to amend the patent as a standalone roadmap, R.46
// preliminary objection) once they have a proceeding-type code of their
// own. New entries here are picked up by the spawn-as-standalone
// renderer in FristenrechnerService.Calculate without further wiring.
var SubTrackRoutings = map[string]SubTrackRouting{
CodeUPCCounterclaim: {
Code: CodeUPCCounterclaim,
ParentCode: CodeUPCInfringement,
DefaultFlags: []string{"with_ccr"},
NoteDE: "Die Nichtigkeitswiderklage läuft normalerweise innerhalb eines UPC-Verletzungsverfahrens mit aktiver Nichtigkeitswiderklage. Diese Zeitleiste zeigt das Verletzungsverfahren mit gesetztem with_ccr-Flag.",
NoteEN: "The counterclaim for revocation normally runs inside a UPC infringement action with the counterclaim flag set. This timeline shows the infringement action with with_ccr automatically enabled.",
},
}
// SubTrackRoutings exposes the sub-track routing registry. SubTrackRouting
// is aliased in fristenrechner.go.
var SubTrackRoutings = lp.SubTrackRoutings
// LookupSubTrackRouting returns the sub-track routing for a proceeding
// code, or (zero, false) if the code is not a sub-track. Used by the
// fristenrechner Calculate path to spawn the parent flow with the sub-
// track's default flags.
// code, or (zero, false) if the code is not a sub-track. Delegates to
// litigationplanner.LookupSubTrackRouting.
func LookupSubTrackRouting(code string) (SubTrackRouting, bool) {
r, ok := SubTrackRoutings[code]
return r, ok
return lp.LookupSubTrackRouting(code)
}

View File

@@ -97,6 +97,58 @@ func TestApplyLookaheadCap_NoCapWhenUnderLimit(t *testing.T) {
}
}
// t-paliad-289: conditional rows (Status="conditional", Date=nil) must
// pass through applyLookaheadCap untouched — they're not "future
// predicted" rows by either Status or Date semantics, so they belong in
// the pass-through bucket alongside court_set / undated rows. The cap
// must NOT consume one of its slots for a conditional row, and the
// row must survive even when projTotal exceeds the cap.
func TestApplyLookaheadCap_ConditionalRowsPassThrough(t *testing.T) {
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
rows := []TimelineEvent{
// Three predicted future — cap=2 means the third drops.
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "f1", Title: "F1"},
{Kind: "projected", Status: "predicted", Date: &jun1, RuleCode: "f2", Title: "F2"},
{Kind: "projected", Status: "predicted", Date: &jul1, RuleCode: "f3", Title: "F3"},
// Two conditional — must survive uncapped, must NOT count
// against projTotal / projShown.
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c1", Title: "C1",
DependsOnRuleCode: "p1", DependsOnRuleName: "Parent 1"},
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c2", Title: "C2",
DependsOnRuleCode: "p2", DependsOnRuleName: "Parent 2"},
}
kept, total, shown, overdue := applyLookaheadCap(rows, 2)
if total != 3 {
t.Errorf("ProjectedTotal = %d, want 3 (conditionals must not count)", total)
}
if shown != 2 {
t.Errorf("ProjectedShown = %d, want 2", shown)
}
if overdue != 0 {
t.Errorf("PredictedOverdue = %d, want 0", overdue)
}
// 2 predicted (capped) + 2 conditional pass-through = 4 rows.
if len(kept) != 4 {
t.Errorf("kept rows = %d, want 4", len(kept))
}
keptTitles := map[string]bool{}
for _, r := range kept {
keptTitles[r.Title] = true
}
for _, want := range []string{"F1", "F2", "C1", "C2"} {
if !keptTitles[want] {
t.Errorf("expected kept row %q missing", want)
}
}
if keptTitles["F3"] {
t.Errorf("F3 should have been dropped (cap=2)")
}
}
func TestRuleAnchorKind(t *testing.T) {
hearing := "hearing"
decision := "decision"

View File

@@ -147,6 +147,17 @@ type TimelineEvent struct {
// checkbox). At parent-node levels, rows with BubbleUp=true survive
// the levelPolicy kind/status filter unconditionally.
BubbleUp bool `json:"bubble_up,omitempty"`
// IsConditional marks projected rows whose anchor is uncertain —
// the projection layer mirrors UIDeadline.IsConditional from the
// fristenrechner so the SmartTimeline can render an "abhängig von
// <parent>" chip in place of the date column. When true, Date is
// nil and DependsOnRuleCode / DependsOnRuleName carry the parent
// reference (already populated by annotateDependsOn for projected
// rows; for conditional rows we additionally fall back to the
// UIDeadline-supplied ParentRule* when the parent has no
// computed date). Status is set to "conditional". (t-paliad-289)
IsConditional bool `json:"is_conditional,omitempty"`
}
// LaneInfo describes one column in the parent-node aggregated view.
@@ -933,12 +944,13 @@ func (s *ProjectionService) computeProjections(
Title: ruleDisplayName(rule, ui, lang(opts.Lang)),
RuleCode: ui.Code,
DeadlineRuleParty: ui.Party,
IsConditional: ui.IsConditional,
}
idCopy := ruleID
ev.DeadlineRuleID = &idCopy
// Date — UIDeadline.DueDate is YYYY-MM-DD when set, "" for
// court-set rules whose date isn't bound yet.
// court-set / conditional rules whose date isn't bound yet.
if ui.DueDate != "" {
if t, perr := time.Parse("2006-01-02", ui.DueDate); perr == nil {
dt := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
@@ -946,7 +958,38 @@ func (s *ProjectionService) computeProjections(
}
}
// Conditional rows from the fristenrechner (t-paliad-289):
// pre-stamp the dependency reference here so the row carries
// the "abhängig von <parent>" payload even when the parent has
// no computed date for annotateDependsOn to pick up later.
// annotateDependsOn won't overwrite a non-empty DependsOnRuleCode,
// and the parent's actual date (if anchored elsewhere) still
// flows into DependsOnDate via the actuals-first preference.
if ui.IsConditional && ui.ParentRuleCode != "" {
ev.DependsOnRuleCode = ui.ParentRuleCode
switch lang(opts.Lang) {
case "en":
if ui.ParentRuleNameEN != "" {
ev.DependsOnRuleName = ui.ParentRuleNameEN
} else {
ev.DependsOnRuleName = ui.ParentRuleName
}
default:
ev.DependsOnRuleName = ui.ParentRuleName
}
}
switch {
case ui.IsConditional:
// Anchor uncertain (court-set ancestor without override,
// backward-anchor without forward date, or optional event
// not recorded). Surface as conditional so the frontend
// renders "abhängig von <parent>" in place of a date.
// Conditional rows must not carry a date even if the
// calculator left one — clear it to match the wire contract.
// (t-paliad-289)
ev.Date = nil
ev.Status = "conditional"
case ui.IsCourtSet && ev.Date == nil:
// Pure court-set rule — date is bound by the court at
// hearing/decision time. Surface as undated court_set.

View File

@@ -604,92 +604,6 @@ func (s *RuleEditorService) getByID(ctx context.Context, id uuid.UUID) (*models.
return &r, nil
}
// ExportMigrationsSince returns a SQL blob containing one UPDATE / INSERT
// per audited rule change after the given audit row id. Used by the
// admin "export changes to a migration file" flow (Q-H-5: pure SQL
// format). Returns SQL + count + the latest audit id seen so the
// caller can pass it as ?since= on the next call.
//
// v1 generates one UPDATE per audit row using the after_json snapshot.
// Slice 11b will polish the output (re-order so foreign-key edges
// resolve, collapse consecutive UPDATEs on the same row, format the
// header comment with author + reason). v1 emits one statement per
// audit row in chronological order — sufficient for hand-review.
type ExportResult struct {
MigrationSQL string `json:"migration_sql"`
Count int `json:"count"`
LatestAuditID string `json:"latest_audit_id"`
}
func (s *RuleEditorService) ExportMigrationsSince(ctx context.Context, sinceAuditID string) (*ExportResult, error) {
type auditRow struct {
ID uuid.UUID `db:"id"`
RuleID uuid.UUID `db:"rule_id"`
ChangedAt time.Time `db:"changed_at"`
Action string `db:"action"`
AfterJSON json.RawMessage `db:"after_json"`
Reason string `db:"reason"`
}
var rows []auditRow
q := `SELECT id, rule_id, changed_at, action, after_json, reason
FROM paliad.deadline_rule_audit
WHERE migration_exported = false`
args := []any{}
if sinceAuditID != "" {
sid, err := uuid.Parse(sinceAuditID)
if err != nil {
return nil, fmt.Errorf("%w: invalid since= uuid", ErrInvalidInput)
}
q += ` AND changed_at >= (SELECT changed_at FROM paliad.deadline_rule_audit WHERE id = $1)`
args = append(args, sid)
}
q += ` ORDER BY changed_at ASC`
if err := s.db.SelectContext(ctx, &rows, q, args...); err != nil {
return nil, fmt.Errorf("list audit since: %w", err)
}
var sb strings.Builder
sb.WriteString("-- Auto-generated rule-editor migration export.\n")
sb.WriteString("-- Generated at: " + time.Now().UTC().Format(time.RFC3339) + "\n")
sb.WriteString("-- Rows: " + fmt.Sprintf("%d", len(rows)) + "\n\n")
sb.WriteString("SELECT set_config('paliad.audit_reason',\n")
sb.WriteString(" 'rule-editor export: replay of " + fmt.Sprintf("%d", len(rows)) + " edits', true);\n\n")
latest := ""
for _, r := range rows {
sb.WriteString("-- audit " + r.ID.String() + " (" + r.Action + " " + r.ChangedAt.Format(time.RFC3339) + "): " + sqlEscape(r.Reason) + "\n")
switch r.Action {
case "create", "update":
if len(r.AfterJSON) == 0 {
sb.WriteString("-- (no after_json — skipped)\n\n")
continue
}
sb.WriteString("INSERT INTO paliad.deadline_rules\n")
sb.WriteString(" SELECT (jsonb_populate_record(NULL::paliad.deadline_rules, '")
sb.WriteString(sqlEscape(string(r.AfterJSON)))
sb.WriteString("'::jsonb)).*\n")
sb.WriteString("ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, name_en = EXCLUDED.name_en,\n")
sb.WriteString(" duration_value = EXCLUDED.duration_value, duration_unit = EXCLUDED.duration_unit,\n")
sb.WriteString(" timing = EXCLUDED.timing, priority = EXCLUDED.priority,\n")
sb.WriteString(" is_court_set = EXCLUDED.is_court_set,\n")
sb.WriteString(" condition_expr = EXCLUDED.condition_expr,\n")
sb.WriteString(" lifecycle_state = EXCLUDED.lifecycle_state,\n")
sb.WriteString(" updated_at = now();\n\n")
case "delete", "archive":
sb.WriteString("UPDATE paliad.deadline_rules SET lifecycle_state='archived', updated_at=now() WHERE id='")
sb.WriteString(r.RuleID.String())
sb.WriteString("';\n\n")
}
latest = r.ID.String()
}
return &ExportResult{
MigrationSQL: sb.String(),
Count: len(rows),
LatestAuditID: latest,
}, nil
}
// =============================================================================
// Internal helpers
// =============================================================================
@@ -814,6 +728,3 @@ func nullableJSON(b json.RawMessage) any {
return []byte(b)
}
func sqlEscape(s string) string {
return strings.ReplaceAll(s, "'", "''")
}

View File

@@ -0,0 +1,49 @@
package litigationplanner
import "context"
// Catalog supplies proceeding-type metadata + rules for the calculator.
//
// Implementations:
// - paliad: reads paliad.deadline_rules + paliad.proceeding_types,
// filtered to lifecycle_state='published' AND is_active=true.
// ProjectHint scopes future per-project rule merges.
// - embedded/upc (Slice C): in-memory map keyed by code, populated
// once at init from the embedded JSON snapshot.
//
// All methods return ErrUnknownProceedingType / ErrUnknownRule when the
// caller asks for a code/id that doesn't exist in the catalog.
type Catalog interface {
// LoadProceeding returns the proceeding-type metadata + the full
// rule list (sorted by sequence_order). Caller passes the user-
// facing proceeding code (e.g. "upc.inf.cfi"). The hint scopes a
// future per-project rule merge — implementations that don't
// support projects ignore it.
LoadProceeding(ctx context.Context, code string, hint ProjectHint) (*ProceedingType, []Rule, error)
// LoadProceedingByID is the resolver used by CalculateRule when it
// has a rule + needs the rule's parent proceeding metadata.
LoadProceedingByID(ctx context.Context, id int) (*ProceedingType, error)
// LoadRuleByID resolves a rule UUID to the rule row. Used by
// CalculateRule when the caller supplies CalcRuleParams.RuleID.
LoadRuleByID(ctx context.Context, ruleID string) (*Rule, error)
// LoadRuleByCode resolves a rule by (proceedingCode, submissionCode)
// + returns the parent proceeding for use in the response identity.
// Used by CalculateRule when the caller supplies the (code, local)
// pair from a concept-card pill.
LoadRuleByCode(ctx context.Context, proceedingCode, submissionCode string) (*Rule, *ProceedingType, error)
// LoadRulesByTriggerEvent lists Pipeline-C trigger-event-rooted
// rules (rules whose trigger_event_id matches). Used by
// EventDeadlineService → Calculate via CalcOptions.TriggerEventIDFilter.
LoadRulesByTriggerEvent(ctx context.Context, triggerEventID int64) ([]Rule, error)
// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows
// for the conditional-label override (t-paliad-294 /
// m/paliad#126). Returns a map keyed by event id; missing ids
// are simply absent (caller treats absence as "no override").
// Empty input returns an empty map without a DB roundtrip.
LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]TriggerEvent, error)
}

View File

@@ -0,0 +1,49 @@
package litigationplanner
// CourtRegistry maps a court id (e.g. "upc-ld-paris", "de-bgh") to its
// (country, regime) tuple, which drives non-working-day adjustment.
//
// Implementations:
// - paliad: reads paliad.courts (CourtService.CountryRegime).
// - embedded/upc (Slice C): in-memory map populated from the embedded
// JSON snapshot.
//
// Empty courtID falls back to (defaultCountry, defaultRegime) so callers
// without a court_id (the abstract Verfahrensablauf path) still get
// sensible behaviour. Returns an error when courtID is non-empty and
// not in the registry.
type CourtRegistry interface {
CountryRegime(courtID, defaultCountry, defaultRegime string) (country, regime string, err error)
}
// Country and regime constants — keep in sync with the paliad.countries
// seed list and the holidays_regime_chk / courts_regime_chk constraints.
const (
CountryDE = "DE"
RegimeUPC = "UPC"
RegimeEPO = "EPO"
)
// DefaultsForJurisdiction maps the proceeding-type jurisdiction text
// ('UPC' | 'DE' | 'EPA' | 'DPMA' | nil) to the (country, regime) tuple
// a holiday lookup should default to when the caller didn't pass an
// explicit CourtID. UPC proceedings get DE+UPC (München LD is HLC's
// most common venue, German federal holidays plus UPC vacations apply);
// DE / DPMA / EPA get DE-only (German federal). Future EPA-specific
// closures will require callers to pick an EPA court explicitly so the
// EPO regime kicks in.
//
// Helper kept tiny and stateless — when a caller passes a real CourtID,
// these defaults are bypassed entirely and the court's actual country +
// regime are used.
func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) {
if jurisdiction == nil {
return CountryDE, ""
}
switch *jurisdiction {
case "UPC":
return CountryDE, RegimeUPC
default:
return CountryDE, ""
}
}

View File

@@ -0,0 +1,17 @@
// Package litigationplanner is the canonical Fristen / Verfahrensablauf
// compute engine — the deadline-rule model, the calendar arithmetic, the
// condition-expression gate, the sub-track routing, and the timeline
// composer that drives Paliad's /tools/fristenrechner,
// /tools/verfahrensablauf, and the per-project SmartTimeline.
//
// The package owns its types (Rule, ProceedingType, Timeline,
// TimelineEntry, CalcOptions, …) and exposes three interfaces for the
// stateful inputs: Catalog (proceeding + rule lookup), HolidayCalendar
// (non-working-day adjustment), and CourtRegistry (court → country/regime
// resolution). Paliad implements them against its Postgres database;
// downstream consumers (youpc.org) implement them against an embedded
// JSON snapshot of the UPC subset.
//
// See docs/design-litigation-planner-2026-05-26.md (t-paliad-292 /
// m/paliad#124) for the full design.
package litigationplanner

View File

@@ -0,0 +1,76 @@
package litigationplanner
import "time"
// ApplyDuration is the unified date-arithmetic helper used by every
// calculator path (proceeding-tree, trigger-event, CalculateRule single-
// rule). Phase 3 Slice 4 (t-paliad-185) replaced the prior split
// between addDuration (proceeding-tree, no timing / working_days) and
// ApplyDurationOnCalendar (Pipeline-C, full support) with this single
// helper.
//
// Returns (raw, adjusted, didAdjust, reason):
//
// - raw: the date strictly implied by the rule before rollover.
// - adjusted: post-rollover for calendar units. 'working_days' lands
// on a working day by construction so raw == adjusted there.
// - didAdjust: true iff rollover moved the date.
// - reason: populated when didAdjust is true; nil otherwise.
//
// timing='before' negates the sign. timing='after' (or any other value
// including the empty string) keeps it positive — preserves the pre-
// Slice-4 behaviour for proceeding-tree rules whose Timing field is
// sometimes NULL (mig 003 defaults to 'after' but legacy callers pass
// r.Timing dereferenced).
func ApplyDuration(
base time.Time, value int, unit, timing, country, regime string, holidays HolidayCalendar,
) (raw, adjusted time.Time, didAdjust bool, reason *AdjustmentReason) {
sign := 1
if timing == "before" {
sign = -1
}
switch unit {
case "days":
raw = base.AddDate(0, 0, sign*value)
case "weeks":
raw = base.AddDate(0, 0, sign*value*7)
case "months":
raw = base.AddDate(0, sign*value, 0)
case "working_days":
raw = AddWorkingDays(base, sign*value, country, regime, holidays)
// Working-day arithmetic lands on a working day by construction
// — the per-step skip loop in AddWorkingDays already passes over
// weekends and holidays. No post-rollover required.
return raw, raw, false, nil
default:
raw = base
}
adjusted, _, didAdjust, reason = holidays.AdjustForNonWorkingDaysWithReason(raw, country, regime)
return raw, adjusted, didAdjust, reason
}
// AddWorkingDays advances from `from` by `n` working days, skipping
// weekends and holidays applicable to the given country/regime. Negative
// n walks backward. n=0 keeps the input date as-is (caller decides
// whether to roll forward via AdjustForNonWorkingDays).
//
// Bounded by an inner 30-step skip per advance — vacation runs in our
// holiday tables are < 14 consecutive days, so 30 is a safety margin.
func AddWorkingDays(from time.Time, n int, country, regime string, holidays HolidayCalendar) time.Time {
if n == 0 {
return from
}
step := 1
if n < 0 {
step = -1
n = -n
}
cur := from
for i := 0; i < n; i++ {
cur = cur.AddDate(0, 0, step)
for j := 0; j < 30 && holidays.IsNonWorkingDay(cur, country, regime); j++ {
cur = cur.AddDate(0, 0, step)
}
}
return cur
}

View File

@@ -0,0 +1,908 @@
package litigationplanner
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
)
// Calculate renders the full UI timeline for a proceeding type + trigger date.
// Preserves the pre-Phase-C in-memory calculator's classification:
//
// - Rules with duration_value = 0 and no parent_id → IsRootEvent
// (due date = trigger date)
// - Rules with duration_value = 0 and a parent_id → IsCourtSet
// (due date empty, UI shows "court-set" placeholder)
// - All other rules → calculate from either the trigger date (no parent)
// or the previously-computed date for their parent rule.
//
// Audit-driven extensions:
//
// - opts.Flags can flip flag-conditioned rules onto their alt_* values
// (e.g. upc.inf.cfi inf.reply / inf.rejoin under "with_ccr").
// - opts.PriorityDateStr overrides the anchor for rules with
// anchor_alt='priority_date' (e.g. epa.grant.exa publication date
// is 18mo from priority, not filing).
// - opts.AnchorOverrides per-rule (rule_code → YYYY-MM-DD) lets the
// caller redirect a downstream rule's parent anchor to a user-set
// date.
func Calculate(
ctx context.Context,
proceedingCode string,
triggerDateStr string,
opts CalcOptions,
catalog Catalog,
holidays HolidayCalendar,
courts CourtRegistry,
) (*Timeline, error) {
// Phase-3 dispatch: TriggerEventIDFilter routes to the event-driven
// branch (Pipeline-C unified rules). proceedingCode is ignored on
// this path.
if opts.TriggerEventIDFilter != nil {
return calculateByTriggerEvent(ctx, *opts.TriggerEventIDFilter, triggerDateStr, opts, catalog, holidays, courts)
}
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
if err != nil {
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
}
var priorityDate *time.Time
if opts.PriorityDateStr != "" {
pd, err := time.Parse("2006-01-02", opts.PriorityDateStr)
if err != nil {
return nil, fmt.Errorf("invalid priority date %q: %w", opts.PriorityDateStr, err)
}
priorityDate = &pd
}
flagSet := make(map[string]struct{}, len(opts.Flags))
for _, f := range opts.Flags {
flagSet[f] = struct{}{}
}
// v1 simplification (t-paliad-265): when any IncludeCCRFor entry
// exists, we treat with_ccr as set in the flag context.
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.
overrideDates := make(map[string]time.Time, len(opts.AnchorOverrides))
for code, dateStr := range opts.AnchorOverrides {
od, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return nil, fmt.Errorf("invalid anchor override for %q (%q): %w", code, dateStr, err)
}
overrideDates[code] = od
}
// Look up proceeding type metadata.
pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint)
if err != nil {
return nil, err
}
// Sub-track routing (m/paliad#58). When the user picks a proceeding
// that has no native rules and is normally a sub-track of another
// proceeding (today: upc.ccr.cfi → upc.inf.cfi + with_ccr), route
// rule lookup to the parent and merge the default flags into the
// user's flag set. The response identity stays on the user-picked
// proceeding so the page header still reads "Counterclaim for
// Revocation", but the timeline body is the parent's full flow with
// the sub-track flag enabled.
var subTrackNote SubTrackRouting
var hasSubTrackNote bool
pt := pickedProceeding
if route, ok := LookupSubTrackRouting(proceedingCode); ok {
subTrackNote = route
hasSubTrackNote = true
parentPt, parentRules, err := catalog.LoadProceeding(ctx, route.ParentCode, opts.ProjectHint)
if err != nil {
return nil, fmt.Errorf("sub-track %q routes to %q which is not active: %w", proceedingCode, route.ParentCode, err)
}
pt = parentPt
rules = parentRules
// Merge default flags into the user's flag set so the gated
// rules render. User-supplied flags win on conflict.
for _, f := range route.DefaultFlags {
if _, exists := flagSet[f]; !exists {
flagSet[f] = struct{}{}
}
}
}
// Resolve (country, regime) for non-working-day adjustment. Court
// wins when supplied; otherwise default by proceeding regime.
defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
country, regime, err := courts.CountryRegime(opts.CourtID, defaultCountry, defaultRegime)
if err != nil {
return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
}
if len(opts.RuleOverrides) > 0 {
rules = ApplyRuleOverrides(rules, opts.RuleOverrides)
}
// ruleByID lets the conditional-rendering branches resolve a parent
// rule's display fields (submission_code, name, name_en) for the
// "abhängig von <ParentRuleName>" chip without re-scanning the rules
// slice on every iteration. (t-paliad-289)
ruleByID := make(map[uuid.UUID]Rule, len(rules))
for _, r := range rules {
ruleByID[r.ID] = r
}
// triggerEventByID powers the trigger-event override on the
// conditional-label chip (m/paliad#126 / t-paliad-294). When a rule
// carries a real paliad.trigger_events row, that catalog event —
// not the rule's parent_id — is the rule's actual semantic anchor.
// The override fires below when stamping ParentRule* on the wire so
// the chip reads e.g. "abhängig von Antrag auf Vertraulichkeit
// gegenüber der Öffentlichkeit" for R.262(2) — instead of the
// (misleading) parent_id-derived "abhängig von Klageerhebung".
//
// Bulk-loaded in one round-trip; trees in the live corpus carry at
// most a handful of trigger_event_id-bearing rules (2 today on
// upc.inf.cfi), so the IN(...) is small.
var triggerIDs []int64
seenTrigger := make(map[int64]struct{}, len(rules))
for _, r := range rules {
if r.TriggerEventID == nil {
continue
}
if _, ok := seenTrigger[*r.TriggerEventID]; ok {
continue
}
seenTrigger[*r.TriggerEventID] = struct{}{}
triggerIDs = append(triggerIDs, *r.TriggerEventID)
}
triggerEventByID, err := catalog.LoadTriggerEventsByIDs(ctx, triggerIDs)
if err != nil {
return nil, fmt.Errorf("load trigger events for conditional labels: %w", err)
}
// Walk the rule list in sequence_order (already sorted by the
// catalog query) and compute each entry, keeping a code→date map so
// RelativeTo / parent_id references resolve to the adjusted
// predecessor date.
computed := make(map[string]time.Time, len(rules))
courtSet := make(map[uuid.UUID]bool, len(rules))
deadlines := make([]TimelineEntry, 0, len(rules))
skipRules := opts.SkipRules
perCardAppellant := opts.PerCardAppellant
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
hiddenCount := 0
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 no alt_* values exist, the rule is dropped from the
// timeline entirely (purely conditional). When alt_* values
// exist, the gate-false branch still renders, just without
// the alt-swap.
gateMet := EvalConditionExpr([]byte(r.ConditionExpr), flagSet)
if !gateMet && r.AltDurationValue == nil {
continue
}
// SkipRules suppression (t-paliad-265).
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
// we re-surface the directly-skipped row (faded via IsHidden)
// instead of dropping it.
var isHidden bool
if r.SubmissionCode != nil {
if _, skipped := skipRules[*r.SubmissionCode]; skipped {
hiddenCount++
if !opts.IncludeHidden {
skippedIDs[r.ID] = struct{}{}
continue
}
isHidden = true
}
}
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 := TimelineEntry{
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),
IsHidden: isHidden,
}
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
}
if r.PrimaryParty != nil {
d.Party = *r.PrimaryParty
}
if r.RuleCode != nil {
d.RuleRef = *r.RuleCode
}
if r.LegalSource != nil {
d.LegalSource = *r.LegalSource
d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
}
if r.DeadlineNotes != nil {
d.Notes = *r.DeadlineNotes
}
if r.DeadlineNotesEn != nil {
d.NotesEN = *r.DeadlineNotesEn
}
// Resolve the parent rule once so every conditional-rendering
// branch (incl. the optional-not-recorded path below) can stamp
// ParentRule* on the wire without re-scanning. Populated even
// for non-conditional rows — the frontend dependency-footer
// ("Folgt aus …") already consumes this on regular projected
// rows. (t-paliad-289)
var parentRule *Rule
if r.ParentID != nil {
if pr, ok := ruleByID[*r.ParentID]; ok {
parentRule = &pr
if pr.SubmissionCode != nil {
d.ParentRuleCode = *pr.SubmissionCode
}
d.ParentRuleName = pr.Name
d.ParentRuleNameEN = pr.NameEN
}
}
// Trigger-event override on the user-facing dependency identity
// (m/paliad#126 / t-paliad-294). When a rule has a real
// trigger_event_id, that catalog event is the actual semantic
// anchor — not the parent_id node, which is only the calc-time
// arithmetic anchor. Only the user-facing wire fields shift;
// parentRule (and the parent_id chain feeding parentIsCourtSet
// and the calc-time arithmetic below) stays anchored on the
// rule tree.
if r.TriggerEventID != nil {
if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
d.ParentRuleCode = te.Code
d.ParentRuleName = te.NameDE
d.ParentRuleNameEN = te.Name
}
}
// Propagate court-set status from a parent rule whose date the
// court determines: if the anchor itself has no real date,
// nothing downstream can be computed either — UNLESS the user
// has supplied an override date for the parent.
parentOverridden := false
if r.ParentID != nil && courtSet[*r.ParentID] {
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.SubmissionCode != nil {
if _, ok := overrideDates[*prev.SubmissionCode]; ok {
parentOverridden = true
}
}
break
}
}
}
parentIsCourtSet := r.ParentID != nil && courtSet[*r.ParentID] && !parentOverridden
// Zero-duration rules fall into one of four buckets:
// 1. parent=nil, not court-determined → IsRootEvent (trigger anchor)
// 2. parent=nil, court-determined → IsCourtSet
// 3. parent set, court-determined → IsCourtSet (waypoint)
// 4. parent set, NOT court-determined → "filed-with-parent"
//
// AnchorOverrides: when the user has set a date for any zero-
// duration rule, that override wins over both the court-set
// placeholder and the parent-inheritance.
if r.DurationValue == 0 {
if r.SubmissionCode != nil {
if ov, ok := overrideDates[*r.SubmissionCode]; ok {
d.DueDate = ov.Format("2006-01-02")
d.OriginalDate = d.DueDate
d.IsOverridden = true
computed[*r.SubmissionCode] = ov
deadlines = append(deadlines, d)
continue
}
}
if r.ParentID == nil && !r.IsCourtSet {
// Bucket 1: timeline anchor.
d.IsRootEvent = true
d.DueDate = triggerDateStr
d.OriginalDate = triggerDateStr
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = triggerDate
}
} else if r.ParentID != nil && !r.IsCourtSet {
// Bucket 4: filed-with-parent. Inherit parent's date.
if parentIsCourtSet {
// Indirect: rule isn't itself court-determined,
// it's blocked because its parent is.
d.IsCourtSet = true
d.IsCourtSetIndirect = true
d.IsConditional = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
} else {
var parentDate time.Time
var haveParentDate bool
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.SubmissionCode != nil {
if ov, ok := overrideDates[*prev.SubmissionCode]; ok {
parentDate = ov
haveParentDate = true
} else if ref, ok := computed[*prev.SubmissionCode]; ok {
parentDate = ref
haveParentDate = true
}
}
break
}
}
if haveParentDate {
d.DueDate = parentDate.Format("2006-01-02")
d.OriginalDate = d.DueDate
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = parentDate
}
} else {
// Parent not yet computed (defensive).
d.IsCourtSet = true
d.IsCourtSetIndirect = true
d.IsConditional = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
}
}
} else {
// Buckets 2 + 3: court-determined directly.
d.IsCourtSet = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
}
deadlines = append(deadlines, d)
continue
}
// If the parent is court-determined and not overridden we have
// no real anchor date; surface this rule as court-set too
// rather than fabricating one off the trigger date. IsConditional
// surfaces the "abhängig von <ParentRuleName>" UX (t-paliad-289).
if parentIsCourtSet {
d.IsCourtSet = true
d.IsCourtSetIndirect = true
d.IsConditional = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
deadlines = append(deadlines, d)
continue
}
// Anchor: prefer alt-anchor (e.g. priority_date for
// epa.grant.exa publish) when supplied, then parent's computed
// date (or user override), then trigger date.
baseDate := triggerDate
if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
baseDate = *priorityDate
} else if r.ParentID != nil {
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.SubmissionCode != nil {
if ov, ok := overrideDates[*prev.SubmissionCode]; ok {
baseDate = ov
} else if ref, ok := computed[*prev.SubmissionCode]; ok {
baseDate = ref
}
}
break
}
}
}
// Flag-conditioned alt-swap (legacy with_ccr pattern): when the
// gate fires AND alt_* values exist, swap the primary duration
// to the alt values. This is distinct from combine_op below —
// alt-swap is a one-or-the-other choice keyed on flags, whereas
// combine_op computes both legs and picks max/min.
durationValue := r.DurationValue
durationUnit := r.DurationUnit
timing := ""
if r.Timing != nil {
timing = *r.Timing
}
if r.CombineOp == nil && gateMet && HasConditionExpr(r.ConditionExpr) && r.AltDurationValue != nil {
durationValue = *r.AltDurationValue
if r.AltDurationUnit != nil {
durationUnit = *r.AltDurationUnit
}
if r.AltRuleCode != nil {
d.RuleRef = *r.AltRuleCode
}
}
// User override on this rule: replace the calculated date with
// the user's date. Skip holiday rollover — the user's date is
// authoritative.
if r.SubmissionCode != nil {
if ov, ok := overrideDates[*r.SubmissionCode]; ok {
d.OriginalDate = ov.Format("2006-01-02")
d.DueDate = ov.Format("2006-01-02")
d.WasAdjusted = false
d.AdjustmentReason = nil
d.IsOverridden = true
computed[*r.SubmissionCode] = ov
deadlines = append(deadlines, d)
continue
}
}
origDate, adjusted, wasAdj, reason := ApplyDuration(
baseDate, durationValue, durationUnit, timing, country, regime, holidays,
)
// combine_op composite: compute the alt leg too, apply max/min.
if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
altOrig, altAdj, altWasAdj, altReason := ApplyDuration(
baseDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, holidays,
)
switch *r.CombineOp {
case "max":
if altAdj.After(adjusted) {
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
}
case "min":
if altAdj.Before(adjusted) {
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
}
}
}
d.OriginalDate = origDate.Format("2006-01-02")
d.DueDate = adjusted.Format("2006-01-02")
d.WasAdjusted = wasAdj
d.AdjustmentReason = reason
// Optional-on-the-other-side detection (t-paliad-289 Symptom B).
// Rules with priority='optional' AND primary_party='both' whose
// data-model parent is the proceeding's trigger anchor (parent
// has parent_id=NULL and is not court-set, i.e. the SoC root
// rule) represent a rule whose REAL triggering event sits
// outside the rule data — e.g. R.262(2) Erwiderung auf
// Vertraulichkeitsantrag anchors on SoC in the data, but the
// real trigger is the opposing party's confidentiality motion
// which may never happen. Without an explicit anchor on the
// rule itself, the projection must NOT claim a concrete date.
if !d.IsOverridden && !d.IsConditional &&
r.Priority == "optional" &&
r.PrimaryParty != nil && *r.PrimaryParty == "both" &&
parentRule != nil && parentRule.ParentID == nil && !parentRule.IsCourtSet {
d.IsConditional = true
d.DueDate = ""
d.OriginalDate = ""
d.WasAdjusted = false
d.AdjustmentReason = nil
// Mark this rule's ID as having an uncertain anchor so
// rules chaining off it also surface conditional via the
// parentIsCourtSet path.
courtSet[r.ID] = true
}
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = adjusted
}
deadlines = append(deadlines, d)
}
// t-paliad-296: within consecutive runs of rules sharing the same
// trigger group (parent_id + trigger_event_id), reorder by duration
// ascending so optional events following the same anchor render in
// their likely-sequence order. Different trigger groups keep their
// proceeding-sequence position — the chunk walk only sorts adjacent
// same-group rows. Court-set / conditional rows sort LAST.
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
resp := &Timeline{
ProceedingType: pickedProceeding.Code,
ProceedingName: pickedProceeding.Name,
ProceedingNameEN: pickedProceeding.NameEN,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
HiddenCount: hiddenCount,
}
// Sub-track routing keeps the user-picked proceeding's identity,
// so the trigger-event label rides on `pickedProceeding`.
if pickedProceeding.TriggerEventLabelDE != nil {
resp.TriggerEventLabel = *pickedProceeding.TriggerEventLabelDE
}
if pickedProceeding.TriggerEventLabelEN != nil {
resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN
}
if hasSubTrackNote {
resp.ContextualNote = subTrackNote.NoteDE
resp.ContextualNoteEN = subTrackNote.NoteEN
}
return resp, nil
}
// calculateByTriggerEvent renders the Pipeline-C timeline for an event
// trigger (mig 085 + Slice 3). Pipeline-C rules are flat (no parent_id
// chains), have no flag gating, no priority_date alt-anchor, no party
// classification, and no IsRootEvent / IsCourtSet semantics. The math
// is just: base + (timing-signed) duration → optional alt-leg combine
// → optional weekend/holiday rollover for calendar units.
//
// Timeline.ProceedingType / ProceedingName stay empty —
// EventDeadlineService owns the trigger-event metadata.
func calculateByTriggerEvent(
ctx context.Context,
triggerEventID int64,
triggerDateStr string,
opts CalcOptions,
catalog Catalog,
holidays HolidayCalendar,
courts CourtRegistry,
) (*Timeline, error) {
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
if err != nil {
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
}
// Pipeline-C rules originate from youpc's UPC-flavoured deadline
// corpus — DE / UPC defaults match the legacy EventDeadlineService.
country, regime, err := courts.CountryRegime(opts.CourtID, CountryDE, RegimeUPC)
if err != nil {
return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
}
rules, err := catalog.LoadRulesByTriggerEvent(ctx, triggerEventID)
if err != nil {
return nil, err
}
if len(opts.RuleOverrides) > 0 {
rules = ApplyRuleOverrides(rules, opts.RuleOverrides)
}
deadlines := make([]TimelineEntry, 0, len(rules))
for _, r := range rules {
timing := ""
if r.Timing != nil {
timing = *r.Timing
}
baseRaw, baseAdj, baseChanged, baseReason := ApplyDuration(
triggerDate, r.DurationValue, r.DurationUnit, timing, country, regime, holidays,
)
picked := baseAdj
original := baseRaw
wasAdj := baseChanged
reason := baseReason
if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
altRaw, altAdj, altChanged, altReason := ApplyDuration(
triggerDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, holidays,
)
switch *r.CombineOp {
case "max":
if altAdj.After(baseAdj) {
picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason
}
case "min":
if altAdj.Before(baseAdj) {
picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason
}
}
}
d := TimelineEntry{
RuleID: r.ID.String(),
Name: r.Name,
NameEN: r.NameEN,
Priority: r.Priority,
ConditionExpr: json.RawMessage(r.ConditionExpr),
DueDate: picked.Format("2006-01-02"),
OriginalDate: original.Format("2006-01-02"),
WasAdjusted: wasAdj,
AdjustmentReason: reason,
}
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
}
if r.PrimaryParty != nil {
d.Party = *r.PrimaryParty
}
if r.RuleCode != nil {
d.RuleRef = *r.RuleCode
}
if r.LegalSource != nil {
d.LegalSource = *r.LegalSource
d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
}
if r.DeadlineNotes != nil {
d.Notes = *r.DeadlineNotes
}
if r.DeadlineNotesEn != nil {
d.NotesEN = *r.DeadlineNotesEn
}
deadlines = append(deadlines, d)
}
return &Timeline{
// Trigger-event responses don't carry proceeding metadata —
// EventDeadlineService.Calculate fills the trigger fields in
// the legacy CalculateResponse shape. Leaving these empty is
// the stable contract.
ProceedingType: "",
ProceedingName: "",
TriggerDate: triggerDateStr,
Deadlines: deadlines,
}, nil
}
// CalculateRule computes a single deadline from a rule + trigger date.
// Used by the v4 result-card click flow. Distinct from Calculate: no
// parent-chain walk, no full-timeline rendering — just one date out.
//
// When the rule is court-determined, DueDate is empty and
// IsCourtSet=true; the caller should disable the "Add to project" CTA.
//
// When the rule has a condition_expr gate and the caller's Flags
// satisfy it AND alt_duration_value is non-NULL, the calc swaps to
// alt_*. When the gate is not satisfied, the calc still proceeds with
// the base duration_value and surfaces FlagsRequired.
func CalculateRule(
ctx context.Context,
params CalcRuleParams,
catalog Catalog,
holidays HolidayCalendar,
courts CourtRegistry,
) (*RuleCalculation, error) {
triggerDate, err := time.Parse("2006-01-02", params.TriggerDate)
if err != nil {
return nil, fmt.Errorf("invalid trigger date %q: %w", params.TriggerDate, err)
}
rule, pt, err := resolveRule(ctx, params, catalog)
if err != nil {
return nil, err
}
mandWire, _ := wireFlagsFromPriority(rule.Priority)
out := &RuleCalculation{
Rule: RuleCalculationRule{
ID: rule.ID.String(),
NameDE: rule.Name,
NameEN: rule.NameEN,
DurationValue: rule.DurationValue,
DurationUnit: rule.DurationUnit,
IsMandatory: mandWire,
},
Proceeding: RuleCalculationProceeding{
Code: pt.Code,
NameDE: pt.Name,
NameEN: pt.NameEN,
},
TriggerDate: params.TriggerDate,
}
if rule.SubmissionCode != nil {
out.Rule.LocalCode = *rule.SubmissionCode
}
if rule.RuleCode != nil {
out.Rule.RuleRef = *rule.RuleCode
}
if rule.LegalSource != nil {
out.Rule.LegalSource = *rule.LegalSource
out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource)
out.Rule.LegalSourceURL = BuildLegalSourceURL(*rule.LegalSource)
}
if rule.PrimaryParty != nil {
out.Rule.Party = *rule.PrimaryParty
}
if rule.DeadlineNotes != nil {
out.Rule.NotesDE = *rule.DeadlineNotes
}
if rule.DeadlineNotesEn != nil {
out.Rule.NotesEN = *rule.DeadlineNotesEn
}
// Slice 9 (t-paliad-195) replacement for the dropped condition_flag
// text[] enumeration: walk the jsonb gate to pull out flag-leaf
// names. Returns nil on an unconditional rule.
out.FlagsRequired = ExtractFlagsFromExpr(rule.ConditionExpr)
// Court-determined: no calculable date.
if rule.IsCourtSet {
out.IsCourtSet = true
return out, nil
}
// Resolve flag-conditional duration via the unified condition_expr
// evaluator.
flagSet := make(map[string]struct{}, len(params.Flags))
for _, f := range params.Flags {
flagSet[f] = struct{}{}
}
durationValue := rule.DurationValue
durationUnit := rule.DurationUnit
gateMet := EvalConditionExpr([]byte(rule.ConditionExpr), flagSet)
if gateMet && HasConditionExpr(rule.ConditionExpr) {
out.FlagsApplied = out.FlagsRequired
if rule.AltDurationValue != nil {
durationValue = *rule.AltDurationValue
}
if rule.AltDurationUnit != nil {
durationUnit = *rule.AltDurationUnit
}
if rule.AltRuleCode != nil {
out.Rule.RuleRef = *rule.AltRuleCode
}
}
// Zero-duration non-court-determined rules are "filed at the same
// time as parent" markers: effectively mean "due on the trigger
// date itself".
if durationValue == 0 {
out.OriginalDate = params.TriggerDate
out.DueDate = params.TriggerDate
return out, nil
}
defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
country, regime, err := courts.CountryRegime(params.CourtID, defaultCountry, defaultRegime)
if err != nil {
return nil, fmt.Errorf("resolve court %q: %w", params.CourtID, err)
}
timing := ""
if rule.Timing != nil {
timing = *rule.Timing
}
endDate, adjusted, wasAdj, reason := ApplyDuration(
triggerDate, durationValue, durationUnit, timing, country, regime, holidays,
)
out.OriginalDate = endDate.Format("2006-01-02")
out.DueDate = adjusted.Format("2006-01-02")
out.WasAdjusted = wasAdj
out.AdjustmentReason = reason
return out, nil
}
// resolveRule resolves CalcRuleParams to a rule + its proceeding type.
// Accepts either RuleID (UUID) or (ProceedingCode, RuleLocalCode). The
// frontend uses the latter form (it has the pill context) and the
// programmatic / test caller can use the former.
func resolveRule(ctx context.Context, params CalcRuleParams, catalog Catalog) (*Rule, *ProceedingType, error) {
if params.RuleID == "" && (params.ProceedingCode == "" || params.RuleLocalCode == "") {
return nil, nil, fmt.Errorf("CalcRuleParams: either RuleID or (ProceedingCode + RuleLocalCode) is required")
}
if params.RuleID != "" {
rule, err := catalog.LoadRuleByID(ctx, params.RuleID)
if err != nil {
return nil, nil, err
}
if rule.ProceedingTypeID == nil {
return nil, nil, fmt.Errorf("rule %q has no proceeding_type_id", params.RuleID)
}
pt, err := catalog.LoadProceedingByID(ctx, *rule.ProceedingTypeID)
if err != nil {
return nil, nil, fmt.Errorf("resolve proceeding for rule %q: %w", params.RuleID, err)
}
return rule, pt, nil
}
rule, pt, err := catalog.LoadRuleByCode(ctx, params.ProceedingCode, params.RuleLocalCode)
if err != nil {
return nil, nil, err
}
return rule, pt, nil
}
// ApplyRuleOverrides replaces rules whose ID appears in `overrides`
// with the override row, and appends any override whose ID isn't in
// the source list (net-new drafts the rule editor wants to preview).
//
// Used by the Slice 11a (t-paliad-191) preview endpoint: the editor
// passes the draft as an override so Calculate runs against the
// proposed shape without writing to the DB. Empty overrides slice =
// pass-through.
func ApplyRuleOverrides(src, overrides []Rule) []Rule {
if len(overrides) == 0 {
return src
}
byID := make(map[uuid.UUID]Rule, len(overrides))
for _, o := range overrides {
byID[o.ID] = o
}
out := make([]Rule, 0, len(src)+len(overrides))
seen := make(map[uuid.UUID]bool, len(overrides))
for _, r := range src {
if ov, ok := byID[r.ID]; ok {
out = append(out, ov)
seen[ov.ID] = true
continue
}
out = append(out, r)
}
for _, o := range overrides {
if seen[o.ID] {
continue
}
out = append(out, o)
}
return out
}
// wireFlagsFromPriority derives the legacy (IsMandatory, IsOptional)
// pair from the unified priority enum so the wire shape stays
// pixel-identical. Mapping mirrors mig 083's backfill (per design §2.3):
//
// 'mandatory' → (true, false)
// 'optional' → (true, true)
// 'recommended' → (false, false)
// 'informational' → (false, false)
// (unknown) → (true, false)
func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
switch priority {
case "mandatory":
return true, false
case "optional":
return true, true
case "recommended":
return false, false
case "informational":
return false, false
default:
return true, false
}
}
// AllFlagsSet is retained as a tiny utility for callers that have a
// flat list of flag strings + a flag-set lookup. The new condition_expr
// gate is the canonical evaluator; this helper exists for forward-
// compat with any future caller that wants the legacy AND-over-list
// semantic without rebuilding the jsonb.
func AllFlagsSet(required []string, set map[string]struct{}) bool {
return allFlagsSet(required, set)
}
// WireFlagsFromPriority is the public form of wireFlagsFromPriority so
// the paliad-side test suite (which historically asserted the mapping
// directly) can still test the contract.
func WireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
return wireFlagsFromPriority(priority)
}

View File

@@ -0,0 +1,145 @@
package litigationplanner
import "encoding/json"
// allFlagsSet returns true when every element of `required` is present in
// `set`. Empty `required` returns true (no condition). Retained as the
// fallback predicate used by EvalConditionExpr when condition_expr is
// NULL but the legacy condition_flag text[] is set — preserves
// transition-window behaviour for any row Slice 2 missed (it shouldn't,
// but defensive).
func allFlagsSet(required []string, set map[string]struct{}) bool {
for _, f := range required {
if _, ok := set[f]; !ok {
return false
}
}
return true
}
// EvalConditionExpr returns true iff the rule's gate predicate is
// satisfied for the caller's flag set. Drives flag-conditional rendering
// + flag-conditional alt-swap throughout the calculator.
//
// Grammar (design §2.4 long form, mig 084 backfill):
//
// {"flag": "<name>"} — leaf: true iff <name> ∈ flags
// {"op": "and", "args": [<n>...]} — true iff every arg evaluates true
// {"op": "or", "args": [<n>...]} — true iff any arg evaluates true
// {"op": "not", "args": [<one>]} — true iff the single arg is false
//
// NULL / empty / "null" expression → true (unconditional). Malformed
// JSON → true (defensive: the rule still renders, the lawyer sees
// it even if the gate is broken).
//
// Slice 9 (t-paliad-195, mig 091) dropped the legacy condition_flag
// text[] column; the fallback that AND'd over it is gone. Any future
// row needing array-of-flags semantics writes the equivalent
// {"op":"and","args":[{"flag":"<a>"},...]} jsonb directly.
func EvalConditionExpr(expr []byte, flags map[string]struct{}) bool {
if len(expr) == 0 || string(expr) == "null" {
return true
}
return EvalConditionExprNode(expr, flags)
}
// EvalConditionExprNode walks one node of the condition_expr jsonb
// tree. Recursion depth is bounded by the editor (Slice 11 caps tree
// depth + arg count); pre-Slice-11 backfilled rows have at most a
// 2-arg AND (mig 084).
func EvalConditionExprNode(raw []byte, flags map[string]struct{}) bool {
var node struct {
Flag string `json:"flag"`
Op string `json:"op"`
Args []json.RawMessage `json:"args"`
}
if err := json.Unmarshal(raw, &node); err != nil {
// Malformed → unconditional. The Slice 11 editor's validation
// will block such writes; in the live corpus today mig 084's
// jsonb_build_object output is well-formed by construction.
return true
}
if node.Flag != "" {
_, ok := flags[node.Flag]
return ok
}
switch node.Op {
case "and":
for _, a := range node.Args {
if !EvalConditionExprNode(a, flags) {
return false
}
}
return true
case "or":
for _, a := range node.Args {
if EvalConditionExprNode(a, flags) {
return true
}
}
return false
case "not":
if len(node.Args) != 1 {
// Malformed NOT — fall through to unconditional rather
// than risk suppressing a rule the lawyer expects to see.
return true
}
return !EvalConditionExprNode(node.Args[0], flags)
}
// Unknown op (forward-compat with editor extensions): treat as
// unconditional so the rule still renders.
return true
}
// HasConditionExpr returns true when the rule carries a non-empty,
// non-"null" jsonb gate. Slice 9 (t-paliad-195) replacement for the
// pre-drop `len(r.ConditionFlag) > 0` predicate that guarded the
// flag-keyed alt-swap branch. Same intent: "this rule has a gate;
// when the gate flips to met, swap to alt".
func HasConditionExpr(expr NullableJSON) bool {
if len(expr) == 0 {
return false
}
s := string(expr)
return s != "null" && s != "{}"
}
// ExtractFlagsFromExpr walks the jsonb gate and returns the unique
// flag names referenced as {"flag":"<name>"} leaves. Used by
// CalculateRule's response (FlagsRequired) so the result-card calc
// panel can render flag checkboxes for each gate input. Replaces the
// dropped condition_flag text[] enumeration. Returns nil on a NULL
// expression or one that contains no flag leaves.
func ExtractFlagsFromExpr(expr NullableJSON) []string {
if !HasConditionExpr(expr) {
return nil
}
seen := make(map[string]struct{})
walkFlagLeaves([]byte(expr), seen)
if len(seen) == 0 {
return nil
}
out := make([]string, 0, len(seen))
for f := range seen {
out = append(out, f)
}
return out
}
func walkFlagLeaves(raw []byte, into map[string]struct{}) {
var node struct {
Flag string `json:"flag"`
Op string `json:"op"`
Args []json.RawMessage `json:"args"`
}
if err := json.Unmarshal(raw, &node); err != nil {
return
}
if node.Flag != "" {
into[node.Flag] = struct{}{}
return
}
for _, a := range node.Args {
walkFlagLeaves(a, into)
}
}

View File

@@ -0,0 +1,25 @@
package litigationplanner
import "time"
// HolidayCalendar adjusts dates onto working days for a given
// (country, regime) pair. The calculator only needs three primitives:
//
// - IsNonWorkingDay — used by the addWorkingDays walker
// - AdjustForNonWorkingDays — forward snap (timing='after')
// - AdjustForNonWorkingDaysBackward — backward snap (timing='before')
// - AdjustForNonWorkingDaysWithReason — like the forward snap but
// also returns *AdjustmentReason so the timeline can render the
// "rolled past holiday X" footer in TimelineEntry.AdjustmentReason.
//
// Implementations:
// - paliad: reads paliad.holidays, caches per-year, merges DE
// federal fallback.
// - embedded/upc (Slice C): in-memory year-keyed map populated from
// the embedded JSON snapshot.
type HolidayCalendar interface {
IsNonWorkingDay(date time.Time, country, regime string) bool
AdjustForNonWorkingDays(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool)
AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool)
AdjustForNonWorkingDaysWithReason(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool, reason *AdjustmentReason)
}

View File

@@ -0,0 +1,123 @@
package litigationplanner
import "strings"
// FormatLegalSourceDisplay renders a structured legal_source code into
// the form HLC users read in pleadings:
//
// UPC.RoP.23.1 → "UPC RoP R.23(1)"
// UPC.RoP.139 → "UPC RoP R.139"
// DE.PatG.82.1 → "PatG §82(1)"
// DE.ZPO.276.1 → "ZPO §276(1)"
// EU.EPÜ.108 → "EPÜ Art.108"
// EU.EPC-R.79.1 → "EPC R.79(1)"
// EU.RPBA.12.1.c → "RPBA Art.12(1)(c)"
//
// Returns the empty string for an empty input. Unknown jurisdictions
// fall through with the structured form preserved (caller decides
// whether to display).
func FormatLegalSourceDisplay(src string) string {
src = strings.TrimSpace(src)
if src == "" {
return ""
}
parts := strings.Split(src, ".")
if len(parts) < 3 {
// Malformed — return as-is so the caller still has something.
return src
}
code := parts[1]
rest := parts[2:]
var prefix string
switch code {
case "RoP":
prefix = "UPC RoP R."
case "PatG":
prefix = "PatG §"
case "ZPO":
prefix = "ZPO §"
case "EPÜ":
prefix = "EPÜ Art."
case "EPC-R":
prefix = "EPC R."
case "RPBA":
prefix = "RPBA Art."
default:
prefix = code + " "
}
var b strings.Builder
b.Grow(len(prefix) + len(src))
b.WriteString(prefix)
b.WriteString(rest[0])
for _, p := range rest[1:] {
b.WriteByte('(')
b.WriteString(p)
b.WriteByte(')')
}
return b.String()
}
// BuildLegalSourceURL maps a structured legal_source code to a
// youpc.org/laws permalink when the cited body is hosted there. Today
// youpc only carries the UPC corpus (UPCA, UPCS, UPCRoP); DE national
// codes (PatG, ZPO) and EPO bodies (EPÜ, EPC-R, RPBA) have no youpc
// home yet, so the helper returns the empty string for those and the
// caller renders the display string as plain text.
//
// Inputs mirror FormatLegalSourceDisplay — structured dot-separated
// codes like UPC.RoP.23.1, UPC.UPCA.83. Sub-paragraph segments beyond
// the law-number position are dropped; youpc resolves the page at
// <type>.<number> granularity. The law-number is zero-padded to 3
// digits to match how youpc stores law_number (laws-data.json carries
// "001" / "023" / "220" forms).
//
// UPC.RoP.23.1 → https://youpc.org/laws#UPCRoP.023
// UPC.RoP.220.1 → https://youpc.org/laws#UPCRoP.220
// UPC.RoP.29.a → https://youpc.org/laws#UPCRoP.029
// UPC.UPCA.83 → https://youpc.org/laws#UPCA.083
// DE.ZPO.276.1 → "" (no youpc home — render display text plain)
func BuildLegalSourceURL(src string) string {
src = strings.TrimSpace(src)
if src == "" {
return ""
}
parts := strings.Split(src, ".")
if len(parts) < 3 {
return ""
}
var lawType string
switch parts[0] + "." + parts[1] {
case "UPC.RoP":
lawType = "UPCRoP"
case "UPC.UPCA":
lawType = "UPCA"
case "UPC.UPCS":
lawType = "UPCS"
default:
return ""
}
number := padLawNumber(parts[2])
if number == "" {
return ""
}
return "https://youpc.org/laws#" + lawType + "." + number
}
// padLawNumber zero-pads a pure-digit law-number segment to 3 digits.
// Non-digit-only inputs (e.g. "112a" if youpc ever ingests EPÜ Art.
// 112a) pass through unchanged so the URL still resolves. Empty input
// returns the empty string.
func padLawNumber(s string) string {
if s == "" {
return ""
}
for _, c := range s {
if c < '0' || c > '9' {
return s
}
}
if len(s) >= 3 {
return s
}
return strings.Repeat("0", 3-len(s)) + s
}

View File

@@ -0,0 +1,139 @@
package litigationplanner
// proceeding_mapping bridges the two proceeding-type vocabularies in the
// codebase: the **litigation** conceptual category (INF / REV / APP /
// CCR / AMD / APM / ZPO_CIVIL) used by the historical project-binding
// + Pipeline-A rules, and the **fristenrechner** code category
// (upc.inf.cfi / de.inf.lg / epa.opp.opd / …) used by the Determinator
// cascade + rule engine. Post-Phase-3-Slice-5 (t-paliad-186) projects
// bind to fristenrechner codes directly, but the litigation→fristenrechner
// mapping is still needed for the ~40 Pipeline-A rules that remain on
// litigation proceedings and for any other surface that thinks in
// litigation terms.
//
// The mapping table here is the single source of truth — see
// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the
// design rationale + ambiguity notes, and
// docs/design-proceeding-code-taxonomy-2026-05-18.md for the
// lowercase dot-separated naming convention applied by mig 096
// (t-paliad-206). **Never silent FK promotion**: every ambiguous case
// returns ok=false so callers can degrade gracefully ("no narrowing")
// instead of guessing.
// Stable code constants — the strings landed by mig 096. Use these
// throughout the codebase so a future rename only needs to touch this
// file. The id-anchored FKs (deadline_rules.proceeding_type_id,
// projects.proceeding_type_id) are unaffected by the rename.
const (
CodeUPCInfringement = "upc.inf.cfi"
CodeUPCRevocation = "upc.rev.cfi"
CodeUPCCounterclaim = "upc.ccr.cfi"
CodeUPCPreliminary = "upc.pi.cfi"
CodeUPCDamages = "upc.dmgs.cfi"
CodeUPCDiscovery = "upc.disc.cfi"
CodeUPCAppealMerits = "upc.apl.merits"
CodeUPCAppealOrder = "upc.apl.order"
CodeUPCAppealCost = "upc.apl.cost"
CodeDEInfringementLG = "de.inf.lg"
CodeDEInfringementOLG = "de.inf.olg"
CodeDEInfringementBGH = "de.inf.bgh"
CodeDENullityBPatG = "de.null.bpatg"
CodeDENullityBGH = "de.null.bgh"
CodeEPAGrant = "epa.grant.exa"
CodeEPAOpposition = "epa.opp.opd"
CodeEPAOppositionAppeal = "epa.opp.boa"
CodeDPMAOpposition = "dpma.opp.dpma"
CodeDPMAAppealBPatG = "dpma.appeal.bpatg"
CodeDPMAAppealBGH = "dpma.appeal.bgh"
)
// MapLitigationToFristenrechner returns the fristenrechner code +
// condition flags implied by a (litigationCode, jurisdiction) pair.
//
// Inputs are case-sensitive — pass the canonical upper-snake form
// (e.g. "INF", "UPC"). Unrecognised codes or genuinely ambiguous
// combinations (APP+DE, ZPO_CIVIL+DE) return ok=false with a zero
// fristenrechner code; callers should treat that as "no narrowing"
// and leave the cascade wide-open rather than auto-pick.
//
// Condition flags are returned as a slice so callers can apply them
// alongside the fristenrechner code (CCR+UPC → upc.inf.cfi + with_ccr,
// AMD+UPC → upc.inf.cfi + with_amend). An empty slice means no flag
// context applies.
func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristenrechnerCode string, conditionFlags []string, ok bool) {
switch litigationCode {
case "INF":
switch jurisdiction {
case "UPC":
return CodeUPCInfringement, nil, true
case "DE":
return CodeDEInfringementLG, nil, true
}
case "REV":
switch jurisdiction {
case "UPC":
return CodeUPCRevocation, nil, true
case "DE":
return CodeDENullityBPatG, nil, true
}
case "CCR":
// Counterclaim revocation — UPC fold-in is structural (the
// counterclaim lives inside an upc.inf.cfi proceeding with the
// with_ccr flag). DE Nichtigkeit is conceptually the same
// adversarial-validity test, no separate flag.
switch jurisdiction {
case "UPC":
return CodeUPCInfringement, []string{"with_ccr"}, true
case "DE":
return CodeDENullityBPatG, nil, true
}
case "AMD":
// Amendment-application bundled into upc.inf.cfi via with_amend.
// No DE / EPA / DPMA analogue today.
if jurisdiction == "UPC" {
return CodeUPCInfringement, []string{"with_amend"}, true
}
case "APP":
// Appeal is ambiguous in DE (OLG vs BGH) and the project
// model doesn't carry the instance hint we'd need to
// disambiguate. UPC is unambiguous — upc.apl.merits covers
// the merits appeal track for inf/rev/ccr/damages.
if jurisdiction == "UPC" {
return CodeUPCAppealMerits, nil, true
}
case "APM":
// Preliminary injunction / urgency procedure — UPC-only
// concept in the fristenrechner taxonomy.
if jurisdiction == "UPC" {
return CodeUPCPreliminary, nil, true
}
case "OPP":
// Opposition — primarily EPA. DPMA has dpma.opp.dpma but it
// doesn't surface from the litigation vocabulary today.
if jurisdiction == "EPA" {
return CodeEPAOpposition, nil, true
}
}
return "", nil, false
}
// ResolveCounterclaimRouting handles the determinator's
// upc.ccr.cfi illustrative-peer route: the code exists in the dropdown
// for taxonomic completeness, but no rules are attached to it. When the
// cascade resolves to upc.ccr.cfi we route the rule lookup back to
// upc.inf.cfi with a default with_ccr=true flag — see
// docs/design-proceeding-code-taxonomy-2026-05-18.md §0.3 sub-decision S1.
//
// `code` is the proceeding code the cascade resolved to. If it's
// upc.ccr.cfi, the function returns (CodeUPCInfringement,
// []string{"with_ccr"}, true). For any other code the function returns
// (code, nil, false) and callers proceed with the code unchanged. The
// boolean signals "routing was applied"; the caller can surface the hint
// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
// weiter." in the UI.
func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) {
if route, ok := SubTrackRoutings[code]; ok {
return route.ParentCode, route.DefaultFlags, true
}
return code, nil, false
}

View File

@@ -0,0 +1,151 @@
package litigationplanner
import (
"fmt"
"sort"
"github.com/google/uuid"
)
// SortDeadlinesByDurationWithinTriggerGroup is the public form of
// sortDeadlinesByDurationWithinTriggerGroup. Exported so paliad's
// test suite (which historically reached the helper directly) can
// keep invoking it via a tiny wrapper.
func SortDeadlinesByDurationWithinTriggerGroup(
deadlines []TimelineEntry,
ruleByID map[uuid.UUID]Rule,
) {
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
}
// sortDeadlinesByDurationWithinTriggerGroup walks consecutive runs of
// deadlines whose underlying rule shares the same trigger group
// (parent_id + trigger_event_id) and reorders each run in place by
// duration ascending. Different trigger groups keep their original
// proceeding-sequence position — the walk only ever permutes adjacent
// same-group rows.
//
// Sort key (within a run):
// 1. Conditional / court-set rows (no concrete date in the duration
// ladder) sort LAST, tiebroken by submission_code.
// 2. duration_unit weight ASC: days/working_days < weeks < months < years
// 3. duration_value ASC
// 4. submission_code ASC (deterministic tiebreak)
//
// Issue: m/paliad#128 — post-decision optional events (R.151/R.353
// 1-month before R.118.4/R.220.1 2-month) were rendering in catalog
// order instead of likely-sequence order. (t-paliad-296)
func sortDeadlinesByDurationWithinTriggerGroup(
deadlines []TimelineEntry,
ruleByID map[uuid.UUID]Rule,
) {
if len(deadlines) < 2 {
return
}
n := len(deadlines)
i := 0
for i < n {
gid := triggerGroupKey(deadlines[i], ruleByID)
j := i + 1
for j < n && triggerGroupKey(deadlines[j], ruleByID) == gid {
j++
}
// Root rules (no parent and no trigger_event) get gid="" and
// would otherwise collapse into one big run. Skip the sort for
// the "root" pseudo-group — each root rule represents its own
// anchor (SoC, oral hearing, decision …) and the proceeding-
// sequence order between them must be preserved.
if j-i > 1 && gid != "" {
chunk := deadlines[i:j]
sort.SliceStable(chunk, func(a, b int) bool {
return durationLessForSort(chunk[a], chunk[b], ruleByID)
})
}
i = j
}
}
// triggerGroupKey returns a string key identifying which trigger group
// a deadline belongs to. Same key = same group = candidates for sort.
// Empty string means "root" (no parent, no trigger_event) — used as a
// sentinel by the caller to skip sorting roots against each other.
func triggerGroupKey(d TimelineEntry, ruleByID map[uuid.UUID]Rule) string {
rid, err := uuid.Parse(d.RuleID)
if err != nil {
return ""
}
r, ok := ruleByID[rid]
if !ok {
return ""
}
if r.ParentID != nil {
return "p:" + r.ParentID.String()
}
if r.TriggerEventID != nil {
return fmt.Sprintf("t:%d", *r.TriggerEventID)
}
return ""
}
// durationLessForSort compares two deadlines for the duration-ascending
// sort. Court-set / conditional rows (no concrete date) sort LAST
// regardless of duration — they don't fit the duration ladder.
func durationLessForSort(
a, b TimelineEntry,
ruleByID map[uuid.UUID]Rule,
) bool {
aLast := a.IsCourtSet || a.IsConditional
bLast := b.IsCourtSet || b.IsConditional
if aLast != bLast {
return !aLast
}
if aLast && bLast {
return a.Code < b.Code
}
ra := lookupRuleFromDeadline(a, ruleByID)
rb := lookupRuleFromDeadline(b, ruleByID)
wa := durationUnitWeight(ra.DurationUnit)
wb := durationUnitWeight(rb.DurationUnit)
if wa != wb {
return wa < wb
}
if ra.DurationValue != rb.DurationValue {
return ra.DurationValue < rb.DurationValue
}
return a.Code < b.Code
}
func lookupRuleFromDeadline(
d TimelineEntry,
ruleByID map[uuid.UUID]Rule,
) Rule {
if d.RuleID == "" {
return Rule{}
}
rid, err := uuid.Parse(d.RuleID)
if err != nil {
return Rule{}
}
return ruleByID[rid]
}
// durationUnitWeight maps a duration unit to its sort weight so the
// trigger-group sort can order shorter durations first. days and
// working_days share weight 0 (both are sub-week granularities);
// unknown units sort to the end so they're visible as a tail rather
// than silently winning.
func durationUnitWeight(unit string) int {
switch unit {
case "days", "working_days":
return 0
case "weeks":
return 1
case "months":
return 2
case "years":
return 3
}
return 4
}

View File

@@ -0,0 +1,53 @@
package litigationplanner
// SubTrackRouting describes a proceeding type that has no native rules
// of its own and is normally rendered inside a parent proceeding's flow
// with one or more condition flags enabled. The Procedure Roadmap
// (verfahrensablauf) routes calc requests for these codes to the parent
// proceeding + default flags, but preserves the user-picked code/name
// in the response identity and surfaces a contextual note explaining
// the framing — see m/paliad#58 and the design doc cited above.
//
// Adding a new sub-track is a data-only change here: extend
// SubTrackRoutings with the (code, parent, flags, note) tuple and the
// renderer picks it up automatically. The note copy lives in this file
// because it's semantic to the routing, not UI chrome.
type SubTrackRouting struct {
// Code is the user-picked proceeding code (e.g. "upc.ccr.cfi").
Code string
// ParentCode is the proceeding whose rules to use (e.g. "upc.inf.cfi").
ParentCode string
// DefaultFlags are merged into the user's flag set so the
// gated rules render. Order is preserved.
DefaultFlags []string
// NoteDE / NoteEN are the contextual banner above the timeline,
// explaining that the proceeding type is normally a sub-track.
// Plain text — the frontend renders them as a banner.
NoteDE string
NoteEN string
}
// SubTrackRoutings — single-source-of-truth registry. Today: just CCR.
// The pattern generalises to other "sub-track" proceeding types (e.g.
// R.30 application to amend the patent as a standalone roadmap, R.46
// preliminary objection) once they have a proceeding-type code of their
// own. New entries here are picked up by the spawn-as-standalone
// renderer in Calculate without further wiring.
var SubTrackRoutings = map[string]SubTrackRouting{
CodeUPCCounterclaim: {
Code: CodeUPCCounterclaim,
ParentCode: CodeUPCInfringement,
DefaultFlags: []string{"with_ccr"},
NoteDE: "Die Nichtigkeitswiderklage läuft normalerweise innerhalb eines UPC-Verletzungsverfahrens mit aktiver Nichtigkeitswiderklage. Diese Zeitleiste zeigt das Verletzungsverfahren mit gesetztem with_ccr-Flag.",
NoteEN: "The counterclaim for revocation normally runs inside a UPC infringement action with the counterclaim flag set. This timeline shows the infringement action with with_ccr automatically enabled.",
},
}
// LookupSubTrackRouting returns the sub-track routing for a proceeding
// code, or (zero, false) if the code is not a sub-track. Used by the
// fristenrechner Calculate path to spawn the parent flow with the sub-
// track's default flags.
func LookupSubTrackRouting(code string) (SubTrackRouting, bool) {
r, ok := SubTrackRoutings[code]
return r, ok
}

View File

@@ -0,0 +1,428 @@
package litigationplanner
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
)
// NullableJSON is a jsonb column that may be NULL. json.RawMessage
// (and *json.RawMessage) doesn't implement sql.Scanner, so a NULL value
// from Postgres breaks the row scan with "unsupported Scan, storing
// driver.Value type <nil> into type *json.RawMessage" — exactly the
// error that hid every approval_request from the inbox when m's first
// "create" lifecycle row arrived with NULL pre_image (m's dogfood
// 2026-05-08 20:35). Using NullableJSON on every nullable jsonb column
// fixes the scan and preserves inline JSON output (no base64 cast).
type NullableJSON []byte
// Scan implements sql.Scanner.
func (n *NullableJSON) Scan(value any) error {
if value == nil {
*n = nil
return nil
}
switch v := value.(type) {
case []byte:
*n = append((*n)[:0], v...)
return nil
case string:
*n = []byte(v)
return nil
}
return fmt.Errorf("NullableJSON: unsupported scan type %T", value)
}
// Value implements driver.Valuer.
func (n NullableJSON) Value() (driver.Value, error) {
if len(n) == 0 {
return nil, nil
}
return []byte(n), nil
}
// MarshalJSON emits the raw JSON bytes (or "null").
func (n NullableJSON) MarshalJSON() ([]byte, error) {
if len(n) == 0 {
return []byte("null"), nil
}
return []byte(n), nil
}
// UnmarshalJSON consumes raw JSON bytes (literal "null" maps to nil).
func (n *NullableJSON) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
*n = nil
return nil
}
*n = append((*n)[:0], data...)
return nil
}
// Rule is one rule in the proceeding-rule tree (UPC R.023, etc.).
//
// JSON + db tags are intentionally identical to the historical
// paliad.deadline_rules row shape — sqlx scans onto Rule directly and
// the wire bytes the frontend reads are unchanged from the pre-extract
// shape.
type Rule struct {
ID uuid.UUID `db:"id" json:"id"`
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
SubmissionCode *string `db:"submission_code" json:"submission_code,omitempty"`
Name string `db:"name" json:"name"`
NameEN string `db:"name_en" json:"name_en"`
Description *string `db:"description" json:"description,omitempty"`
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
EventType *string `db:"event_type" json:"event_type,omitempty"`
DurationValue int `db:"duration_value" json:"duration_value"`
DurationUnit string `db:"duration_unit" json:"duration_unit"`
Timing *string `db:"timing" json:"timing,omitempty"`
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
DeadlineNotesEn *string `db:"deadline_notes_en" json:"deadline_notes_en,omitempty"`
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"`
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
// ConceptDefaultEventTypeID is the canonical paliad.event_types row for
// this rule's concept (joined via paliad.deadline_concept_event_types
// where is_default = true). Lets the deadline create form auto-populate
// the Typ chip when the user picks this rule. Hydrated by the service
// layer; not a column. NULL when the concept has no mapped event_type.
ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"`
LegalSource *string `db:"legal_source" json:"legal_source,omitempty"`
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
IsActive bool `db:"is_active" json:"is_active"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
// TriggerEventID points at paliad.trigger_events when this rule is
// event-rooted (Pipeline C unification, design §2.5). NULL on
// proceeding-rooted rules.
TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"`
// SpawnProceedingTypeID is the cross-proceeding spawn target —
// when is_spawn=true and this is non-NULL, the calculator follows
// the FK and emits the target proceeding's root rule chain.
SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"`
// CombineOp is 'max' or 'min' for composite-rule arithmetic
// (R.198 / R.213: "31d OR 20 working_days, whichever is longer").
// NULL = single-anchor arithmetic.
CombineOp *string `db:"combine_op" json:"combine_op,omitempty"`
// ConditionExpr is the jsonb gating expression. Grammar:
// {"flag": "<name>"}
// {"op":"and"|"or", "args":[<node>, ...]}
// {"op":"not", "args":[<node>]}
// NULL or {} = unconditional.
ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"`
// Priority is the 4-way unified enum: 'mandatory' (default),
// 'recommended', 'optional', 'informational'.
Priority string `db:"priority" json:"priority"`
// IsCourtSet replaces the runtime heuristic (primary_party='court'
// OR event_type IN ('hearing','decision','order')).
IsCourtSet bool `db:"is_court_set" json:"is_court_set"`
// LifecycleState drives the rule-editor flow:
// 'draft' | 'published' | 'archived'.
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
// DraftOf points at the published rule this draft will replace on
// publish. NULL on published / archived rows.
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
// PublishedAt records when the row entered LifecycleState='published'.
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).
ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`
}
// ProceedingType is one of the litigation conceptual codes (INF/REV/CCR
// /APM/APP/AMD/ZPO_CIVIL — matter management) or the lowercase dot-
// separated fristenrechner codes (upc.*.*, de.*.*, epa.*.*, dpma.*.*) —
// see docs/design-proceeding-code-taxonomy-2026-05-18.md.
type ProceedingType struct {
ID int `db:"id" json:"id"`
Code string `db:"code" json:"code"`
Name string `db:"name" json:"name"`
NameEN string `db:"name_en" json:"name_en"`
Description *string `db:"description" json:"description,omitempty"`
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
Category *string `db:"category" json:"category,omitempty"`
DefaultColor string `db:"default_color" json:"default_color"`
SortOrder int `db:"sort_order" json:"sort_order"`
IsActive bool `db:"is_active" json:"is_active"`
// TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf
// "Auslösendes Ereignis". When set, overrides the proceedingName fallback
// that fires when no rule has IsRootEvent=true.
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
}
// AdjustmentReason describes why a date was rolled forward / backward
// off a non-working day. Populated by HolidayCalendar implementations
// when AdjustForNonWorkingDaysWithReason moves the date.
//
// Date fields are JSON-serialised as YYYY-MM-DD strings (matching
// TimelineEntry.DueDate / OriginalDate) so the frontend doesn't need a
// separate RFC3339 parser.
type AdjustmentReason struct {
// Kind is the dominant cause; longest cause wins when several apply
// (vacation > public_holiday > weekend).
Kind string `json:"kind"`
// Holidays collects every named holiday encountered while walking
// past the non-working run, deduped by (date, name). May be empty
// when the only cause is a weekend.
Holidays []HolidayDTO `json:"holidays,omitempty"`
// VacationName, VacationStart and VacationEnd describe the
// contiguous vacation block the original date sits in. Populated
// only when Kind == "vacation". Span boundaries are the first/last
// vacation day in the block (excludes the weekends that pad it).
VacationName string `json:"vacationName,omitempty"`
VacationStart string `json:"vacationStart,omitempty"`
VacationEnd string `json:"vacationEnd,omitempty"`
// OriginalWeekday is the English weekday name of the original date —
// "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI
// can localise it.
OriginalWeekday string `json:"originalWeekday,omitempty"`
}
// HolidayDTO is the JSON shape for a holiday emitted in
// AdjustmentReason — distinct from a DB-level Holiday row so dates
// serialise as YYYY-MM-DD strings.
type HolidayDTO struct {
Date string `json:"date"`
Name string `json:"name"`
IsVacation bool `json:"isVacation,omitempty"`
IsClosure bool `json:"isClosure,omitempty"`
}
// CalcOptions carries optional inputs for Calculate. Callers can leave
// fields empty/nil for the legacy behaviour.
//
// - PriorityDateStr: when non-empty (YYYY-MM-DD), rules with
// anchor_alt='priority_date' (e.g. epa.grant.exa.ep_grant.publish
// per Art. 93 EPÜ) use this date as their base instead of the
// parent's adjusted date / the trigger date.
// - Flags: lowercase string flags from the UI (e.g. "with_ccr",
// "with_amend"). Drive condition_expr evaluation + flag-keyed
// alt-swap.
// - AnchorOverrides: rule_code → YYYY-MM-DD. Per-rule user overrides
// of the computed deadline date. When a child rule chains off a
// parent whose code is in AnchorOverrides, the override date is
// used as the anchor instead of the parent's calculated date.
// - CourtID picks the forum the proceeding is filed in (e.g.
// "upc-ld-paris", "de-bgh"). The calculator resolves it to
// (country, regime) for non-working-day computation.
// - TriggerEventIDFilter scopes Calculate to event-driven Pipeline-C
// rules: when non-nil, the proceedingCode argument is ignored and
// the engine selects rules WHERE trigger_event_id = *filter.
// - RuleOverrides substitutes specific rules in the calculator's
// rule list with caller-supplied in-memory rows. Used by the
// rule-editor preview.
// - PerCardAppellant / SkipRules / IncludeCCRFor / IncludeHidden
// drive per-event-card choice overlays (t-paliad-265, t-paliad-290).
// - ProjectHint scopes the catalog lookup to a project context
// (paliad's catalog uses this to merge in project-scoped rules
// in future slices; v1 catalogs may ignore it).
type CalcOptions struct {
PriorityDateStr string
Flags []string
AnchorOverrides map[string]string
CourtID string
TriggerEventIDFilter *int64
RuleOverrides []Rule
PerCardAppellant map[string]string
SkipRules map[string]struct{}
IncludeCCRFor map[string]struct{}
IncludeHidden bool
ProjectHint ProjectHint
}
// ProjectHint scopes a Catalog call to a specific project. Paliad's
// catalog uses ProjectID to merge in project-scoped rules in a future
// slice (m/paliad#124 §6 — currently dropped per m's 2026-05-26
// decision; the field stays for forward-compat). Other catalogs (the
// embedded UPC snapshot used by youpc.org) ignore the hint.
//
// Zero value = no project context (the abstract Verfahrensablauf /
// public Fristenrechner case).
type ProjectHint struct {
ProjectID uuid.UUID
}
// CalcRuleParams identifies a single rule and the inputs needed to
// compute one deadline from it. Caller supplies either RuleID OR the
// (ProceedingCode, RuleLocalCode) pair — whichever the frontend has on
// hand from the concept-card pill it just received a click on.
type CalcRuleParams struct {
RuleID string // optional — UUID
ProceedingCode string // optional — used with RuleLocalCode
RuleLocalCode string // optional — paliad.deadline_rules.submission_code
TriggerDate string // required — YYYY-MM-DD
Flags []string // optional — condition_flag inputs
CourtID string // optional — selects holiday calendar
}
// Timeline is the package's structured return for Calculate. JSON tags
// are aligned with paliad's historical UIResponse so handlers can serve
// it directly — the wire bytes the frontend reads are unchanged.
type Timeline struct {
ProceedingType string `json:"proceedingType"`
ProceedingName string `json:"proceedingName"`
ProceedingNameEN string `json:"proceedingNameEN,omitempty"`
TriggerDate string `json:"triggerDate"`
Deadlines []TimelineEntry `json:"deadlines"`
ContextualNote string `json:"contextualNote,omitempty"`
ContextualNoteEN string `json:"contextualNoteEN,omitempty"`
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
HiddenCount int `json:"hiddenCount"`
}
// TimelineEntry matches the frontend's CalculatedDeadline TypeScript
// interface (camelCase JSON to keep /tools/fristenrechner byte-identical).
type TimelineEntry struct {
RuleID string `json:"ruleId,omitempty"`
Code string `json:"code"`
Name string `json:"name"`
NameEN string `json:"nameEN"`
Party string `json:"party"`
Priority string `json:"priority"`
RuleRef string `json:"ruleRef"`
LegalSource string `json:"legalSource,omitempty"`
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
LegalSourceURL string `json:"legalSourceURL,omitempty"`
Notes string `json:"notes,omitempty"`
NotesEN string `json:"notesEN,omitempty"`
DueDate string `json:"dueDate"`
OriginalDate string `json:"originalDate"`
WasAdjusted bool `json:"wasAdjusted"`
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
IsRootEvent bool `json:"isRootEvent"`
IsCourtSet bool `json:"isCourtSet"`
ConditionExpr json.RawMessage `json:"conditionExpr,omitempty"`
IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"`
// IsConditional signals the rule's anchor is uncertain — no
// concrete date can be projected. Set when the rule depends on:
// - a court-set ancestor whose date isn't anchored (overlaps
// with IsCourtSetIndirect; the two are kept distinct because
// IsCourtSet wraps a specific UX message "wird vom Gericht
// bestimmt", whereas IsConditional is the broader "render as
// 'abhängig von <parent>'" signal)
// - timing='before' rules whose forward anchor isn't set
// - optional opposing-side rules whose true triggering event
// hasn't been recorded for this project (e.g. R.262(2)
// Erwiderung auf Vertraulichkeitsantrag)
// When true, DueDate and OriginalDate are empty and the frontend
// renders an "abhängig von <ParentRuleName>" chip in place of a
// date. Suppressed by an explicit user anchor. (t-paliad-289)
IsConditional bool `json:"isConditional,omitempty"`
// ParentRuleCode / ParentRuleName / ParentRuleNameEN surface the
// parent's identity so the frontend can render
// "abhängig von <ParentRuleName>" when IsConditional=true.
// Populated whenever the rule has a parent_id, not only when
// conditional — keeps the wire shape stable. Empty for root rules.
// When a rule has a real trigger_event_id, these fields are
// overridden to point at the trigger_events catalog row instead of
// the parent_id chain (t-paliad-294 / m/paliad#126).
ParentRuleCode string `json:"parentRuleCode,omitempty"`
ParentRuleName string `json:"parentRuleName,omitempty"`
ParentRuleNameEN string `json:"parentRuleNameEN,omitempty"`
IsOverridden bool `json:"isOverridden,omitempty"`
ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"`
AppellantContext string `json:"appellantContext,omitempty"`
IsHidden bool `json:"isHidden,omitempty"`
}
// RuleCalculation is the single-rule calc response that backs the
// result-card click → calc-panel flow. Distinct from TimelineEntry
// (which represents one rendered row inside a full-proceeding
// response): RuleCalculation is self-contained.
type RuleCalculation struct {
Rule RuleCalculationRule `json:"rule"`
Proceeding RuleCalculationProceeding `json:"proceeding"`
TriggerDate string `json:"triggerDate"`
OriginalDate string `json:"originalDate"`
DueDate string `json:"dueDate"`
WasAdjusted bool `json:"wasAdjusted"`
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
IsCourtSet bool `json:"isCourtSet"`
FlagsApplied []string `json:"flagsApplied,omitempty"`
FlagsRequired []string `json:"flagsRequired,omitempty"`
}
// RuleCalculationRule mirrors the small subset of Rule the
// frontend needs to render the calc panel.
type RuleCalculationRule struct {
ID string `json:"id"`
LocalCode string `json:"localCode,omitempty"`
NameDE string `json:"nameDE"`
NameEN string `json:"nameEN"`
RuleRef string `json:"ruleRef,omitempty"`
LegalSource string `json:"legalSource,omitempty"`
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
LegalSourceURL string `json:"legalSourceURL,omitempty"`
DurationValue int `json:"durationValue"`
DurationUnit string `json:"durationUnit"`
Party string `json:"party,omitempty"`
IsMandatory bool `json:"isMandatory"`
NotesDE string `json:"notesDE,omitempty"`
NotesEN string `json:"notesEN,omitempty"`
}
// RuleCalculationProceeding identifies the proceeding context for the
// rule. Used by the frontend for display + by the add-to-project flow.
type RuleCalculationProceeding struct {
Code string `json:"code"`
NameDE string `json:"nameDE"`
NameEN string `json:"nameEN"`
}
// FristenrechnerType mirrors the /api/tools/proceeding-types response
// metadata.
type FristenrechnerType struct {
Code string `json:"code"`
Name string `json:"name"`
NameEN string `json:"nameEN"`
Group string `json:"group"`
}
// TriggerEvent is a UPC procedural event referenced by deadline rules
// whose semantic anchor is an event rather than a parent rule (the
// classic case: R.262(2) Erwiderung auf Vertraulichkeitsantrag is
// triggered by the opposing party's confidentiality application, not
// by the SoC parent rule). The conditional-rendering branch reads
// this when stamping ParentRule* on the wire.
type TriggerEvent struct {
ID int64 `db:"id" json:"id"`
Code string `db:"code" json:"code"`
Name string `db:"name" json:"name"`
NameDE string `db:"name_de" json:"name_de"`
Description string `db:"description" json:"description"`
IsActive bool `db:"is_active" json:"is_active"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// Sentinel errors surfaced by Calculate / CalculateRule / Catalog
// implementations. Handlers map these to HTTP statuses.
var (
ErrUnknownProceedingType = errors.New("unknown proceeding type")
ErrUnknownRule = errors.New("unknown rule")
)

View File

@@ -315,11 +315,11 @@ func buildDocumentXML() string {
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}}")
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
// {{deadline.*}} placeholders stay resolvable in the variable bag
// for custom templates that want them, but the default HL skeleton
// no longer renders them in the submission body: the deadline is
// internal/admin context and has no place in a court-bound document.
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.]")
@@ -349,7 +349,6 @@ func buildDocumentXML() string {
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}}")

View File

@@ -137,14 +137,19 @@ const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
</w:styles>`
// Document body — a code-agnostic Schriftsatz skeleton: firm letterhead +
// case caption + parties + submission heading + deadline + a single
// neutral body block. Mirrors the variable bag from SubmissionVarsService
// (48 keys across firm.* / today.* / user.* / project.* / parties.* /
// rule.* / deadline.*) without baking in DE-LG-Klageerwiderung-specific
// structure. A lawyer customising this template for a UPC SoC, EPO
// opposition, or DPMA appeal replaces the [Schriftsatztext] block and
// renames the party labels — every placeholder still resolves regardless
// of the submission_code chosen.
// case caption + parties + submission heading + a single neutral body
// block. Mirrors the variable bag from SubmissionVarsService (firm.* /
// today.* / user.* / project.* / parties.* / rule.*) without baking in
// DE-LG-Klageerwiderung-specific structure. A lawyer customising this
// template for a UPC SoC, EPO opposition, or DPMA appeal replaces the
// [Schriftsatztext] block and renames the party labels — every
// placeholder still resolves regardless of the submission_code chosen.
//
// The {{deadline.*}} placeholders are deliberately NOT rendered by the
// default skeleton (t-paliad-287). The deadline is internal context for
// the lawyer, not text that belongs in a court-bound submission. The
// keys stay resolvable in the bag so a custom template can still
// reference them where it actually wants them.
//
// Every placeholder occupies its own <w:r> run so the renderer's pass-1
// (format-preserving, single-run) substitution catches it. The
@@ -194,11 +199,12 @@ func buildDocumentXML() string {
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
heading2(&b, "Frist")
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
// {{deadline.*}} placeholders stay resolvable in the variable bag
// (lawyer can still drop them into a custom paragraph) but the
// default skeleton no longer renders them in the submission body:
// the deadline is internal/admin context and has no place in a
// document going out to court.
heading2(&b, "Schriftsatztext")
plain(&b, "[Hier folgt der eigentliche Schriftsatztext. Diese Skelett-Vorlage enthält keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ({{rule.name}}) ergänzen.]")
@@ -217,7 +223,7 @@ func buildDocumentXML() string {
// the bare {{today}} alias. A lawyer customising the template can
// delete this block; the renderer round-trips it cleanly today.
heading2(&b, "Locale-aware variants (SKELETON)")
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
plain(&b, "EN long date: {{today.long_en}}")
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")