Compare commits

..

20 Commits

Author SHA1 Message Date
mAi
a6d0acbcb4 mAi: #111 - t-paliad-279 — Verfahrensablauf form reorder + project auto-fill chip
Reorder Verfahrensablauf 'Browse a proceeding' so the user-input flow
matches the importance hierarchy: proceeding-type → side → appellant →
date / court / flags. Side was previously below the date input; it is
the most-defining input after proceeding-type, so it belongs above.

- frontend/src/verfahrensablauf.tsx: move .verfahrensablauf-perspective
  block above .date-input-group inside step-2. Wrap the side radio
  cluster in #side-radio-cluster and add a sibling #side-chip (hidden by
  default) that the client swaps in when a project pre-fills the side.
  Add a 1px divider between perspective and date-input groups. Update
  step-2 heading from "Ausgangsdatum eingeben" → "Perspektive und Datum"
  to honestly describe both controls now under the heading.

- frontend/src/client/verfahrensablauf.ts: read ?project=<id> on init,
  fetch /api/projects/<id>, map our_side onto the side axis (mirrors
  fristenrechner.ts ourSideToPerspective: claimant/applicant/appellant
  → claimant, defendant/respondent → defendant, else null) and render
  the side row as a read-only chip + "Andere Seite wählen" override
  link. The chip respects ?side= as an explicit user pick — URL wins
  over project auto-fill, same precedence as fristenrechner. Override
  swaps back to the radio cluster and drops ?project= from the URL.
  Side-chip label is language-aware via onLangChange.

- frontend/src/styles/global.css: .verfahrensablauf-step2-divider
  (1px hr between perspective and date blocks); .side-chip / -tag /
  -value / -override styles mirror .proceeding-summary's chip look so
  the two read as the same visual family.

- frontend/src/client/i18n.ts + i18n-keys.ts: 3 new keys
  (deadlines.step2.perspective, deadlines.side.from_project,
  deadlines.side.override) in DE + EN.

URL state stays backward-compatible: ?side= and ?appellant= survive
the reorder unchanged. Adding ?project= opts in to auto-fill; without
it the page behaves identically to before.

No backend / projection logic change.
2026-05-25 16:51:27 +02:00
mAi
930771a898 Merge: t-paliad-275 — HL-formatted skeleton template with placeholders (m/paliad#107) 2026-05-25 16:37:34 +02:00
mAi
f2fbf93adf feat(submissions): HL-formatted skeleton template with placeholders (t-paliad-275)
Adds a firm-formatted Schriftsatz skeleton between the per-submission_code
template and the generic universal skeleton in the fallback chain. Carries
every HL paragraph + character style from the HL Patents Style .dotm
(HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
HLpat-Table-Recitals-Party/Details/Roles/Sequencers, HLpat-Signature,
HLpat-Requests-Intro/Level1, HLpat-EvidenceOffering, …) and the firm
letterhead (header logo + firm-address footer), plus the full 48-key
SubmissionVarsService placeholder bag exercised in a real Schriftsatz
layout (rubrum → Betreff → Anträge → Sachverhalt → Rechtsausführungen →
Beweis → Schlussformel) with a locale-aware verification footer covering
every DE/EN alias and the rule.* legacy keys.

Resolved fallback chain after this CL:

  1. per-firm per-submission_code template (submissionTemplateRegistry)
  2. _firm-skeleton.docx — HL styles + placeholders (NEW)
  3. universal _skeleton.docx — placeholders only
  4. HL Patents Style.dotm — letterhead only

scripts/gen-hl-skeleton-template/main.go reads the source .dotm,
strips VBA macros + ribbon customizations + glossary parts, patches
[Content_Types].xml and the document rels, and replaces document.xml
with HL-styled paragraphs containing the placeholders. Keeps styles.xml,
theme/, header[12].xml, footer[12].xml, numbering.xml, settings.xml,
fontTable.xml, and media untouched so the firm typography survives.

Template uploaded to HL/mWorkRepo at
6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx
(commit 0a41b45, blob SHA 07f7547d).

Verified end-to-end against the in-house renderer with a 48-key sample
project: every placeholder substitutes cleanly, no orphan {{ markers,
no VBA / glossary / customUI leftovers, header/footer rIds resolve.
2026-05-25 16:35:38 +02:00
mAi
7368e7012b Merge: t-paliad-274 — bidirectional draft editor link + click-field-highlights (m/paliad#106) 2026-05-25 16:34:28 +02:00
mAi
d4df81e374 mAi: #106 - t-paliad-274 — bidirectional draft editor link + click-field-highlights
Extension of #92 (m/paliad/issues/106). Two related polish fixes for the
submission draft editor's preview ↔ sidebar wiring.

Concern A — link persists after fill (regression coverage + UX visibility)

  Audited the Go renderer: substituteInTextNodes / substituteAcrossRuns
  already pass both filled and missing values through htmlPreviewWrapper,
  so the <span class="draft-var" data-var="…"> wrapping is present for
  every substituted placeholder regardless of source (resolved bag,
  lawyer override, missing marker). What looked broken to m was a
  visibility problem: the always-on rgba(198, 244, 28, 0.12) tint is
  imperceptible against the serif preview prose, so a filled value
  reads as plain text and the user concludes "the link is gone".

  Added TestRenderHTML_WrapsOverriddenValueSameAsResolved that pins the
  invariant explicitly — an override (project.case_number = "UPC_CFI_
  42/2026") and a resolved value (firm.name = "HLC") both end up in
  matching draft-var spans. Locks future refactors out of dropping the
  wrap on either path.

  CSS rewrite per m's "prose stays clean when not interacting" guidance
  (issue body): drop the always-on background; on hover of a
  --has-input span, layer a dotted-underline + brighter lime tint so
  the click affordance reveals itself. Missing markers carry their own
  [KEIN WERT: …] / [NO VALUE: …] gap-text and don't need extra visual.

Concern B — sidebar-field-focus → preview-occurrence highlight (new)

  Reverse direction of the click-to-jump from #92. focusin on any
  .submission-draft-var-input applies .draft-var--active to every
  matching span in the preview; focusout (or focus shift via Tab)
  clears them. Sticky-while-focused, not a one-shot flash — the lawyer
  can scan "where does this variable land in my prose?" while the
  field stays focused.

  New CSS class .draft-var--active uses a brighter lime + box-shadow
  ring so all occurrences pop at once. Handlers are wired in
  paintVariables and re-applied at the end of both paintVariables AND
  paintPreview because:
    - paintVariables runs after autosave and re-creates inputs via
      innerHTML, so the focusin listener attached to the old input is
      gone; restoreVarFocus puts focus back programmatically without
      firing focusin again. We re-apply explicitly to bridge.
    - paintPreview blows away the preview HTML on every autosave, so
      any prior --active class is gone too. Re-apply based on the
      currently-focused sidebar input.

Files

  internal/services/submission_merge_test.go — new regression test
  frontend/src/client/submission-draft.ts    — focus handlers + re-apply
  frontend/src/styles/global.css             — draft-var rewrite, --active

Hard rules

  - .docx export path unchanged (Render passes nil wrap, covered by
    existing TestRender_DocxOutputUnchangedByPreviewWrap).
  - Both directions survive autosave-driven preview re-renders (see
    paintPreview re-apply + paintVariables re-apply).
  - go build ./... && go test ./internal/... && bun run build all clean.
2026-05-25 16:32:45 +02:00
mAi
f4dee97493 hotfix: drop is_optional + condition_flag refs from mig 125 (both dropped in earlier mig; unblock prod) 2026-05-25 16:12:13 +02:00
mAi
7aed8e4ec5 Merge: t-paliad-271 — Tier 3 deadline-rule primitives Slice A (working_days + combine_op + before-mode, mig 128) (m/paliad#103) 2026-05-25 16:08:33 +02:00
mAi
b429dabf9e hotfix: drop is_mandatory ref from mig 125 (column removed in mig 091; was blocking prod boot) 2026-05-25 16:07:31 +02:00
mAi
d3c28009de mAi: #103 - t-paliad-271 Wave 2 Tier-3 Slice A — deadline-rule primitives
Implements three Tier 3 primitives from curie's bulletproof completeness
audit (docs/research-deadlines-completeness-2026-05-25.md §10 T3.1, T3.2,
T3.5), per m's 2026-05-25 15:29 steer to build the full primitives
instead of documenting workarounds.

Primitive 1 — duration_unit='working_days':
  Calculator walks day-by-day skipping weekends + court holidays via
  HolidayService.IsNonWorkingDay. Event day is not counted; result is
  always a working day for the (country, regime). Unlocks T1.8/T1.9
  modeling and the R.198 / R.213 alt leg.

Primitive 2 — combine_op='max' (and 'min'):
  When alt_duration_value + alt_duration_unit + combine_op are set, the
  calculator evaluates both legs and picks the later (max) or earlier
  (min) of the two adjusted end dates. The DB already had two rules
  shaped this way ('31d OR 20wd, whichever is longer' — R.198 / R.213);
  the calculator was silently dropping the alt leg.

Primitive 5 — timing='before' backward snap-to-working-day:
  For backward rules (R.109.1: 1 month before oral hearing; R.109.4:
  2 weeks before) the calculator now snaps to the PRECEDING working day
  when the computed cut-off lands on a weekend/holiday. Forward snap
  (the prior behavior) would push the cut-off past the statutory limit
  and miss the deadline. Adds HolidayService.AdjustForNonWorkingDays-
  Backward as the symmetric counterpart of AdjustForNonWorkingDays.

Migration 128 — DB schema:
  Adds CHECK constraints on deadline_rules.duration_unit and
  alt_duration_unit pinning the allowed set to days/weeks/months/
  working_days. Live data audited and passes (no rows excluded).

Tests (12 new + 1 flipped):
  - 5 working_days cases: forward over weekend, 20wd anchored on Fri,
    across Karfreitag/Ostermontag, across year boundary, backward
    from Friday, anchored on Saturday.
  - 2 backward snap cases: Sun → preceding Fri; cluster Sun → Sat →
    Karfreitag → Thu.
  - 4 combine_op cases: max with primary winning, max with alt winning
    over Christmas+Neujahr cluster, min with primary winning, NULL-alt
    short-circuit.
  - TestCalculateEndDate_BeforeTiming renamed and flipped from forward
    (Sun → Mon, the prior wrong behavior) to backward (Sun → Fri).

No regression on existing rules: every pre-existing days/weeks/months
'after' rule still computes the same date. Frontend build + full
go test ./internal/... clean.

Slot 128 assigned per next-available convention (mig 127 = Wave 0
Tier-0 fixes, mig 128 = Wave 2 Tier-3 Slice A primitives).
2026-05-25 16:06:35 +02:00
mAi
8be7af7cd6 Merge: t-paliad-262 Slice A — procedural-events prose-only rename + {{rule.X}}↔{{procedural_event.X}} bidirectional aliases (m/paliad#93) 2026-05-25 16:03:42 +02:00
mAi
d52995a4d6 feat(procedural-events): t-paliad-262 Slice A — prose-only rename (m/paliad#93)
Renames the procedural-event surface of paliad.deadline_rules from
"rule" wording to "procedural event" / "Verfahrensschritt" wording.
No DB change, no API change, no Go-type rename. Fully reversible.

m's locks via head (2026-05-25):
- Q1=C: cosmetic now, structural rework (Slice B) as planned t-paliad-273.
- Q2: umbrella term = procedural event / Verfahrensschritt.
- Q7: legacy {{rule.X}} placeholder aliases kept forever (@deprecated).
- Q9: Slice B filed as on-hold task immediately.

Changes:
- internal/services/submission_vars.go: emit procedural_event.* keys
  alongside legacy rule.* keys with identical values. Package + function
  comments updated. Function name kept (addRuleVars) to avoid coupling
  Slice A to the Go-type rename which is Slice B (B.5).
- internal/services/submission_vars_aliases_test.go (new): regression
  test asserts (a) every (canonical, legacy) key pair resolves to the
  same string for both DE and EN; (b) NULL source columns still emit
  both keys with "". Removing either guard surfaces here.
- frontend/src/client/submission-draft.ts: placeholder catalog now
  shows canonical procedural_event.* labels first; legacy rule.*
  entries kept as "(legacy)"-marked aliases.
- frontend/src/client/i18n.ts: admin labels updated in place
  ("Regeln verwalten" → "Verfahrensschritte verwalten", etc.) under
  existing admin.rules.* keys; canonical admin.procedural_events.*
  keys added with identical values so .tsx files can rebind in Slice B.
- frontend/src/i18n-keys.ts: auto-regenerated by build pipeline.

Design doc: docs/design-procedural-events-model-2026-05-25.md (shipped
on the inventor branch mai/cronus/inventor-procedural).

Slice B (planned, on-hold): t-paliad-273.
2026-05-25 16:03:03 +02:00
mAi
f0c343c638 Merge: t-paliad-267 — Auto-rule resolved name on its own row in deadline form (m/paliad#98) 2026-05-25 16:02:41 +02:00
mAi
f11390d18b Merge: t-paliad-270 — i18n event.title.approval_decided + member_role_changed (m/paliad#101) 2026-05-25 16:01:56 +02:00
mAi
3d985ef0c2 Merge: t-paliad-269 — Paliadin chat-bubble lifted above PWA bottom-nav on mobile (m/paliad#100) 2026-05-25 16:00:26 +02:00
mAi
f72e8a7b85 mAi: #101 - add missing event.title.approval_decided + member_role_changed i18n
The FilterBar project_event_kind chip cluster (frontend/src/client/
filter-bar/axes.ts) renders one chip per KnownProjectEventKind via
tDyn(`event.title.${kind}`), which falls back to the raw key when the
catalog is missing the entry. Two kinds were uncovered:

  - approval_decided      → "Genehmigung entschieden" / "Approval decided"
  - member_role_changed   → "Teamrolle geändert"      / "Team role changed"

Both are now present in DE + EN. i18n-keys.ts regenerated by the build.

Audit of KnownProjectEventKinds (filter_spec.go:200) vs. the catalog —
all 18 kinds now have DE + EN labels.
2026-05-25 16:00:17 +02:00
mAi
013facb9db mAi: #100 - paliadin trigger: lift above bottom-nav at <=767px (t-paliad-269)
The Paliadin floating-button trigger was overlapping the PWA bottom-nav
on mobile because its lift rule was scoped to @media (max-width: 640px)
while .bottom-nav itself appears at @media (max-width: 767px). Phones in
landscape and small tablets between those breakpoints saw the desktop
bottom: 20px and got covered by the navbar.

Two changes:
- Widen the trigger lift breakpoint to 767px (matches .bottom-nav).
- Replace hardcoded 72px with calc(var(--bottom-nav-height) + 16px +
  env(safe-area-inset-bottom, 0px)) so the math tracks the navbar
  height variable already used elsewhere (e.g. dashboard-save-toast).

The drawer's full-screen rule (.paliadin-widget-drawer width: 100vw)
stays at <=640px — only the trigger lift moves.

Desktop layout (bottom: 20px) unchanged; widget open/close animation
unchanged.
2026-05-25 15:58:38 +02:00
mAi
ff503ffc43 Merge: Wave 0 Tier-0 deadline-rule fixes — 13 UPDATEs + #99 SoC mapping (mig 127) from curie's #94 audit (m/paliad#94, m/paliad#99) 2026-05-25 15:57:15 +02:00
mAi
05f7ea2af5 mAi: #99 #94 - t-paliad-263 Wave 0 - Tier 0 deadline-rule corrections
Migration 127 lands curie's audit-doc Tier 0 sweep (docs/research-
deadlines-completeness-2026-05-25.md section 10) plus the UPC
Statement of Claim citation backfill from m/paliad#99.

14 single-row UPDATEs touching UPC + DE-LG + DPMA + EPA proceedings:

T0.1  upc.rev.cfi.defence      dur 3mo -> 2mo (RoP.049.1)
T0.2  upc.rev.cfi.rejoin       dur 2mo -> 1mo (RoP.052)
T0.3  upc.apl.merits.response  dur 2mo -> 3mo (RoP.235.1)
T0.4  de.inf.lg.beruf_begr     parent_id berufung -> NULL (ZPO 520.2)
T0.7  upc.rev.cfi.reply        citation backfill RoP.051
T0.9  upc.apl.merits.notice    citation RoP.220.1 -> RoP.224.1.a
T0.10 upc.apl.merits.grounds   citation RoP.220.1 -> RoP.224.2.a
T0.12 dpma.opp.dpma.erwiderung   flip is_court_set, drop PatG 59.3
T0.13 dpma.appeal.bpatg.begruendung flip is_court_set, drop PatG 75.1
T0.14 de.null.bpatg.erwidg     citation PatG 82.1 -> PatG 82.3
T0.15 de.null.bgh.begruendung  citation PatG 111.1 -> ZPO 520.2 (via PatG 117)
T0.16 de.null.bgh.erwiderung   flip is_court_set, recite as ZPO 521.2 (via PatG 117)
T0.17 epa.opp.opd.erwidg       flip is_court_set (EPO Guidelines D-IV 5.2)
#99   upc.inf.cfi.soc          backfill UPC RoP R.13(1) citation

T0.5 and T0.6 (de.inf.lg.replik / .duplik) shipped separately as
mig 124 (m/paliad#95). T0.8 / T0.11 dedup'd into T0.2 / T0.1 per
the audit doc.

Each UPDATE guarded by a WHERE clause matching only the pre-fix
row state (mig 095 convention) - re-apply against a DB carrying
the fix matches zero rows and no-ops, no duplicate deadline_rule_
audit entries on idempotent re-runs. Verification DO block at the
end RAISE EXCEPTIONs if any row remains in inconsistent state.

Applied to live youpc DB via Supabase MCP with audit_reason set
(13 rows touched - T0.4 also fired; all 14 verified in post-fix
shape via direct query). applied_migrations entry NOT pre-recorded;
the boot-time runner inserts version=127 cleanly on next deploy
because every guarded UPDATE no-ops at that point.

Build hygiene: go build / go test ./internal/... / bun run build
all clean (2824 i18n keys, no scan warnings). No code changes -
pure data migration.

Cites: UPC RoP (UPCRoP.013.1 / 049.1 / 051 / 052 / 224.1.a /
224.2.a / 235.1), PatG 82.3 / 117, ZPO 520.2 / 521.2, EPC R.79(1)
+ EPO Guidelines D-IV 5.2.
2026-05-25 15:56:19 +02:00
mAi
df2a1275cb Merge: t-paliad-272 — docker-compose: PALIAD_EXPORT_DIR env + paliad_exports volume (m/paliad#105) 2026-05-25 15:56:12 +02:00
mAi
3700d68c68 mAi: #105 - docker-compose: add PALIAD_EXPORT_DIR + paliad_exports volume
Slice A Backup Mode (m/paliad#77) needs PALIAD_EXPORT_DIR set on the web
container, otherwise /admin/backups returns 503. Declare it via env
interpolation with a sensible compose-level default and mount a named
volume so backups persist across container restarts.

- env: PALIAD_EXPORT_DIR=${PALIAD_EXPORT_DIR:-/var/lib/paliad/exports}
- volume mount: paliad_exports:/var/lib/paliad/exports
- top-level: declare paliad_exports volume (default driver)

Verified: `docker compose config` resolves cleanly,
`go build ./... && go test ./internal/...` clean,
`cd frontend && bun run build` clean (no code change).

Closes m/paliad#105 once Dokploy auto-redeploys.
2026-05-25 15:54:46 +02:00
21 changed files with 2410 additions and 178 deletions

View File

@@ -42,5 +42,14 @@ services:
- AICHAT_URL=${AICHAT_URL:-}
- AICHAT_TOKEN=${AICHAT_TOKEN:-}
- AICHAT_PERSONA=${AICHAT_PERSONA:-paliadin}
# Backup Mode (m/paliad#77 Slice A). Local-disk export target; the
# paliad_exports named volume below persists it across container
# restarts. Unset → /admin/backups returns 503 (BackupService gate).
- PALIAD_EXPORT_DIR=${PALIAD_EXPORT_DIR:-/var/lib/paliad/exports}
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred
volumes:
- paliad_exports:/var/lib/paliad/exports
restart: unless-stopped
volumes:
paliad_exports:

View File

@@ -207,6 +207,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.step1": "Verfahrensart w\u00e4hlen",
"deadlines.step2": "Ausgangsdatum eingeben",
"deadlines.step2.perspective": "Perspektive und Datum",
"deadlines.step3": "Ergebnis",
"deadlines.upc": "UPC",
"deadlines.de": "Deutsche Gerichte",
@@ -421,6 +422,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.side.claimant": "Klägerseite",
"deadlines.side.defendant": "Beklagtenseite",
"deadlines.side.both": "Beide",
"deadlines.side.from_project": "Aus Akte:",
"deadlines.side.override": "Andere Seite wählen",
"deadlines.appellant.label": "Berufung durch:",
"deadlines.appellant.claimant": "Klägerseite",
"deadlines.appellant.defendant": "Beklagtenseite",
@@ -1117,6 +1120,10 @@ const translations: Record<Lang, Record<string, string>> = {
"event.title.appointment_updated": "Termin ge\u00e4ndert",
"event.title.appointment_deleted": "Termin gel\u00f6scht",
"event.title.appointment_project_changed": "Termin verschoben",
// Umbrella audit kind + admin churn surfaced by the FilterBar
// project_event_kind chip cluster (KnownProjectEventKinds).
"event.title.approval_decided": "Genehmigung entschieden",
"event.title.member_role_changed": "Teamrolle ge\u00e4ndert",
// 4-eye approval lifecycle (t-paliad-138). Verlauf renders these as
// a paired card with the original lifecycle event (e.g.
// "Frist angelegt" + "Genehmigung erteilt von Bert").
@@ -2841,21 +2848,26 @@ const translations: Record<Lang, Record<string, string>> = {
"views.bar.save.error.network": "Netzwerkfehler — bitte erneut versuchen.",
// t-paliad-192 Slice 11b — Admin rule-editor UI.
"nav.admin.rules": "Regeln verwalten",
"nav.admin.rules_export": "Regel-Migrations",
"admin.card.rules.title": "Regeln verwalten",
"admin.card.rules.desc": "Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
// t-paliad-262 Slice A — "Regel" relabelled as "Verfahrensschritt".
// The admin URL `/admin/rules` and i18n key prefix `admin.rules.*` stay
// (URL change is Slice B.6); the visible labels rename. Canonical
// `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.",
"admin.rules.list.title": "Regeln verwalten — Paliad",
"admin.rules.list.heading": "Regeln verwalten",
"admin.rules.list.subtitle": "Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.",
"admin.rules.list.new": "+ Neue Regel",
"admin.rules.list.title": "Verfahrensschritte verwalten — Paliad",
"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…",
"admin.rules.empty": "Keine Regeln für die gewählten Filter.",
"admin.rules.error.load": "Konnte Regeln nicht laden.",
"admin.rules.empty": "Keine Verfahrensschritte für die gewählten Filter.",
"admin.rules.error.load": "Konnte Verfahrensschritte nicht laden.",
"admin.rules.filter.proceeding": "Verfahrenstyp",
"admin.rules.filter.proceeding.any": "Alle",
@@ -2866,7 +2878,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.filter.search": "Suche",
"admin.rules.filter.search.placeholder": "Name, Submission Code, Rechtsgrundlage…",
"admin.rules.col.submission_code": "Submission Code / Einreichung-Kennung",
"admin.rules.col.submission_code": "Code (Verfahrensschritt)",
"admin.rules.col.legal_citation": "Rechtsgrundlage",
"admin.rules.col.name": "Name",
"admin.rules.col.proceeding": "Verfahrenstyp",
@@ -2896,8 +2908,8 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.orphans.reason.manual_unbound": "Manuell entkoppelt",
"admin.rules.orphans.resolved": "Orphan zugeordnet.",
"admin.rules.modal.new.title": "Neue Regel anlegen",
"admin.rules.modal.new.body": "Eine neue Regel wird als Draft angelegt. Bitte einen Grund (mind. 10 Zeichen) angeben — dieser wandert ins Audit-Log und beim Export in die Migration.",
"admin.rules.modal.new.title": "Neuen Verfahrensschritt anlegen",
"admin.rules.modal.new.body": "Ein neuer Verfahrensschritt wird als Draft angelegt. Bitte einen Grund (mind. 10 Zeichen) angeben — dieser wandert ins Audit-Log und beim Export in die Migration.",
"admin.rules.modal.resolve.title": "Orphan zuordnen",
"admin.rules.modal.resolve.body": "Bitte einen Grund (mind. 10 Zeichen) angeben. Die Regel-Verknüpfung wird sofort auf der Deadline gespeichert.",
"admin.rules.modal.reason": "Grund",
@@ -2912,12 +2924,12 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.modal.error.create": "Anlegen fehlgeschlagen.",
"admin.rules.modal.error.resolve": "Zuordnung fehlgeschlagen.",
"admin.rules.edit.title": "Regel bearbeiten — Paliad",
"admin.rules.edit.heading.loading": "Regel laden…",
"admin.rules.edit.breadcrumb": "← Regeln verwalten",
"admin.rules.edit.error.bad_id": "Ungültige Regel-ID in der URL.",
"admin.rules.edit.error.not_found": "Regel nicht gefunden.",
"admin.rules.edit.error.load": "Konnte Regel nicht laden.",
"admin.rules.edit.title": "Verfahrensschritt bearbeiten — Paliad",
"admin.rules.edit.heading.loading": "Verfahrensschritt laden…",
"admin.rules.edit.breadcrumb": "← Verfahrensschritte verwalten",
"admin.rules.edit.error.bad_id": "Ungültige Verfahrensschritt-ID in der URL.",
"admin.rules.edit.error.not_found": "Verfahrensschritt nicht gefunden.",
"admin.rules.edit.error.load": "Konnte Verfahrensschritt nicht laden.",
"admin.rules.edit.section.identity": "Identität",
"admin.rules.edit.section.proceeding": "Verfahren & Trigger",
@@ -2930,14 +2942,14 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.name": "Name (DE)",
"admin.rules.edit.field.name_en": "Name (EN)",
"admin.rules.edit.field.description": "Beschreibung",
"admin.rules.edit.field.submission_code": "Submission Code / Einreichung-Kennung",
"admin.rules.edit.field.submission_code": "Code (Verfahrensschritt-Identifikator)",
"admin.rules.edit.field.rule_code": "Rechtsgrundlage (Kurzform)",
"admin.rules.edit.field.legal_source": "Rechtsgrundlage (Langform)",
"admin.rules.edit.field.proceeding": "Verfahrenstyp",
"admin.rules.edit.field.proceeding.none": "—",
"admin.rules.edit.field.trigger": "Trigger-Ereignis",
"admin.rules.edit.field.trigger.none": "—",
"admin.rules.edit.field.parent": "Parent-Regel (UUID)",
"admin.rules.edit.field.parent": "Übergeordneter Verfahrensschritt (UUID)",
"admin.rules.edit.field.concept": "Konzept (UUID)",
"admin.rules.edit.field.sequence_order": "Reihenfolge",
"admin.rules.edit.field.duration_value": "Dauer",
@@ -2949,7 +2961,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.alt_rule_code": "Alt-Rule-Code",
"admin.rules.edit.field.anchor_alt": "Alt-Anchor",
"admin.rules.edit.field.primary_party": "Primäre Partei",
"admin.rules.edit.field.event_type": "Event-Typ (frei)",
"admin.rules.edit.field.event_type": "Art des Verfahrensschritts (filing / hearing / decision / order)",
"admin.rules.edit.field.deadline_notes": "Hinweise (DE)",
"admin.rules.edit.field.deadline_notes_en": "Hinweise (EN)",
"admin.rules.edit.field.priority": "Priorität",
@@ -3060,6 +3072,21 @@ const translations: Record<Lang, Record<string, string>> = {
"date_range.custom.invalid": "Bis-Datum muss nach Von-Datum liegen.",
"date_range.custom.invalid_format": "Datum nicht erkannt (Format JJJJ-MM-TT).",
"date_range.custom.invalid_missing": "Bitte beide Datumsfelder ausfüllen.",
// t-paliad-262 Slice A — canonical `procedural_event` i18n contract.
// The values are identical to the legacy `admin.rules.*` keys above —
// these aliases let .tsx files rebind in Slice B (B.5) without
// touching DE/EN strings then. Adding/changing values? Update BOTH
// sides.
"admin.procedural_events.list.title": "Verfahrensschritte verwalten — Paliad",
"admin.procedural_events.list.heading": "Verfahrensschritte verwalten",
"admin.procedural_events.list.new": "+ Neuer Verfahrensschritt",
"admin.procedural_events.col.code": "Code (Verfahrensschritt)",
"admin.procedural_events.edit.title": "Verfahrensschritt bearbeiten — Paliad",
"admin.procedural_events.edit.breadcrumb":"← Verfahrensschritte verwalten",
"admin.procedural_events.edit.field.code": "Code (Verfahrensschritt-Identifikator)",
"admin.procedural_events.edit.field.event_kind": "Art des Verfahrensschritts (filing / hearing / decision / order)",
"admin.procedural_events.edit.field.parent": "Übergeordneter Verfahrensschritt (UUID)",
},
en: {
@@ -3250,6 +3277,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.step1": "Select Proceeding Type",
"deadlines.step2": "Enter Trigger Date",
"deadlines.step2.perspective": "Perspective and Date",
"deadlines.step3": "Result",
"deadlines.upc": "UPC",
"deadlines.de": "German Courts",
@@ -3471,6 +3499,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.side.claimant": "Claimant",
"deadlines.side.defendant": "Defendant",
"deadlines.side.both": "Both",
"deadlines.side.from_project": "From case:",
"deadlines.side.override": "Choose other side",
"deadlines.appellant.label": "Appeal filed by:",
"deadlines.appellant.claimant": "Claimant",
"deadlines.appellant.defendant": "Defendant",
@@ -4143,6 +4173,10 @@ const translations: Record<Lang, Record<string, string>> = {
"event.title.appointment_updated": "Appointment updated",
"event.title.appointment_deleted": "Appointment deleted",
"event.title.appointment_project_changed": "Appointment moved",
// Umbrella audit kind + admin churn surfaced by the FilterBar
// project_event_kind chip cluster (KnownProjectEventKinds).
"event.title.approval_decided": "Approval decided",
"event.title.member_role_changed": "Team role changed",
// 4-eye approval lifecycle (t-paliad-138).
"event.title.deadline_approval_requested": "Approval requested",
"event.title.deadline_approval_approved": "Approval granted",
@@ -5854,21 +5888,22 @@ const translations: Record<Lang, Record<string, string>> = {
"views.bar.save.error.network": "Network error — please retry.",
// t-paliad-192 Slice 11b — Admin rule-editor UI.
"nav.admin.rules": "Manage Rules",
"nav.admin.rules_export": "Rule Migrations",
"admin.card.rules.title": "Manage Rules",
"admin.card.rules.desc": "Author, edit and publish deadline rules. Audit log, preview, migration export.",
// 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.",
"admin.rules.list.title": "Manage Rules — Paliad",
"admin.rules.list.heading": "Manage Rules",
"admin.rules.list.subtitle": "Author, edit and publish deadline rules. Lifecycle: draft → published → archived.",
"admin.rules.list.new": "+ New Rule",
"admin.rules.list.title": "Manage procedural events — Paliad",
"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…",
"admin.rules.empty": "No rules for the chosen filters.",
"admin.rules.error.load": "Could not load rules.",
"admin.rules.empty": "No procedural events for the chosen filters.",
"admin.rules.error.load": "Could not load procedural events.",
"admin.rules.filter.proceeding": "Proceeding type",
"admin.rules.filter.proceeding.any": "Any",
@@ -5879,7 +5914,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.filter.search": "Search",
"admin.rules.filter.search.placeholder": "Name, submission code, legal citation…",
"admin.rules.col.submission_code": "Submission code",
"admin.rules.col.submission_code": "Code (procedural event)",
"admin.rules.col.legal_citation": "Legal citation",
"admin.rules.col.name": "Name",
"admin.rules.col.proceeding": "Proceeding type",
@@ -5909,8 +5944,8 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.orphans.reason.manual_unbound": "Manually unbound",
"admin.rules.orphans.resolved": "Orphan resolved.",
"admin.rules.modal.new.title": "Create new rule",
"admin.rules.modal.new.body": "A new rule will be created as a draft. Please supply a reason (≥10 chars) — recorded in the audit log and exported into the migration file.",
"admin.rules.modal.new.title": "Create new procedural event",
"admin.rules.modal.new.body": "A new procedural event will be created as a draft. Please supply a reason (≥10 chars) — recorded in the audit log and exported into the migration file.",
"admin.rules.modal.resolve.title": "Resolve orphan",
"admin.rules.modal.resolve.body": "Please supply a reason (≥10 chars). The rule binding is persisted immediately on the deadline.",
"admin.rules.modal.reason": "Reason",
@@ -5925,12 +5960,12 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.modal.error.create": "Creation failed.",
"admin.rules.modal.error.resolve": "Resolution failed.",
"admin.rules.edit.title": "Edit Rule — Paliad",
"admin.rules.edit.heading.loading": "Loading rule…",
"admin.rules.edit.breadcrumb": "← Manage Rules",
"admin.rules.edit.error.bad_id": "Invalid rule id in URL.",
"admin.rules.edit.error.not_found": "Rule not found.",
"admin.rules.edit.error.load": "Could not load rule.",
"admin.rules.edit.title": "Edit procedural event — Paliad",
"admin.rules.edit.heading.loading": "Loading procedural event…",
"admin.rules.edit.breadcrumb": "← Manage procedural events",
"admin.rules.edit.error.bad_id": "Invalid procedural-event id in URL.",
"admin.rules.edit.error.not_found": "Procedural event not found.",
"admin.rules.edit.error.load": "Could not load procedural event.",
"admin.rules.edit.section.identity": "Identity",
"admin.rules.edit.section.proceeding": "Proceeding & Trigger",
@@ -5943,14 +5978,14 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.name": "Name (DE)",
"admin.rules.edit.field.name_en": "Name (EN)",
"admin.rules.edit.field.description": "Description",
"admin.rules.edit.field.submission_code": "Submission code",
"admin.rules.edit.field.submission_code": "Code (procedural-event identifier)",
"admin.rules.edit.field.rule_code": "Legal citation (short form)",
"admin.rules.edit.field.legal_source": "Legal citation (long form)",
"admin.rules.edit.field.proceeding": "Proceeding type",
"admin.rules.edit.field.proceeding.none": "—",
"admin.rules.edit.field.trigger": "Trigger event",
"admin.rules.edit.field.trigger.none": "—",
"admin.rules.edit.field.parent": "Parent rule (UUID)",
"admin.rules.edit.field.parent": "Parent procedural event (UUID)",
"admin.rules.edit.field.concept": "Concept (UUID)",
"admin.rules.edit.field.sequence_order": "Order",
"admin.rules.edit.field.duration_value": "Duration",
@@ -5962,7 +5997,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.alt_rule_code": "Alt rule code",
"admin.rules.edit.field.anchor_alt": "Alt anchor",
"admin.rules.edit.field.primary_party": "Primary party",
"admin.rules.edit.field.event_type": "Event type (free)",
"admin.rules.edit.field.event_type": "Procedural-event kind (filing / hearing / decision / order)",
"admin.rules.edit.field.deadline_notes": "Notes (DE)",
"admin.rules.edit.field.deadline_notes_en": "Notes (EN)",
"admin.rules.edit.field.priority": "Priority",
@@ -6070,6 +6105,19 @@ const translations: Record<Lang, Record<string, string>> = {
"date_range.custom.invalid": "End date must be strictly after start date.",
"date_range.custom.invalid_format": "Date not recognised (format YYYY-MM-DD).",
"date_range.custom.invalid_missing": "Please fill in both date fields.",
// t-paliad-262 Slice A — canonical `procedural_event` i18n contract.
// Mirrors the DE block; values identical to the legacy
// `admin.rules.*` keys. Adding/changing values? Update BOTH sides.
"admin.procedural_events.list.title": "Manage procedural events — Paliad",
"admin.procedural_events.list.heading": "Manage procedural events",
"admin.procedural_events.list.new": "+ New procedural event",
"admin.procedural_events.col.code": "Code (procedural event)",
"admin.procedural_events.edit.title": "Edit procedural event — Paliad",
"admin.procedural_events.edit.breadcrumb":"← Manage procedural events",
"admin.procedural_events.edit.field.code": "Code (procedural-event identifier)",
"admin.procedural_events.edit.field.event_kind": "Procedural-event kind (filing / hearing / decision / order)",
"admin.procedural_events.edit.field.parent": "Parent procedural event (UUID)",
},
};

View File

@@ -155,14 +155,29 @@ const VARIABLE_LABELS: Record<string, VariableLabel> = {
"parties.defendant.representative":{ de: "Beklagten-Vertreter", en: "Defendant representative" },
"parties.other.name": { de: "Weitere Partei", en: "Other party" },
"parties.other.representative": { de: "Weitere-Partei-Vertreter", en: "Other party representative" },
"rule.submission_code": { de: "Schriftsatz-Code", en: "Submission code" },
"rule.name": { de: "Schriftsatz", en: "Submission" },
"rule.name_de": { de: "Schriftsatz (DE)", en: "Submission (DE)" },
"rule.name_en": { de: "Schriftsatz (EN)", en: "Submission (EN)" },
"rule.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
"rule.legal_source_pretty": { de: "Rechtsgrundlage", en: "Legal source" },
"rule.primary_party": { de: "Partei (typisch)", en: "Primary party" },
"rule.event_type": { de: "Schriftsatz-Typ", en: "Event type" },
// Procedural-event namespace (t-paliad-262 Slice A, design doc
// docs/design-procedural-events-model-2026-05-25.md). The canonical
// placeholder names are below; the `rule.*` aliases that follow are
// @deprecated but kept forever per m's Q7 lock — existing Word
// templates and saved drafts authored with the old names keep
// merging identically.
"procedural_event.code": { de: "Code (Verfahrensschritt)", en: "Code (procedural event)" },
"procedural_event.name": { de: "Verfahrensschritt", en: "Procedural event" },
"procedural_event.name_de": { de: "Verfahrensschritt (DE)", en: "Procedural event (DE)" },
"procedural_event.name_en": { de: "Verfahrensschritt (EN)", en: "Procedural event (EN)" },
"procedural_event.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
"procedural_event.legal_source_pretty":{ de: "Rechtsgrundlage", en: "Legal source" },
"procedural_event.primary_party": { de: "Partei (typisch)", en: "Primary party" },
"procedural_event.event_kind": { de: "Art des Verfahrensschritts", en: "Procedural-event kind" },
// Legacy aliases — @deprecated, kept forever (m/paliad#93 Q7).
"rule.submission_code": { de: "Schriftsatz-Code (legacy)", en: "Submission code (legacy)" },
"rule.name": { de: "Schriftsatz (legacy)", en: "Submission (legacy)" },
"rule.name_de": { de: "Schriftsatz (DE, legacy)", en: "Submission (DE, legacy)" },
"rule.name_en": { de: "Schriftsatz (EN, legacy)", en: "Submission (EN, legacy)" },
"rule.legal_source": { de: "Rechtsgrundlage (Code, legacy)", en: "Legal source (code, legacy)" },
"rule.legal_source_pretty": { de: "Rechtsgrundlage (legacy)", en: "Legal source (legacy)" },
"rule.primary_party": { de: "Partei (typisch, legacy)", en: "Primary party (legacy)" },
"rule.event_type": { de: "Schriftsatz-Typ (legacy)", en: "Event type (legacy)" },
"deadline.due_date": { de: "Frist (ISO)", en: "Deadline (ISO)" },
"deadline.due_date_long_de": { de: "Frist (DE lang)", en: "Deadline (DE long)" },
"deadline.due_date_long_en": { de: "Frist (EN lang)", en: "Deadline (EN long)" },
@@ -174,14 +189,14 @@ const VARIABLE_LABELS: Record<string, VariableLabel> = {
const VARIABLE_GROUPS: VariableGroup[] = [
{
id: "rule",
label: { de: "Schriftsatz", en: "Submission" },
id: "procedural_event",
label: { de: "Verfahrensschritt", en: "Procedural event" },
keys: [
"rule.name",
"rule.legal_source_pretty",
"rule.primary_party",
"rule.event_type",
"rule.submission_code",
"procedural_event.name",
"procedural_event.legal_source_pretty",
"procedural_event.primary_party",
"procedural_event.event_kind",
"procedural_event.code",
],
},
{
@@ -595,10 +610,25 @@ function paintVariables(): void {
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
inp.addEventListener("input", () => onVarChange(inp));
// t-paliad-274 (B) — focus into a sidebar field highlights every
// matching .draft-var span in the preview (sticky while focused,
// clears on blur). Survives autosave repaints because paintVariables
// is called by flushAutosave and we re-bind every render.
inp.addEventListener("focusin", () => onVarFocusEnter(inp.dataset.var ?? ""));
inp.addEventListener("focusout", () => onVarFocusLeave(inp.dataset.var ?? ""));
});
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-reset").forEach((btn) => {
btn.addEventListener("click", () => onVarReset(btn.dataset.resetKey ?? ""));
});
// After repaint, re-apply the active highlight if a field is still
// focused (paintVariables runs after autosave; the same input regains
// focus via restoreVarFocus and would otherwise emit focusin too
// late for our handler — re-apply explicitly).
const active = document.activeElement;
if (isVarField(active)) {
const key = active.dataset.var;
if (key) applyPreviewActiveHighlight(key);
}
}
function paintPreview(): void {
@@ -606,6 +636,16 @@ function paintPreview(): void {
if (!host || !state.view) return;
host.innerHTML = state.view.preview_html ?? "";
wireDraftVars(host);
// t-paliad-274 (B) — preview HTML was just blown away by innerHTML,
// so any prior --active classes are gone. Re-apply for whichever
// sidebar field is currently focused (typing in a field triggers an
// autosave round-trip that ends in paintPreview, and the user should
// see the highlight stay put across that cycle).
const active = document.activeElement;
if (isVarField(active)) {
const key = active.dataset.var;
if (key) applyPreviewActiveHighlight(key);
}
}
// t-paliad-261 (B) — click a substituted variable in the preview to
@@ -680,6 +720,48 @@ function onDraftVarClick(key: string, ev: Event): void {
flashVarRow(input);
}
// t-paliad-274 (B) — sidebar-field-focus → preview-occurrence highlight.
// Reverse direction of the click-to-jump from #92: when the user focuses
// any .submission-draft-var-input, every matching .draft-var span in the
// preview gets the --active modifier; on blur (or focus shift to a
// different field), the previous key's highlights clear and the new
// key's apply. Sticky-while-focused, not a one-shot flash — the lawyer
// can scan the preview for "where does this variable land in my prose?"
// while the field stays focused.
function onVarFocusEnter(key: string): void {
if (!key) return;
// Clear any leftover highlight before applying the new one — covers
// the focus-shift-without-blur case (Tab between fields).
clearPreviewActiveHighlight();
applyPreviewActiveHighlight(key);
}
function onVarFocusLeave(_key: string): void {
// We don't need the key here — if focus moves to a different sidebar
// input, that input's focusin will re-call apply with the new key
// (after our clearPreviewActiveHighlight). If focus leaves the sidebar
// entirely, this clears.
clearPreviewActiveHighlight();
}
function applyPreviewActiveHighlight(key: string): void {
const host = document.getElementById("submission-draft-preview");
if (!host) return;
host.querySelectorAll<HTMLElement>(
`.draft-var[data-var="${cssEscape(key)}"]`,
).forEach((el) => {
el.classList.add("draft-var--active");
});
}
function clearPreviewActiveHighlight(): void {
const host = document.getElementById("submission-draft-preview");
if (!host) return;
host.querySelectorAll<HTMLElement>(".draft-var--active").forEach((el) => {
el.classList.remove("draft-var--active");
});
}
function flashVarRow(input: HTMLElement): void {
const row = input.closest<HTMLElement>(".submission-draft-var-row");
if (!row) return;

View File

@@ -38,6 +38,13 @@ let lastResponse: DeadlineResponse | null = null;
let currentSide: Side = null;
let currentAppellant: Side = null;
// Project-driven auto-fill state (t-paliad-279 / m/paliad#111). When the
// page is opened with ?project=<id> and that project has our_side set,
// the side row renders as a read-only chip instead of the radio cluster.
// The user can flip to free-pick via the "Andere Seite wählen" override
// link, which clears this flag (radio cluster takes over again).
let sidePrefilledFromProject = false;
// Proceedings where one party initiates and "both" rows are role-swap
// (i.e. either party files depending on who acted at the lower
// instance). For these proceedings the appellant selector is meaningful
@@ -388,6 +395,125 @@ function syncRadioGroup(name: string, value: string) {
});
}
// Project context (t-paliad-279 / m/paliad#111). When the page is opened
// with ?project=<id> and the project carries an our_side value, the side
// row renders as a read-only chip with an "Andere Seite wählen" override
// link. The proceeding picker + appellant axis stay untouched — only the
// side selector pre-fills.
interface ProjectOurSide {
id: string;
our_side?:
| "claimant"
| "defendant"
| "applicant"
| "appellant"
| "respondent"
| "third_party"
| "other"
| null;
}
function readProjectFromURL(): string {
return new URLSearchParams(window.location.search).get("project") || "";
}
// ourSideToSide maps the project-level our_side enum (t-paliad-222) onto
// the side-selector's two-value axis. Active roles (claimant / applicant /
// appellant) collapse to "claimant"; reactive roles (defendant /
// respondent) collapse to "defendant"; everything else (third_party /
// other / NULL) returns null = no pre-fill. Mirrors fristenrechner.ts
// ourSideToPerspective() so projects render consistently across both
// surfaces.
function ourSideToSide(os: ProjectOurSide["our_side"] | undefined): Side {
switch (os) {
case "claimant":
case "applicant":
case "appellant":
return "claimant";
case "defendant":
case "respondent":
return "defendant";
default:
return null;
}
}
async function fetchProjectOurSide(projectID: string): Promise<ProjectOurSide | null> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}`, {
credentials: "same-origin",
});
if (!resp.ok) return null;
return (await resp.json()) as ProjectOurSide;
} catch {
return null;
}
}
function sideLabelI18n(s: Side): string {
if (s === "claimant") return t("deadlines.side.claimant");
if (s === "defendant") return t("deadlines.side.defendant");
return t("deadlines.side.both");
}
// renderSideChip swaps the radio cluster for a read-only chip showing
// the auto-filled side + an "Andere Seite wählen" override link. Called
// after fetchProjectOurSide resolves to a side. The override link clears
// the prefilled flag and swaps back to the radio cluster — the user can
// then pick any side freely.
function renderSideChip(side: Side) {
const cluster = document.getElementById("side-radio-cluster");
const chip = document.getElementById("side-chip");
const value = document.getElementById("side-chip-value");
if (!cluster || !chip || !value) return;
cluster.style.display = "none";
chip.style.display = "";
value.textContent = sideLabelI18n(side);
}
function showSideRadioCluster() {
const cluster = document.getElementById("side-radio-cluster");
const chip = document.getElementById("side-chip");
if (!cluster || !chip) return;
cluster.style.display = "";
chip.style.display = "none";
}
// applySidePrefill takes a project's our_side, maps it to the side axis,
// and locks the side row to a read-only chip if a mapping exists. URL
// wins — if ?side= is already explicit, the user (or shared link) has
// already chosen and we never overwrite. When we do prefill, write the
// derived side to the URL so reload + back/forward round-trip cleanly.
function applySidePrefill(os: ProjectOurSide["our_side"] | undefined) {
if (readSideFromURL() !== null) return;
const next = ourSideToSide(os);
if (next === null) return;
currentSide = next;
writeSideToURL(next);
syncRadioGroup("side", next);
sidePrefilledFromProject = true;
renderSideChip(next);
if (lastResponse) renderResults(lastResponse);
}
function clearSidePrefill() {
sidePrefilledFromProject = false;
showSideRadioCluster();
// Drop ?project= from the URL so a reload doesn't re-lock the side.
// ?side= stays — that's the user's last pick at this point.
const url = new URL(window.location.href);
url.searchParams.delete("project");
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
async function initProjectAutofill() {
const projectID = readProjectFromURL();
if (!projectID) return;
const project = await fetchProjectOurSide(projectID);
if (!project) return;
applySidePrefill(project.our_side);
}
function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
// Mirrors the events.ts pattern (body.events-view-*). The print
// stylesheet keys `body.verfahrensablauf-view-timeline` to
@@ -529,6 +655,15 @@ document.addEventListener("DOMContentLoaded", () => {
initViewToggle();
initPerspectiveControls();
// Override link on the prefilled side chip — swaps back to the radio
// cluster and clears ?project= from the URL.
document.getElementById("side-chip-override")?.addEventListener("click", clearSidePrefill);
// Project autofill — runs after the radio cluster has its URL-driven
// state so we never clobber an explicit ?side= pick. Fire-and-forget;
// the chip swap happens once the project resolves.
void initProjectAutofill();
onLangChange(() => {
// Active-button name updates with language change (the data-i18n
// pass swaps the inner <strong>'s text). Re-collapse the summary
@@ -539,6 +674,12 @@ document.addEventListener("DOMContentLoaded", () => {
const summary = document.getElementById("proceeding-summary-name");
if (summary) summary.textContent = proceedingDisplayName(activeBtn);
}
// Side-chip label tracks language so a DE/EN flip while the chip is
// visible re-renders the inferred side in the active language.
if (sidePrefilledFromProject) {
const value = document.getElementById("side-chip-value");
if (value) value.textContent = sideLabelI18n(currentSide);
}
if (lastResponse) renderResults(lastResponse);
syncTriggerEventLabel();
});

View File

@@ -290,6 +290,15 @@ export type I18nKey =
| "admin.partner_units.new.heading"
| "admin.partner_units.subtitle"
| "admin.partner_units.title"
| "admin.procedural_events.col.code"
| "admin.procedural_events.edit.breadcrumb"
| "admin.procedural_events.edit.field.code"
| "admin.procedural_events.edit.field.event_kind"
| "admin.procedural_events.edit.field.parent"
| "admin.procedural_events.edit.title"
| "admin.procedural_events.list.heading"
| "admin.procedural_events.list.new"
| "admin.procedural_events.list.title"
| "admin.rules.col.legal_citation"
| "admin.rules.col.lifecycle"
| "admin.rules.col.modified"
@@ -1437,7 +1446,9 @@ export type I18nKey =
| "deadlines.side.both"
| "deadlines.side.claimant"
| "deadlines.side.defendant"
| "deadlines.side.from_project"
| "deadlines.side.label"
| "deadlines.side.override"
| "deadlines.source.caldav"
| "deadlines.source.fristenrechner"
| "deadlines.source.imported"
@@ -1468,6 +1479,7 @@ export type I18nKey =
| "deadlines.step2.happened.desc"
| "deadlines.step2.happened.title"
| "deadlines.step2.heading"
| "deadlines.step2.perspective"
| "deadlines.step3"
| "deadlines.step3a.back"
| "deadlines.step3a.draft.desc"
@@ -1604,6 +1616,7 @@ export type I18nKey =
| "event.title.appointment_deleted"
| "event.title.appointment_project_changed"
| "event.title.appointment_updated"
| "event.title.approval_decided"
| "event.title.checklist_created"
| "event.title.checklist_deleted"
| "event.title.checklist_linked"
@@ -1622,6 +1635,7 @@ export type I18nKey =
| "event.title.deadline_reopened"
| "event.title.deadline_updated"
| "event.title.deadlines_imported"
| "event.title.member_role_changed"
| "event.title.note_created"
| "event.title.our_side_changed"
| "event.title.project_archived"

View File

@@ -3572,6 +3572,59 @@ input[type="range"]::-moz-range-thumb {
margin-bottom: 0;
}
/* Visual divider between the perspective block (side + appellant)
and the date / court / flag knobs below. t-paliad-279 reorder put
the most-defining inputs (side, appellant) at the top of step-2; the
divider keeps the date block from reading as a continuation of the
perspective rows. */
.verfahrensablauf-step2-divider {
height: 1px;
margin: 1rem 0;
background: var(--color-border, #e5e5e5);
border: 0;
}
/* Read-only auto-fill chip for #side-row. Renders when ?project=<id>
resolves a project whose our_side is set: shows the inferred side
with a small "Andere Seite wählen" override link that swaps the row
back to the radio cluster. t-paliad-279 / m/paliad#111. */
.side-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
border: 1px solid var(--color-border, #e5e5e5);
border-radius: 0.5rem;
background: var(--color-bg-subtle, #fafafa);
font-size: 0.95rem;
}
.side-chip-tag {
color: var(--color-text-muted, #666);
font-size: 0.85rem;
}
.side-chip-value {
color: var(--color-text, #222);
}
.side-chip-override {
margin-left: 0.3rem;
padding: 0.15rem 0.55rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 9999px;
background: var(--color-bg, #fff);
color: var(--color-text-muted, #555);
font-size: 0.8rem;
cursor: pointer;
transition: background 120ms, border-color 120ms;
}
.side-chip-override:hover {
background: var(--color-bg-subtle, #f4f4f4);
border-color: var(--color-text-muted, #aaa);
}
.side-chip-override:focus-visible {
outline: 2px solid var(--color-accent, #c6f41c);
outline-offset: 1px;
}
/* Compact note hint — sits in the timeline-meta line when the notes
toggle is off. Native browser tooltip via title= attribute carries
the full text on hover; tabindex=0 + aria-label make it
@@ -5880,16 +5933,27 @@ dialog.modal::backdrop {
font-style: italic;
}
/* t-paliad-261 (B) — substituted variables in the preview are wrapped
in <span class="draft-var" data-var="…"> by the Go HTML renderer.
.draft-var by itself shows a subtle dotted underline so the lawyer
can SEE which text was filled in from a variable. .draft-var--has-input
(added client-side when a matching sidebar input exists) layers on
the clickable affordance — pointer cursor + brighter hover background.
Non-matching draft-vars (derived variables not exposed in the
sidebar) stay visually distinct but non-interactive. */
/* t-paliad-261 / t-paliad-274 — substituted variables in the preview
are wrapped in <span class="draft-var" data-var="…"> by the Go HTML
renderer for BOTH filled values and missing-marker text. The lawyer
can click any wrapped span and jump to the matching sidebar input;
conversely, focusing a sidebar input lights up every matching span in
the preview via .draft-var--active.
Visual contract:
.draft-var — invisible by default (prose stays clean
per t-paliad-274 m's request).
.draft-var--has-input — pointer cursor; dotted underline on
hover so the click affordance reveals
itself, plus a brighter lime tint.
.draft-var--active — sticky lime highlight applied while the
matching sidebar input is focused
(t-paliad-274 reverse direction).
[KEIN WERT: …] / [NO VALUE: …] markers carry their own warning
style via .submission-draft-var-marker on the sidebar hint; in the
preview they read as obvious gap text, so .draft-var itself doesn't
need an always-on visual to flag them. */
.draft-var {
background-color: rgba(198, 244, 28, 0.12);
border-radius: 2px;
padding: 0 2px;
box-decoration-break: clone;
@@ -5904,9 +5968,21 @@ dialog.modal::backdrop {
.draft-var--has-input:hover,
.draft-var--has-input:focus-visible {
background-color: rgba(198, 244, 28, 0.45);
text-decoration: underline dotted rgba(198, 244, 28, 0.85);
text-underline-offset: 2px;
outline: none;
}
/* t-paliad-274 (B) — sticky highlight while the matching sidebar input
is focused. Brighter than the hover tint so the user's eye lands on
every occurrence at once when they click into a field. Applies to ALL
.draft-var spans for that data-var, not just one. */
.draft-var--active,
.draft-var--has-input.draft-var--active {
background-color: rgba(198, 244, 28, 0.55);
box-shadow: 0 0 0 1px rgba(198, 244, 28, 0.85);
}
/* t-paliad-261 (B) — brief lime flash on the sidebar row after a
click-jump from the preview, so the user's eye lands on the right
input even after the smooth-scroll motion. Animation restarts on
@@ -15182,8 +15258,10 @@ dialog.quick-add-sheet::backdrop {
* Floating trigger at bottom-right + slide-out drawer from the
* right edge. Hidden by default; revealed by paliadin-widget.ts
* after /api/me confirms the caller is the Paliadin owner.
* Mobile (≤640px): drawer goes full-screen; trigger sits above
* the bottom-nav slots.
* Mobile (≤640px): drawer goes full-screen.
* Phone breakpoint (≤767px, matches .bottom-nav): trigger lifts
* above the bottom-nav slots so it doesn't collide with the
* navbar on PWA standalone (t-paliad-269).
*/
.paliadin-widget-trigger {
@@ -15270,8 +15348,20 @@ dialog.quick-add-sheet::backdrop {
.paliadin-widget-drawer {
width: 100vw;
}
}
/* Lift the trigger above the BottomNav at the same breakpoint where
the nav appears (<768px in global.css ".bottom-nav"). The navbar is
--bottom-nav-height tall plus the iOS safe-area inset; 16px gap
keeps the bubble clear without crowding the nav slots. Bubble sits
at the right edge so the center FAB-circle (margin-top: -10px) is
not in its column.
t-paliad-269: previously this rule was scoped to <=640px, but the
.bottom-nav shows at <=767px, leaving phones in landscape and small
tablets with an overlapping bubble. */
@media (max-width: 767px) {
.paliadin-widget-trigger {
bottom: calc(72px + env(safe-area-inset-bottom, 0px));
bottom: calc(var(--bottom-nav-height, 56px) + 16px + env(safe-area-inset-bottom, 0px));
}
}

View File

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

View File

@@ -55,8 +55,6 @@ INSERT INTO paliad.deadline_rules (
name_en,
primary_party,
event_type,
is_mandatory,
is_optional,
is_court_set,
is_spawn,
duration_value,
@@ -70,7 +68,6 @@ INSERT INTO paliad.deadline_rules (
deadline_notes_en,
legal_source,
condition_expr,
condition_flag,
sequence_order,
is_active,
priority,
@@ -90,8 +87,6 @@ SELECT
'Petition for re-establishment of rights (UPC R.320)',
NULL::text,
NULL::text,
true,
false,
false,
false,
2,
@@ -105,7 +100,6 @@ SELECT
'Period is 2 months from removal of the obstacle (UPC R.320 RoP). Latest 12 months after expiry of the missed deadline.',
'UPC.RoP.320',
NULL::jsonb,
NULL::text[],
1207,
true,
'mandatory',

View File

@@ -0,0 +1,146 @@
-- Revert t-paliad-263 Wave 0 + m/paliad#99.
-- Restores each Tier 0 row to its pre-fix state per
-- docs/research-deadlines-completeness-2026-05-25.md §10. T0.5 and
-- T0.6 are NOT reverted here — they live in mig 124's down.
--
-- audit_reason set_config required for the mig 079 trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 127 revert: unwind Tier 0 deadline-rule corrections (Wave 0 + #99)',
true);
-- T0.1 defence: 2mo + RoP.049.1 → 3mo + RoP.49.1
UPDATE paliad.deadline_rules
SET duration_value = 3,
rule_code = 'RoP.49.1',
updated_at = now()
WHERE submission_code = 'upc.rev.cfi.defence'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.2 rejoin: 1mo + RoP.052/UPC.RoP.52 → 2mo + NULL/NULL
UPDATE paliad.deadline_rules
SET duration_value = 2,
rule_code = NULL,
legal_source = NULL,
updated_at = now()
WHERE submission_code = 'upc.rev.cfi.rejoin'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.3 response: 3mo + RoP.235.1 → 2mo + NULL
UPDATE paliad.deadline_rules
SET duration_value = 2,
rule_code = NULL,
legal_source = NULL,
updated_at = now()
WHERE submission_code = 'upc.apl.merits.response'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.4 beruf_begr: parent_id NULL → de.inf.lg.berufung
UPDATE paliad.deadline_rules
SET parent_id = (
SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'de.inf.lg.berufung'
AND is_active = true
AND lifecycle_state = 'published'
LIMIT 1
),
updated_at = now()
WHERE submission_code = 'de.inf.lg.beruf_begr'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.7 reply: clear citation
UPDATE paliad.deadline_rules
SET rule_code = NULL,
legal_source = NULL,
updated_at = now()
WHERE submission_code = 'upc.rev.cfi.reply'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.9 notice: revert citation
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.220.1',
legal_source = 'UPC.RoP.220.1',
updated_at = now()
WHERE submission_code = 'upc.apl.merits.notice'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.10 grounds: revert citation
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.220.1',
legal_source = 'UPC.RoP.220.1',
updated_at = now()
WHERE submission_code = 'upc.apl.merits.grounds'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.12 dpma.opp erwiderung: restore court-set=false + §59 citation
UPDATE paliad.deadline_rules
SET is_court_set = false,
rule_code = '§ 59 PatG',
legal_source = 'DE.PatG.59.3',
updated_at = now()
WHERE submission_code = 'dpma.opp.dpma.erwiderung'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.13 dpma.appeal.bpatg begründung: restore court-set=false + §75 citation
UPDATE paliad.deadline_rules
SET is_court_set = false,
rule_code = '§ 75 PatG',
legal_source = 'DE.PatG.75.1',
updated_at = now()
WHERE submission_code = 'dpma.appeal.bpatg.begruendung'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.14 bpatg erwidg: revert citation
UPDATE paliad.deadline_rules
SET rule_code = '§ 82 PatG',
legal_source = 'DE.PatG.82.1',
updated_at = now()
WHERE submission_code = 'de.null.bpatg.erwidg'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.15 bgh begründung: revert citation
UPDATE paliad.deadline_rules
SET rule_code = '§ 111 PatG',
legal_source = 'DE.PatG.111.1',
updated_at = now()
WHERE submission_code = 'de.null.bgh.begruendung'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.16 bgh erwiderung: revert court-set + citation
UPDATE paliad.deadline_rules
SET is_court_set = false,
rule_code = '§ 111 PatG',
legal_source = 'DE.PatG.111.3',
updated_at = now()
WHERE submission_code = 'de.null.bgh.erwiderung'
AND is_active = true
AND lifecycle_state = 'published';
-- T0.17 epa.opp opd erwidg: revert court-set
UPDATE paliad.deadline_rules
SET is_court_set = false,
updated_at = now()
WHERE submission_code = 'epa.opp.opd.erwidg'
AND is_active = true
AND lifecycle_state = 'published';
-- #99 upc.inf.cfi.soc: clear citation backfill
UPDATE paliad.deadline_rules
SET rule_code = NULL,
legal_source = NULL,
updated_at = now()
WHERE submission_code = 'upc.inf.cfi.soc'
AND is_active = true
AND lifecycle_state = 'published';

View File

@@ -0,0 +1,477 @@
-- t-paliad-263 Wave 0 + m/paliad#99 — Tier 0 deadline-rule corrections.
--
-- Source: docs/research-deadlines-completeness-2026-05-25.md §10 Tier 0
-- (curie's bulletproof completeness audit, merged 2026-05-25 as commit
-- 94a9e7e). 16 distinct single-row UPDATEs across UPC + DE-LG + DPMA +
-- EPA proceedings; T0.5 + T0.6 were shipped separately as mig 124
-- (m/paliad#95, de.inf.lg Replik/Duplik sequencing) and are not
-- repeated here. T0.8 (covered by T0.2) and T0.11 (covered by T0.1)
-- are dedup'd out per the audit's own note.
--
-- Also folds in m/paliad#99 (UPC Statement of Claim missing legal
-- citation): upc.inf.cfi.soc.rule_code / legal_source backfilled to
-- UPC RoP R.13(1). Same migration file, separate UPDATE block with
-- its own guard.
--
-- All fixes within the existing schema (no new columns). Each UPDATE
-- is guarded by a WHERE clause that matches only the pre-fix row
-- state (per mig 095 convention) — re-applying against a DB that
-- already carries the fix matches zero rows and no-ops, so there are
-- no duplicate deadline_rule_audit entries on idempotent re-runs.
--
-- Verification DO block at the end RAISEs EXCEPTION if any of the
-- patched rows is left in an inconsistent shape (mixing pre-fix and
-- post-fix state).
--
-- audit_reason set_config required at the top — the mig 079 trigger
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
-- on any UPDATE without it.
--
-- Slot 127 reserved per paliadin: sequence is 124 brunel #95 (done),
-- 125 hermes #97, 126 icarus #80, 127 brunel Wave 0 + #99, 128+ next.
SELECT set_config(
'paliad.audit_reason',
'mig 127: t-paliad-263 Wave 0 + m/paliad#99 — Tier 0 deadline-rule corrections from curie''s audit (docs/research-deadlines-completeness-2026-05-25.md §10) plus UPC SoC R.13 citation',
true);
-- =============================================================================
-- T0.1 upc.rev.cfi.defence — duration 3mo → 2mo per RoP.049.1.
-- Zero-pads the rule_code citation to canonical form. Audit §5
-- (wrong period — every UPC_REV tracked in paliad today computes
-- Defence at +3 months, statute says +2). Verbatim from
-- UPCRoP.049.1: "The defendant shall lodge a Defence to revocation
-- within two months of service of the Statement for revocation."
-- =============================================================================
UPDATE paliad.deadline_rules
SET duration_value = 2,
rule_code = 'RoP.049.1',
updated_at = now()
WHERE submission_code = 'upc.rev.cfi.defence'
AND is_active = true
AND lifecycle_state = 'published'
AND duration_value = 3
AND rule_code = 'RoP.49.1';
-- =============================================================================
-- T0.2 upc.rev.cfi.rejoin — duration 2mo → 1mo per RoP.052; add citation.
-- Audit §5 (wrong period). Verbatim from UPCRoP.052: "Within one
-- month of the service of the Reply the defendant may lodge a
-- Rejoinder to the Reply to the Defence to revocation."
-- =============================================================================
UPDATE paliad.deadline_rules
SET duration_value = 1,
rule_code = 'RoP.052',
legal_source = 'UPC.RoP.52',
updated_at = now()
WHERE submission_code = 'upc.rev.cfi.rejoin'
AND is_active = true
AND lifecycle_state = 'published'
AND duration_value = 2
AND rule_code IS NULL;
-- =============================================================================
-- T0.3 upc.apl.merits.response — duration 2mo → 3mo per RoP.235.1.
-- Audit §5 (wrong period — every main-track appellate respondent).
-- Verbatim from UPCRoP.235.1: "Within three months of service of
-- the Statement of grounds of appeal pursuant to Rule 224.2(a),
-- any other party … may lodge a Statement of response."
-- =============================================================================
UPDATE paliad.deadline_rules
SET duration_value = 3,
rule_code = 'RoP.235.1',
legal_source = 'UPC.RoP.235.1',
updated_at = now()
WHERE submission_code = 'upc.apl.merits.response'
AND is_active = true
AND lifecycle_state = 'published'
AND duration_value = 2
AND rule_code IS NULL;
-- =============================================================================
-- T0.4 de.inf.lg.beruf_begr — parent_id = NULL (was de.inf.lg.berufung).
-- Audit §7.1 — every DE-LG-Verletzung appeal renders the
-- Berufungsbegründung at trigger + 1mo (Berufung) + 2mo = 3 months
-- from Urteil-service. Per ZPO §520(2) "die Frist für die
-- Berufungsbegründung beträgt zwei Monate. Sie beginnt mit der
-- Zustellung des in vollständiger Form abgefassten Urteils" → 2
-- months from Urteil-service (parallel to, not chained off, the
-- Berufungsfrist itself). NULL parent_id makes the rule anchor
-- on the proceeding's trigger date — matches how the symmetric
-- de.inf.olg.begruendung is modelled.
-- =============================================================================
UPDATE paliad.deadline_rules
SET parent_id = NULL,
updated_at = now()
WHERE submission_code = 'de.inf.lg.beruf_begr'
AND is_active = true
AND lifecycle_state = 'published'
AND parent_id = (
SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'de.inf.lg.berufung'
AND is_active = true
AND lifecycle_state = 'published'
LIMIT 1
);
-- =============================================================================
-- T0.5 / T0.6 de.inf.lg.replik + de.inf.lg.duplik — already shipped
-- as mig 124 (m/paliad#95). Not repeated here. Idempotency of the
-- audit's Tier 0 sweep against a fresh DB is preserved because mig
-- 124 runs before this one and is itself guarded.
-- =============================================================================
-- =============================================================================
-- T0.7 upc.rev.cfi.reply — backfill rule_code + legal_source per RoP.051.
-- Audit §4.1 — duration (2mo) unchanged. Verbatim from UPCRoP.051:
-- "Reply to Defence to revocation and Application to amend the
-- patent. The claimant in the revocation action may, within two
-- months of service of the Defence to revocation and the
-- Application to amend the patent, if any, lodge a Reply…"
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.051',
legal_source = 'UPC.RoP.51',
updated_at = now()
WHERE submission_code = 'upc.rev.cfi.reply'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code IS NULL
AND legal_source IS NULL;
-- =============================================================================
-- T0.9 upc.apl.merits.notice — citation drift RoP.220.1 → RoP.224.1.a.
-- Audit §4.1 — duration unchanged. R.220.1 is the umbrella ("an
-- appeal may be brought"); R.224.1(a) carries the Notice-of-appeal
-- 2-month period explicitly.
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.224.1.a',
legal_source = 'UPC.RoP.224.1.a',
updated_at = now()
WHERE submission_code = 'upc.apl.merits.notice'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code = 'RoP.220.1'
AND legal_source = 'UPC.RoP.220.1';
-- =============================================================================
-- T0.10 upc.apl.merits.grounds — citation drift RoP.220.1 → RoP.224.2.a.
-- Audit §4.1 — duration unchanged. R.224.2(a) sets the Grounds
-- 4-month period for decisions referred to in R.220.1(a) and (b).
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.224.2.a',
legal_source = 'UPC.RoP.224.2.a',
updated_at = now()
WHERE submission_code = 'upc.apl.merits.grounds'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code = 'RoP.220.1'
AND legal_source = 'UPC.RoP.220.1';
-- =============================================================================
-- T0.12 dpma.opp.dpma.erwiderung — flip is_court_set = true; drop the
-- § 59(3) PatG citation. Audit §4.3 + §9.1: §59(3) addresses
-- Anhörung, not a 4-month response period. No statutory
-- Erwiderungsfrist exists in §59 — the 4-month figure is DPMA
-- practice (DPMA-Richtlinien D-IV 5.2). Modelled court-set, the
-- 4-month value remains the default-display heuristic the
-- lawyer overrides via "Datum setzen".
-- =============================================================================
UPDATE paliad.deadline_rules
SET is_court_set = true,
rule_code = NULL,
legal_source = NULL,
updated_at = now()
WHERE submission_code = 'dpma.opp.dpma.erwiderung'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = false
AND legal_source = 'DE.PatG.59.3';
-- =============================================================================
-- T0.13 dpma.appeal.bpatg.begruendung — flip is_court_set = true; drop
-- the § 75 PatG citation. Audit §4.3 + §9.1: §75 PatG addresses
-- aufschiebende Wirkung only, not a Begründungsfrist. No fixed
-- Begründungsfrist for BPatG-Beschwerde exists in PatG §§73-80 —
-- the BPatG sets it in the individual case. 1-month default
-- retained as display heuristic.
-- =============================================================================
UPDATE paliad.deadline_rules
SET is_court_set = true,
rule_code = NULL,
legal_source = NULL,
updated_at = now()
WHERE submission_code = 'dpma.appeal.bpatg.begruendung'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = false
AND legal_source = 'DE.PatG.75.1';
-- =============================================================================
-- T0.14 de.null.bpatg.erwidg — citation DE.PatG.82.1 → DE.PatG.82.3.
-- Audit §4.4 — duration (2 months) is correct. §82(1) carries the
-- 1-month Erklärungsfrist ("sich darüber zu erklären"); the full
-- Klageerwiderung 2-month period lives in §82(3).
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = '§ 82 Abs. 3 PatG',
legal_source = 'DE.PatG.82.3',
updated_at = now()
WHERE submission_code = 'de.null.bpatg.erwidg'
AND is_active = true
AND lifecycle_state = 'published'
AND legal_source = 'DE.PatG.82.1';
-- =============================================================================
-- T0.15 de.null.bgh.begruendung — citation DE.PatG.111.1 →
-- DE.ZPO.520.2 (via PatG §117). Audit §4.4 — duration (3 months)
-- is correct. §111 PatG defines the Grounds of Berufung
-- (Verletzung des Bundesrechts), not a Begründungsfrist; the
-- 3-month figure is supplied by §117 PatG → ZPO §520(2).
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = '§ 520 Abs. 2 ZPO i.V.m. § 117 PatG',
legal_source = 'DE.ZPO.520.2',
updated_at = now()
WHERE submission_code = 'de.null.bgh.begruendung'
AND is_active = true
AND lifecycle_state = 'published'
AND legal_source = 'DE.PatG.111.1';
-- =============================================================================
-- T0.16 de.null.bgh.erwiderung — flip is_court_set = true; recite as
-- DE.ZPO.521.2 (via PatG §117). Audit §4.4 + §9.1 — §111 PatG
-- has no Erwiderungsfrist clause. The actual Erwiderungsfrist
-- for BGH-Nichtigkeitsberufung is set by the court per §117
-- PatG → ZPO §521(2). 2-month default retained as display
-- heuristic.
-- =============================================================================
UPDATE paliad.deadline_rules
SET is_court_set = true,
rule_code = '§ 521 Abs. 2 ZPO i.V.m. § 117 PatG',
legal_source = 'DE.ZPO.521.2',
updated_at = now()
WHERE submission_code = 'de.null.bgh.erwiderung'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = false
AND legal_source = 'DE.PatG.111.3';
-- =============================================================================
-- T0.17 epa.opp.opd.erwidg — flip is_court_set = true. Audit §4.5 +
-- §9.1: R.79(1) EPÜ authorises the Opposition Division to set
-- the period, but does not specify a fixed 4 months. The 4-month
-- figure is administrative practice (EPO Guidelines D-IV 5.2).
-- Citation retained as the rule-of-authority for the OD's
-- discretion. 4-month default retained as display heuristic.
-- =============================================================================
UPDATE paliad.deadline_rules
SET is_court_set = true,
updated_at = now()
WHERE submission_code = 'epa.opp.opd.erwidg'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = false
AND legal_source = 'EU.EPC-R.79.1';
-- =============================================================================
-- m/paliad#99 upc.inf.cfi.soc — backfill UPC RoP R.13(1) citation.
-- The Statement of Claim is defined in UPC RoP R.13 (R.13.1
-- lists the required contents). The row carries no statutory
-- deadline (duration_value = 0, parent_id IS NULL — the SoC is
-- the originating filing that anchors the proceeding's trigger
-- date), but the catalog UI surfaces the rule citation in
-- result cards and the Type=Statement-of-Claim / Rule=Auto
-- resolution; both render blank today because rule_code +
-- legal_source are NULL. Backfill leaves duration / anchor /
-- party untouched.
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.013.1',
legal_source = 'UPC.RoP.13.1',
updated_at = now()
WHERE submission_code = 'upc.inf.cfi.soc'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code IS NULL
AND legal_source IS NULL;
-- =============================================================================
-- Hard assertions. Each touched row must end up in its post-fix
-- shape. Re-running the migration after a successful first run is a
-- no-op for the data but the assertions still pass because they
-- check the post-fix state.
-- =============================================================================
DO $$
DECLARE
v_count integer;
BEGIN
-- T0.1 defence: dur=2 + canonical zero-padded rule_code
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.rev.cfi.defence'
AND is_active = true
AND lifecycle_state = 'published'
AND duration_value = 2
AND rule_code = 'RoP.049.1';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.1: upc.rev.cfi.defence not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.2 rejoin: dur=1
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.rev.cfi.rejoin'
AND is_active = true
AND lifecycle_state = 'published'
AND duration_value = 1
AND rule_code = 'RoP.052';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.2: upc.rev.cfi.rejoin not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.3 response: dur=3
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.apl.merits.response'
AND is_active = true
AND lifecycle_state = 'published'
AND duration_value = 3
AND rule_code = 'RoP.235.1';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.3: upc.apl.merits.response not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.4 beruf_begr: parent_id IS NULL
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'de.inf.lg.beruf_begr'
AND is_active = true
AND lifecycle_state = 'published'
AND parent_id IS NULL;
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.4: de.inf.lg.beruf_begr not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.7 reply: citation backfilled
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.rev.cfi.reply'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code = 'RoP.051'
AND legal_source = 'UPC.RoP.51';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.7: upc.rev.cfi.reply not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.9 notice: citation RoP.224.1.a
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.apl.merits.notice'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code = 'RoP.224.1.a'
AND legal_source = 'UPC.RoP.224.1.a';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.9: upc.apl.merits.notice not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.10 grounds: citation RoP.224.2.a
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.apl.merits.grounds'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code = 'RoP.224.2.a'
AND legal_source = 'UPC.RoP.224.2.a';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.10: upc.apl.merits.grounds not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.12 dpma.opp erwiderung: court-set, no citation
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'dpma.opp.dpma.erwiderung'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = true
AND legal_source IS NULL
AND rule_code IS NULL;
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.12: dpma.opp.dpma.erwiderung not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.13 dpma.appeal.bpatg begründung: court-set, no citation
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'dpma.appeal.bpatg.begruendung'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = true
AND legal_source IS NULL
AND rule_code IS NULL;
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.13: dpma.appeal.bpatg.begruendung not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.14 bpatg erwidg: §82.3
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'de.null.bpatg.erwidg'
AND is_active = true
AND lifecycle_state = 'published'
AND legal_source = 'DE.PatG.82.3';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.14: de.null.bpatg.erwidg not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.15 bgh begründung: ZPO §520.2
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'de.null.bgh.begruendung'
AND is_active = true
AND lifecycle_state = 'published'
AND legal_source = 'DE.ZPO.520.2';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.15: de.null.bgh.begruendung not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.16 bgh erwiderung: court-set, ZPO §521.2
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'de.null.bgh.erwiderung'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = true
AND legal_source = 'DE.ZPO.521.2';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.16: de.null.bgh.erwiderung not in post-fix shape (got % matches)', v_count;
END IF;
-- T0.17 epa.opp opd erwidg: court-set
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'epa.opp.opd.erwidg'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = true;
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 T0.17: epa.opp.opd.erwidg not in post-fix shape (got % matches)', v_count;
END IF;
-- #99 upc.inf.cfi.soc: citation backfilled
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.soc'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code = 'RoP.013.1'
AND legal_source = 'UPC.RoP.13.1';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 127 #99: upc.inf.cfi.soc not in post-fix shape (got % matches)', v_count;
END IF;
END $$;

View File

@@ -0,0 +1,11 @@
-- Revert t-paliad-271 Wave 2 Tier-3 Slice A — drop duration_unit /
-- alt_duration_unit CHECK constraints. Pre-mig-128 the columns accepted
-- arbitrary text, so dropping the CHECKs restores that shape exactly.
-- No data revert necessary — the constraint addition was purely
-- additive and validated against live data before adding.
ALTER TABLE paliad.deadline_rules
DROP CONSTRAINT IF EXISTS deadline_rules_duration_unit_check;
ALTER TABLE paliad.deadline_rules
DROP CONSTRAINT IF EXISTS deadline_rules_alt_duration_unit_check;

View File

@@ -0,0 +1,36 @@
-- t-paliad-271 Wave 2 Tier-3 Slice A — duration_unit CHECK constraint with
-- 'working_days' added to the allowed set.
--
-- Per docs/research-deadlines-completeness-2026-05-25.md Tier 3 Primitive 1
-- (T3.1) — the calculator gains a business-day arithmetic path for UPC RoP
-- R.198 / R.213 (and downstream for any rule that needs the 31d-OR-20wd
-- combine-max pattern). The schema currently accepts free-text on
-- duration_unit (no CHECK), which is why 'working_days' rows already exist
-- in the DB but were silently dropped by the calculator. Adding the CHECK
-- pins the contract and prevents typos.
--
-- alt_duration_unit gets the same constraint (NULL-tolerant) so the alt
-- path stays in lockstep with the primary path.
--
-- Idempotent: DROP CONSTRAINT IF EXISTS before ADD. Existing data was
-- audited via `SELECT DISTINCT duration_unit FROM paliad.deadline_rules`
-- on 2026-05-25 (returned only days/weeks/months) plus the two live
-- alt-unit rows already at 'working_days' — both shapes pass.
--
-- audit_reason set_config is NOT needed for DDL (mig 079 trigger fires on
-- INSERT/UPDATE/DELETE on the rows, not on ALTER TABLE).
ALTER TABLE paliad.deadline_rules
DROP CONSTRAINT IF EXISTS deadline_rules_duration_unit_check;
ALTER TABLE paliad.deadline_rules
ADD CONSTRAINT deadline_rules_duration_unit_check
CHECK (duration_unit IN ('days', 'weeks', 'months', 'working_days'));
ALTER TABLE paliad.deadline_rules
DROP CONSTRAINT IF EXISTS deadline_rules_alt_duration_unit_check;
ALTER TABLE paliad.deadline_rules
ADD CONSTRAINT deadline_rules_alt_duration_unit_check
CHECK (alt_duration_unit IS NULL
OR alt_duration_unit IN ('days', 'weeks', 'months', 'working_days'));

View File

@@ -79,6 +79,24 @@ var fileRegistry = map[string]fileEntry{
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
},
// Firm-formatted skeleton (t-paliad-275). Carries the same 48-key
// placeholder bag as the universal _skeleton.docx, but additionally
// preserves every HL paragraph + character style from the HL Patents
// Style .dotm (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
// HLpat-Table-Recitals-*, HLpat-Signature, …) and the firm letterhead
// (header logo + firm-address footer). Slotted ahead of the universal
// skeleton in the fallback chain so any submission_code without a
// dedicated per-code template still renders as a real firm-branded
// Schriftsatz with variables substituted, rather than a plain skeleton.
// Generated via scripts/gen-hl-skeleton-template against the .dotm.
firmSkeletonSubmissionSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
DownloadName: branding.Name + " — Firm Schriftsatz-Skelett.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
},
}
// skeletonSubmissionSlug names the universal skeleton template inside
@@ -87,6 +105,14 @@ var fileRegistry = map[string]fileEntry{
// the same string the registry uses.
const skeletonSubmissionSlug = "submission/_skeleton.docx"
// firmSkeletonSubmissionSlug names the firm-formatted skeleton template
// inside the shared fileRegistry cache (t-paliad-275). Same placeholder
// surface as skeletonSubmissionSlug; carries HL paragraph + character
// styles from the source .dotm on top. Sits between the per-code
// template and the generic universal skeleton in the fallback chain so
// codes without a dedicated template still render with firm branding.
const firmSkeletonSubmissionSlug = "submission/_firm-skeleton.docx"
// submissionTemplateRegistry maps a deadline-rule submission_code to a
// fileRegistry slug. Lookup order matches the cronus design fallback
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
@@ -219,11 +245,28 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
// call warms the cache synchronously from mWorkRepo via Gitea; later
// calls return immediately while a background refresh runs.
func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
entry, ok := fileRegistry[skeletonSubmissionSlug]
return fetchSubmissionTemplateSlug(ctx, skeletonSubmissionSlug)
}
// fetchFirmSkeletonBytes returns the cached firm-formatted skeleton
// template bytes (HL paragraph/character styles + 48-key placeholder
// bag) plus its provenance SHA. Sits between the per-code template and
// the generic universal skeleton in resolveSubmissionTemplate's
// fallback chain (t-paliad-275). Same stale-while-revalidate caching
// as the other Gitea-backed template parts.
func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
return fetchSubmissionTemplateSlug(ctx, firmSkeletonSubmissionSlug)
}
// fetchSubmissionTemplateSlug is the shared cache-aware fetcher used by
// the firm-skeleton and universal-skeleton accessors. Factored out so
// the two paths can't drift apart on caching semantics.
func fetchSubmissionTemplateSlug(ctx context.Context, slug string) ([]byte, string, error) {
entry, ok := fileRegistry[slug]
if !ok {
return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
return nil, "", fmt.Errorf("file proxy: %s not registered", slug)
}
ce := getCacheEntry(skeletonSubmissionSlug)
ce := getCacheEntry(slug)
ce.mu.RLock()
hasData := len(ce.data) > 0
@@ -241,7 +284,7 @@ func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", slug)
}
out := make([]byte, len(ce.data))
copy(out, ce.data)

View File

@@ -904,19 +904,25 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
// resolveSubmissionTemplate returns the .docx bytes for the given
// submission code. Lookup order matches the cronus design fallback chain
// §8 plus the t-paliad-259 universal-skeleton slot:
// §8 plus the t-paliad-259 universal-skeleton slot and the t-paliad-275
// firm-skeleton slot:
//
// 1. per-firm per-submission_code template registered in
// submissionTemplateRegistry (e.g. de.inf.lg.erwidg.docx) — code-
// specific structure plus the full variable bag.
// 2. universal _skeleton.docx — same variable bag, no submission_code-
// specific prose. Catches every code without a dedicated template
// so the editor preview / generate flow still has variables to
// substitute instead of falling through to the bare letterhead.
// 3. universal HL Patents Style .dotm — macro-only letterhead, no
// placeholders. Final fallback when even the skeleton is unreachable
// (mWorkRepo outage etc.). Preserves the pre-t-paliad-259 behaviour
// for resilience.
// 2. firm-formatted _firm-skeleton.docx — full HL paragraph + character
// styles (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
// HLpat-Table-Recitals-*, HLpat-Signature, …) preserved from the
// source .dotm, the firm letterhead header/footer, plus the full
// 48-key placeholder bag. Catches every code without a dedicated
// template so the editor still renders firm-branded output.
// 3. universal _skeleton.docx — same variable bag, no firm formatting.
// Backstop for when the firm skeleton is unreachable (e.g. a future
// firm hasn't authored one yet).
// 4. universal HL Patents Style .dotm — macro-only letterhead, no
// placeholders. Final fallback when even both skeletons are
// unreachable (mWorkRepo outage etc.). Preserves the
// pre-t-paliad-259 behaviour for resilience.
//
// The returned SHA is the cache entry's commit SHA so the export audit
// row can record provenance.
@@ -926,6 +932,11 @@ func resolveSubmissionTemplate(ctx context.Context, submissionCode string) ([]by
} else if found {
return data, sha, nil
}
if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil {
return data, sha, nil
} else {
log.Printf("submission_drafts: firm-skeleton fetch failed for code=%s, falling back to universal skeleton: %v", submissionCode, err)
}
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
return data, sha, nil
} else {

View File

@@ -27,33 +27,119 @@ func NewDeadlineCalculator(holidays *HolidayService) *DeadlineCalculator {
}
// CalculateEndDate applies a single rule's duration + timing to the event date,
// then bumps forward off non-working days for the given (country, regime).
// Returns (adjusted, original, didAdjust).
// then bumps off non-working days for the given (country, regime). For
// rules with both a primary and an alt duration (alt_duration_value/_unit)
// and a combine_op of 'max' or 'min', both legs are computed independently
// and combined per the operator — this implements RoP R.198 / R.213
// ("31 days OR 20 working days, whichever is longer") and the equivalent
// shape under EPC. Returns (adjusted, original, didAdjust).
//
// Snap direction follows timing: 'after' snaps forward to the next
// working day (RoP R.300.b — period extends to the next working day),
// 'before' snaps *backward* to the preceding working day so the
// statutory cut-off is not pushed past its hard limit.
//
// duration_unit='working_days' walks day-by-day via the holiday service
// (skipping weekends + court holidays), so its result is always already a
// working day — no post-arithmetic snap needed for that leg.
//
// Per Tier 3 Primitives §10 of docs/research-deadlines-completeness-2026-05-25.md
// (m's 2026-05-25 15:29 steer: build the full primitives, no workarounds).
func (c *DeadlineCalculator) CalculateEndDate(eventDate time.Time, rule models.DeadlineRule, country, regime string) (time.Time, time.Time, bool) {
endDate := eventDate
timing := "after"
if rule.Timing != nil {
timing = *rule.Timing
}
adjusted, raw, wasAdjusted := c.computeLeg(eventDate, rule.DurationValue, rule.DurationUnit, timing, country, regime)
// combine_op + alt_duration_*: compute the alt leg independently,
// then pick the later (max) or earlier (min) of the two adjusted
// end-dates. Live use case is UPC RoP R.198 / R.213 (31 calendar
// days vs. 20 working days, whichever is longer).
if rule.CombineOp != nil && rule.AltDurationValue != nil && rule.AltDurationUnit != nil {
altAdj, altRaw, altWasAdj := c.computeLeg(eventDate, *rule.AltDurationValue, *rule.AltDurationUnit, timing, country, regime)
switch *rule.CombineOp {
case "max":
if altAdj.After(adjusted) {
adjusted, raw, wasAdjusted = altAdj, altRaw, altWasAdj
}
case "min":
if altAdj.Before(adjusted) {
adjusted, raw, wasAdjusted = altAdj, altRaw, altWasAdj
}
}
}
return adjusted, raw, wasAdjusted
}
// computeLeg evaluates a single (value, unit) duration against the event
// date in the given timing direction and snap-adjusts the result. Returns
// the snap-adjusted end-date, the pre-snap end-date, and whether a snap
// occurred. working_days arithmetic never needs a snap (the walker lands
// on a working day by construction).
func (c *DeadlineCalculator) computeLeg(eventDate time.Time, value int, unit string, timing string, country, regime string) (adjusted, raw time.Time, wasAdjusted bool) {
sign := 1
if timing == "before" {
sign = -1
}
switch rule.DurationUnit {
case "days":
endDate = endDate.AddDate(0, 0, sign*rule.DurationValue)
case "weeks":
endDate = endDate.AddDate(0, 0, sign*rule.DurationValue*7)
case "months":
endDate = endDate.AddDate(0, sign*rule.DurationValue, 0)
raw = c.addDuration(eventDate, value, unit, sign, country, regime)
if unit == "working_days" {
return raw, raw, false
}
if timing == "before" {
return c.holidays.AdjustForNonWorkingDaysBackward(raw, country, regime)
}
return c.holidays.AdjustForNonWorkingDays(raw, country, regime)
}
original := endDate
adjusted, _, wasAdjusted := c.holidays.AdjustForNonWorkingDays(endDate, country, regime)
return adjusted, original, wasAdjusted
// addDuration adds `sign * value` of the given unit to eventDate. For
// 'working_days' it walks day-by-day skipping weekends and court
// holidays via the holiday service.
func (c *DeadlineCalculator) addDuration(eventDate time.Time, value int, unit string, sign int, country, regime string) time.Time {
switch unit {
case "days":
return eventDate.AddDate(0, 0, sign*value)
case "weeks":
return eventDate.AddDate(0, 0, sign*value*7)
case "months":
return eventDate.AddDate(0, sign*value, 0)
case "working_days":
return c.addWorkingDays(eventDate, sign*value, country, regime)
}
return eventDate
}
// addWorkingDays walks `n` business days from `date` (negative `n` walks
// backward). The event day itself is never counted; we step first, then
// skip past non-working days, repeated n times. Result is always a
// working day for the given (country, regime). Matches UPC RoP R.300.b's
// "the day on which the event happens shall not be counted" convention
// applied to the business-day axis.
//
// Bound: each business-day step is bounded by a 60-day inner cap so a
// misconfigured holiday table can never spin forever. The longest
// real-world non-working run between adjacent business days is the
// Christmas Eve → Neujahr window (~6 days), so 60 is over-provisioned.
func (c *DeadlineCalculator) addWorkingDays(date time.Time, n int, country, regime string) time.Time {
if n == 0 {
return date
}
step := 1
count := n
if n < 0 {
step = -1
count = -n
}
cur := date
for i := 0; i < count; i++ {
cur = cur.AddDate(0, 0, step)
for j := 0; j < 60 && c.holidays.IsNonWorkingDay(cur, country, regime); j++ {
cur = cur.AddDate(0, 0, step)
}
}
return cur
}
// CalculateFromRules calculates deadlines for a slice of rules using the

View File

@@ -93,7 +93,14 @@ func TestCalculateEndDate_Weeks_LandsOnHoliday(t *testing.T) {
}
}
func TestCalculateEndDate_BeforeTiming(t *testing.T) {
// TestCalculateEndDate_BeforeTiming_SnapsBackward — Tier 3 Primitive 5
// (m/paliad#103 Slice A). For timing='before' rules (R.109.1 / R.109.4
// "no later than X before the oral hearing"), a computed cut-off that
// lands on a weekend / holiday must snap *backward* to the preceding
// working day. Forward snap would push the cut-off past the statutory
// limit and miss the deadline. See
// docs/research-deadlines-completeness-2026-05-25.md §10 T3.5.
func TestCalculateEndDate_BeforeTiming_SnapsBackward(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
@@ -104,11 +111,322 @@ func TestCalculateEndDate_BeforeTiming(t *testing.T) {
DurationUnit: "months",
Timing: ptr("before"),
}
// "before" subtracts: 2026-04-15 - 1 month = 2026-03-15 (Sunday).
// Adjust: Sunday → Monday 2026-03-16.
// "before" subtracts: 2026-04-15 (Wed) - 1 month = 2026-03-15 (Sunday).
// Backward snap: Sunday → Friday 2026-03-13 (Karfreitag is later
// in 2026, so no extra holiday in this window).
in := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
wantOrig := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
wantAdj := time.Date(2026, 3, 13, 0, 0, 0, 0, time.UTC)
if !original.Equal(wantOrig) {
t.Errorf("original: got %s, want %s", original, wantOrig)
}
if !adjusted.Equal(wantAdj) {
t.Errorf("adjusted: got %s, want %s", adjusted, wantAdj)
}
if !wasAdjusted {
t.Error("expected wasAdjusted=true (Sun → preceding Fri)")
}
}
// Tier 3 Primitive 5 — backward snap across Karfreitag / Ostermontag.
// 2026 Ostern: Karfreitag = 2026-04-03 (Fri), Ostermontag = 2026-04-06 (Mon).
// Anchor Tue 2026-05-05 minus 1 month = Sun 2026-04-05 → backward through
// Sat → Karfreitag → Thu 2026-04-02.
func TestCalculateEndDate_BeforeTiming_BackwardSkipsHolidayCluster(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "1-month before, Ostern cluster",
DurationValue: 1,
DurationUnit: "months",
Timing: ptr("before"),
}
in := time.Date(2026, 5, 5, 0, 0, 0, 0, time.UTC)
adjusted, _, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
if !wasAdjusted {
t.Error("expected wasAdjusted=true (Sun→Karfreitag→Thu)")
}
}
// Tier 3 Primitive 1 — working_days arithmetic forward over a weekend.
// Anchor Mon 2026-01-12 + 5 working days = Tue 13 (1), Wed 14 (2),
// Thu 15 (3), Fri 16 (4), Mon 19 (5). Result = Mon 2026-01-19.
func TestCalculateEndDate_WorkingDays_ForwardSkipsWeekend(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "5 working days",
DurationValue: 5,
DurationUnit: "working_days",
Timing: ptr("after"),
}
in := time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC)
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 1, 19, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
// working_days arithmetic lands on a working day by construction, so the
// "snap" reports no adjustment and original == adjusted.
if !original.Equal(want) {
t.Errorf("original: got %s, want %s", original, want)
}
if wasAdjusted {
t.Error("working_days result should not report a snap adjustment")
}
}
// Tier 3 Primitive 1 — working_days arithmetic with anchor on Friday;
// 20 working days lands on the Friday four weeks later. Anchor Fri
// 2026-01-09 → +20wd → Fri 2026-02-06. No DE federal holiday in
// window. This exercises the R.198 / R.213 "20 working days" leg.
func TestCalculateEndDate_WorkingDays_TwentyDays(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "20 working days",
DurationValue: 20,
DurationUnit: "working_days",
Timing: ptr("after"),
}
in := time.Date(2026, 1, 9, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC)
want := time.Date(2026, 2, 6, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 1 — working_days across Karfreitag/Ostermontag. Anchor
// Thu 2026-04-02 + 3 working days: skip Karfreitag (Fri 04-03), weekend,
// Ostermontag (Mon 04-06). Walk: Tue 04-07 (1), Wed 04-08 (2), Thu 04-09
// (3). Result = Thu 2026-04-09.
func TestCalculateEndDate_WorkingDays_AcrossEasterCluster(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "3 working days over Ostern",
DurationValue: 3,
DurationUnit: "working_days",
Timing: ptr("after"),
}
in := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 4, 9, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 1 — working_days across year boundary. Anchor Mon
// 2025-12-29 + 5 working days. Calendar: Tue 30 (1), Wed 31 (2),
// Thu 2026-01-01 = Neujahr (skip), Fri 2026-01-02 (3), Mon 05 (4),
// Tue 06 (5). Result = Tue 2026-01-06.
func TestCalculateEndDate_WorkingDays_AcrossYearBoundary(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "5 working days over year-end",
DurationValue: 5,
DurationUnit: "working_days",
Timing: ptr("after"),
}
in := time.Date(2025, 12, 29, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 1, 6, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 1 — working_days backward (timing='before'). Anchor
// Fri 2026-04-17 - 5 working days: Thu 16 (1), Wed 15 (2), Tue 14 (3),
// Mon 13 (4), Fri 10 (5 — Mon 13 - 3 days skipping Sun/Sat). Result =
// Fri 2026-04-10.
func TestCalculateEndDate_WorkingDays_BackwardSkipsWeekend(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "5 working days before",
DurationValue: 5,
DurationUnit: "working_days",
Timing: ptr("before"),
}
in := time.Date(2026, 4, 17, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 1 — working_days anchored on a Saturday (rare but
// must not loop). +3 working days from Sat 2026-01-10: Mon 12 (1), Tue
// 13 (2), Wed 14 (3). Result = Wed 2026-01-14.
func TestCalculateEndDate_WorkingDays_AnchorOnWeekend(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "3 working days from Saturday",
DurationValue: 3,
DurationUnit: "working_days",
Timing: ptr("after"),
}
in := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 1, 14, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 2 — combine_op='max' picks the LATER of two adjusted
// end-dates. Matches UPC RoP R.198 / R.213 "31 calendar days OR 20
// working days, whichever is longer". Anchor Mon 2026-01-12.
// - Primary: 31 cal days → Sun 2026-02-12... wait, Mon Jan 12 + 31 =
// Thu 2026-02-12 (verify: Jan has 31 days; 12 + 31 = day-43 of year
// = Feb 12). Feb 12 2026 is Thursday → no snap, +31d.
// - Alt: 20 working_days → Mon Jan 12 + 20wd: Tue 13 (1) ... walk
// gives Mon 2026-02-09 (20 business days later, no DE holiday).
//
// max(Feb 12 Thu, Feb 09 Mon) = Feb 12 → primary wins.
func TestCalculateEndDate_CombineMax_PrimaryWins(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "31d OR 20wd, max",
DurationValue: 31,
DurationUnit: "days",
Timing: ptr("after"),
AltDurationValue: ptr(20),
AltDurationUnit: ptr("working_days"),
CombineOp: ptr("max"),
}
in := time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 2, 12, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 2 — combine_op='max', alt wins. Anchor that makes the
// 20-working-days leg longer than the 31-cal-day leg. Anchor Fri
// 2026-01-09: +31 cal days = Mon 2026-02-09 (calendar weekday, no snap);
// +20 working_days = Fri 2026-02-06 ... actually let's pick an anchor
// where the working-days side overshoots. Anchor over a long-weekend
// cluster: Wed 2026-12-23, +31cal = Sat 2027-01-23 → forward-snap to Mon
// 2027-01-25 (DE has no holiday that day). +20wd = walk skipping Heilig
// Abend, Christmas, Neujahr, weekends. Pick simpler: anchor where 31cal
// + snap ≈ 20wd + cluster.
//
// Concrete: anchor Mon 2026-01-12, mock the 31d leg landing on Sun
// 2026-02-15 (no — Jan 12 + 34 days = Feb 15, not 31). For deterministic
// "alt wins", we use a configurable anchor and check the relative order
// instead.
func TestCalculateEndDate_CombineMax_AltWins(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
// Anchor Thu 2026-12-24 (Heilig Abend is not a DE federal holiday;
// holiday service only has Neujahr/Easter/.../Weihnachtstag — Dec
// 24 is a working day here). +14 calendar days = Thu 2027-01-07.
// +20 working_days walks Fri 12-25 (1. Weihnachtstag — skip), ...
// arrives much later. Use 14 days vs 20 working_days to make alt
// reliably win on this stretch.
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "14d OR 20wd, max",
DurationValue: 14,
DurationUnit: "days",
Timing: ptr("after"),
AltDurationValue: ptr(20),
AltDurationUnit: ptr("working_days"),
CombineOp: ptr("max"),
}
in := time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
// Primary 14 cal days: Dec 24 (Thu) + 14 = Jan 7 2027 (Thu), working
// day → no snap. Alt 20 working_days walks past Christmas + Neujahr:
// Fri 12-25 (1.W) skip, Sat/Sun 12-26/27 skip (Sat counts as
// non-working; 2.W on 26 also skips), Mon 12-28 (1), Tue 12-29 (2),
// Wed 12-30 (3), Thu 12-31 (4), Fri 01-01-2027 Neujahr skip, Mon
// 01-04 (5), Tue 01-05 (6), Wed 01-06 (7), Thu 01-07 (8), Fri 01-08
// (9), Mon 01-11 (10), Tue 01-12 (11), Wed 01-13 (12), Thu 01-14
// (13), Fri 01-15 (14), Mon 01-18 (15), Tue 01-19 (16), Wed 01-20
// (17), Thu 01-21 (18), Fri 01-22 (19), Mon 01-25 (20). Result =
// Mon 2027-01-25. After max(Jan 7, Jan 25) → Jan 25.
want := time.Date(2027, 1, 25, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 2 — combine_op='min' picks the EARLIER end-date.
// Same shape as the max test but inverted. Same Dec 24 2026 anchor,
// 14d vs 20wd: min = Jan 7 2027 (the primary leg).
func TestCalculateEndDate_CombineMin_PrimaryWins(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "14d OR 20wd, min",
DurationValue: 14,
DurationUnit: "days",
Timing: ptr("after"),
AltDurationValue: ptr(20),
AltDurationUnit: ptr("working_days"),
CombineOp: ptr("min"),
}
in := time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2027, 1, 7, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 2 — combine_op with NULL alt fields short-circuits to
// the primary-only result (defensive: drift in seed data shouldn't crash
// the calculator). Same as the basic days test but with combine_op set
// and alt fields nil.
func TestCalculateEndDate_CombineOp_AltNil_FallsBackToPrimary(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "Primary only, stray combine_op",
DurationValue: 10,
DurationUnit: "days",
Timing: ptr("after"),
CombineOp: ptr("max"),
}
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 1, 23, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
@@ -168,4 +486,3 @@ func TestAdjustForNonWorkingDays_WalksPastSummerVacation(t *testing.T) {
// PR-3 ("SoD 3mo from 2026-04-30 → adjusted Mon 2026-08-31, not Sat
// 2026-08-29") locks the live behaviour.
}

View File

@@ -189,6 +189,25 @@ func (s *HolidayService) IsNonWorkingDay(date time.Time, country, regime string)
return h != nil && h.IsClosure
}
// AdjustForNonWorkingDaysBackward is the symmetric counterpart of
// AdjustForNonWorkingDays: walks the date *backward* day-by-day until it
// lands on a working day for the given (country, regime). Used for
// timing='before' rules (e.g. UPC R.109.1 "no later than 1 month before
// the oral hearing") — when the computed cut-off lands on a weekend or
// public holiday, the lawyer must finish *earlier*, not later. Forward
// snap would push the cut-off past the statutory limit and cause the
// step to be filed too late. Bound by the same 60-iter cap as the
// forward variant.
func (s *HolidayService) AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) {
original = date
adjusted = date
for i := 0; i < 60 && s.IsNonWorkingDay(adjusted, country, regime); i++ {
adjusted = adjusted.AddDate(0, 0, -1)
wasAdjusted = true
}
return adjusted, original, wasAdjusted
}
// AdjustForNonWorkingDays moves the date forward to the next working day for
// the given (country, regime). Returns adjusted date, the original
// (unmodified) date, and whether any adjustment was made.

View File

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

View File

@@ -6,17 +6,28 @@ package services
//
// Variables span six namespaces:
//
// firm.* process-wide (branding.Name)
// user.* caller's user row
// today.* server time in Europe/Berlin, locale-aware
// project.* paliad.projects + joined proceeding type
// parties.* paliad.parties grouped by role
// rule.* paliad.deadline_rules row keyed by submission_code
// deadline.* next open paliad.deadlines row for (project, rule), if any
// firm.* process-wide (branding.Name)
// user.* caller's user row
// today.* server time in Europe/Berlin, locale-aware
// project.* paliad.projects + joined proceeding type
// parties.* paliad.parties grouped by role
// procedural_event.* paliad.deadline_rules row keyed by submission_code
// — the "what kind of step in the proceeding"
// identity (Schriftsatz, Anhörung, Entscheidung,
// …). See docs/design-procedural-events-model-
// 2026-05-25.md (t-paliad-262 Slice A).
// rule.* legacy alias for procedural_event.*; emitted
// unconditionally for backward compatibility
// with Word templates and saved drafts authored
// before the rename. @deprecated — new templates
// should use the procedural_event.* form.
// deadline.* next open paliad.deadlines row for
// (project, procedural_event), if any
//
// Locale handling: every long-form date string is computed in both DE
// and EN; the renderer picks based on the user's lang preference. The
// rule pretty-printer (legalSourcePretty) also has DE/EN variants.
// procedural-event pretty-printer (legalSourcePretty) also has DE/EN
// variants.
//
// Visibility: caller passes userID; ProjectService.GetByID enforces
// paliad.can_see_project — unauthorised callers get the standard
@@ -173,9 +184,12 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
return out, nil
}
// loadPublishedRule fetches the deadline_rule that owns the given
// submission_code. Restricts to lifecycle_state='published' so drafts
// never end up shaping a real submission.
// loadPublishedRule fetches the published procedural-event template
// (paliad.deadline_rules row) keyed by submission_code. Restricts to
// lifecycle_state='published' so drafts never end up shaping a real
// submission. Function name retained for Slice A (prose-only); Slice
// B renames it to loadPublishedProceduralEvent when the Go type is
// renamed (t-paliad-262 §6).
func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
if submissionCode == "" {
return nil, ErrSubmissionRuleNotFound
@@ -346,21 +360,55 @@ func addPartyVars(bag PlaceholderMap, parties []models.Party) {
}
}
// addRuleVars populates rule.* — submission_code, name(_en),
// legal_source (+ pretty form), primary_party, event_type.
// addRuleVars populates the procedural-event variable namespace —
// code, name(_en), legal_source (+ pretty form), primary_party, kind.
//
// Two key prefixes are emitted for every value:
//
// - procedural_event.* — canonical name (t-paliad-262 Slice A,
// design docs/design-procedural-events-model-2026-05-25.md).
// - rule.* — legacy alias kept forever (m's call,
// issue m/paliad#93 Q7); existing Word templates and saved
// submission_drafts authored before the rename keep working.
//
// `procedural_event.event_kind` is the canonical key for the
// procedural-event kind (filing|reply|hearing|decision|order). The
// legacy `rule.event_type` alias holds the same string. The column
// itself stays named `event_type` on `paliad.deadline_rules` — Slice
// A is prose-only; the column-level rename to `event_kind` is Slice B.
//
// Function name stays `addRuleVars` to avoid coupling Slice A to the
// Go-type rename which is Slice B (B.5 sub-slice).
func addRuleVars(bag PlaceholderMap, r *models.DeadlineRule, lang string) {
bag["rule.submission_code"] = derefString(r.SubmissionCode)
code := derefString(r.SubmissionCode)
var localizedName string
if strings.EqualFold(lang, "en") {
bag["rule.name"] = r.NameEN
localizedName = r.NameEN
} else {
bag["rule.name"] = r.Name
localizedName = r.Name
}
legalSource := derefString(r.LegalSource)
legalSourcePrettyVal := legalSourcePretty(legalSource, lang)
primaryParty := derefString(r.PrimaryParty)
eventKind := derefString(r.EventType)
bag["procedural_event.code"] = code
bag["procedural_event.name"] = localizedName
bag["procedural_event.name_de"] = r.Name
bag["procedural_event.name_en"] = r.NameEN
bag["procedural_event.legal_source"] = legalSource
bag["procedural_event.legal_source_pretty"] = legalSourcePrettyVal
bag["procedural_event.primary_party"] = primaryParty
bag["procedural_event.event_kind"] = eventKind
bag["rule.submission_code"] = code
bag["rule.name"] = localizedName
bag["rule.name_de"] = r.Name
bag["rule.name_en"] = r.NameEN
bag["rule.legal_source"] = derefString(r.LegalSource)
bag["rule.legal_source_pretty"] = legalSourcePretty(derefString(r.LegalSource), lang)
bag["rule.primary_party"] = derefString(r.PrimaryParty)
bag["rule.event_type"] = derefString(r.EventType)
bag["rule.legal_source"] = legalSource
bag["rule.legal_source_pretty"] = legalSourcePrettyVal
bag["rule.primary_party"] = primaryParty
bag["rule.event_type"] = eventKind
}
// addDeadlineVars populates deadline.* from the next pending row. When

View File

@@ -0,0 +1,153 @@
package services
// Regression test for the procedural-event placeholder aliases
// (t-paliad-262 Slice A, m/paliad#93 Q7).
//
// The variable bag emits TWO key prefixes for the procedural-event
// namespace:
//
// - procedural_event.* (canonical, post-rename)
// - rule.* (legacy, @deprecated)
//
// m's lock: keep the legacy aliases forever so lawyer-authored Word
// templates and existing paliad.submission_drafts rows that already
// contain `{{rule.X}}` keep merging correctly.
//
// This test pins the contract: every (canonical, legacy) pair must
// resolve to the same string in the placeholder map, for every value
// of (lang, present-vs-NULL columns). Removing the legacy aliases —
// or letting them drift in value from the canonical — must light up
// here BEFORE the change can land in main.
import (
"strings"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
func TestAddRuleVars_CanonicalAndLegacyAliasesMatch(t *testing.T) {
t.Parallel()
// Pairs are (canonical key, legacy key). Order matters only for
// the assertion message — the test checks string equality both
// ways round.
pairs := []struct {
canonical string
legacy string
}{
{"procedural_event.code", "rule.submission_code"},
{"procedural_event.name", "rule.name"},
{"procedural_event.name_de", "rule.name_de"},
{"procedural_event.name_en", "rule.name_en"},
{"procedural_event.legal_source", "rule.legal_source"},
{"procedural_event.legal_source_pretty", "rule.legal_source_pretty"},
{"procedural_event.primary_party", "rule.primary_party"},
{"procedural_event.event_kind", "rule.event_type"},
}
// Build a fully-populated rule row. Every nullable column has a
// distinct non-empty value so missing-value bugs (e.g. the legacy
// key copying "" while the canonical key copies the real value)
// would surface.
code := "dpma.appeal.bgh.begruendung"
desc := "Rechtsbeschwerdebegründung — § 102 PatG"
party := "both"
kind := "filing"
legal := "DE.PatG.102"
ruleCode := "§ 102 PatG"
rule := &models.DeadlineRule{
ID: uuid.New(),
SubmissionCode: &code,
Name: "Rechtsbeschwerdebegründung",
NameEN: "Appeal brief",
Description: &desc,
PrimaryParty: &party,
EventType: &kind,
LegalSource: &legal,
RuleCode: &ruleCode,
}
for _, lang := range []string{"de", "en"} {
t.Run(lang, func(t *testing.T) {
t.Parallel()
bag := PlaceholderMap{}
addRuleVars(bag, rule, lang)
for _, p := range pairs {
canonicalVal, canonicalOK := bag[p.canonical]
legacyVal, legacyOK := bag[p.legacy]
if !canonicalOK {
t.Errorf("canonical key %q missing from bag (lang=%s); "+
"Slice A must emit both forms", p.canonical, lang)
}
if !legacyOK {
t.Errorf("legacy alias %q missing from bag (lang=%s); "+
"removing legacy aliases would break existing Word "+
"templates that paliad doesn't see — keep the "+
"emission per m/paliad#93 Q7", p.legacy, lang)
}
if canonicalVal != legacyVal {
t.Errorf("alias drift: %q=%q vs %q=%q (lang=%s)",
p.canonical, canonicalVal,
p.legacy, legacyVal, lang)
}
}
// Sanity: the localized name actually localizes (the
// canonical and legacy `name` keys depend on lang). If
// this fails the loop above wouldn't catch it (both keys
// would agree on the wrong language).
localized := bag["procedural_event.name"]
if strings.EqualFold(lang, "en") && localized != rule.NameEN {
t.Errorf("expected EN localized name=%q, got %q",
rule.NameEN, localized)
}
if strings.EqualFold(lang, "de") && localized != rule.Name {
t.Errorf("expected DE localized name=%q, got %q",
rule.Name, localized)
}
})
}
}
func TestAddRuleVars_NullableFieldsEmitEmptyOnBothPrefixes(t *testing.T) {
t.Parallel()
// A minimal rule with every optional column NULL. The bag must
// still emit every canonical + legacy key — with the empty
// string — so downstream merging produces the standard
// "[KEIN WERT: ...]" marker rather than a broken template.
rule := &models.DeadlineRule{
ID: uuid.New(),
Name: "Generic step",
NameEN: "Generic step",
}
bag := PlaceholderMap{}
addRuleVars(bag, rule, "de")
mustHave := []string{
"procedural_event.code", "rule.submission_code",
"procedural_event.legal_source", "rule.legal_source",
"procedural_event.legal_source_pretty", "rule.legal_source_pretty",
"procedural_event.primary_party", "rule.primary_party",
"procedural_event.event_kind", "rule.event_type",
}
for _, key := range mustHave {
val, ok := bag[key]
if !ok {
t.Errorf("key %q missing from bag even with NULL source column; "+
"derefString must materialize the empty string so the "+
"merger sees the variable and renders the missing-value "+
"marker", key)
}
if val != "" {
t.Errorf("key %q = %q, want \"\" (source column was NULL)", key, val)
}
}
}

View File

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