Compare commits

..

478 Commits

Author SHA1 Message Date
mAi
31ebec769a Merge: t-paliad-367 rebuild firm-skeleton from HLC Patents Style .dotm (authored letterhead+recitals, HLCpat-)
Some checks are pending
Paliad CI gate / build (push) Waiting to run
Paliad CI gate / test-go (push) Waiting to run
Paliad CI gate / deploy (push) Blocked by required conditions
2026-06-01 17:14:22 +02:00
mAi
9dba2b627c Merge remote-tracking branch 'origin/main' into mai/bohr/coder-rebuild-merge-safe 2026-06-01 17:14:21 +02:00
mAi
8a51fbb9e4 feat(generation): t-paliad-367 rebuild firm-skeleton FROM HLC Patents Style .dotm — use the firm's authored letterhead+recitals tables
m mandate (2026-06-01): 'I dont want that formatting in code! it should use
our word files and styles.' The t-paliad-364 generator synthesised a flat
Rubrum in Go, borrowing only the firm's style NAMES. The HLC Patents Style
.dotm already contains the real, firm-authored letterhead + Rubrum in Word —
header table (logo via header2 + address blocks), case-info table, recitals
table — all in HLCpat- styles.

This rewrites gen-hl-skeleton-template to LIFT that authored layout verbatim:
- ConvertDotmToDocx strips macros → clean .docx carrier (idempotent on .docx)
- footer1 firm-NAME SDT → {{firm.name}} (A-S3); footer2 legal block kept
- document.xml body kept verbatim through the recitals table; the TOC +
  checklist demo + 'Template Info And Manual' style-guide section truncated
- carrier <w:document> open tag + <w:sectPr> reused → header1/2 + footer1/2 +
  titlePg letterhead wiring preserved
- structure-aware, occurrence-ordered paragraph walk swaps the firm's example
  text for paliad merge tokens (parties/caption/project/user/today/proc-event);
  transformed paragraphs keep their <w:pPr> verbatim — the named style supplies
  formatting, none authored in code

Reconciles t-paliad-366 item 1 (HLpat→HLCpat drift): carrier + Rubrum both HLCpat-.

DATA GAP flagged to m: no structured HL office postal addresses + no court_id FK
(project.court is free text), so the sender address block + recipient court
address lines are kept as the .dotm's authored editable text; only the court
NAME is wired to {{project.court}}. Rules flip to placeholders when that data lands.

Verified: HasMergePlaceholders=true (de+en); header2/footer2/media/styles.xml/
theme/numbering byte-identical to the .dotm; package integrity clean (macros
stripped, no dangling rels, main type demoted); merge dry-run fills with zero
leftover {{}} / zero [KEIN WERT]. go vet + go test ./... + bun build green.
2026-06-01 17:04:50 +02:00
mAi
327dc1169a chore(patentstyle): publish HLC Patents Style v0.260601-1442
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
sha256 074ED1339FFBC5514E9C347E28C3477F531306ABEA30EC3083B23E6095BD7E05
Source: HL/mWorkRepo#37 (Build.ps1 -> publish.sh pipeline).
2026-06-01 16:43:07 +02:00
mAi
08fb6ed501 chore(patentstyle): publish HLC Patents Style v0.260601
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
sha256 07AE5DC2BED8905964CC1FBB0B89300F5B444BC1729FCACFFCDE5E83A2F99EF0
Source: HL/mWorkRepo#37 (Build.ps1 -> publish.sh pipeline).
2026-06-01 16:30:43 +02:00
mAi
b582e62b2b Merge: t-paliad-364 styled+filled submissions — project-less caption fill (P3b) + merge-safe styled firm-skeleton generator (P3a Option B)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 16:17:19 +02:00
mAi
f152109bd3 Merge remote-tracking branch 'origin/main' into mai/bohr/coder-generation-core 2026-06-01 16:17:08 +02:00
mAi
c1781c9a89 feat(generation): t-paliad-364 styled+filled submissions — project-less caption fill (P3b) + merge-safe styled firm-skeleton generator (P3a Option B)
P3(b) fill-what-we-can (submission_vars.go):
The project-less branch of SubmissionVarsService.Build ran only firm/today/
user/proceduralEvent resolvers, so caption.*/project.proceeding.* never
populated and every Rubrum value rendered [KEIN WERT]. The rule is already
loaded on this path and carries ProceedingTypeID. Now the branch loads the
proceeding type via loadProceedingType(rule.ProceedingTypeID) (tolerates a nil
id) and appends a proceeding-only projectResolver{project:nil} + a
captionResolver{project:nil} — both nil-project safe. Result: caption heading /
designations / versus / subject + the proceeding line fill from the
submission_code's proceeding; only party names / case number / court stay blank
for the lawyer. Preserves the "Ohne Projekt" affordance (t-paliad-243).

addProjectVars is now nil-project safe (guards the project.* direct fields,
keeps the pt-driven project.proceeding.* block) so projectResolver can serve as
the proceeding-only resolver. Pinned by TestProjectlessFill_* +
TestAddProjectVars_NilProjectFillsProceedingOnly (no DB).

P3(a) styling — merge-safe styled firm-skeleton (scripts/gen-hl-skeleton-template):
Generation landed on docx.BuildFallbackSkeleton (generic Heading1/2/Normal)
because the firm-skeleton's body had been repurposed into an anchors-only
Composer base, which HasMergePlaceholders rejects. Rewrote the generator to take
the deployed clean .docx carrier and replace ONLY word/document.xml with a clean
caption-driven Rubrum that uses the firm Rubrum styles (Table-Recitals-Party/
PartyDetails/PartyRoles/Sequencers, Heading-H2, Signature, Body-B0) and the same
{{key}}/{{caption.*}} placeholders the in-process fallback uses — preserving the
carrier's styles/theme/numbering/letterhead/logo and its sectPr verbatim. Adds a
-lang flag (DE _firm-skeleton.docx, EN _skeleton.en.docx) and auto-detects the
firm style prefix (HLpat-/HLCpat-) so it stays correct across the .dotm rebrand
drift. The resolver's tier-4/tier-3 merge-safe guard auto-prefers the restored
templates — no handler change.

Dry-run (TEST_DATABASE_URL, de.inf.lg.erwidg): project-less render fills caption
heading="In dem Rechtsstreit"/Klägerin/gegen/Beklagte/Patentverletzung + the
proceeding line, only party/case/court blank; full-project render additionally
fills parties + case number + court. Both carry the HLpat-Table-Recitals-* /
HLpat-Heading-H2 / HLpat-Signature styles, HasMergePlaceholders=true, no
{{#section}} junk.
2026-06-01 16:14:28 +02:00
mAi
72ba8fb2b9 Merge: t-paliad-365 picker quality — re-kind 8 court-act rules (mig 164) + group-header contrast
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 15:59:19 +02:00
mAi
13166cd784 fix(submissions): t-paliad-365 picker quality — re-kind 8 court-act rules (mig 164) + group-header contrast
P1: 8 anchor/trigger rows (Zustellung des Urteils / Veröffentlichung der
Erteilung) were mislabeled event_type='filing'+primary_party='both' and leaked
into the /submissions/new draftable-submission picker. Migration 164 re-kinds
them to event_type='decision'+primary_party='court', aligning them with the 16
sibling court-act rows the model already has; the picker's event_type='filing'
filter then excludes them. Defensive guard added to loadSubmissionCatalog
(primary_party IS DISTINCT FROM 'court') as belt-and-braces against future drift.
Safety: the only event_type coupling for these rows is ruleAnchorKind
(projection_service.go) — they now anchor as appointments (correct, sibling-
consistent); deadline computation keys off is_court_set not event_type, and the
child sequence guard parentHasAnchoredActual UNIONs both anchor tables, so no
chain breaks. Verified: catalog 113->105, 8 court acts gone, 48 'both' party
submissions (incl. UPC appeal briefs) retained.

P2: the grouped picker table already renders a correct colspan group-header row
(a911a2d); the defect was contrast — the band used --color-bg-subtle, the SAME
token as the thead, so groups read as undelimited floating text. New
--color-bg-group-header token (cool/deeper in light, raised-cream in dark) +
heavier top divider + neutral left accent + darker label make each proceeding a
distinct section. Live deployed bundle confirmed current (not stale).
2026-06-01 15:58:32 +02:00
mAi
9e515551b2 Merge: t-paliad-363 generation-quality diagnosis (picker filter, layout, Rubrum styling+fill)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 15:50:56 +02:00
mAi
63e973df88 docs(generation-quality): t-paliad-363 diagnosis — picker court-act mislabel, group-header contrast, unstyled+unfilled Rubrum (2-layer P3 + 3 forks) 2026-06-01 15:48:11 +02:00
mAi
8eba20f46a Merge: t-paliad-361 caption-wording — Respondent + restored Streitpatent line + DE appeal/nullity role-label backfill (mig 163, lexy-confirmed)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 15:23:09 +02:00
mAi
d30556397a Merge remote-tracking branch 'origin/main' into mai/bohr/coder-apply-m-s-caption 2026-06-01 15:22:56 +02:00
mAi
1882468780 feat(caption): apply m's caption-wording decisions — Respondent + Streitpatent line + DE appeal/nullity role labels
Some checks are pending
Paliad CI gate / build (push) Waiting to run
Paliad CI gate / test-go (push) Waiting to run
Paliad CI gate / deploy (push) Blocked by required conditions
t-paliad-361, follow-up to t-paliad-358 A-S2. m ruled on the 7 lexy-wording
flags (AskUserQuestion 2026-06-01 14:30). Most flags CONFIRMED the live
wording; three changes land here, all caption (Rubrum) wording, all in one
reversible migration 163.

Change 1 — UPC appeal responding party (EN): 'Appellee' → 'Respondent'.
  Fixed at the data source: the mig-137 role-label override on
  upc.apl.unified (the only place 'Appellee' was stored). The caption
  resolver's instance-derived EN fallback already said 'Respondent', so no
  code change. DE side (Berufungsbeklagter) untouched per m.

Change 2 — restore the standalone 'Streitpatent: {{project.patent_number_upc}}'
  (DE) / 'Patent in suit:' (EN) line in the upc-formal Composer caption seed,
  dropped in A-S2 (mig 161). Keeps the parametric 'In der Sache' heading (m did
  not revert that). Only the upc-formal base is touched; grouped with the case
  number ahead of {{project.court}}.

Change 3 — backfill lexy-confirmed role-label overrides for the four DE
  appeal/nullity proceedings that carried none, so designations are correct
  even when project.instance_level is unset (statute-grounded: §§ 511/542/544
  ZPO, §§ 81/110 PatG; bracketed-inclusive gender style):
    de.inf.olg    Berufungskläger(in) / Berufungsbeklagte(r)              // Appellant / Respondent
    de.inf.bgh    Revisionskläger(in) / Revisionsbeklagte(r)              // Appellant / Respondent
    de.null.bpatg Nichtigkeitskläger(in) / Beklagte(r) (Patentinhaber(in)) // Nullity claimant / Defendant (patent proprietor)
    de.null.bgh   Berufungskläger(in) / Berufungsbeklagte(r)              // Appellant / Respondent

Updates submission_vars_caption_test.go: adds EN assertions + cases pinning the
Respondent change and the four backfilled designations (each with instance_level
unset, proving the override path). go vet + go test ./... + bun build clean.
2026-06-01 15:21:37 +02:00
mAi
3042bea3c2 Merge: patentstyle Content-Disposition derives filename from requested file (HLC install fix)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 15:18:32 +02:00
mAi
ea9dd261ae fix(patentstyle): derive Content-Disposition filename from requested file (HLC download saved under wrong name, install macro rejected) 2026-06-01 15:18:32 +02:00
mAi
78f9aa4f46 Merge: patentstyle download href flip → HLC-Patents-Style.dotm
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 15:04:43 +02:00
mAi
19f9604827 fix(patentstyle): flip download href to HLC-Patents-Style.dotm (m blocked on HL file) 2026-06-01 15:04:43 +02:00
mAi
c303c01652 chore(patentstyle): publish HLC Patents Style v0.260601
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
sha256 DE6B6A17AC603FF4A9B3893CD2A7EF8263C9E2D4224A0A5E28E2FABF5E27A798
Source: HL/mWorkRepo#37 (Build.ps1 -> publish.sh pipeline).
2026-06-01 14:52:35 +02:00
mAi
97a2742f10 Merge: patentstyle landing page — English + HLC rebrand (work/paliad coordination)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 13:28:04 +02:00
mAi
b26360111a docs(patentstyle): English + HLC rebrand of landing page (work/paliad coordination) 2026-06-01 13:27:57 +02:00
mAi
e914bac79a chore(patentstyle): publish HL Patents Style v0.260601
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
sha256 D4D0BDD31CC2C4F9A2362363FEC2A7D86B8BE8A4EA7B0F2CEF5F1944A15B3A4A
Source: HL/mWorkRepo#37 (Build.ps1 -> publish.sh pipeline).
2026-06-01 13:17:15 +02:00
mAi
713a4d4206 Merge: t-paliad-358 A-S3 — firm-agnostic merge-fallback + firm-skeleton letterhead (firm.name placeholderised) — COMPLETES Rubrum Option A
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 13:11:16 +02:00
mAi
cd3cd0230c Merge remote-tracking branch 'origin/main' into mai/bohr/coder-rubrum-letterhead 2026-06-01 13:11:05 +02:00
mAi
cd793b1d98 feat(submissions): firm-agnostic merge-fallback letterhead (t-paliad-358 A-S3)
A-S3 part 1 (merge-fallback letterhead, in-repo): the fallback skeleton
(docx.BuildFallbackSkeleton) gains a minimal Word page-header letterhead
(word/header1.xml) carrying only {{firm.name}}, filled from branding by the
variable bag. A generated fallback-path document now repeats a correct,
firm-agnostic firm identity on every page (the firm name moved out of the body
into the header; no hard-coded firm name anywhere). Wired the header part:
Content_Types override + document.xml.rels relationship (rId2) + sectPr
headerReference; document gains xmlns:r. Deliberately minimal — the fallback is
a starter, not full firm chrome.

A-S3 part 2 (firm-skeleton .docx, mWorkRepo — separate commit): _firm-skeleton.docx
footer1's "Firm name" SDT content control hard-coded "Hogan Lovells" → now
{{firm.name}}, filled by the Composer's final renderer pass / merge.go (both run
SubmissionRenderer over header/footer parts). Surgical <w:t> edit + repack (all
38 parts preserved, verified renders + fills cleanly, integrity OK). Pushed to
HL/mWorkRepo as mAi (commit 5a3a1722).

Path taken: option (a) for the firm NAME (cleanly placeholderised). NOT option
(a) for footer2's HL legal-entity boilerplate (registered no. OC323639, LLP
structure, 40+ office cities) — token-swapping the name into it would assert
false legal facts for a non-HLC firm; left intact and flagged (needs per-firm
legal-footer config, not templating). No corrupt .docx shipped.

Completes the Rubrum + letterhead auto-fill Option A train (A-S1 signature_block
+ generate-fallback fix; A-S2 parametric caption.*; A-S3 firm-agnostic letterhead).

Tests: fallback header asserts {{firm.name}} present + renders firm-agnostically
(de+en); patched firm-skeleton verified to render + fill (transient check).
go vet ./... + bun build clean; touched packages green.
2026-06-01 13:10:04 +02:00
mAi
a50ddc3927 Merge: t-paliad-356 Slice 5 — firm-wide default name compositions (mig 162) — COMPLETES nomen train
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 13:05:50 +02:00
mAi
c639c5695c Merge remote-tracking branch 'origin/main' into mai/fermi/coder-implement-nomen 2026-06-01 13:05:30 +02:00
mAi
a05ae1f2ae feat(settings): firm-wide default name compositions (t-paliad-356 Slice 5)
Completes the nomen train (S1–S5). Adds the FIRM tier of the name-composition
precedence chain — per-document → user → FIRM → system (PRD §3.1/§3.2) —
mirroring firm_dashboard_default exactly.

Storage + service:
- Migration 162: paliad.firm_name_compositions singleton (id=1, CHECK id=1,
  RLS read-all + service-role writes) — same shape as firm_dashboard_default
  (mig 117), holding a validated { artifact_id: Composition } jsonb map.
- FirmNameCompositionService (Get/Set/Clear) + getFirmNameCompositions /
  setFirmNameCompositions / clearFirmNameCompositions singleton helpers in
  name_composition_spec.go.

Resolution:
- resolveComposition is now variadic over ordered specs (user, firm); first
  valid wins, else system default. Existing single-spec callers unchanged.
- Render path threads the firm tier: renderSubmissionDraftTitle /
  RenderSubmissionFilenameFor gain a firm param; newDraftName +
  submissionDownloadFilename load it (nil-safe). A firm default thus changes
  the effective name for every user without a personal override.

Admin surface (mirrors firm_dashboard_default):
- GET/PUT/DELETE /api/admin/name-compositions{/artifact_id} (adminGate) read
  back / set / clear the firm default per artifact.
- /settings Namensschemata cards gain an admin-only "Firmenstandard" block
  (set from the current template field / clear) revealed via is_admin, plus a
  "Firmenstandard" badge for non-admin users whose effective name comes from
  the firm tier. SettingsNameArtifact now resolves user→firm→system and
  exposes firm_is_set/firm_template.

Tests: pure precedence (user>firm>system) + firm-tier view + live firm
round-trip/Validate-rejection (via db.ApplyMigrations). go vet, go test ./...,
bun build all clean; gated live tests green against TEST_DATABASE_URL.

NOTE (merge ordering): golang-migrate is forward-only. Migration 162 must not
reach a DB before bohr's 161 (Rubrum Composer seed) exists, or 161 will be
skipped (current>161 → never applied). Merge 161 before/with 162.

Browser Playwright of the admin firm controls deferred to post-deploy
mai-tester — shared Supabase login wall blocks pre-merge browser login (same
ceiling as t-paliad-354).
2026-06-01 13:04:11 +02:00
mAi
7fe37bb550 Merge: t-paliad-358 A-S2 — parametric caption.* keys unify Rubrum across all render paths (mig 161)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 13:01:21 +02:00
mAi
57310ab3a4 Merge remote-tracking branch 'origin/main' into mai/bohr/coder-rubrum-letterhead 2026-06-01 13:00:54 +02:00
mAi
b99b6d6fb5 feat(submissions): parametric caption.* keys — unify Rubrum across all render paths (t-paliad-358 A-S2)
Promotes the case caption (Rubrum) to ONE parametric set of resolver keys
(caption.*) consumed identically by every render path, so the wording no
longer diverges per path and reflects the forum.

New caption.* keys (addCaptionVars, submission_vars.go), each in bare +
_de + _en forms (bare resolves to the draft language):
  caption.heading · caption.claimant_designation · caption.defendant_designation
  caption.versus · caption.subject

Parametrised from data already in the bag — NO new schema:
  - designations reuse the proceeding-type role-label overrides (mig 137:
    upc.apl.unified→Berufungskläger, upc.rev.cfi→Antragsteller (Nichtigkeit),
    epa.opp.*→Einsprechende(r)/Patentinhaber(in)); else instance-derived
    appeal/cassation (project.instance_level); else civil default Klägerin/
    Beklagte // Claimant/Defendant.
  - heading + subject from jurisdiction + the dotted code's nature segment
    (inf→"In dem Rechtsstreit"/"Patentverletzung", null→"In der
    Patentnichtigkeitssache", UPC→"In der Sache"/"In the matter",
    opp→"Im Einspruchsverfahren").
Also exposes project.proceeding.jurisdiction.

All three render paths now reference the SAME keys:
  1. docx.BuildFallbackSkeleton (merge fallback) — heading/designations/versus/
     wegen-subject are {{caption.*}} placeholders.
  2. demo per-code template de.inf.lg.erwidg.docx (mWorkRepo) — regenerated via
     scripts/gen-demo-submission-template; caption wired to caption.*; closing
     drops the duplicate {{firm.name}} line (now carried by signature_block).
     Pushed to HL/mWorkRepo as mAi (commit 3682299).
  3. Composer caption seeds — mig 161 rewrites the caption section seed_md of
     all 4 bases (hlc-letterhead, neutral, lg-duesseldorf, upc-formal) to the
     parametric form (position-independent jsonb_agg patch; reversible down).

our_side is intentionally NOT a caption driver — the caption designates both
parties by procedural role regardless of which side we act for.

Tests: resolveCaption forum matrix (DE-LG/BPatG/UPC/role-label/instance-appeal/
EPA-opp), bare-resolves-to-lang, fallback skeleton renders caption.* keys.
mig 161 passes TestMigrations_DryRun. go vet ./... + bun build clean; all
touched packages green (the live approval/migration_136 failures are
pre-existing shared-DB env issues, unrelated).

LEXY-REVIEW FLAGS in the report — DE caption conventions are practitioner
convention, not in the youpc corpus; specific wordings flagged for sign-off.
2026-06-01 12:59:29 +02:00
mAi
5468a7259d Merge: t-paliad-356 Slice 4 — name-composition token-template editor on /settings
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 12:47:21 +02:00
mAi
230306518d Merge remote-tracking branch 'origin/main' into mai/fermi/coder-implement-nomen 2026-06-01 12:46:58 +02:00
mAi
6e56b9d51f feat(settings): name-composition token-template editor (t-paliad-356 Slice 4)
Adds the /settings "Namensschemata" tab so users can customise the two wired
name artifacts (submission_draft_title, submission_docx_filename) via a
single-line {token} template, with a clickable palette, live preview, and
reset-to-default — PRD §7.

Engine (pure, pkg/nomen):
- Composition.Template() serialises a composition to "{var}" shorthand;
  ParseTemplate() is its inverse — tokens + literal separators (trailing,
  owned by the left segment) + paren Wrap. Missing-rules are NOT in the
  shorthand (PRD §7); the parser leaves every segment KindOmit. Leading /
  trailing literals are rejected (the trailing-separator model can't carry
  them) so a save never silently drops characters. Table + round-trip tests.

Paliad glue (internal/services/name_template.go):
- ParseNameTemplate overlays each segment's missing-rule from the artifact's
  system default and validates against the catalog.
- PreviewNameComposition renders against the fixed PRD sample (Bayer AG / UPC
  / Sandoz / UPC_CFI_123/2026 / today) and an empties resolver so the
  missing-rule behaviour is visible. The frontend never parses templates —
  the nomen engine stays the single source of truth.
- SettingsNameArtifacts / SettingsNameArtifact build the per-artifact cards
  (current template, system default, override flag, ordered palette, previews).

API (internal/handlers/name_compositions.go):
- GET    /api/me/name-compositions               — cards
- POST   /api/me/name-compositions/preview        — live preview + validation
- PUT    /api/me/name-compositions/{artifact_id}   — store override
- DELETE /api/me/name-compositions/{artifact_id}   — reset to system default
Storage reuses the Slice-3 service surface (UserNameCompositions /
SetUserNameCompositions) via read-modify-write; no new column, no migration.

Frontend: new tab + JS-built cards (palette insert-at-cursor, 250ms-debounced
preview, save/reset, DE/EN labels), CSS, and i18n keys (de + en).

Gates: go vet, go test ./..., bun build all clean. Browser verification of the
settings UX is deferred to post-deploy mai-tester — the shared Supabase login
wall blocks pre-merge browser login (same ceiling as t-paliad-354).
2026-06-01 12:46:07 +02:00
mAi
375d631f1b Merge: t-paliad-358 A-S1 — fill firm.signature_block + fix generate-fallback junk (merge-safe Rubrum fallback)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 12:40:53 +02:00
mAi
e3a604b4c4 Merge remote-tracking branch 'origin/main' into mai/bohr/coder-rubrum-letterhead 2026-06-01 12:40:40 +02:00
mAi
0763b7daa2 feat(submissions): fill firm.signature_block + fix generate-fallback junk (t-paliad-358 A-S1)
Two letterhead/Rubrum auto-fill fixes (Option A, no schema change):

1. firm.signature_block: was hardcoded "" ("reserved for Phase 2"), so every
   template referencing {{firm.signature_block}} rendered blank. Now filled
   from branding.Name — the firm identity line of a submission's signature
   block (the signature section seeds with signature_block + user.display_name).
   Firm-agnostic: a FIRM_NAME redeploy signs with the right firm.

2. Generate-fallback junk (kepler audit §1 Path 3): resolveSubmissionTemplate is
   the merge-path resolver (every caller feeds merge.go), but its lower tiers
   fetched _firm-skeleton.docx / _skeleton.docx — which were repurposed into
   anchors-only Composer bases (t-paliad-313 Slice B). Their bodies hold only
   {{#section:KEY}} markers, which placeholderRegex ignores, so merge.go emitted
   them verbatim as literal "{{#section:letterhead}}…" junk for every code
   without a per-code template (i.e. everything except de.inf.lg.erwidg).

   Fix:
   - docx.BuildFallbackSkeleton(lang): in-process, lang-aware, merge-safe basic
     Schriftsatz with a data-driven basic Rubrum (real {{key}} placeholders the
     var bag fills). Always available, no Gitea round-trip.
   - docx.HasMergePlaceholders guards tiers 3/4/5: a fetched skeleton is used
     only if it carries real placeholders, else we fall through to the embedded
     fallback. Today's anchors-only/placeholder-free files are skipped; a future
     merge-safe firm-skeleton (with letterhead) is preferred again automatically.
   - merge.go strips stray {{#section:…}}/{{/section:…}} markers defensively so
     no anchors-only carrier can ever leak Composer junk into a merged document.

Verified: confirmed live that deployed _firm-skeleton.docx + _skeleton.docx are
anchors-only (fetch+unzip); unit tests cover BuildFallbackSkeleton rendering a
real Rubrum (de+en), HasMergePlaceholders classification, marker stripping, and
the signature_block fill. go build / vet ./... / test ./... + bun build clean.

Out of scope (flagged for next slices): demo template's closing prints
{{firm.name}} then {{firm.signature_block}} (=firm.name) → A-S2 dedups the demo
wording. Restoring firm letterhead chrome to the merge fallback → A-S3.
2026-06-01 12:39:53 +02:00
mAi
c4e3a74e35 Merge: t-paliad-359 regroup draft-editor sidebar — naming (name+keyword) then template (base+language)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 12:38:08 +02:00
mAi
9d234f275f fix(submissions): regroup draft-editor sidebar — naming (name+keyword) then template (base+language) 2026-06-01 12:37:26 +02:00
mAi
83d5ed27e0 Merge: t-paliad-356 Slice 3 — per-user name-composition overrides (system→user precedence, mig 160)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 12:21:27 +02:00
mAi
73f379d305 feat(submissions): per-user name-composition overrides, system→user precedence (t-paliad-356 Slice 3)
PRD §3. A user can now override the system-default name composition for an
artifact; rendering prefers a valid user override over the system default.
No UI yet (Slice 4) — overrides are settable via the service API + tests.

- Migration 160: users.name_compositions jsonb NOT NULL DEFAULT '{}' — a
  { artifact_id: Composition } map.
- NameCompositionSpec (internal/services/name_composition_spec.go): Validate
  (write: known artifact + segments reference the artifact's catalog vars)
  and SanitizeForRead (read: drop unknown artifacts, drop segments with
  unknown vars, clamp version) — mirrors DashboardLayoutSpec. Plus
  nomen.Composition.SanitizeForRead and lowercase JSON tags on the nomen
  types so the stored shape is stable.
- Resolution: resolveComposition(artifact, overrides) returns the user
  override when present + valid, else the system default. The firm slot
  (PRD §3.1) is reserved for Slice 5 — system is the fallback below user
  here. Wired into BOTH artifacts: the draft-title via the create path
  (newDraftName → autoNameFor{Project,NonProject}) and the .docx filename
  via RenderSubmissionFilenameFor in the three download handlers.
  AutoSubmissionTitle / RenderSubmissionFilename stay as the nil-override
  (system-default) references the #155/354 matrices pin.
- Per-document keyword override generalised: writes now land at
  composer_meta.name_overrides.keyword (the general {var:value} shape);
  reads honour both that and the legacy composer_meta.filename_keyword
  (back-compat). The read moved to services.SubmissionFilenameKeyword
  (handlers delegate) so it is live-testable.

BACK-COMPAT FINDING: the one shipped filename_keyword row needs no data
migration — the read resolves legacy composer_meta.filename_keyword as
name_overrides.keyword. Verified by a live round-trip (seed legacy jsonb →
Get → SubmissionFilenameKeyword == the legacy value).

Verified (TEST_DATABASE_URL): (a) name_compositions Set/Get round-trip +
write-time Validate rejection of an out-of-catalog variable; (b) a user
override beats the system default for the title (through Create:
'Klageerwiderung <date>' vs default '<date> Klageerwiderung') and the
filename (through RenderSubmissionFilenameFor), with the nil-override
default unchanged; (c) legacy filename_keyword back-compat read. Plus unit
tests for nomen.SanitizeForRead, NameCompositionSpec Validate/Sanitize/
resolve, and the keyword back-compat read. go vet + go test ./... (15
pkgs) + bun build clean. No Playwright (no UI this slice).
2026-06-01 12:20:19 +02:00
mAi
9a5ee93f2e Merge: t-paliad-357 Rubrum+letterhead auto-fill audit (gap map)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 12:13:51 +02:00
mAi
213be10ada docs(submissions): gap map — Rubrum + letterhead auto-fill from project data (t-paliad-357)
Read-only audit. Maps the 3 doc-generation fill paths (merge.go fills
header/footer placeholders; compose.go passes headers byte-for-byte;
skeleton-as-merge-fallback latent bug), three gap tables (template /
var-bag / data-model), forum-dependence grounded on UPC RoP r.13, and a
tracer-bullet-first wiring proposal. States the A/B fork for m: wire what
current data supports (basic caption, no schema) vs. forum-correct Rubrum
(structured party address/Rechtsform/Sitz + court details + capture UI).
2026-06-01 12:12:10 +02:00
mAi
6dd9befba3 Merge: t-paliad-356 Slice 2 — non-project drafts get date-first name via nomen engine
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 12:06:43 +02:00
mAi
e10b5e6546 feat(submissions): non-project drafts get a date-first name (t-paliad-356 Slice 2)
PRD §6. Project-less drafts no longer fall back to the bare 'Entwurf N'
counter — they render the submission_draft_title artifact through the
nomen engine, leading with the date like project drafts do (m's ask).

newDraftName's non-project branch now resolves a keyword (document type)
from the draft's submission_code and renders '<date> <keyword>', e.g.
'2026-06-01 Klageerwiderung'. When the code has no published filing rule
the keyword degrades to the localized 'Entwurf'/'Draft' word, yielding
'<date> Entwurf'. Collisions get the usual ' (N)' suffix via
uniqueDraftName.

FLAG resolved: project-less drafts DO carry a required submission_code
(the global-create handler rejects an empty code), and submission_code ->
rule name is a function across the published filing rules (the code
encodes the proceeding, e.g. de.inf.lg.erwidg -> Klageerwiderung), so the
keyword resolves project-independently via a LIMIT 1 catalog lookup. Both
branches (rule-name / Entwurf fallback) flow through the same composition.

The keyword is a new, normally-omitted segment on the title composition;
project drafts leave it empty so they render identically to #155 — the
regression guard. The identity trio and the keyword are mutually exclusive
by construction (project => trio, no project => keyword).

Verification: PRIMARY gate is a service-level live test (TEST_DATABASE_URL)
asserting Create(projectID=nil) yields '<date> <keyword>', the ' (2)'
collision suffix, EN document type, and the '<date> Entwurf'/'Draft'
fallback — all green against the real DB. Existing project-title matrix
(TestAutoSubmissionTitle) unchanged. go vet + go test ./... + bun build
clean. Browser (Playwright) verification not meaningful pre-merge: S2 is
not yet deployed (only S1/cd3f784 is) and the naming is server-side in
Create behind the shared-auth login wall.
2026-06-01 12:05:57 +02:00
mAi
cd3f7843a7 Merge: t-paliad-356 Slice 1 — nomen name-composition engine + fold in #155/354 schemes (byte-equal refactor) + PRD
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 11:57:13 +02:00
mAi
4920328b09 feat(nomen): name-composition engine + fold in the two shipped schemes (t-paliad-356 Slice 1)
Slice 1 of the filename-generator train (PRD 2026-06-01 §8). Pure refactor
behind byte-equality — no user-visible change.

pkg/nomen: the reusable engine. A Composition (ordered Segments, each with
a trailing separator, optional wrap, and an omit/placeholder/literal
missing-rule) renders against a VarResolver and a RenderTarget. Targets
split into SanitiseValue (per-variable) + Finalise (whole-string + suffix)
so a human title and a sanitised filename are two targets of one
composition. VarCatalog + Validate guard stored compositions.

internal/services/namegen.go: paliad-side wiring — the two seed system-
default compositions that reproduce AutoSubmissionTitle (#155) and
submissionFileName (354) as DATA, their variable catalogs, the resolvers
(built from the existing submission_autoname helpers), and the artifact
registry binding artifact -> catalog -> target -> default.

Repointed call-sites: AutoSubmissionTitle and handlers.submissionFileName
are now thin wrappers rendering through the registry; the assembly logic
lives in the engine. Removed the hardcoded title/filename assembly and the
handler's Az.-folgt const (now the case_number segment's placeholder).

FLAG resolved (separators): the PRD sketched LEADING separators; that can't
reproduce #155's client-absent case (date must join forum with a space
while forum->opponent stays ' ./. '). Switched to TRAILING separators
(owned by the left segment) — the minimal faithful fix. PRD §2.1 annotated.

FLAG resolved (back-compat): the shipped composer_meta.filename_keyword
override still flows through the engine — live round-trip test green.

Acceptance: all existing #155/354 test matrices pass UNCHANGED (the
byte-equality gate); new pkg/nomen unit tests cover trailing-sep, the three
missing-rules, targets, and Validate; namegen_test validates the seeds
against their catalogs. go vet + go test ./... + bun build all clean.
2026-06-01 11:56:27 +02:00
mAi
385abc7a98 docs(prd): composable name/filename generator engine (t-paliad-355)
Engine (pkg/nomen) renders a name from (composition, var-bag, render-
target). Structured segments + string shorthand; omit/placeholder/literal
missing-rules; title vs filename targets. Both shipped schemes (#155
draft title, 354 export filename) fold in as data-driven seed defaults
with byte-equality as the acceptance gate. Non-project drafts get a
date-first <date> <keyword> name (m's immediate ask).

Settings built ON the dashboard-layout-spec precedent: system -> firm ->
user -> per-document precedence, validated jsonb spec. Project-level
deferred to v1.1 (storage path reserved on projects.metadata).

5-slice train: engine+faithful-refactor -> non-project fix -> user
override -> settings UX -> firm default. All 8 grilling questions
answered (matched recommendations), captured in the decisions section.
2026-06-01 11:42:50 +02:00
mAi
94adeeb8cb Merge: t-paliad-354 generated-doc filename <date> <keyword> (<case>) + user-replaceable keyword
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-06-01 10:42:25 +02:00
mAi
d834b36313 test(submissions): live-DB round-trip for filename_keyword composer_meta merge/clear (t-paliad-354)
Some checks are pending
Paliad CI gate / build (push) Waiting to run
Paliad CI gate / test-go (push) Waiting to run
Paliad CI gate / deploy (push) Blocked by required conditions
2026-06-01 10:40:55 +02:00
mAi
4092c889c4 feat(submissions): generated-doc filename <date> <keyword> (<case>) + user-replaceable keyword
Generated documents now download as "YYYY-MM-DD keyword (case number).docx"
(date first/sortable, case number bracketed) instead of the old
"rule-case-date.docx" shape.

- submissionFileName: date-led frame; keyword = user override > lang-aware
  rule name > "submission"; case number always bracketed, placeholder
  "Az. folgt" (named const) when the project has no Aktenzeichen.
- SanitiseSubmissionFileName hardened to fold the full Windows-reserved
  set (colon star question angle pipe) on top of slash/backslash, while
  preserving spaces + parentheses so the assembled frame stays
  human-facing yet filesystem-safe.
- User-replaceable keyword stored in the draft's composer_meta jsonb
  (filename_keyword, no migration). Editor gains a "Stichwort (Dateiname)"
  input that placeholders the auto rule name and persists via the draft
  PATCH path. One-click /generate has no draft row -> keeps auto keyword.

Tests: submissionFileName (full / no-AZ / override / EN / slash case-no /
blank override / empty rule), submissionFilenameKeyword, extended
sanitiser cases.

t-paliad-354
2026-06-01 10:35:23 +02:00
mAi
db1040968f Merge: t-paliad-352 submission draft auto-naming (m/paliad#155)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-31 15:29:32 +02:00
mAi
f292338919 feat(submissions): auto-name new drafts <date> <client>./.<forum>./.<opponent> (m/paliad#155)
New project-bound submission drafts now default to a sortable, legal-
convention title instead of the bare "Entwurf N" counter:

    <YYYY-MM-DD> <ClientName> ./. <ForumShort> ./. <OpponentName>

- Date leads (ISO, Europe/Berlin) so drafts list chronologically; " ./. "
  is the German legal "gegen" separator.
- Client = root 'client' ancestor of the project tree.
- Forum = proceeding-type jurisdiction (UPC/EPA/DPMA); German proceedings
  resolve to the deciding court (LG/OLG/BGH/BPatG) from the code tail.
- Opponent = primary opposing party, picked by our_side posture
  (active → defendant bucket, reactive → claimant bucket).
- Any segment that resolves empty is omitted with its leading separator;
  a project-less draft keeps the legacy "Entwurf N" scheme entirely.
- Create-time only: existing drafts are never renamed, and a lawyer's
  later manual rename via Update is untouched. Same-slot collisions
  de-duplicate with a " (N)" suffix.

Customization scope (per-user / firm / template, issue #155 Q4) is v1.1 —
the template is hardcoded in submission_autoname.go for now; the override
string is documented as the single extension point on AutoSubmissionTitle.

Example output:
  full:        2026-05-31 Bayer AG ./. UPC ./. Novartis Pharma
  no opponent: 2026-05-31 Bayer AG ./. BPatG
  no forum:    2026-05-31 Bayer AG ./. Novartis Pharma
  date only:   2026-05-31

AutoSubmissionTitle + segment resolvers are pure and table-tested
(submission_autoname_test.go); the Create flow is covered end-to-end
against real Postgres in submission_draft_autoname_live_test.go (gated
on TEST_DATABASE_URL).
2026-05-31 15:28:54 +02:00
mAi
2b240e7dd0 Merge: docs PRD schema corrections (planck feedback)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-31 15:16:55 +02:00
mAi
c945cbd330 docs(prd): fix 3 schema inaccuracies in litigation-planner PRD
planck flagged via mai report feedback (id 12301) after the B5+B6
verification round caught them:

- §5.4 'INSERT into paliad.project_parties' → real table is paliad.parties
- §5.4 'status=open' → real CHECK constraint allows pending/completed/cancelled/waived
- §7.4 listed verfahrensablauf-detail-mode.ts as dead code, but builder
  imports filterByDetailMode from it; struck through with KEEP note.

Code shipped (B5+B6) used the correct values throughout; this aligns
the historical PRD with reality so a future reader doesn't repeat the
verification time planck spent.
2026-05-31 15:16:55 +02:00
mAi
639ff4f672 Merge: t-paliad-350 B6 — mobile basic-read + dead U0-U4 cleanup + i18n finalise (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-29 20:52:06 +02:00
mAi
264cc39a6b chore(builder): B6 — mobile basic-read + dead U0-U4 cleanup + i18n finalise (t-paliad-350)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Litigation Builder slice B6 (m/paliad#153 PRD §7.1 + §7.4 + §10) — last
slice of the train; the Builder is now complete.

Mobile basic-read (<640px, PRD §10):
- builder.ts wireMobileGuard — a capture-phase document click listener that,
  only when matchMedia("(max-width:640px)") matches, intercepts taps on
  mutating affordances (rename/share/promote/new-scenario/add-proceeding +
  every in-triplet button/input/select), preventDefault+stopPropagation,
  and surfaces a "Auf größerem Bildschirm öffnen" toast. Desktop code paths
  are untouched (guard early-returns off-mobile). Column-triplets already
  collapse to a single column via the reused .fr-columns-view @640px rule;
  reading (open/switch scenarios, search, mode tabs) stays fully functional.
- global.css — .builder-mobile-toast + full-bleed modal on phones.

Dead U0-U4 catalog cleanup (PRD §7.4) — deleted, no remaining references
(grep "from.*fristenrechner-|from.*verfahrensablauf" shows only the kept
verfahrensablauf-core + verfahrensablauf-detail-mode the builder reuses):
- client/fristenrechner-mode-a.ts, fristenrechner-result.ts(+test),
  fristenrechner-wizard.ts(+test)
- client/verfahrensablauf.ts, client/views/event-card-choices.ts,
  client/views/verfahrensablauf-state.ts(+test)
- components/VerfahrensablaufBody.tsx
(/tools/fristenrechner + /tools/verfahrensablauf stay as 301 redirects to
/tools/procedures — handlers already redirectToProcedures.)

i18n finalised (DE+EN): removed 4 duplicate deadlines.party.* keys per
block (behaviour-preserving — the later, winning copy is kept) and added
the missing DE "cal.today". Codegen clean, no dupes, full DE/EN parity.

go build/vet clean; bun build + 227 frontend tests pass. Playwright-
verified: at 375px the triplet collapses to one column + the scenario
list reads, while "+ Verfahren hinzufügen" and "Teilen" are blocked
(toast shown, no action); at 1280px the same actions work normally.
2026-05-29 20:44:40 +02:00
mAi
28d860a07d Merge: t-paliad-350 B5 — share + promote-to-project wizard (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-29 20:37:37 +02:00
mAi
d913f4fc30 feat(builder): B5 — share + promote-to-project wizard (t-paliad-350)
Litigation Builder slice B5 (m/paliad#153 PRD §2.4 + §2.5 + §5.4 + §10).

Backend (internal/services/scenario_builder_service.go):
- ListSharedWithMe — scenarios shared read-only with the caller (the
  "Geteilt mit mir" bucket).
- PromoteScenario — transactional promote-to-project (PRD §10, no partial
  promotions). One Postgres tx: INSERT paliad.projects ('case',
  origin_scenario_id, proceeding_type_id + scenario_flags from the primary
  triplet) → creator team lead + wizard-selected colleagues → parties →
  deadlines (filed→completed, planned→pending with computed/actual date,
  skipped→none) → flip scenario to 'promoted' + promoted_project_id. The
  primary top-level proceeding + its spawned descendants form the one case
  file; additional standalone proceedings are reported via
  ProceedingsSkipped and stay in the scenario. Planned dates come from the
  injected FristenrechnerService.Calculate; court-set/undated planned
  events are skipped + counted.
- NewScenarioBuilderService gains a *FristenrechnerService dep (wired in
  cmd/server/main.go; nil in tests that don't promote).

Handlers/routes:
- GET /api/builder/scenarios/shared, POST /api/builder/scenarios/{id}/promote.

Frontend:
- builder-shares.ts — share modal (HLC user picker + current-shares list +
  revoke).
- builder-promote.ts — 3-step wizard (Bestätigen → Parteien ergänzen →
  Akte-Metadaten) → POST /promote → navigate to /projects/{id}.
- builder.ts — bucketed side panel (Aktiv / Geteilt mit mir / Als Projekt
  angelegt / Archiviert), read-only chrome (watermark + locked affordances)
  for shared/promoted scenarios, wired share + promote buttons, deep-link
  auto-load now covers shared scenarios.
- procedures.tsx — enabled buttons, bucket containers, readonly watermark slot.
- global.css — modal scaffold, share UI, promote wizard, buckets, readonly
  state. i18n.ts + i18n-keys.ts — DE+EN keys.

Tests: TestScenarioBuilderPromote (live-DB) pins the transactional cascade
+ readonly-after-promote + re-promote rejection. go build/vet/test + bun
build clean. Verified end-to-end via Playwright: Journey E (share → 2nd
user read-only watermark + locked canvas, incl. deep-link) and Journey D
(promote wizard 3 steps → project created with party → navigate → scenario
flipped to promoted).
2026-05-29 20:37:05 +02:00
mAi
e091716f48 Merge: t-paliad-349 docforge slice 8 — neutral model + Markdown importer + Exporter iface (m/paliad#157)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-29 18:11:39 +02:00
mAi
8763ab013c feat(docforge): slice 8 — neutral model + Markdown importer + Exporter iface (t-paliad-349)
The final slice: land the format-neutral document model with REAL consumers
and unify the Markdown parser — no duplication, byte-identical output.

Neutral model (pkg/docforge/model.go): Document / Block / InlineSpan.
BlockKind values are the stylemap keys. A hyperlink is a span with Link set
+ Children (the label's spans), preserving link boundaries so adjacent
same-URL links stay distinct — byte-exact with the pre-model walker.

Markdown importer (pkg/docforge/markdown): Import(md) → Document. The SINGLE
Markdown parser for docforge — block split, marker detection, inline
bold/italic/link tokenisation, {{placeholder}} pass-through (the b78a984
fix). Relocated out of the docx walker.

docx renderer (pkg/docforge/docx/markdown.go): now RENDERS a Document →
OOXML (RenderDocumentToOOXML); RenderMarkdownToOOXML[WithStyles] = render(
markdown.Import(md)). The shipped submission walker routes through the model,
so there is one parser, not two. The comprehensive byte-exact render tests
(RenderMarkdownToOOXML_*) all PASS unchanged = output identical.

Exporter interface (pkg/docforge/exporter.go, PRD §4 B4): Exporter{Format,
MIMEType, RenderBody(Document)} with the .docx impl (pkg/docforge/docx/
exporter.go). The seam a future PDF/HTML exporter slots into.

Tests: parser tests relocated to the markdown pkg (parseSpans/detectBlockMarker)
+ new importer Document tests + exporter conformance test.

Verification: go build/vet clean; gofmt clean; full NO-DB test suite GREEN
(authoritative — proves no regression); docforge byte-exact render oracle
PASS; composer live test renders through the rewired walker (PASS); bun build
+ bun test 274/274. The shared-DB live run fails ~85 tests across unrelated
services from a harness pq-42P08 $1-type seeding quirk + a stale
deadline_rules test — systemic/environmental (the no-DB run is clean), not
this change.

docforge train complete: 8 slices, the engine extracted + cleaned + a working
author→generate→export loop on uploaded templates, plus the neutral model +
importer + exporter seam for future formats/consumers.

m/paliad#157
2026-05-29 18:10:16 +02:00
mAi
e1e8db7fc9 Merge: t-paliad-349 docforge slice 7 — generation on uploaded templates (m/paliad#157)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-29 17:57:30 +02:00
mAi
b746ec36c7 feat(docforge): slice 7 — generation on uploaded templates (t-paliad-349)
A submission draft can now render from an uploaded docforge template
instead of a legacy Gitea base. DB-VERIFIED against TEST_DATABASE_URL (the
head greenlit option C) before commit — not just compiled.

Schema: migration 159 adds submission_drafts.template_version_id (nullable,
FK template_versions ON DELETE SET NULL) — the snapshot pin (PRD A3). A
later template edit creates a new version; the pinned draft keeps rendering
its version.

Draft service: TemplateVersionID on the model + draftColumns + the JOIN
list + DraftPatch (two-level pointer like base_id) + Update SET. Column-sync
verified live (Create_seeds_section_rows + the new pin test both pass).

Export/preview (handlers): a template-version path checked FIRST — load the
carrier via TemplateStore.GetVersion, render via the existing Export/
RenderPreview (the carrier already carries {{slots}}; no Composer/sections
needed). Falls through to base_id / v1 if the pin is missing. Both preview
sites + the view assembly branch on it.

Store: TemplateMeta.VersionID exposes the current version's row id (slice-4
gap — a consumer needs it to pin); populated in List/Get/GetVersion + the
authoring JSON. New GET /api/templates (authenticated, firm-filtered) is the
picker list any lawyer reads; admin authoring endpoints stay gated.

Frontend: the submission editor's base picker now offers uploaded templates
as a 'tpl:<version_id>' optgroup; selecting one PATCHes template_version_id
(clearing base_id) and vice versa — mutually exclusive render paths.

Live test (submission_draft_template_live_test.go, gated): pin round-trips
Update→Get, the uploaded carrier renders ({{firm.name}}→HLC via Export), and
clearing nulls it — all PASS against real Postgres.

Verification: go build/vet/gofmt clean; bun build + bun test 274/274; slice-7
+ slice-4 store + draft/composer live tests PASS against TEST_DATABASE_URL.
Pre-existing env failures (approval/projection seed $1-type quirk,
migration136 stale deadline_rules table) are unrelated — confirmed my branch
touches none of that code.

m/paliad#157
2026-05-29 17:55:31 +02:00
mAi
28aaafeb05 Merge: t-paliad-349 docforge slice 6c — authoring page (frontend) (m/paliad#157)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-29 16:08:46 +02:00
mAi
f9331e9bb9 Merge: t-paliad-349 docforge slice 6b — authoring HTTP endpoints (m/paliad#157) 2026-05-29 16:08:46 +02:00
mAi
e53bcf8cc2 Merge: t-paliad-349 docforge slice 6a — authoring core + TemplateStore wiring (m/paliad#157) 2026-05-29 16:08:46 +02:00
mAi
68fcbc6fbf feat(docforge): slice 6c — template authoring page (frontend) (t-paliad-349)
The WYSIWYG authoring surface at /admin/templates (admin-gated page route):
  - templates-authoring.tsx — page shell (upload form, template list,
    workspace: palette / run-addressable preview / placed slots).
  - client/templates-authoring.ts — hydrates it: lists templates, uploads a
    .docx (multipart), renders the run-span preview, builds the variable
    palette from the Go catalogue (GET /api/docforge/variables), and wires
    the select-then-pick gesture: select text within one .docforge-run, click
    a palette variable → POST the slot → re-render with the response. Reuses
    the docforge-editor lib (escapeHtml, catalogue client). Cross-run
    selections rejected with a hint (v1: single-run text slots).
  - build.ts emits dist/templates-authoring.html + bundles the client.
  - handleTemplatesAuthoringPage serves the shell; GET /admin/templates
    registered under adminGate.
  - 12 i18n keys (DE+EN) for the page; i18n-keys.ts regenerated (3079).

Verification: go build/vet/test green (13 pkgs); bun run build.ts clean
(i18n scan passes); bun test 274/274; gofmt-clean. The docx surgery + store
+ catalogue are unit/live-tested. VERIFICATION CEILING: the integrated live
flow (upload→render→select→inject→save in a browser) needs the app running
with DATABASE_URL + Supabase auth + Playwright — verified post-merge, not in
this env.

m/paliad#157
2026-05-29 16:07:43 +02:00
mAi
31e15d4b20 feat(docforge): slice 6b — template authoring HTTP endpoints (t-paliad-349)
Admin-gated authoring API over docforge.TemplateStore + the docx authoring
engine (handlers/templates.go, routes under adminGate):
  GET  /api/admin/templates            — catalog list
  POST /api/admin/templates            — multipart upload → ImportForAuthoring
                                          (validate + detect slots) → Create v1
  GET  /api/admin/templates/{id}       — authoring view (run-addressable
                                          preview + slots)
  POST /api/admin/templates/{id}/slots — InjectSlot at the selection →
                                          AddVersion (re-detect slots from the
                                          new carrier so template_slots mirror
                                          the carrier's actual {{tokens}})

docforge.ErrTemplateNotFound → 404; injection failures (bad selection/key)
→ 400 with the engine's message for the UI to surface. Upload capped at
10 MB. Slot placement creates a version per placement (immutable snapshot);
batching a session into one version on explicit save is a documented
refinement.

Verification: go build/vet clean, handlers test green, gofmt-clean. The docx
surgery + store are unit/live-tested; the integrated HTTP flow is verified
post-merge (needs DATABASE_URL + Supabase auth).

m/paliad#157
2026-05-29 16:03:07 +02:00
mAi
a111a82640 feat(docforge): slice 6a — docx authoring core + TemplateStore wiring (t-paliad-349)
The verifiable backend heart of the authoring surface, before the HTTP +
frontend layers.

pkg/docforge/docx/authoring.go:
  - ImportForAuthoring(carrier) → AuthoringView{PreviewHTML, Slots}: parses
    an uploaded .docx into a run-addressable preview (one
    <span class="docforge-run" data-run="N"> per <w:t>, document order)
    plus the {{placeholder}} slots already present.
  - InjectSlot(carrier, runIndex, selectedText, slotKey) → new carrier:
    replaces the selection inside run N with a {{slot_key}} token. Keys on
    the selected TEXT (not a byte/UTF-16 offset) so umlauts can't desync the
    client selection from the server slice; preview + injection walk runs in
    the identical paragraph→<w:t> order so data-run indices line up.
  - v1 scope: text slots in body paragraphs; out-of-run / cross-run / not-
    found selections return an error the UI turns into a hint.

6 unit tests cover run-addressable preview, slot detection, injection +
round-trip re-import, umlaut/run-targeting, and the error paths (selection
absent, out-of-range run, invalid slot key) — all passing.

Wired PgTemplateStore through the stack (main.go → handlers.Services →
dbServices) so the upcoming authoring endpoints can reach it.

Verification: go build/vet clean, full module test green (13 pkgs), new
files gofmt-clean. The HTTP endpoints + frontend authoring page land next;
their live flow needs the post-merge e2e/manual loop (DB+Supabase).

m/paliad#157
2026-05-29 16:00:27 +02:00
mAi
63a9bedf7e Merge: t-paliad-349 docforge slice 5 — editor pkg + variable catalogue SSOT (m/paliad#157)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-29 15:51:38 +02:00
mAi
b8709b903d feat(docforge): slice 5 — docforge-editor pkg + variable catalogue SSOT (t-paliad-349)
Establish the shared frontend editor package and make the Go resolvers the
single source of truth for variable labels.

Go — catalogue SSOT:
  - VariableResolver gains Keys() []VariableKey; ResolverSet gains
    Catalogue(). The 7 submission resolvers implement Keys() with the
    bilingual labels ported from the TS VARIABLE_LABELS table (incl. the
    legacy rule.* aliases). Keys() is entity-independent, so
    SubmissionVariableCatalogue() builds a metadata-only ResolverSet.
  - GET /api/docforge/variables serves the catalogue (auth-gated, static).
  - Tests: docforge ResolverSet (BuildBag merge + Catalogue order) and the
    submission catalogue integrity (no dupes, labels present, spot-checks).

Frontend — frontend/src/lib/docforge-editor/ (new shared package):
  - dom.ts: escapeHtml + cssEscape (pure), with bun tests. Dedupes the two
    identical escapeHtml/escapeHTML copies + the cssEscape copy that lived
    in the submission editor.
  - catalogue.ts: fetchVariableCatalogue() + labelMap() — the client for
    the Go catalogue.
  - submission-draft.ts now imports escapeHtml/cssEscape from the lib and
    fetches the catalogue on boot into state.varLabels (labelFor reads it,
    falling back to the raw key if the fetch fails — graceful degrade). The
    hardcoded VARIABLE_LABELS table is removed; VARIABLE_GROUPS stays
    (presentation: which keys to show + how to section them, legitimately
    frontend).

Scope note: the DOM-coupled editor plumbing (wireDraftVars/focus
preservation/autosave debounce) is extracted in slice 6 alongside its first
reuse — the authoring page — rather than speculatively now (extract with the
consumer; same principle as slices 2-3). Slice 5 lands the pure utilities +
the catalogue, which the slice-6 authoring palette consumes.

Verification: go build/vet/test green (Go files gofmt-clean; handlers.go
pre-existing drift, added region clean); bun run build.ts clean;
bun test 274/274 (incl. 5 new docforge-editor tests).

m/paliad#157
2026-05-29 15:50:42 +02:00
mAi
938222d602 Merge: t-paliad-349 docforge slice 4 — template tables + TemplateStore (m/paliad#157)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-29 15:36:46 +02:00
mAi
47deeaf5ed feat(docforge): slice 4 — template tables + TemplateStore (t-paliad-349)
Persistence foundation for authoring (slice 6) + generation-on-templates
(slice 7). docforge owns no tables — it defines the contract; paliad
implements it (litigationplanner pattern).

Migration 158_docforge_templates (additive, generic — NOT submission_*-named
so a second docforge consumer reuses it):
  - templates          — catalog row; current_version_id pins the live
                         version (FK added post-create to break the
                         templates<->versions cycle; ON DELETE SET NULL).
  - template_versions  — immutable snapshots; carrier .docx in a bytea
                         column (the TemplateStore bytea backend) + stylemap
                         jsonb. Versioning = snapshot-at-create (PRD A3).
  - template_slots     — variable slots per version; anchor = sentinel token
                         locating the slot in the carrier OOXML (PRD §5
                         lean), slot_key = the bound variable.
  RLS mirrors submission_bases: firm-shared SELECT for authenticated,
  mutations admin-only + gated in Go (no mutation policy = denied).

docforge root: TemplateStore interface + neutral types (TemplateMeta,
Template, TemplateSlot, *Input, TemplateFilter) + ErrTemplateNotFound.
CarrierBytes is format-opaque []byte so the root never imports the docx
adapter; the exporter wraps (CarrierBytes, Stylemap) into a docx.Carrier.

paliad: PgTemplateStore (sqlx, follows the submission_base_service pattern):
List / Get (current version) / GetVersion (pinned snapshot) / Create
(version 1 + pin) / AddVersion (next version + re-pin), all transactional.
Gated live round-trip test (TEST_DATABASE_URL) covers carrier+stylemap+slot
round-trip and the version bump. No handler wires this yet (PRD: no UI in
slice 4).

Verification: go build ./... clean, go vet clean, gofmt clean, full module
test green, migration NoDuplicateSlot structural test green.

m/paliad#157
2026-05-29 15:35:36 +02:00
mAi
a2da501917 Merge: t-paliad-349 docforge slice 3 — VariableResolver interface + ResolverSet (m/paliad#157)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-29 15:27:23 +02:00
mAi
8ea78fd376 refactor(docforge): slice 3 — VariableResolver interface + ResolverSet (t-paliad-349)
Move the variable-bag contract (PlaceholderMap, MissingPlaceholderFn,
DefaultMissingMarker) up to the pkg/docforge root (placeholder.go) — it is
format-neutral, consumed by the resolver layer and any future exporter.
The {{key}} substitution grammar (placeholderRegex, PUA preview sentinels,
replacePlaceholders) stays in pkg/docforge/docx: it is the .docx renderer's
own machinery, not a root concern.

New at the root (vars.go):
  - VariableResolver{Namespace() string; Populate(bag PlaceholderMap)} —
    a PUSH interface, deliberately not pull Resolve(key): some namespaces
    emit a data-dependent key set (parties.claimant.0.name, .1.name, … one
    per party) that a fixed key-by-key pull can't enumerate.
  - ResolverSet + BuildBag() — composes resolvers into one bag, replacing
    the hard-coded addFooVars-then-addBarVars sequencing in Build.

paliad side (submission_vars_resolvers.go): seven resolver types wrap the
UNCHANGED addXxxVars push-builders (firm/today/user/procedural_event/
project/parties/deadline), each capturing the entity it needs. The builder
bodies are byte-for-byte untouched, so the bag is identical by
construction; SubmissionVarsService.Build now wires the applicable
resolvers and calls ResolverSet.BuildBag(). Resolvers stay in paliad
because they read paliad's domain model; a second docforge consumer plugs
its own resolvers into a ResolverSet the same way.

Keys()/Catalogue() (the static key list that will data-drive the authoring
palette + kill the hardcoded VARIABLE_GROUPS in submission-draft.ts) is
deferred to the UI slice that consumes it, sourced from the frontend's
existing labels — building it now, ahead of its consumer, would be
speculative (PRD §4 B3 principle).

Verification: go build ./... clean, go vet clean, full module test green.
Alias-parity (procedural_event ≡ rule) and party-form tests pass unchanged
= bag byte-identical.

m/paliad#157
2026-05-29 15:16:02 +02:00
mAi
e189d3fe6a Merge: t-paliad-349 docforge slice 2 — composer + Carrier to pkg/docforge/docx (m/paliad#157)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-29 15:10:33 +02:00
mAi
58907554fc Merge: t-paliad-349 docforge slice 1 — extract .docx engine to pkg/docforge/docx (m/paliad#157) 2026-05-29 15:10:33 +02:00
mAi
9b8a865c5f Merge: t-paliad-349 — docforge PRD (m/paliad#157) 2026-05-29 15:10:33 +02:00
mAi
f8067c2fe5 refactor(docforge): slice 2 — composer to pkg/docforge/docx + Carrier (t-paliad-349)
Move the full compose pipeline (anchor-pair splicing, append-before-sectPr,
hyperlink-rels patching, zip split/repack, final placeholder pass) into
pkg/docforge/docx/compose.go, decoupled from paliad's DB row types. The
engine now owns the entire .docx assembly.

New neutral types in docx:
  - Carrier{Bytes, Stylemap} — the opaque base .docx, preserved
    byte-for-byte outside the spliced regions (the lossless docforge
    carrier for .docx).
  - Section{Key, OrderIndex, Included, ContentMDDE, ContentMDEN} — the
    format-neutral content input.
  - Composer / NewComposer / ComposeOptions on those neutral types.

internal/services keeps SubmissionComposer + ComposeOptions as a thin
mapping wrapper (SubmissionSection -> docx.Section, Base.SectionSpec.Stylemap
+ BaseBytes -> docx.Carrier). handlers + the comprehensive compose_test are
unchanged; the test drives the wrapper end-to-end and its byte-exact OOXML
assertions pass = behaviour preserved.

Retired the slice-1 docx.XMLAttrEscape wrapper + its services forwarder:
compose now calls the local xmlAttrEscape inside the docx package.

Sequencing note: the paragraph-level neutral model (Document/Block/Slot the
PRD §3.2 sketches) is deferred to slice 6, where the authoring importer +
format exporters consume it. Building it now, ahead of any consumer, would
be speculative and risk the byte-identical guarantee for no gain (PRD §4 B3
principle). Carrier is the part of the model that earns its keep this cycle.

Verification: go build ./... clean, go vet clean, full module test green.

m/paliad#157
2026-05-29 14:57:34 +02:00
mAi
78a30a7ee0 refactor(docforge): slice 1 — extract .docx engine to pkg/docforge/docx (t-paliad-349)
Relocate the in-house OOXML machinery out of internal/services into the
first docforge adapter, with zero behaviour change:

  submission_merge.go  -> pkg/docforge/docx/merge.go     (placeholder
                          substitution renderer + preview-HTML emitter)
  submission_md.go     -> pkg/docforge/docx/markdown.go  (Markdown->OOXML
                          walker incl. the b78a984 underscore-fix)
  submission_render.go -> pkg/docforge/docx/dotm.go      (.dotm->.docx)
  + their _test.go files (git-tracked renames, 84-99% identical)

internal/services keeps thin type-alias + forwarder shims
(docforge_shims.go) so every caller in services/handlers/main compiles
and behaves identically: PlaceholderMap, MissingPlaceholderFn,
SubmissionRenderer, HyperlinkAllocator (aliases); NewSubmissionRenderer,
DefaultMissingMarker, RenderMarkdownToOOXML[WithStyles], ConvertDotmToDocx,
SanitiseSubmissionFileName (forwarders). docx.XMLAttrEscape is exported so
submission_compose.go's hyperlink-rels inserts reuse the walker's escaping.

Three mis-filed pretty-printer tests (legalSourcePretty, ourSideDE/EN,
patentNumberUPC) that exercise the vars layer move back to
internal/services/submission_vars_pretty_test.go.

Placeholder grammar + PlaceholderMap stay co-located with the renderer in
docx for now; slice 3 hoists the format-neutral grammar to the docforge
root with the VariableResolver interface.

Verification: go build ./... clean, go vet clean, full module test green
(the byte-exact OOXML golden tests in merge/compose/render pass unchanged
= behaviour preserved). gofmt drift on the moved files is pre-existing
(72/169 services files already drift; no gofmt gate).

m/paliad#157
2026-05-29 14:51:59 +02:00
mAi
091804923a docs(docforge): PRD — modular doc-generator engine (t-paliad-349)
Extract the submission generator into pkg/docforge: neutral document model
+ opaque carrier (lossless .docx), VariableResolver interface per namespace,
pluggable importer/exporter (.docx first), WYSIWYG authoring page, generic
editor UI package. 8-slice train, extract-in-place migration that protects
the b78a984 underscore fix, the placeholderRegex + data-var contracts, and
the building-block/section model.

Includes all 13 of m's decisions (5 prose-grill metaphor + 8 structured).
upc-kommentar deferred as a live consumer (it is Bun/SvelteKit/TS, zero Go);
abstractions sized for a later HTTP veneer.

m/paliad#157
2026-05-29 14:33:26 +02:00
mAi
9201501941 Merge: t-paliad-348 — port engine semantics to TS calc + manuscript regen (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-28 11:03:58 +02:00
mAi
05247d7bd7 docs(exports): canonicalize deadline manuscript generator + filter optionals (t-paliad-348)
Some checks failed
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
m had a one-off /tmp/paliad-deadline-export.py (work/head delegation
#2572) that dumped every published sequencing_rules row. Output
showed 37 entries on upc.inf.cfi including optional rules
(Lodging of translations, Review of CMO, ...) which fights the
engine's IncludeOptional=false default and m's "naked proceeding
with options but not always displayed" mental model.

Move to exports/gen-deadline-list.py as the canonical re-runnable
script and add a SQL-level priority filter that matches the
engine. Default suppresses priority='optional'; --include-optional
opts back in for an exhaustive catalog dump.

- DSN overridable via PALIAD_DEADLINE_EXPORT_DSN env var.
- argparse-driven: --include-optional / -o OUT / --generated-for LABEL.
- Header explains the mode so the PA reader knows what's suppressed.
- Regenerated exports/upc-deadlines-2026-05-28.md: now 178 rules across
  25 proceedings (vs the unfiltered run). upc.inf.cfi section drops
  from ~37 to 28 mandatory + conditional rules - the optional ones
  are gone; trigger_event_id mandatory rules stay in the catalog
  (they're a real PA-knowable surface; runtime anchor state is what
  decides whether they project into a timeline, separate concern).

Run:
    uv run exports/gen-deadline-list.py [--include-optional]

(m/paliad#153)
2026-05-28 11:02:03 +02:00
mAi
a81581878e fix(builder): port engine semantics into Builder triplet calc surface (t-paliad-348)
The Litigation Builder triplet renders /api/tools/fristenrechner output
verbatim and never applied the pre-existing filterByDetailMode pass that
the legacy /tools/verfahrensablauf page uses. With the engine fix
(3c840c0 — pkg/litigationplanner default IncludeOptional=false + trigger
event semantic anchoring) already in main, optional rules are dropped
server-side but rules with an unsatisfied trigger_event_id surface as
IsConditional. Without filterByDetailMode those still rendered as
"abhängig von ..." cards on the triplet, polluting m's "naked
proceeding with options but not always displayed" mental model.

upc.inf.cfi went from 7 mandatory backbone events to 29 visible cards
(22 conditional noise — Lodging of translations, Mängelbeseitigung,
Antrag auf Verweisung, Wiedereinsetzung, ...). Live BEFORE/AFTER
captured in exports/screenshots/.

Fix layers:

- Go handler (internal/handlers/fristenrechner.go): accept
  includeOptional + triggerEventAnchors from request body and
  forward to services.CalcOptions. Default zero values match the
  engine defaults (suppress optionals + no fabricated dates for
  trigger_event_id rules), so the wire is unchanged when callers
  don't set them.

- TS calc surface (frontend/src/client/views/verfahrensablauf-core.ts):
  add the same two fields to CalcParams + forward in the fetch body;
  surface rulesAwaitingAnchor on DeadlineResponse mirroring
  Timeline.RulesAwaitingAnchor.

- Builder triplet (frontend/src/client/builder.ts hydrateTriplet):
  apply filterByDetailMode(detailgrad) before renderColumnsBody, with
  detailgrad sourced from the proceeding row. "selected" (default)
  drops conditional + optional rules; "all_options" passes
  includeOptional=true so the engine returns the optional rules the
  user can opt into.

- Legacy /tools/verfahrensablauf (frontend/src/client/verfahrensablauf.ts):
  pass includeOptional based on detailMode + a small hasOptionalOptIn
  helper so per-rule rule:<uuid>=true deviations still surface their
  optional rule even in "selected" mode (the engine has no rule:<uuid>
  awareness; without the opt-in the user's pick would silently no-op).

Tests:
- frontend/src/client/views/verfahrensablauf-core.test.ts: pin the
  fetch body shape - includeOptional=true and triggerEventAnchors={...}
  round-trip through the request; empty/default values are omitted so
  the wire stays minimal.

bun build + bun test (269 pass) + go vet + go test
./internal/handlers/... ./pkg/litigationplanner/... all clean.

(m/paliad#153)
2026-05-28 11:01:49 +02:00
mAi
8d8a882f46 Merge: t-paliad-347 B4 — Akte mode + project-backed scenarios (m/paliad#153)
Some checks failed
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
2026-05-28 10:45:14 +02:00
mAi
9679a98666 feat(builder): B4 — Akte mode + project-backed scenarios (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
PRD §2.3 + §10. Implements the dual-write rule (load-bearing
complexity per PRD §10): project-backed scenarios mirror flag
toggles to paliad.projects.scenario_flags and filed event states
to paliad.deadlines, while kontextfrei scenarios continue writing
only to paliad.scenario_events. Visible affordances: page-header
Akte picker, enabled "Aus Akte" mode tab, Akte banner on the
project-backed canvas, cross-surface scenario-flag-changed
dispatch + listener for live peer-surface coherence.

Backend
- ScenarioBuilderService takes ProjectService + ScenarioFlagsService
  deps so dual-write hits live tables.
- CreateScenarioFromProject seeds a scenario from a project: copies
  proceeding_type_id + scenario_flags, normalises our_side to the
  builder's binary claimant|defendant axis, surfaces existing
  rule-bound deadlines as scenario_events (filed when completed,
  planned otherwise).
- PatchProceeding on a project-backed top-level triplet dual-writes
  scenario_flags to projects.scenario_flags via flagDeltaFromBuilder.
- PatchEvent transitioning to state='filed' on a project-backed
  scenario upserts paliad.deadlines (status='completed', completed_
  at, source='rule') inside the same tx as the scenario_events
  UPDATE — canvas and project surfaces never diverge mid-flight.
- POST /api/builder/scenarios/from-project handler wires the entry
  point.

Frontend
- builder-akte.ts: project list fetch + dropdown render, Akte
  banner, createScenarioFromProject POST helper.
- builder.ts: mode branching — picking an Akte (search hit or
  page-header pick) creates a project-backed scenario and loads it;
  loaded scenarios reflect their origin_project_id on the picker +
  banner; flag toggles on Akte-backed top-level triplets dispatch
  scenario-flag-changed so the Verfahrensablauf strip / project
  surfaces refresh; the builder listens to inbound scenario-flag-
  changed and refetches its scenario when the changed project
  matches origin_project_id.
- procedures.tsx: enable the previously-disabled Aus Akte tab.
- i18n + CSS: builder.akte.banner.prefix key (DE+EN); lime-tinted
  banner styling.

Tests
- TestScenarioBuilderAkteDualWrite (live DB) pins the dual-write
  contract: Akte flag toggle → projects.scenario_flags updated,
  Akte filed event → deadlines row inserted; kontextfrei flag
  toggle leaves projects.scenario_flags untouched, kontextfrei
  filed event leaves deadlines untouched.
- Existing TestScenarioBuilderService passes against the new
  signature (nil deps short-circuit dual-write paths).

Verification: go test ./... + go vet ./... + bun run build all
clean. Playwright smoke against the static dist build confirms
the Akte tab + picker render correctly, fetchAkteProjects fires
on mount, and the scenario-flag-changed CustomEvent dispatches +
receives without runtime errors.

t-paliad-347
2026-05-28 10:44:33 +02:00
mAi
fcdfba209d Merge: t-paliad-346 B3 — event-triggered mode + universal search (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-28 10:11:05 +02:00
mAi
3e93e94d10 feat(builder): B3 — event-triggered mode + universal search (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
PRD §2.2 + §3.1: the page-header search box drives a typed dropdown
returning grouped event / scenario / project hits, and the "Ereignis"
entry mode is enabled. Picking an event creates a scratch scenario
with one triplet anchored on that event's proceeding type, with the
event card auto-anchored (lime band + "━━━━ DU BIST HIER ━━━━" divider
above the next-coming events).

Backend: new GET /api/builder/search reuses
DeadlineSearchService.SearchEvents for the events corpus (UPC v1),
filters owned scenarios by ILIKE on name, and reuses ProjectService.List
for the Akten group (team-RLS via visibilityPredicate). Each group is
capped independently (default 8 events / 5 scenarios / 5 projects, max
30). Missing services degrade gracefully — empty group, not 503.

Frontend: builder-search.ts owns the dropdown (debounced 180ms,
arrow-key navigation, Enter to pick, abort on next query). builder.ts
gains mode state ("cold" | "event" | "akte"), wires the mode bar +
search input, and runs applyAnchorHighlight after triplet hydration —
the helper finds the .fr-col-item with the picked rule_id, adds the
.builder-anchor-card lime band, and inserts a full-width
.builder-anchor-divider after the anchor's row in the columns grid
via JS row-index math (the grid is row-major with 3 header cells
+ 3-cells-per-row body).

Filter pill reset: setMode() clears the search input and closes the
dropdown when switching entry modes. Forum/proc/party/kind chips are
not yet rendered separately (they live in the search dropdown today);
the reset hook attaches there too when those land in a follow-up.

Verification:
  - bun build (frontend bundles + i18n scan clean)
  - go vet ./... + go test ./... (all packages pass)
  - Playwright: mode switch focuses search, debounced fetch fires,
    typed result groups render with N · M · K pluralization, event
    pick creates scratch scenario + adds proceeding, anchor card
    + DU BIST HIER divider render in the columns grid (screenshots
    confirmed visually)
2026-05-28 10:10:33 +02:00
mAi
28ea103260 Merge: t-paliad-345 — surface proceeding_type id so Builder add-proceeding works (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-28 09:50:57 +02:00
mAi
1c77cb6e67 fix(builder): surface proceeding_type id so add-proceeding POST works (t-paliad-345)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (pull_request) Has been cancelled
Paliad CI gate / test-go (pull_request) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / deploy (pull_request) Has been cancelled
The Litigation Builder's "+ Verfahren hinzufügen" silently failed in
prod after t-paliad-343 B2 shipped — clicking a Verfahren chip in the
picker did nothing, no visible error.

Root cause: the wire shape FristenrechnerType (the response of
/api/tools/proceeding-types) carried code+name+nameEN+group but not
id. Builder.ts mountAddProceedingPicker's callback POSTed
`{proceeding_type_id: meta.id}` to
/api/builder/scenarios/{id}/proceedings — meta.id was undefined,
JSON.stringify dropped the key, the server returned 400 ("invalid
input: proceeding_type_id is required"), and fetchJSON swallowed the
error to console. The user saw "nothing happens".

Fix:
- Add `ID int json:"id"` to lp.FristenrechnerType.
- SELECT id in FristenrechnerService.ListProceedings + Scan into the
  new field.
- Defensive guard in builder.ts openAddProceedingPicker — refuse to
  POST without a positive integer id and log a clear error, so a
  future wire-shape regression cannot recreate the silent-fail.
- Regression test in pkg/litigationplanner/types_wire_test.go pins the
  contract (id present in JSON, round-trips as integer).

Side-benefit: fristenrechner-wizard.ts:599-628 documented this exact
gap as a known limitation ("S5/follow-up can extend the wire shape to
include id"). That workaround can now be retired in a follow-up.

Refs m/paliad#153 (Litigation Builder)
2026-05-28 09:48:32 +02:00
mAi
1f6e586c63 Merge: t-paliad-344 — fix stale deadlines.rule_id refs + builder null-guards (m/paliad#154)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-28 00:48:17 +02:00
mAi
a4b865d6bd fix(builder): initialise scenario sub-arrays + client null-guard (t-paliad-344)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
GetScenarioDeep returned nil slices for proceedings/events/shares when
a scenario had zero rows, which Go's encoding/json serialises as `null`
rather than `[]`. The builder's renderCanvas then unconditionally calls
`state.active.proceedings.filter(...)` on a null and dies with
`procedures.js:101 TypeError: Cannot read properties of null
(reading 'filter')` — every cold-open scenario crashed the page before
the empty canvas could render.

Backend (root cause): initialise Proceedings / Events / Shares to empty
slices in BuilderScenarioDeep before SelectContext, so the wire shape
is always arrays. Existing rows still load via SelectContext, which
truncates the placeholder and refills from the DB.

Frontend (defence in depth): on loadScenario(), normalise each of the
three arrays to `[]` if the server response is not an array. Catches a
future regression (or an older deployed build) without re-introducing
the same crash class.

bun build clean, go vet + go test ./... green.
2026-05-28 00:47:19 +02:00
mAi
a905911cf4 fix(deadlines): restore /api/events deadline rail after mig 140 column drop (t-paliad-344)
Two SELECTs still referenced paliad.deadlines.rule_id after mig 140
(Slice B.4) dropped that column in favour of sequencing_rule_id:

  - internal/services/deadline_service.go:268 — DeadlineService.
    ListVisibleForUser. Powers /api/events?type=deadline (dashboard
    deadline rail, /deadlines page, every status bucket). Threw
    `pq: column f.rule_id does not exist` on every request → 500
    for any authenticated user hitting the dashboard.

  - internal/services/projection_service.go:1250 — collectActualsForOverrides.
    Same column on `paliad.deadlines d`. Logged once per projection
    pass (`ERROR service: projection: deadlines: ...`); aliased the
    rename to `rule_id` so the receiving struct tag still scans.

Live container logs confirmed the failure mode — a 60-row burst of
`pq: column f.rule_id does not exist at position 3:36 (42703)` starting
the minute the post-B0 container came up (mig 140 had applied to the
DB but the SELECT still used the dropped name). EXPLAIN against the
live schema after the edit plans cleanly; the LEFT JOIN to
paliad.deadline_rules_unified on sequencing_rule_id was already correct
(only the SELECT projection was stale).

Root cause: mig 140 commit (1129bab) renamed the JOIN to
`f.sequencing_rule_id` but left the SELECT clause on the older name.
The model tag is already `db:"sequencing_rule_id" json:"rule_id"`, so
the wire shape is unchanged — only the column reference flips.

bun build clean, go vet ./... clean, go test ./... green.
2026-05-28 00:47:08 +02:00
mAi
88c03e922f Merge: t-paliad-343 B2 — multi-triplet + spawn + per-event state (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-28 00:29:50 +02:00
mAi
6bcac2dd20 Merge: t-paliad-343 B1 — Litigation Builder shell + cold-open (m/paliad#153) 2026-05-28 00:29:50 +02:00
mAi
46dc4ec94b feat(builder): B2 — multi-triplet stack + spawn nesting + per-event state (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Builds on B1 (commit 6c1d8cc). After this slice a user can compose a
multi-proceeding scenario kontextfrei: stack proceedings, flip
perspective per-triplet, toggle scenario flags, auto-spawn child
proceedings on flag transitions, and mark individual event cards as
planned / filed / skipped — all auto-saved to paliad.scenario_*.

PRD §7.1 B2 acceptance shipped:
  - Multi-triplet stack: top-level proceedings sorted by ordinal,
    child proceedings nested inline with a left lime border.
  - Per-triplet controls bar: perspective radio (none / claimant /
    defendant), Detailgrad pill (selected / all options), Entfernen
    action. Each control PATCHes the proceeding row and re-renders the
    affected triplet.
  - Per-triplet flag strip: every paliad.scenario_flag_catalog row
    rendered as a checkbox, bound to scenario_proceedings.scenario_flags.
    Active flags also surface as chips in the triplet header for quick
    legibility.
  - Spawn nesting: when `with_ccr` flips ON on upc.inf.cfi the builder
    auto-POSTs an upc.ccr.cfi child proceeding linked via
    parent_scenario_proceeding_id; flip OFF deletes the child (events
    cascade via the schema). The SPAWN_MAP table is data-driven so
    future spawn flags slot in.
  - 3-state event cards (planned / filed / skipped):
    overlayEventStates walks the rendered .fr-col-item nodes (the
    data-rule-id hook added to verfahrensablauf-core in this slice)
    and stamps each card with data-builder-state + per-state action
    buttons (File / Skip / Reset to planned). Filed cards prompt for
    a date; skipped cards prompt for an optional reason. POSTs or
    PATCHes paliad.scenario_events keyed by sequencing_rule_id.
  - Per-card optional horizon chip: stores horizon_optional on the
    scenario_event row, increment / decrement chip on every card.
    The full surface awaits a calc-engine "optionals available"
    counter (PRD §3.4 follow-up); the persistence layer + UX hook are
    in place so the wiring lands without another schema touch.
  - Page-header Stichtag drives default dates for every triplet (the
    triplet's per-stichtag override path is wired but the per-triplet
    Stichtag input is a B3+ affordance).

verfahrensablauf-core.renderColumnsBody now stamps data-rule-id (and
data-submission-code as a future hook) on every .fr-col-item root —
non-breaking enhancement; the legacy /tools/* pages don't read either
attribute. Verified by re-running the existing 57-test suite.

Backend: one new read-only endpoint
GET /api/builder/scenario-flag-catalog passes through
ScenarioFlagsService.ListCatalog so the builder doesn't need a
per-project round-trip to render flag toggles.

bun run build clean (3050 i18n keys), go vet ./... clean, go test ./...
clean, frontend bun test (verfahrensablauf-core suite) 57 / 57 pass.
2026-05-28 00:28:48 +02:00
mAi
6c1d8cc0cf feat(builder): B1 — Litigation Builder shell + cold-open mode (m/paliad#153)
Replaces cronus's U0-U4 catalog at /tools/procedures with a
persistence-backed builder shell on top of B0's API surface
(/api/builder/scenarios/*, t-paliad-340).

PRD §7.1 B1 acceptance shipped:
  - Page header: scenario picker, name action, Akte picker stub,
    Stichtag input, search input, save status indicator.
  - Entry-mode radio (cold-open active; event-triggered + akte
    rendered disabled for B3/B4 layout stability).
  - Empty canvas with "Neues Szenario starten" CTA and a 5-most-recent
    list rendered when the user has saved scenarios.
  - Side panel "Meine Szenarien" with the Aktiv bucket; clicking an
    item loads the scenario into the canvas.
  - Add-proceeding inline picker (Forum chip row → Verfahren chip row
    → Hinzufügen). UPC v1; other forums chipped but disabled.
  - First proceeding triplet renders end-to-end via
    verfahrensablauf-core.calculateDeadlines + renderColumnsBody (the
    existing 3-column proaktiv|court|reaktiv body, read-only in B1).
  - Auto-save with 500ms debounce on name + stichtag patches; save
    status flips idle → saving → saved/error in the page header.

New client modules under frontend/src/client/:
  - builder.ts       — orchestrator (URL state, fetch, auto-save loop,
                       canvas render, scenario-list re-paint).
  - builder-picker.ts — inline Forum/Verfahren popover for the
                       add-proceeding affordance.
  - builder-triplet.ts — single-triplet header + body wrapper.

procedures.tsx rewritten as the shell scaffolding (sidebar, page
header, mode radio, two-column body); procedures.ts now boots the
builder instead of toggling the 4-tab catalog.

Legacy U0-U4 modules (verfahrensablauf.ts, verfahrensablauf-state.ts,
VerfahrensablaufBody.tsx, procedures' tab toggle in client/procedures.ts,
fristenrechner-* mounts) are no longer reachable from /tools/procedures
but kept in the tree for the B6 cleanup sweep per PRD §7.4.

i18n.ts grew 60 keys × 2 langs under builder.*. global.css grew a
self-contained .builder-* block at the file tail.

bun run build, go vet ./..., and go test ./... all green.
2026-05-28 00:20:46 +02:00
mAi
0c857026a2 Merge: pkg/litigationplanner respect trigger_event_id + suppress optional from default (yoUPC#178 + #2568/#2570)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-28 00:05:37 +02:00
mAi
3c840c0366 fix(litigationplanner): respect trigger_event_id + suppress optional from default (yoUPC#178 + #2568/#2570)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Two paired engine semantics fixes:

1. trigger_event_id is now the authoritative semantic anchor. When a
   rule carries trigger_event_id, the engine no longer falls back to
   the proceeding's trigger date — it resolves the anchor via
   CalcOptions.TriggerEventAnchors keyed by paliad.trigger_events.code.
   Missing anchor renders the rule as IsConditional (empty date) and
   propagates via courtSet so descendants also surface as conditional.
   Fixes the RoP.109.5 bug where the engine fabricated a date 2 weeks
   before the user's SoC instead of waiting for the oral_hearing date.

2. priority='optional' rules are suppressed from the default
   Calculate output. Callers (paliad /tools/procedures,
   youpc.org/deadlines) opt in via CalcOptions.IncludeOptional=true to
   restore the legacy "show optional applications" behaviour. The
   suppression cascades through skippedIDs so child rules drop too.

Wire shape additions:

  - CalcOptions.IncludeOptional bool
  - CalcOptions.TriggerEventAnchors map[string]string
  - Timeline.RulesAwaitingAnchor int (count of suppressed-by-missing-
    anchor rules, for caller telemetry / "N rules need an anchor" UX)

Existing before-court-set-anchor tests opt in to IncludeOptional=true
to preserve their non-optional-related test intent.

Refs: youpcorg/head delegations #2568 + #2570, m/paliad#153 (Litigation
Builder PRD path).
2026-05-28 00:04:30 +02:00
mAi
1b4b2e4758 Merge: submission-md placeholder underscores preserved
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-28 00:01:30 +02:00
mAi
b78a984a7c fix(submission-md): preserve {{...}} placeholders verbatim through inline scanner
The Markdown inline scanner (parseInlineSpans) treats _ and * as
italic delimiters. A placeholder like {{project.case_number}} fed
through the scanner had its underscores consumed as italic markers,
leaving {{project.casenumber}} in the composed OOXML. The v1
placeholder pass then looked up the wrong key, surfacing
[KEIN WERT: project.casenumber] in the preview. The form ↔ preview
highlighting also stopped working because data-var attributes
mismatched between the input (snake_case) and the rendered span
(stripped).

parseInlineSpans now detects {{ at the cursor and skips ahead to
the matching }}, copying the entire placeholder verbatim into the
current text run. Unmatched {{ falls through to the existing
character handling so legal prose with stray braces still renders.

Tests: regression test for underscored keys (single + multiple +
mixed-with-italics), direct guard on parseInlineSpans, and an
italic-around-placeholder structural test.
2026-05-28 00:01:30 +02:00
mAi
1844df3ae6 Merge: t-paliad-340 B0 — Scenario DB foundation (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-27 23:53:49 +02:00
mAi
0f3c30a647 feat(scenario-builder): B0 schema foundation + minimal API (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
t-paliad-340 — B0 of edison's 7-slice train (PRD §7.1). DB-only: schema
+ RLS land, dev-only test route exercises the surface, no user-facing
change. B1 wires the actual builder UI on top.

Migration 157 (additive on the legacy mig-145 scenarios table — 0 rows
in prod, safe to relax):
- paliad.scenarios gets owner_id / status / origin_project_id /
  promoted_project_id / stichtag / notes. spec drops NOT NULL and the
  scenarios_unique_per_scope constraint drops (the builder allows
  multiple scratch + Unbenanntes Szenario rows per user).
- New tables: scenario_proceedings, scenario_events, scenario_shares.
- paliad.projects.origin_scenario_id for the promote-to-project audit
  trail (the FK lands now; the wizard ships in B5).
- paliad.can_see_scenario(uuid) STABLE SECURITY DEFINER helper covering
  owner / share / global_admin / two legacy paths.
- Replacement RLS on scenarios + RLS on the three new tables; legacy
  service + handlers stay live and unchanged.

PRD §5.1 deviations called out in the migration header:
- proceeding_type_id is integer (live schema), not uuid (PRD draft).
- FK target is paliad.users, matching the rest of paliad's schema.

Go surface:
- ScenarioBuilderService — list/create/get-deep/patch scenarios,
  add/patch/delete proceedings, add/patch/delete events,
  add/delete shares. Writes wrap in transactions with set_config(
  paliad.audit_reason, ..., true) per event_choice_service.go pattern.
- /api/builder/scenarios/* — handlers register under a builder/
  prefix so the legacy /api/scenarios surface still works.
- /dev/scenario-builder — single-page HTML form gated to
  PaliadinOwnerEmail, exercises the B0 surface without Postman.
- Live-DB integration test (TEST_DATABASE_URL gated) covers
  create + list + deep-get + share + visibility negatives + patch.

Audit-first: every DDL block ran clean via BEGIN/ROLLBACK against
the live DB before commit; end-to-end sanity (insert chain + CHECK
constraints + CASCADE-on-delete) verified via the Supabase MCP.

bun build clean. go vet + go test -short ./... green.
2026-05-27 23:50:14 +02:00
mAi
2c2b93bc7c Merge: t-paliad-339 — PRD for Procedures Litigation Builder (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-27 23:04:07 +02:00
mAi
661d87273c docs(plans): PRD — Procedures Litigation Builder (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
PRD for the columnar litigation planner replacing today's 4-tab catalog
at /tools/procedures with a Litigation Builder backed by a new Scenario
DB. Captures 20 chip-picker decisions (5 batches via AskUserQuestion)
covering: unified-builder shape with 3 entry modes (cold-open /
event-triggered / Akte), separate paliad.scenarios table with
multi-proceeding constellations, auto-save + named-list, per-proceeding
flags + perspective + Detailgrad, 3-state event cards
(planned/filed/skipped), per-event-card optional horizon, vertical
stacked column-triplets with inline spawn children, universal search
(events + scenarios + Akten), 3-step promote-to-project wizard,
read-only team sharing, desktop v1 + mobile basic-read.

Includes data model deltas (4 new tables + 1 column on
paliad.projects), 6-slice migration plan from the current live U0-U4
catalog, and coder hand-off notes. Cross-proceeding peer triggers and
DE/EPA/DPMA full expansion deferred to v1.1.
2026-05-27 23:00:24 +02:00
mAi
ed3c5d1f32 Revert "Merge: t-paliad-338 T1 — workflow-tracker shell replaces catalog (m/paliad#152)"
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
This reverts commit 2e6427dca6, reversing
changes made to 9fe06094a8.
2026-05-27 22:09:06 +02:00
mAi
be570c2fd0 Revert "Merge: t-paliad-338 T3-T5 — workflow-tracker Akte/polish/cleanup (m/paliad#152)"
This reverts commit 6506d7d862, reversing
changes made to 2e6427dca6.
2026-05-27 22:09:06 +02:00
mAi
58692513a8 Revert "Merge: tracker filter pill dark-mode contrast (m/paliad#152)"
This reverts commit 702f786771, reversing
changes made to 6506d7d862.
2026-05-27 22:09:06 +02:00
mAi
702f786771 Merge: tracker filter pill dark-mode contrast (m/paliad#152)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-27 22:03:00 +02:00
mAi
93c664c865 fix(procedures): tracker filter pill — dark-mode contrast (m/paliad#152)
Selected .tracker-pill.is-active used --color-accent-fg, which in dark
mode resolves to lime → lime text on lime background, unreadable.
Switch to --color-accent-dark (midnight in both modes) so the selected
pill has midnight text on lime in both light + dark. Same pattern as
the older .filter-pill.active rule.
2026-05-27 22:03:00 +02:00
mAi
6506d7d862 Merge: t-paliad-338 T3-T5 — workflow-tracker Akte/polish/cleanup (m/paliad#152)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-27 22:00:03 +02:00
mAi
73f49c46ed chore(procedures): T5 — drop dead code from the U0-U4 catalog (m/paliad#152)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
The workflow tracker (T1-T4) replaces every consumer of the entry-mode
modules. Verified via grep that no non-deleted file imports the
following before removal:

Deleted (10 files):
  - client/fristenrechner-mode-a.ts (Mode A search panel)
  - client/fristenrechner-wizard.ts (Mode B guided wizard)
  - client/fristenrechner-wizard.test.ts
  - client/fristenrechner-result.ts (post-commit result-view)
  - client/fristenrechner-result.test.ts
  - client/verfahrensablauf.ts (Verfahrensablauf panel client)
  - client/views/event-card-choices.ts (per-card choice popover —
    only verfahrensablauf.ts consumed it)
  - client/views/verfahrensablauf-state.ts (URL + storage helpers —
    only verfahrensablauf.ts consumed it)
  - client/views/verfahrensablauf-state.test.ts
  - components/VerfahrensablaufBody.tsx (the 4-tab proceeding picker
    body — no consumer after T1)

Kept (still load-bearing):
  - client/views/verfahrensablauf-core.ts — procedures-tracker uses
    calculateDeadlines + CalculatedDeadline + escHtml + formatDate.
  - client/views/verfahrensablauf-core.test.ts
  - client/verfahrensablauf-detail-mode.ts — procedures-tracker uses
    filterByDetailMode under the per-proceeding "Alle Optionen"
    toggle (T4).
  - client/verfahrensablauf-detail-mode.test.ts

The .css classes (.fristen-wizard-*, .verfahrensablauf-*) still live
in global.css; they're cheap orphans (no selector match in the new
DOM) and a CSS housekeeping pass is outside this train's scope. The
i18n keys (deadlines.flag.*, deadlines.detail.*, deadlines.view.*,
deadlines.side.*) likewise stay — some are used dynamically via tDyn
on the tracker, others remain candidates for a future i18n sweep.

Frontend tests: 217 pass (264 → 217, the deltas are the 3 deleted
test files: fristenrechner-result, fristenrechner-wizard,
verfahrensablauf-state). Build + go vet clean.

t-paliad-338
2026-05-27 21:57:51 +02:00
mAi
c80723fc85 feat(procedures): T4 — appeal-target + Alle Optionen + cross-party + polish (m/paliad#152)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
The final tracker layer per design §3.4 / §3.6 / §11 polish list:

- Per-proceeding "· Gewählt · / Alle Optionen" toggle (§3.4) lives in
  the card header next to the show/hide button. State persists in
  localStorage per proceeding code, so a page with multiple cards
  can keep one expanded without affecting siblings. Toggle drives the
  detail mode for filterByDetailMode + sets includeHidden=true on the
  calc, so previously-skipped conditional rules re-surface muted.
- Appeal-target chip group (§3.2 #3) renders below the header on
  proceedings with applies_to_target rules — today only upc.apl.unified.
  Endentscheidung / Kostenentscheidung / Anordnung / Schadensbemessung
  / Bucheinsicht. Picking a target re-fetches the calc with the
  appealTarget param so the timeline narrows to the matching subset.
- Cross-party muted treatment (§3.6) — when the find-header Partei
  pill is set, rows whose primary_party is the opposite side render
  with a "Gegen." badge and a muted style. Court / both / informational
  rows are never cross-party.
- "Unselected" + "hidden" styling — under "Alle Optionen" the rules
  that filterByDetailMode stamps __detailUnselected on render dotted
  italic, and previously-skipped (isHidden) rules render at reduced
  opacity. Honest preview of what the user is NOT considering.
- Cross-surface scenario-flag-changed listener — the tracker now reseeds
  its flags state when Mode B / Verfahrensablauf / Verlauf patches the
  same project's flags, so toggling there flows through here without a
  refresh.

Out of T4 (court-set choices_offered chip groups and the court-set date
override from appointments) — those need a follow-up backend pass to
surface the choicesOffered payload on TimelineEntry through the calc
response in a usable shape. The data field exists on CalculatedDeadline
but isn't yet wired to a paint route on the tracker.

t-paliad-338
2026-05-27 21:55:52 +02:00
mAi
1ed75c56e3 feat(procedures): T3 — Akte landing + actuals overlay (m/paliad#152)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Wires the workflow tracker to projects via ?project=<uuid>, per design
§6.4 + §11.Q5:

- loadAkte fetches /api/projects/{id}, /api/projects/{id}/timeline
  and /api/projects/{id}/scenario-flags in parallel:
    1. Project title + proceeding_type — pre-seeds the Verfahren pill.
    2. Timeline events → ActualsMap keyed by deadline_rule_id with
       status (done / overdue / open / court_set), due / completed
       date, and deadline / appointment ids.
    3. scenario_flags → seeds state.flags so the gating-flag checkboxes
       render in the persisted state. Per-rule rule:<uuid> flags stay
       out of the calc payload (they drive priority deviations via
       isRuleSelected, handled by the existing detail-mode filter).
- Auto-pin: the first render with no explicit ?event= pins the most
  recent status='done' deadline. URL pin (shared link) is preserved.
- Per-node overlay: each node carries the actuals badge — ✓ (done +
  strike-through), ⚠ (overdue + red wash), 📅 (open ≠ projected), ◇
  (open ≡ projected). Date column shows the actual date.
- Fork write-back: PATCH /api/projects/{id}/scenario-flags fires on
  every flag toggle so Mode B / Verlauf / dashboard re-render with the
  same scenario on next visit. Fire-and-forget; UI doesn't wait.
- Find-header summary chips: "Akte: <title>" alongside "Anker: <name>"
  + "{n} Verfahren".

Out of T3 (deferred):
- ?project= picker UI (today's user navigates here from /projects/{id}
  via deep-link).
- Per-rule rule:<uuid> flag write-back (priority deviations) — the
  detail-mode filter doesn't take an interactive toggle yet.
- Cross-surface scenario-flag-changed CustomEvent listener — patching
  fires the event, the tracker just doesn't yet re-render on incoming
  ones (T4 polish).

t-paliad-338
2026-05-27 21:51:42 +02:00
mAi
2e6427dca6 Merge: t-paliad-338 T1 — workflow-tracker shell replaces catalog (m/paliad#152)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-27 21:46:57 +02:00
mAi
7945bfb364 feat(procedures): T2 — anchor pin + zoom + multi-proceeding scope (m/paliad#152)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Layers the anchor / focus interactivity on top of T1's shell per
design §6.1–§6.5:

- Click-to-pin (📌) on every node with a real rule_id sets the anchor.
  Clicking the already-anchored pin un-pins. URL state ?event=<id>.
- Anchored node renders with a "── DU BIST HIER ──" divider beneath
  its meta line + the lime left-band styling. The find-header summary
  surfaces "Anker: <name>" so the user can confirm where they are.
- Fokus chip (🔍) on the anchored node toggles zoom (?zoom=1). Zoom
  renders the anchor's parent chain as a breadcrumb at the top of the
  proceeding card and renders only the anchored subtree below. A
  "{n} weitere Schritte verborgen" footer reports what zoom hid.
- Multi-proceeding scope (§6.5): when an anchor is pinned and >1
  proceeding is visible, non-anchored proceedings auto-collapse to a
  one-line header card with a [zeigen] / [ausblenden] toggle. The
  user's explicit expansions persist for the current anchor; pinning
  a different node clears them.
- Auto-pinning from the search input (T1's single-hit behaviour) now
  routes through onAnchorChanged so the multi-proc scope kicks in
  consistently.

Anchor + zoom state writes through history.replaceState — sharable URL.
Un-pinning clears zoom and restores the full multi-proceeding view
automatically (lastAnchor tracking).

t-paliad-338
2026-05-27 21:46:54 +02:00
mAi
bfb38aab41 feat(procedures): T1 — workflow-tracker shell replaces catalog (m/paliad#152)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Direct-replace per m's Q7 divergent pick in atlas's design
(docs/design-procedures-workflow-tracker-2026-05-27.md §9): /tools/procedures
drops the 4-tab catalog (U0-U4 shipped this morning) for the single
canonical workflow-tracker shape.

T1 ships:
- Sticky find header — search input, forum / Verfahren / Partei pill
  rows, global Stichtag, live result summary.
- Per-proceeding timeline cards — one card per matched proceeding,
  rendered as a chained tree by parent_id with priority-styled bullets
  (mandatory solid, recommended muted, optional dotted, informational
  faded, court-set blue). Party badge per node.
- Cold-open default: the 6 curated proceedings from design §8 / §11.Q4
  (upc.inf.cfi, upc.rev.cfi, upc.apl.unified, de.inf.lg, epa.opp.opd,
  dpma.opp.dpma) render stacked with a hint above.
- Scenario-flag forks — per-proceeding "Optionen" strip on each card's
  header surfaces the applicable flags (with_ccr, with_amend, with_cci)
  derived from condition_expr or a fallback map. Tick re-runs the calc.
- URL state: ?q, ?forum, ?procs, ?party, ?trigger_date, ?event, ?flags.
  ?event= scroll-highlights the matching node (no zoom yet — T2 layers).
- Legacy ?mode= dropped silently on first state write so bookmarks
  self-clean. /tools/fristenrechner + /tools/verfahrensablauf 301s
  still resolve here.

Floor T1 honours: every catalog workflow it replaces — pick proceeding
(forum + Verfahren pills), search event (search input → auto-narrow +
?event= anchor), wizard narrowing (pills compose), Akte entry
(?project= read-only for T1; full overlay in T3).

Per-node fork placement (the design's stated final shape — checkbox on
the gating node itself, not a card-level strip) is a T2 refinement;
T1 keeps forks scoped per proceeding so they're not the global-page
strip m's bug #5 flagged.

Aux-proceedings inline-expandable (design §5) and the appeal-target
chip group are scoped to T4; the calculator currently doesn't surface
isSpawn / spawnProceedingCode through TimelineEntry to support them.

t-paliad-338
2026-05-27 21:42:31 +02:00
mAi
9fe06094a8 Merge: t-paliad-337 — workflow-tracker design for /tools/procedures (m/paliad#152)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
atlas shipped the workflow-tracker design after m's 21:01 grilling-round reframe (single timeline-with-forks, find=search+pills+result-timelines, aux inline, zoom from within full tree). 510-line doc, 2 rewrite iterations.

7 Qs answered in 2 batches (4+3). 5 on-recommendation, 2 divergent:
- Q3 (divergent): multi-proceeding anchor scope — auto-collapse other proceedings to header-only (new §6.5)
- Q7 (divergent): migration strategy — direct replace at T1, no feature flag (§9)

4-slice + cleanup train. T1 ships minimum-viable tracker visibly at /tools/procedures, replacing the catalog UI knuth shipped today.

Inventor parks. Head dispatches Sonnet coder (NOT atlas per project memory directive).
2026-05-27 21:14:11 +02:00
mAi
c8f310c62c design(procedures-tracker): fold m's 7 picks — §11 decisions + §6.5 multi-tl anchor + §9 direct-replace migration (t-paliad-337)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
5 picks on-recommendation, 2 diverged:

Q3 (multi-proceeding anchor scope): m picked 'other timelines auto-collapse to header-only' over the recommended 'stay expanded'. Added §6.5 with the header-card render rule.

Q7 (migration cadence): m picked 'direct replace at T1, no flag' over the recommended flag-gated dev. §9 rewritten end-to-end: T1 ships the minimum-viable tracker visibly to users, replacing the catalog UI in the same PR. T2-T4 layer zoom + Akte + polish. T5 is cleanup-only.

The 5 on-rec picks: inline checkbox forks (Q1), sibling-collapse zoom (Q2), 6 curated defaults on cold open (Q4), latest-done-deadline Akte anchor (Q5), global Stichtag (Q6) — all locked as drafted in §1-§8.

Ready for review. Coder gate held; head decides T1 hire.
2026-05-27 21:13:35 +02:00
mAi
7554e86673 design(procedures-tracker): rewrite after m's reframe — single timeline-tree, inline forks, no view toggle (t-paliad-337)
m's grilling-round answers (2026-05-27 21:01):
1. One canonical view (full timeline/tree); zoom is an interaction on it, not a separate view.
2. Forks = everything: scenario flags + optionals + appeal-target + court-set picks.
3. Find = combined affordance: text + pills + result-timelines are one thing.
4. Aux proceedings inline as expandable child timelines.

Doc rewritten end-to-end. The first draft's three-view toggle + Konstellationen drawer + Querverweise drawer + split-pane/breadcrumb apparatus collapses into:
- Sticky find header (search + pill rows + Akte/date)
- N matched proceedings rendered as inline-forked timeline-trees
- Anchor pin + opt-in zoom mode (collapses siblings, breadcrumb back)
- Aux proceedings expand inline at the spawn point

7 open questions in 4+3 batches for AskUserQuestion. T1-T5 migration unchanged in spirit.
2026-05-27 21:05:41 +02:00
mAi
23b151c0f3 design(procedures-tracker): t-paliad-337 shift-1 — workflow-tracker layer for /tools/procedures (m/paliad#152)
m's reframe (2026-05-27 20:43): /tools/procedures should be a workflow
tracker, not a catalog browser. Pick any procedural event, see backward
(predecessors) + self (where I am) + forward (successors), with
scenario_flags as togglable predicates and alternative constellations
explorable.

This shift-1 doc covers:
- 4-tab UX redo (single-pane radio-revealed entry form to fix the
  pre-form-leak bug)
- Anchor visualisation (vertical waterfall with anchor at centre line)
- Three views — Anchor / Verfahren / Konstellationen — toggle preserves
  anchor + scenario state
- Forward walk (current constellation only by default, conditional
  reveal toggle, view-mode toggle reused from atlas P3)
- Backward walk (3 hops default, Akte mode overlays paliad.deadlines
  actuals onto template chain)
- Compound rules drawer (per-anchor Querverweise affordance — column
  shape owned by curie editorial workstream)
- Constellation viewer (inline per-flag preview drawer + full
  Constellation view for browse)
- Akte entry (anchor derives from latest completed deadline)
- Migration: T1-T5 flag-gated dev under ?tracker=1, then hard-cut

Coder gate held. 11 open questions for m staged for AskUserQuestion in
4+4+3 batches. Decisions append as §13 before the
TRACKER DESIGN READY FOR REVIEW signal.
2026-05-27 20:56:51 +02:00
mAi
1718ea2eae Merge: t-paliad-335 — unified /tools/procedures shipped U0-U4 (m/paliad#151)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
knuth shipped all 5 slices in one shift, per cronus's design:

U0 60907e7 — skeleton: new /tools/procedures route + page shell + 4 entry tabs + filter strip with search box + tree+linear-drawer scaffold
U1 0568d34 — fold Mode A (Direkt suchen) — porting fristenrechner-mode-a.ts
U2 c8261da — fold Mode B (Geführt wizard) — porting fristenrechner-wizard.ts
U3 48a07ef — fold Verfahrensablauf tree + 3-way detail filter — porting verfahrensablauf.ts + detail-mode.ts
U4 39c8ef3 — hard-cut 301: /tools/fristenrechner + /tools/verfahrensablauf → /tools/procedures; retire dual surfaces

Net: -4,452 LoC across 20 files (consolidating + dropping legacy). bun build clean, 264 frontend tests pass, go vet + go test ./... clean.

Comment posted on m/paliad#151. Per-project scenario_flags binding from Phase 2 P0 still drives the unified tool's per-rule chips.
2026-05-27 20:35:44 +02:00
mAi
39c8ef343b feat(procedures): U4 hard-cut legacy URLs + retire dual surfaces (m/paliad#151)
Per m's Q11 divergence in the design (no 2-week dual-ship), this slice
flips /tools/fristenrechner and /tools/verfahrensablauf to permanent 301
redirects to /tools/procedures and deletes the legacy frontend pages.
Bookmarks resolve via Location preservation of query params; no
?legacy=1 escape, no in-product affordance pointed back at the retired
URLs after the merge.

Server:
- handleFristenrechnerPage + handleVerfahrensablaufPage now 301 to
  /tools/procedures, carrying any query string through unchanged.
- pillDrillURL in deadline_search_service.go retargets to
  /tools/procedures so freshly indexed search pills land on the new
  page directly (cached snapshots still work via the 301).

Frontend:
- Deleted src/fristenrechner.tsx, src/verfahrensablauf.tsx,
  src/client/fristenrechner.ts.
- src/client/verfahrensablauf.ts loses its DOMContentLoaded auto-boot
  and the now-unused initI18n / initSidebar imports; procedures.ts is
  the sole caller of initVerfahrensablauf().
- frontend/build.ts drops the legacy entrypoints and renderXxx HTML
  outputs.
- Sidebar.tsx, Header.tsx, index.tsx, paliadin-context.ts repointed
  to /tools/procedures.
- Unused nav.fristenrechner / nav.verfahrensablauf /
  tools.verfahrensablauf.* i18n keys removed.

Tests:
- verfahrensablauf_test.go rewritten to assert both legacy URLs return
  301 with the correct Location (query string preserved).
2026-05-27 20:34:54 +02:00
mAi
48a07ef4ef feat(procedures): U3 fold Verfahrensablauf tree + 3-way detail filter (m/paliad#151)
Mounts the full Verfahrensablauf wizard — proceeding picker, perspective
chooser, date inputs, scenario flag rows, detail-mode toggle, view
toggle, timeline-container — under the /tools/procedures "Verfahren
wählen" tab. Per-rule scenario_flags chips (P0 SSoT) and the
Aufnehmen/Entfernen affordances reach the unified page unchanged since
they're delegated handlers on the timeline-container.

Refactor steps:
- Extracted the wizard body markup into a shared TSX component
  (components/VerfahrensablaufBody) used by both verfahrensablauf.tsx
  (legacy) and procedures.tsx (unified). U4 will retire the legacy
  page; the shared component lets U3 ship without code duplication.
- Lifted the verfahrensablauf.ts DOMContentLoaded body into
  initVerfahrensablauf() and re-exported it. The legacy auto-boot
  stays in place but skips itself when #procedures-panel-proceeding
  is present, so the unified page imports the module without
  double-init. procedures.ts calls initVerfahrensablauf() the first
  time the proceeding tab activates, gated by a one-shot flag to
  preserve module-local selectedType / lastResponse across tab
  toggles.
2026-05-27 20:29:05 +02:00
mAi
bb3d7aabd7 Merge: hide archived from admin/procedural-events default view
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-27 20:27:45 +02:00
mAi
c8390dd02a fix(admin-rules-list): default lifecycle filter to 'published' (hide archived clutter)
m flagged 2026-05-27 20:26: archived rules (e.g. the 5 mig 152 Mängelbeseitigung clones) clutter the /admin/procedural-events default view. They were correctly archived by mig 152 but visually noisy alongside active rules.

Fix: default activeLifecycle = 'published'. The 'Alle' chip still exists for when the user wants to see drafts + archived; 'Archived' chip surfaces them on demand. Initial view shows only the active corpus.
2026-05-27 20:27:45 +02:00
mAi
c8261da492 feat(procedures): U2 fold Mode B (Geführt wizard) (m/paliad#151)
Mounts mountWizard() into #procedures-panel-wizard when the Geführt tab
activates. Same 5-row wizard, same backend (event search + follow-ups
probe) as the legacy /tools/fristenrechner. On R4 launchResult, the
wizard hands off to mountResultView which renders into the same
overhaul-root inside the panel.

The wizard renders into #fristen-overhaul-mode-host while Mode A and
the result view write into #fristen-overhaul-root. To keep those IDs
unique in the DOM — both modes look up via document.getElementById —
the host scaffold is no longer static on the search panel. The new
installOverhaulHost() helper tears down any existing host and installs
a fresh one inside the active tab's panel before each mount, so two
parallel hosts can't cross-wire when the user toggles between the
Direkt-suchen and Geführt tabs.

The U1/U2 placeholders are dropped from the panel markup since the
panels are populated dynamically now.
2026-05-27 20:23:23 +02:00
mAi
0568d340a7 feat(procedures): U1 fold Mode A (Direkt suchen) (m/paliad#151)
Mounts mountModeA() into #procedures-panel-search when the Direkt-suchen
tab activates. The legacy fristenrechner-mode-a code runs unchanged
inside a wrapper that reseeds the #fristen-overhaul-root /
#fristen-overhaul-mode-host scaffold on every tab activation, so
re-clicking the tab always restores a fresh Mode A surface even if the
previous interaction committed an event into the result view.

`?event=<code>` deep links still resolve: boot detects the param,
activates the search tab, and hands directly to mountResultView() —
the result lands inside the same root, the user sees the picked
event's follow-up rules with the Direkt-suchen tab as the visible
context.

Search-box-in-filter-strip composition with chip filters (m's Q3
divergence) lands later, after Mode B + Verfahrensablauf are folded —
the unified state machine pulls all three behind one search input.
2026-05-27 20:21:39 +02:00
mAi
60907e7153 feat(procedures): U0 skeleton — /tools/procedures page shell (m/paliad#151)
First slice of the unified procedural-events tool train. Ships only the
page chrome — route, sidebar/header, filter strip with search box, four
entry-mode tabs (Verfahren wählen / Direkt suchen / Geführt / Aus Akte),
and the host containers later slices mount their UI into. No data wiring.

Per m's decisions (design §11.5): URL is English (/tools/procedures, not
/tools/verfahren); all four tabs visible from boot (not a single-default
landing); search box lives in the top filter strip and will compose with
chip filters once U1+ wire them.

U1 fills #procedures-panel-search (Mode A), U2 fills -wizard (Mode B),
U3 fills -proceeding + #procedures-output-tree (Verfahrensablauf), U4
hard-cuts /tools/fristenrechner and /tools/verfahrensablauf to 301
redirects and drops the legacy pages.
2026-05-27 20:19:15 +02:00
mAi
66b08813c4 Merge: t-paliad-334 — unified procedural-events tool design (m/paliad#151)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
cronus shipped 568-line design ratifying a single /tools/procedures page that folds Fristenrechner (Mode A + Mode B + result view) + /tools/verfahrensablauf into one surface with 4 entry tabs and tree+linear-drawer outputs.

12 m's decisions (9 on-recommendation, 3 divergent):
- Q (divergent): English URL '/tools/procedures' (over '/tools/verfahren')
- Q (divergent): all 4 entry tabs visible + search-in-filter-strip (over single default tab)
- Q (divergent): hard-cut 301 redirect (over 2-week dual-ship)

Stays separate (correctly different shape/audience):
- /admin/procedural-events (editorial write surface)
- /projects/{id} Verlauf (per-Akte actuals)
- SmartTimeline (internal projection)
- youpc.org/deadlines (cross-repo, embedded snapshot)

5-slice migration train U0-U4 (no DB mig — purely UX consolidation atop the shipped Phase 2 substrate).

Inventor parks. Head dispatches Sonnet coder per project memory directive (NOT cronus for impl).
2026-05-27 19:56:04 +02:00
mAi
0aaa523494 design: fold m's 12 decisions into unified procedural-events doc (m/paliad#151)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
- §11.5 m's decisions section (9 on-rec + 3 divergent)
- diverged: Q2 /tools/procedures (English), Q3 all-tabs+search-in-filter-strip, Q11 hard cut no dual ship
- §11.5.1 changes triggered by divergences (URL rename, all-tabs behaviour, U4 rewrite)
- URL refs throughout body updated to /tools/procedures
- U4 slice rewritten to 301 hard-cut per Q11
2026-05-27 19:54:50 +02:00
mAi
d49ff55c41 design: unified procedural-events tool (m/paliad#151 shift-1, draft)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
- audit of 6 surfaces with question→dimension matrix
- proposal: fold Fristenrechner + Verfahrensablauf into /tools/verfahren
- 4 entry paths converge on tree + linear output shapes
- mobile narrow-viewport rules + 3 worked personas
- 5-slice migration train (U0-U4), no DB migration
- 12 open questions in 3 batches for AskUserQuestion
2026-05-27 19:23:24 +02:00
mAi
ae1c0b861d Merge: fix admin-rules-edit URL parser regex (post B.6 rename hotfix)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-27 17:58:49 +02:00
mAi
c8999e2a8b fix(admin-rules-edit): accept /admin/procedural-events/{id}/edit in URL parser
Slice B.6 / S6 renamed the canonical edit URL from /admin/rules/{id}/edit
to /admin/procedural-events/{id}/edit. The backend handler + 301 redirect
landed, but the client-side regex in admin-rules-edit.ts:110 was missed —
it still only matches the legacy /admin/rules/.../edit shape. Result:
visiting the canonical URL from the list page shows 'Ungültige
Verfahrensschritt-ID in der URL.' even though the rule exists.

Fix: regex accepts both '/admin/procedural-events/{id}/edit' (canonical)
and '/admin/rules/{id}/edit' (legacy, kept for stale tabs / bookmarks
during the deprecation window).

m flagged 2026-05-27 17:57 on rule cc439590 (RoP.262.2, upc.inf.cfi).
2026-05-27 17:58:49 +02:00
mAi
0365e84dd1 Merge: t-paliad-331 P2 + P4 partial — condition_expr validator + trigger_events partial deprecation (m/paliad#149)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
ritchie shipped the final two slices of the Phase 2 train.

P2 — condition_expr write-validator:
- New internal/services/condition_expr_validator.go (136 LoC) — locks the grammar to {flag:<str>} OR {op:'and'|'or', args:[<leaf>|<composite>]} per design §4.1
- RuleEditorService.Create + Update reject non-conforming expressions
- 166-LoC test coverage; all 18 existing condition_expr rows validate

P4 (partial) — trigger_events deprecation (mig 156):
- NULLs out the 2 hybrid rules' trigger_event_id (parent_id is the canonical edge per §2.1)
- Adds 'Deprecated: see m/paliad#149' header on the legacy /api/tools/event-deadlines route
- Does NOT drop paliad.trigger_events nor the 5 read sites — those are gated on the editorial reparenting of the 73 orphan globals (NULL proceeding_type_id, served only via trigger_event_id). Editorial work is m's, not coder scope.

Comment on m/paliad#149 (issuecomment-10436) enumerates the exact next steps for the eventual follow-up coder once editorial reparenting completes.

Phase 2 train: P0 + S1+S1a + P1 + P3 + P2 + P4 partial — ALL shipped. Final P4 step waits on editorial.
2026-05-27 15:27:12 +02:00
mAi
d6a5dedb2b feat(deadline-system): P4 (partial) — partial trigger_events deprecation (m/paliad#149)
Phase 2 P4 partial-scope (head approved 2026-05-27 15:24). The full
drop of paliad.trigger_events + the legacy route + 5 read sites is
gated on an editorial backfill that's not in coder scope — 73 active
sequencing_rules carry proceeding_type_id IS NULL and are addressed
ONLY via trigger_event_id today. Dropping anything would break those
73 orphans.

What this lands:

  1. Mig 156 — NULL out trigger_event_id on the 2 hybrid rules that
     carry BOTH parent_id AND trigger_event_id. Per design §2.1 /
     m's Q1, parent_id is the canonical predecessor link; the
     hybrid trigger_event_id was redundant. The 2 rules' parent_id
     chains keep the live edge. Live-DB verified post-apply: 0
     active hybrid rules remain.

  2. Deprecation + Link headers on POST /api/tools/event-deadlines
     per RFC 8594 / RFC 9745. The route stays functional so the 73
     orphans keep working until reparenting lands.

What this does NOT land (gated on editorial):

  - DROP TABLE paliad.trigger_events
  - DROP COLUMN paliad.sequencing_rules.trigger_event_id
  - Remove the legacy /api/tools/event-deadlines handler
  - Remove EventDeadlineService + ExportService::1680 sheet
  - Remove deadline_rule_service.go:226 label-fallback path
  - Remove event_type_service.go:40+414 reads (33 event_types still
    reference trigger_event_id)
  - Update cmd/gen-upc-snapshot/main.go:185-202 to skip trigger_events
  - Drop the sequencing_rules_trigger_event_id_fkey FK

All of the above lands in a follow-up mig once the orphan count
hits zero. Comment to follow on m/paliad#149 with the editorial-
backlog list.

Verified: live-DB pre/post hybrid count (0 active hybrids remain);
mig idempotent; go vet clean.

Design: docs/design-deadline-system-revision-2026-05-27.md §2.1
(parent_id canonical), §3.4 (legacy route fate), §4.3 (table fate),
§5 (slice train P5 row). t-paliad-331.
2026-05-27 15:25:53 +02:00
mAi
9940dd8216 feat(deadline-system): P2 — condition_expr write-validator (m/paliad#149)
Phase 2 P2 (design §4.1). Locks the condition_expr grammar to:

  CondExpr := { "flag": "<known_flag>" }
            | { "op": "and"|"or", "args": [<CondExpr>, ...] }

Where <known_flag> must exist in paliad.scenario_flag_catalog (today:
with_ccr / with_amend / with_cci; editorial adds via the catalog
table as needed).

Wire-time validation in RuleEditorService.Create and UpdateDraft —
the rule editor surfaces a 400 with a friendly message before the row
hits the DB. Empty / JSON null inputs pass through (the "no gate"
shape; stored as NULL column).

The validator:
  * walks the JSON tree once, collecting every leaf flag name
  * rejects mutually-exclusive shapes (leaf + composite in one node)
  * rejects empty args, bad op values, empty flag strings
  * does ONE batch lookup of the collected leaf names against the
    catalog (regardless of expression depth)

Tests:
  * 9 shape-only unit tests covering every reject path (no DB needed)
  * TestValidateConditionExpr_LiveCatalog covers 6 good shapes + 2
    unknown-flag cases against the live catalog
  * TestConditionExpr_AllLiveRowsValidate runs the validator over
    every active+published condition_expr in paliad.sequencing_rules
    to enforce the §4.1 invariant on every deploy (today's 18 rows
    all conform — verified via Supabase MCP pre-flight)

Live-DB tests skip cleanly when TEST_DATABASE_URL is unset (same
posture as sibling live tests in this package).

Design: docs/design-deadline-system-revision-2026-05-27.md §4.1
(grammar formalisation). t-paliad-331.
2026-05-27 15:22:53 +02:00
mAi
f6add95d0a Merge: t-paliad-331 P3 — Verfahrensablauf three-way detail filter (m/paliad#149)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
ritchie shipped m's headline UX (paliadin priority signal 14:58):

  'The new timeline filters for optional / mandatory / show only selected
   is what I am most waiting for. I want this to be consolidated for all
   our deadlines so we can simulate all proceedings.'

Three-way detail-level filter above the Verfahrensablauf result panel:
- Nur Pflicht — only priority='mandatory' rules
- Gewählt (default) — mandatory + recommended + every explicit per-rule override in projects.scenario_flags
- Alle Optionen — every rule, unselected ones rendered dotted-border + muted

State persists per-user via localStorage['verfahrensablauf:view_mode']. Per-rule Aufnehmen/Entfernen chips wire to projects.scenario_flags via the P0 SSoT (rule:<uuid> entries).

New files: verfahrensablauf-detail-mode.ts (125), verfahrensablauf-detail-mode.test.ts (96), filter wiring in verfahrensablauf.ts (+204) and views/verfahrensablauf-core.ts (+37). 63 LoC CSS (dotted-border treatment).

bun build clean, 264 frontend tests pass (8 new), go vet clean.

Ritchie continuing with P2 (condition_expr write-validator) then P4 (legacy deprecation).
2026-05-27 15:20:58 +02:00
mAi
480332a5f5 feat(deadline-system): P3 — three-way detail filter on Verfahrensablauf (m/paliad#149)
m's headline UX ask (2026-05-27 14:58, paliadin priority signal):

  "The new timeline filters for optional / mandatory / show only
   selected is what I am most waiting for. I want this to be
   consolidated for all our deadlines so we can simulate all
   proceedings."

Phase 2 P3. Adds a three-way detail-level filter above the result
panel on /tools/verfahrensablauf:

  ( ) Nur Pflicht     — only priority='mandatory' rules
  (•) Gewählt         — mandatory + recommended (default) + every
                         explicit per-rule override the user has set
                         in projects.scenario_flags
  ( ) Alle Optionen   — every rule, with unselected ones rendered
                         dotted-border + muted so the user sees what
                         they're NOT considering

State persists per-user via localStorage["verfahrensablauf:view_mode"].
The filter is pure client-side narrowing on the calc payload — flipping
the toggle re-renders instantly without a fresh backend call.

Per-rule selection (design §2.4a): every optional / recommended card
now carries an [Aufnehmen] / [Entfernen] chip. Clicking writes a
"rule:<uuid>" entry into the project's scenario_flags via the P0 SSoT
PATCH endpoint, recording only deviations from the priority default:

  recommended + entfernen → rule:<uuid> = false  (explicit deselect)
  optional    + aufnehmen → rule:<uuid> = true   (explicit select)
  flipping back to the default deletes the entry

Mandatory rules never expose the chip — they cannot be deselected.

Wire-shape change: CalculatedDeadline gains `ruleId` (the backend already
emits it as `ruleId` in TimelineEntry; only the frontend interface needed
to surface it).

Conditional handling: a conditional rule whose predicate doesn't fire
is treated as unselected in "Gewählt" mode (even when priority=
mandatory) — mandatory means "must be filed IF the predicate fires",
not "always render". The "Alle Optionen" view re-surfaces it so the
lawyer can see what scenario would unlock it.

Cross-surface coherence: hydrating ?project=<id> reads scenario_flags
from the SSoT and pre-fills the existing flag checkboxes (with_ccr /
with_amend / with_cci) so the page reflects the project's persisted
state on first paint. Every flag toggle + every per-rule chip click
PATCHes back. The page also listens for the scenario-flag-changed
CustomEvent fired by peer surfaces (Mode B Fristenrechner result-view)
and re-renders without a fresh fetch.

i18n: 5 new keys (deadlines.detail.{label,mandatory_only,selected,
all_options,optional_unselected_hint,aufnehmen,entfernen}) DE + EN.

CSS: dotted-border + muted treatment on .timeline-item-header--
unselected; .timeline-selection-chip with --add (lime accent) and
--remove (discreet muted) variants.

Tests: 8 new unit tests covering isRuleSelected (4 priority × 2 flag
state matrix) and filterByDetailMode (3 modes × default/override cases).

Verified: bun build clean, bun test 264/264 (8 new), go vet clean.

Design: docs/design-deadline-system-revision-2026-05-27.md §2.4a
(selection state model), §3.3a (view-mode toggle), §6 (Entry A spec).
t-paliad-331. Re-prioritised by m via paliadin 14:58.
2026-05-27 15:20:07 +02:00
mAi
97d90ce651 Merge: t-paliad-331 — Phase 2 slices P0 + S1+S1a + P1 (m/paliad#149)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
ritchie shipped three slices of the Phase 2 deadline-system revision train per design §5:

P0 — Scenario SSoT (mig 154):
- ALTER TABLE projects ADD COLUMN scenario_flags jsonb DEFAULT '{}'
- New paliad.scenario_flag_catalog table
- GET/PATCH /api/projects/{id}/scenario-flags endpoints
- Verfahrensablauf + result-view checkbox binding read+write through scenario_flags
- Per-rule selection state via 'rule:<uuid>' entries (generalises the with_ccr dropdown pattern, no new column on sequencing_rules)
- New scenario_flags_service.go (375 LoC), scenario-flags.ts client, i18n keys

S1+S1a — Cross-party display + spawn-only picker filter:
- FristenrechnerService.LookupFollowUps stops filtering by party server-side; returns all + primary_party
- UI groups: own-side checked-by-priority, cross-party annotated 'Gegenseitig' badge + unchecked
- SearchEvents SQL adds AND sr.is_spawn = false to filter spawn-only events as triggers
- lookup_events_test.go regression coverage

P1 — upc.apl re-split (m's Q5 divergence, mig 155):
- Reverts upc.apl.unified (id=160) back into upc.apl.merits / upc.apl.cost / upc.apl.order split
- Retargets 16 sequencing_rules to the appropriate split id
- Mig 155 applied to live DB per ritchie's report

Next per m's 14:58 priority signal: P3 (Entry A Verfahrensablauf tree UI with three-way view-mode toggle) — ritchie jumping straight there, then P2 + P4.
2026-05-27 15:12:52 +02:00
mAi
3a4e99cb92 feat(deadline-system): P1 — upc.apl re-split into merits/cost/order (m/paliad#149)
Phase 2 P1 / m's Q5 divergence (2026-05-27, verbatim):

  "Reverse the unification as suggested in 3. They are different
   proceedings, I only wanted the approach to be unified in the
   'determinator' — but they are actually different proceedings!"

Mig 155 reverts the mig-096 unification:

  Before: id=160 upc.apl.unified active (16 rules), id=11/19/20 inactive
  After:  id=11 upc.apl.merits (7 rules), id=19 upc.apl.cost (2 rules),
          id=20 upc.apl.order (7 rules) all active; id=160 inactive

The 16 rules under id=160 split cleanly by event_code prefix; all 10
parent_id edges among them are bucket-local (pre-flight audit), so
the tree shape survives the rebind unchanged.

Spawn FK retarget: pi.cfi.appeal_spawn flips from 11 (merits) → 20
(orders track) per design §3.1 — PI appeals land on orders, not
merits. The inf/rev/dmgs spawns keep target=11 (merits), now active.

Determinator routing layer (proceeding_mapping.go) keeps its single
"Berufung" front door per m's intent — only the data shape changes.

Pre-flight verified: 0 projects bound to id=160, 0 scenarios reference
upc.apl. Zero data migration on the project side.

Tests: lookup_events_test.go assertions on the three appeal_target
buckets updated to the new codes (endentscheidung → upc.apl.merits,
schadensbemessung → upc.apl.merits, bucheinsicht → upc.apl.order).
Same rule set, post-split coordinates.

Snapshot regen (pkg/litigationplanner/embedded/upc/) deferred: the
current snapshot only contains inf+rev so the apl re-split doesn't
shift its contents; regenerating would surface unrelated active PTs
and pollute this slice. Tracked as a follow-up.

Verified: go vet clean, go test ./internal/services/... -run
LookupEvents|proceeding_codes clean.

Design: docs/design-deadline-system-revision-2026-05-27.md §3.1
(re-split mig), §1.3 (spawn graph post-Q5). t-paliad-331.
2026-05-27 15:11:48 +02:00
mAi
3533d79a25 feat(deadline-system): S1+S1a — cross-party display + spawn-only picker (m/paliad#149)
Phase 2 S1 + S1a (pre-ratified from t-paliad-327, folded into the
Phase 2 train).

S1 — Cross-party display:
- FristenrechnerService.LookupFollowUps stops filtering by party
  server-side; queryFollowUpRows drops the perspective WHERE clause
  and returns every published+active child.
- Server now computes is_cross_party per row (true only when
  perspective ∈ {claimant,defendant} AND primary_party is the
  opposite side; NULL/both/court is never cross-party).
- FollowUpRule wire shape gains the boolean.
- Frontend renderRule adds a "Gegenseitig" badge + is-cross-party
  row class (muted styling, disabled checkbox affordance).
- defaultChecked returns false for cross-party rows.
- countSelected + submitWriteBack skip cross-party rows
  unconditionally — even if a user manually checks the box, they
  describe opposing-side filings and don't belong in our Akte set
  (design §2.4 write-back exclusion).
- i18n: deadlines.overhaul.crossparty.badge / .tooltip (DE+EN).
- CSS: .fristen-overhaul-rule-crossparty + .is-cross-party row
  modifier.

S1a — Spawn-only picker filter:
- SearchEvents WHERE now adds `sr.is_spawn = false` so spawn rules
  (e.g. appeal_spawn, the inf.cfi → upc.apl.merits hop) no longer
  surface as picker hits. Spawn rules are consequences, not
  triggers — a lawyer searching "Berufung" wants the appeal-tree
  root, not the inf.cfi spawn link.
- Terminal leaves (Duplik etc.) stay pickable per design §2.2's
  carve-out: their own anchor is non-spawn, so they surface and
  render an honest empty follow-up list.

Honest UX: hiding cross-party follow-ups lied about what the
workflow does next (cf. RoP.029.d falling off when perspective=
claimant on def_to_ccr — the workflow continues, just on the
defendant's docket). The fix makes the data legible without
contaminating the write-back path.

Verified: go vet clean, bun build clean, bun test 256/256,
go test ./internal/services/... -run LookupFollowUps... clean.

Design: docs/design-deadline-system-revision-2026-05-27.md §2.4
(cross-party) + §2.2 (spawn-only picker). t-paliad-331.
2026-05-27 15:07:01 +02:00
mAi
2a69f7fc6c Merge: t-paliad-332 — UPC vacations no longer block deadlines (align with paliad t-paliad-121)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
brunel aligned the embedded snapshot calendar with paliad-side policy from t-paliad-121: UPC vacation rows stay in the data for informational annotation but no longer trigger IsNonWorkingDay → AdjustForNonWorkingDays leaves dates intact.

Changes:
- pkg/litigationplanner/embedded/upc/holidays.go: IsNonWorkingDay now returns true only on closure (not vacation). Vacations still surface via AdjustForNonWorkingDaysWithReason for labelling.
- pkg/litigationplanner/embedded/upc/holidays.json: regenerated from live paliad.holidays. Was 5 placeholders → now 55 holidays (33 vacation + 22 closures). Includes UPC Winter Vacation rows.
- pkg/litigationplanner/embedded/upc/meta.json: snapshot version bump.
- snapshot_test.go: +42 lines covering the vacation non-blocking behavior with regression cases.

Affects youpc.org/deadlines (consumes pkg/litigationplanner via Go module replace) — picks up automatically on rebuild.
2026-05-27 15:05:06 +02:00
mAi
39353d49ed fix(litigationplanner): UPC vacations no longer block deadlines (align with paliad t-paliad-121)
youpc.org/deadlines was rolling a deadline "from 2027-01-02 (UPC Winter
Vacation)" — i.e. across the UPC judicial vacation as if it were a
public holiday. Paliad-side t-paliad-121 already decided vacations are
informational only (the Court keeps running through them, RoP / UPC AC
decision-on-judicial-vacation 2023-05-26), and `HolidayService.Is
NonWorkingDay` in `internal/services/holidays.go` is correct. The
embedded snapshot consumed by youpc.org via Go-module replace had
drifted: `pkg/litigationplanner/embedded/upc/holidays.go:74` blocked on
both `isClosure()` AND `isVacation()`.

This commit aligns the embedded calendar with the paliad-side semantics
and ships a fresh holiday set so the existing 2026/2027 fix actually
takes effect downstream.

Code changes (`holidays.go`):

- `IsNonWorkingDay`: drop the `|| h.isVacation()` branch — only weekends
  and `isClosure()` rows trigger the roll. Godoc rewritten to mirror
  the paliad-side rationale (Court keeps operating, RoP cites,
  vacation rows kept for informational labels).
- `isClosure()`: accept both `"public_holiday"` and `"closure"`. Live
  paliad DB rows use the `public_holiday` value; the placeholder
  snapshot shipped with the original Slice C used `closure` as a
  hand-crafted synonym. Reconciles with
  `internal/services/holidays.go:132` which already does the same
  union. Required to make the regenerated JSON (full of
  `public_holiday`) keep blocking DE national holidays after the
  regeneration in this commit.
- Type-level godoc updated: `SnapshotHolidayCalendar` now documents
  vacation-is-informational, and the `AdjustForNonWorkingDaysWithReason`
  precedence note explains that `vacation` kind only fires when a
  vacation row overlaps a weekend or closure that's already doing the
  rolling.

Data refresh (`holidays.json`):

- Regenerated from paliad prod (postgres @ 100.99.98.201:11833,
  paliad schema). 55 rows for 2026 + 2027: 22 DE public_holiday +
  33 UPC vacation (25 Summer Vacation Jul 27–Aug 28, 8 Winter
  Vacation Dec 24/28–31 + Jan 4–6). The previous placeholder shipped
  only 5 rows (3 Sommerpause + Neujahr + Tag der Arbeit, no Winter
  Vacation at all) — which is why a date landing in late Dec / early
  Jan landed inside an unmodeled gap on the consumer side.
- `meta.json` bumped: version → `2026-05-27-1-holidays-only`,
  `holiday_count` 5 → 55, `source_db_label` flags that only
  holidays.json was refreshed (see friction note below).

Regression test (`snapshot_test.go::TestSnapshotHolidayCalendar`):

- 2026-08-04 (Tue, UPC Summer Vacation) — `IsNonWorkingDay` must be
  false; `AdjustForNonWorkingDays` must NOT mutate the date.
- 2027-01-02 (Sat, m's flagged scenario) — must roll forward through
  Sat/Sun, then STOP on Mon 2027-01-04 (UPC Winter Vacation, no longer
  blocking). Pre-fix this rolled all the way to Thu 2027-01-07.

Cross-repo: youpc.org imports `pkg/litigationplanner` via Go-module
replace; the regenerated snapshot ships on its next rebuild. No
separate youpc.org commit needed — paliad is the source of truth.

Friction note: `cmd/gen-upc-snapshot/main.go` itself is incompatible
with the current paliad schema. Migration 140 (`140_drop_deadline_rules`)
dropped `paliad.deadline_rules`, but the generator still SELECTs from
it (main.go ~L162). Running the tool against prod fails on the rules
step. I bypassed the broken path and generated `holidays.json` directly
from the DB via psql + jq (same JSON shape that `EmbeddedHoliday`
expects, nulls filtered for `omitempty`). The other snapshot files
(rules.json, proceeding_types.json, trigger_events.json, courts.json)
remain at their pre-existing placeholder state — re-flagged in
meta.json's `source_db_label`. Refitting the generator for the post-
mig-140 schema is a separate task.

go vet + go test ./... clean (256+ Go tests pass, including the new
regression cases).
2026-05-27 15:04:05 +02:00
mAi
d36cc9ee15 feat(deadline-system): P0 — per-project scenario_flags SSoT (m/paliad#149)
Phase 2 P0 of the deadline + procedural-events revision. Establishes
paliad.projects.scenario_flags (jsonb) + paliad.scenario_flag_catalog as
the single source of truth for per-project scenario state — replacing
the three fragmented stores athena flagged (project_event_choices,
scenarios.spec, DOM-only). All three were empty per the audit so no
data migration is needed.

The jsonb map carries two key shapes:

  * named flags (whitelist via scenario_flag_catalog) — today
    with_ccr / with_amend / with_cci
  * per-rule selection deviations of shape "rule:<uuid>" — wired up
    here for validation; the consumer UI lands in P3

Endpoints:

  GET   /api/projects/{id}/scenario-flags
  PATCH /api/projects/{id}/scenario-flags

PATCH semantics: bool = write; null = delete (priority-driven default
returns); missing key = leave alone. The service validates every key
on write (catalog lookup + UUID rule-membership + mandatory-cannot-be-
deselected) before persisting, so a single bad key fails the whole
patch.

Frontend bind: new scenario-flags.ts client module + Mode B's flag
checkboxes (ccr-flag / inf-amend-flag / rev-amend-flag / rev-cci-flag)
now hydrate from / persist to the project's scenario_flags on every
toggle. Kontextfrei (no project) is unchanged. Cross-surface coherence
via a scenario-flag-changed CustomEvent (peer surfaces — Verfahrens-
ablauf strip, Mode B result-view — will subscribe in P3).

Mig 154 is audit-defensive (set_config of paliad.audit_reason); no
audit trigger fires on paliad.projects today but a future one will
inherit the reason. Seeds the three known flags. CHECK constraints
enforce the top-level shape (jsonb_typeof = 'object') and the
catalog key pattern (lowercase, not 'rule:%' prefix).

Verified against the live DB: 18 projects default to '{}', catalog
has 3 rows, applied_migrations advanced to 154.

Design: docs/design-deadline-system-revision-2026-05-27.md §2.3, §2.4a,
§4.1, §5 (P0 row). t-paliad-331.
2026-05-27 15:02:01 +02:00
mAi
a9fd979cdb Merge: t-paliad-330 — timeline view dark-mode CSS token migration
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
brunel converted hardcoded hex to existing design tokens for the .timeline-* classes (Verfahrensablauf at ?view=timeline). No new tokens introduced; 11 existing tokens reused. Layout/spacing untouched.

Mirrors the t-paliad-326 playbook applied earlier to the Fristenrechner overhaul CSS, but on the separate timeline view that wasn't in scope then.
2026-05-27 14:52:35 +02:00
mAi
c48fa93e3d fix(verfahrensablauf): dark-mode token migration for timeline view CSS
The /tools/verfahrensablauf?view=timeline page (and the columns-view
mirror via `.fr-col-item--*` modifiers) had hardcoded light backgrounds
that survived a dark-mode flip. Root cause: four undefined custom
properties (`--color-bg-soft`, `--color-border-soft`, `--color-accent-bg`,
`--brand-lime`) consumed by the `.timeline-*` and adjacent
`.event-card-choices-*` rules, each with a hardcoded hex / RGBA fallback.
Since neither :root nor :root[data-theme='dark'] defines those tokens,
every consumer fell through to the light fallback in both themes —
leaving the conditional-row bg, the popover surface, the option-button
bg, the chip-skipped bg, and the popover block-separator border stuck on
near-white in dark mode. Earlier t-paliad-326 covered the new
Fristenrechner overhaul CSS only; the timeline view styles are a
separate block (~L3337-3810 in `frontend/src/styles/global.css`) and
were not touched.

Migrate every consumer to the existing dual-theme tokens already used
across paliad. Same approach m approved for t-paliad-326, no new tokens
introduced (all reuse):

- Card / popover surfaces (`.event-card-choices-popover`,
  `.event-card-choices-option`) → `--color-surface` (white light /
  card-tint dark) so the popover reads as raised above the body in
  both themes.
- Subtle raised surface for conditional row, skipped chip, option
  hover (`.timeline-item--conditional .timeline-content`,
  `.fr-col-item--conditional`, `.event-card-choices-chip-part--skipped`,
  `.event-card-choices-option:hover/:focus-visible`) →
  `--color-surface-muted`. **This is the visible bug fix m flagged.**
- Lime-tint backdrops (`.timeline-context-note` bg,
  `.event-card-choices-caret:hover/:focus-visible`) →
  `--color-bg-lime-tint` (lime-alpha 0.10 light / 0.12 dark).
- Active-option chip bg (`.event-card-choices-chip-part`) →
  `--color-accent-soft-bg`.
- Lime accent borders / fills (`.timeline-context-note` left border,
  `.timeline-date--overridden`, `.frist-date-edit-input`,
  `.event-card-choices-unhide-btn`, `.event-card-choices-option--active`)
  → `--color-accent` / `--color-accent-fg`. Drops the
  legacy `#c6f41c` fallback that doesn't match the current brand
  (`--hlc-lime: #BFF355`).
- Neutral borders (`.event-card-choices-caret`,
  `.timeline-item--hidden .timeline-content`, `.fr-col-item--hidden`,
  `.timeline-item--conditional .timeline-content`,
  `.event-card-choices-popover`, `.event-card-choices-option`,
  `.event-card-choices-block + .event-card-choices-block`) →
  `--color-border` (warm cream-derived light / cream-alpha dark).
- Popover shadow `rgba(0, 0, 0, 0.12)` → `var(--shadow-md)`
  (auto-deepens to `rgba(0, 0, 0, 0.45)` in dark).
- Status red (`.event-card-choices-error`) → `--status-red-fg`
  (defined in both themes; old `#b00020` fallback unreachable).

Zero new tokens introduced — every replacement uses a token already
shipped in both :root and :root[data-theme='dark']. Verified by
mounting `frontend/dist/assets/global.css` against a representative
static DOM (context-note banner, every timeline-item variant —
conditional / skipped / hidden / overridden-date / mandatory —
popover with active/inactive options, unhide button, error message,
all four party-badge stances) and toggling `data-theme` between
light and dark: conditional row bg flips from grey to muted-cream-on-
midnight, popover lifts off the body via `--shadow-md`, every chip and
border stays legible in both themes.

bun build + bun test (256 pass) + go vet clean.
2026-05-27 14:51:15 +02:00
mAi
5f7a66bbec Merge: t-paliad-329 — Phase 2 deadline + procedural-events full revision design (m/paliad#149)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
atlas shipped the comprehensive Phase 2 design covering Option B (full revision absorbs t-paliad-327 narrow scope). 776 lines.

Spine: connection schema for all procedural events as ASCII trees per PT + Mermaid spawn graph. parent_id canonical, trigger_event_id deprecated.

Tier 1 (4 questions, all on-recommendation):
- parent_id canonical predecessor link
- Trigger discoverability via derived EXISTS check
- Scenario state SSoT in projects.scenario_flags jsonb (mig 154)
- Cross-party display contract

Tier 2 (4 questions, 1 divergent):
- Q5 m diverged: revert upc.apl.unified back into merits/cost/order split (mig P1 retargets 16 rules)
- Spawn-only events excluded from picker
- Entry A 'sequence-from-proceeding-type' view extends /tools/verfahrensablauf
- Legacy /api/tools/event-deadlines + paliad.trigger_events deprecated

Tier 3: condition_expr grammar formalised ({flag} / {op:and|or,args:[...]}), editorial backfill workflow on /admin/procedural-events parent-NULL filter, trigger_events table dropped.

Shift-2 additions (per m's 14:31-14:35 direction, folded in 490c8a8):
- Selection state via per-rule scenario_flags entries (rule:<uuid>) — generalises the existing with_ccr dropdown pattern. NO new column on sequencing_rules.
- Three-way view-mode toggle on Verfahrensablauf: Nur Pflicht / Gewählt / Alle Optionen.
- R.109 chain (Antrag auf Simultanübersetzung → Mitteilung Dolmetscherkosten) as Tier 3 editorial worked example: current parent_id linkage is wrong; right shape uses with_interpreter_denied flag.

6-slice migration train:
- P0 Scenario SSoT (mig 154 + endpoints + binding)
- S1+S1a Cross-party display + spawn-only picker filter
- P1 upc.apl re-split (16 rules retarget)
- P2 condition_expr write-validator
- P3 Entry A Verfahrensablauf tree UI
- P4 Legacy deprecation + trigger_events drop

Worked examples for both entry paths. Coder dispatch awaits m's go.
2026-05-27 14:45:43 +02:00
mAi
490c8a8c8c design(deadline-system): fold m's selection + view-mode + R.109 additions (t-paliad-329)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
m's clarification at 14:40 reframed the original "rarity" framing: every
optional rule is a per-scenario selectable card; the Verfahrensablauf
gets a three-way detail-level filter (Nur Pflicht / Gewählt / Alle
Optionen). The CCR-dropdown pattern generalises to per-rule chips.

Three folded additions:

§2.4a — Selection state + detail-level filter. NO new column on
sequencing_rules (reverts the earlier is_edge_case strawman). Extends
projects.scenario_flags jsonb to carry both named flags (with_ccr etc.)
and per-rule entries (rule:<uuid>). Storage only carries deviations
from the priority default (recommended = default-selected,
optional = default-unselected). Whitelist accepts rule:<uuid> when the
UUID resolves to an active rule on the project's PT.

§3.3a — Verfahrensablauf view-mode toggle: three-way segmented control,
localStorage persistence, default "Gewählt". Mode B result view stays
single-purpose (no view-mode toggle there).

§4.2.1 — R.109 translation chain editorial worked example: R.109.1 stays
as optional anchor; R.109.4 reparents to R.109.1 with condition_expr
{flag: with_interpreter_denied} and primary_party=both (parties, not
court); R.109.5 reparents to R.109.1 with {flag: with_translation_granted}.
Introduces two new flags to scenario_flag_catalog.

§6 UI spec updated: two mocked tree states (Gewählt + Alle Optionen)
showing the dotted-border [Aufnehmen] chips, [Entfernen] on selected
optionals, greyed-with-hint on flag-gated conditionals, and the
subtree-hide-on-unselected-ancestor render logic.

§10.0a captures the additions; §10.1 notes they don't change the slice
train (P0 + P3 take the extended scope; no new mig).
2026-05-27 14:45:00 +02:00
mAi
b1c9e8dd97 design(deadline-system): Phase 2 revision — connection schema + 12 m's decisions (t-paliad-329)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Builds on athena's Phase 1 assessment (9aee9e4) + atlas's t-paliad-327
pre-ratified subset. m's Option B direction: "overall schema for all
procedural events and how they are connected" — connection graph as the
spine.

Connection schema (§1):
- Rules are nodes, parent_id is the canonical edge, spawn rules are the
  cross-PT edges, condition_expr filters the visible subgraph
- ASCII trees for the 3 largest PTs (upc.inf.cfi 25, upc.rev.cfi 17,
  upc.apl post-Q5-split 16); Mermaid graph for the 4 spawn cross-PT edges
- Per-PT health table covering all 23 active primaries (17 ruled + 6 empty)

m's 12 design decisions (3 batches of 4 via AskUserQuestion):

Tier 1 — model (all 4 on-recommendation):
- Q1: parent_id is canonical, deprecate trigger_event_id
- Q2: Reparent 73 legacy globals via editorial walk
- Q3: Derive trigger discoverability from data (EXISTS)
- Q4: projects.scenario_flags jsonb (confirms t-paliad-327 design)

Tier 2 — surface (1 divergent, 3 on-recommendation):
- Q5 DIVERGENT: Reverse the upc.apl unification — split back into 3 PTs
  (merits/cost/order). m: "I only wanted the approach to be unified in
  the 'determinator' — but they are actually different proceedings!"
  Mig P1 retargets 16 rules by event_code prefix.
- Q6: Show empty PTs with "Keine Regeln gepflegt" badge
- Q7: Fold Entry A into /tools/verfahrensablauf
- Q8: Drop /event-deadlines after 73-globals reparenting

Tier 3 — editorial (all on-recommendation):
- Q9: Lock condition_expr grammar {flag} | {op:and|or, args}
- Q10: parent-NULL filter on /admin/procedural-events
- Q11: Drop trigger_events table once route is gone
- Q12: ASCII per-PT + Mermaid spawn graph

6-slice migration train (§5): P0 scenario SSoT, P1 appeal re-split, S1/S1a
from t-paliad-327, P2 empty-PT badge, P3 Entry A, P4 editorial walk, P5
trigger_event_id deprecation. P5 gated on P4.

No code yet — coder gate held per inventor SKILL.
2026-05-27 14:32:02 +02:00
mAi
9aee9e4101 Merge: t-paliad-328 — Phase 1 assessment of the deadline + procedural-events system (m/paliad#149)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
athena delivered the consultant audit per RFC m/paliad#149 Phase 1:

- 738-line doc, read-only, no design proposals
- §1 consumer audit: every service / handler / frontend / migration that touches sequencing_rules or procedural_events, cited file:line
- §2 health-check: green / yellow / red / dead-code buckets
- §3 corpus quality: parent_id 47% coverage, condition_expr keys, spawn distribution, primary_party by PT, court-set, trigger_event_id overlap
- §4 editorial gap map per proceeding_type
- §5 risk register: 11 items, severities marked
- §6 recommendation: Tier 1 model decisions to grill first, Tier 2 surface decisions, Tier 3 editorial+cleanup

Headline risks bumped from the RFC's framing:
- R1 cross-party filter (high) — 39 rules dropped
- R2 picker over-accepts (high) — only 67/222 events are real chain-anchors
- R3 4 spawn rules target inactive proceeding_type id=11 (high) — dangling FK
- R4 73 legacy globals with NULL PT (medium) — invisible to Mode B
- R5 5 surfaces still read legacy trigger_events bigint table (medium)
- R6 3 scenario stores, all empty but all live (medium) — clarified: paliad.scenarios.spec jsonb is mig 145, not projects.scenarios as the RFC misstated

Phase 2 (inventor) gates on m's go. The inventor reads this + RFC + grills m on Tier 1 before sketching.
2026-05-27 11:03:33 +02:00
mAi
810b65463e docs(assessment): Phase 1 audit — deadline + procedural-events system (m/paliad#149)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
t-paliad-328. Read-only audit of every consumer of paliad.sequencing_rules
+ paliad.procedural_events + the legacy paliad.trigger_events, plus the
rules-corpus quality on the live database. No design — Phase 2 (inventor)
gates on this landing.

Highlights:
- 226 active+published rules / 222 events (1:1 since mig 136)
- parent_id chain vs trigger_event_id are functionally disjoint
  (2/226 overlap); 73 legacy globals own the trigger_event_id lane
- 11 risk items captured with file:line; B1 (cross-party follow-up
  filter) and B2 (picker accepts spawn-only + leaves) confirmed
  from code at fristenrechner_followups.go:358-367 and :241-287
- 4 spawn rules still point at the inactive upc.apl.merits (id=11);
  the active appeal type is id=160 (upc.apl.unified)
- 6 active proceeding_types are entirely unruled
- 3 scenario stores wired (project_event_choices, scenarios table,
  DOM state); all currently empty, so divergence is dormant
- 738 lines (under the 800 cap)

Recommendation §6 sequences Tier 1 model decisions ahead of Tier 2
surface decisions and Tier 3 editorial cleanup for the inventor.
2026-05-27 11:02:38 +02:00
mAi
33c5fb2983 Merge: t-paliad-326 — dark-mode token migration for Fristenrechner overhaul CSS (m/paliad#146 follow-up)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
brunel fixed m's bug ('Das CSS vom neuen Fristenrechner scheint wieder keinen Darkmode zu supporten') by migrating the 121 hardcoded hex colors knuth added in S2/S3/S4 to the project's design-token system.

Net: 161 inserts / 123 deletes in frontend/src/styles/global.css. 10 new tokens added to :root and :root[data-theme='dark'] for the few shades that didn't have an existing variable (group dividers, party-stance backgrounds, filter-pill subtle states). All 121 hex usages replaced with var(--color-*) references.

Verified visually via standalone harness: trigger card, 4 priority groups, per-rule rows (claimant/defendant/both/court), Mode A filter strip + result list, Mode B wizard with Filter/Qualifier badges, kontextfrei nudge, write-back footer, success/error toasts — all flip cleanly between light and dark. Layout/spacing/sizing untouched.

bun build + go vet clean.
2026-05-27 10:42:22 +02:00
mAi
76d38c4c84 fix(fristenrechner): dark-mode token migration for overhaul CSS (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
The Fristenrechner overhaul CSS shipped in S2/S3/S4 (commits 9ab8dd8,
2a2c5b8, 70985d8) used hardcoded hex literals across the result view,
Mode A search, and Mode B wizard surfaces. The `:root[data-theme="dark"]`
flip had nothing to override, so toggling the theme left the whole
Fristenrechner pane stuck in light-mode colors.

Migrate every hex literal in those sections to the design-token system
that the rest of paliad already uses (PWAHead.tsx flips
`data-theme` from localStorage):

- Surfaces: `#fff`/`#fafaf6`/`#f4f4f0` → `--color-surface` /
  `--color-surface-2` / `--color-bg-subtle`.
- Borders: `#d8d8cf`/`#e0e0d4`/`#ececde` → `--color-border`;
  `#c8c8be`/`#d4d4c9`/`#d4d4cc` → `--color-border-strong`.
- Text: `#1f1f1f`/`#2a2a2a` → `--color-text`; `#444`/`#555`/`#666` →
  `--color-text-muted`; `#777`/`#888`/`#999` → `--color-text-subtle`.
- Status palette: error → `--status-red-*`; spawn/cond badges +
  court-set hint → `--status-amber-*`; ok-msg → `--status-green-*`;
  claimant party + filter-row badge → `--status-blue-*`; recommended
  group stripe → new `--status-blue-border`; conditional stripe →
  `--status-amber-border`.
- Defendant/court party stances → `--status-red-*` /
  new `--status-purple-*` bucket.
- Brand-lime cues (mandatory group stripe, mode-tab active underline,
  wizard row-number circle) → `--color-accent` / `--color-accent-dark`.
- Lime soft tints (nudge, footer, hover bgs, success message, "from
  Akte" wizard row, edit-button hover) → new
  `--color-accent-soft-{bg,fg,border}` tokens.
- Saturated lime pills (active chip, jurisdiction badge, wizard
  active-row outline) → new `--color-accent-strong-{bg,fg,border}`
  tokens.
- Lime accent links (rule-source, edit-date, result-cta, wizard-edit)
  → existing `--color-accent-fg` (midnight in light, lime in dark).
- Wizard active-row glow `rgba(198, 244, 28, 0.15)` → token-driven
  `rgb(var(--hlc-lime-rgb) / 0.15)`.
- Trigger card box-shadow → `var(--shadow)` (auto-deepens in dark).

Ten new tokens introduced in `:root` + mirrored in
`:root[data-theme="dark"]`: 6 accent-soft/-strong, 1 status-blue
border, 3 status-purple bucket.

Verified by mounting `frontend/dist/assets/global.css` against a static
representative DOM (all four group stripes, every party stance, mode-A
filter + result list, mode-B wizard with filter/qualifier badges,
trigger card, write-back footer, kontextfrei nudge, ok/error
messages). Toggled `data-theme="dark"` via JS — every surface, border,
chip, badge, and status pill flipped to its dark counterpart.
`bun run build` + `go vet ./...` clean. Layout / spacing / sizing
untouched (colours, borders, shadows only).

NO CHANGES IN FUNCTIONALITY. PoC pane only flips visuals when the
theme is toggled now.

t-paliad-326.
2026-05-27 10:41:29 +02:00
mAi
233547297c Merge: t-paliad-323 Slice S6 — Fristenrechner cleanup (m/paliad#146 SHIPPED)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
knuth shipped S6, the final slice of the Fristenrechner overhaul:

- frontend/src/client/fristenrechner.ts shrinks by 137 LoC (legacy Pathway-B neutralised; row-stack subtree wired off behind ?legacy=1).
- internal/handlers/fristenrechner_event_categories.go dropped — the /api/tools/fristenrechner/event-categories endpoint is gone (route deregistered in handlers.go).
- paliad.event_categories table stays for future tools (the hidden 'Ich möchte einreichen' forward-workflow), per design §7-S6.
- Deferred follow-ups (knuth's scope discipline): drop the legacy concept-card response shape from /search + lift the dead-code row-stack subtree out of fristenrechner.ts in a separate cleanup PR. Filed as scope note on m/paliad#146 (issuecomment-10414).

S1-S6 complete:
- S1 7ea4151 — backend (search ?kind=events + /follow-ups)
- S2 9ab8dd8 — result view under ?overhaul=1
- S3 2a2c5b8 — Mode A direct search
- S4 70985d8 — Mode B 5-row wizard
- S5 4571bd4 — flip overhaul default
- S6 ba3e079 — cascade endpoint drop + legacy neutralise

Procedure-mode (upper half of fristenrechner.tsx) untouched per design. paliad.event_categories table retained for future tools.
2026-05-27 10:25:26 +02:00
mAi
ba3e0795f8 feat(fristenrechner): Slice S6 — drop cascade endpoint, neutralize legacy Pathway B (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Cleanup pass per design §7 / S6, executed as a measured first cut
that drops the cascade endpoint + neutralizes the legacy Pathway B
row-stack / cascade init without lifting the entire ~1500 LoC
subtree out of `fristenrechner.ts`. The dead helpers stay for one
follow-up that can lift them safely.

Backend:
  * Deleted `internal/handlers/fristenrechner_event_categories.go`.
  * Dropped the `GET /api/tools/fristenrechner/event-categories`
    route from `handlers.go`. The `EventCategoryService` itself
    stays — it still backs the legacy concept-card search's
    `?event_category_slug=` filter, which dies in the same
    follow-up that removes the concept-card response shape.
  * `paliad.event_categories` TABLE is untouched per design §7
    (kept for future tools).

Frontend:
  * `loadEventCategoryTree()` reduced to a stub returning `[]` — the
    endpoint it fetched no longer exists, and no overhaul surface
    calls it.
  * `initB1Cascade()`, `initForumFilter()`, `initInboxFilter()`
    early-return. Their `DOMContentLoaded` registrations stay so
    the bundle exports are stable, but no Pathway B cascade /
    chip-strip / inbox-channel wiring fires in `?legacy=1` mode.
  * The Pathway B markup in `fristenrechner.tsx` stays in place; it
    renders inert when a user hits `?legacy=1&path=b`.
  * `buildRowStack`, `renderRowStack`, `runB1Search`, and the row-
    stack helper functions remain as unreachable code. Removing
    them mechanically requires retiring the entire upper-half
    Pathway B B2 search wiring (`runSearch` + `renderConceptCard`
    + `renderSearchResults` + `SearchResponse` types) which is
    tangled with the legacy concept-card response shape — deferred
    to a follow-up that lands together with the backend
    concept-card removal.

Verified — bun build clean (2971 i18n keys unchanged), 256
frontend tests pass, go build + vet clean, live-DB tests
(TestListProceedings, TestSearchEvents, TestLookupFollowUps)
still green.

Follow-up scope tracked in design §7 S6 — pending the helper-tree
lift and the legacy concept-card response-shape removal from
/search.
2026-05-27 10:24:16 +02:00
mAi
8dfdd77079 Merge: t-paliad-323 Slice S5 — flip overhaul default; legacy under ?legacy=1 (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
knuth flipped the overhaul flag per design §7-S5:

- isOverhaulMode() inverted: true unless ?legacy=1.
- /tools/fristenrechner now lands on the new dual-mode (Direkt suchen + Geführt) by default.
- Legacy row stack still reachable via ?legacy=1 for the 2-week deprecation window.
- Existing ?overhaul=1 deep links continue to work (no-op pass-through).
- Sidebar / header / outbound URLs unchanged — they point at bare /tools/fristenrechner so they pick up the new default automatically.

S6 (drop buildRowStack + cascade reads) next on the same branch.
2026-05-27 10:16:31 +02:00
mAi
4571bd4980 feat(fristenrechner): Slice S5 — flip overhaul default; legacy under ?legacy=1 (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
`isOverhaulMode()` now returns true unless the URL carries
`?legacy=1`. The overhaul UI from S2-S4 (mode tabs + Mode A
search + Mode B wizard + shared result view) becomes the default
landing for /tools/fristenrechner; the legacy three-step wizard +
Pathway A/B + cascade is reachable only via the explicit
`?legacy=1` opt-out for the two-week deprecation window before
S6 drops the legacy code paths entirely.

The pre-existing `?overhaul=1` deep links from S2-S4 still
resolve — the detector treats *absence* of `?legacy=1` as
overhaul, so bookmarks stay valid. No sidebar / header / outbound
link change needed: those all point at the bare
`/tools/fristenrechner` URL, which now boots overhaul.

Verified — bun build clean (2971 i18n keys unchanged), 256
frontend tests pass, go build + vet clean.
2026-05-27 10:16:07 +02:00
mAi
7584b4f428 Merge: t-paliad-323 Slice S4 — Fristenrechner Mode B wizard (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
knuth shipped S4 of the Fristenrechner overhaul (design §3.2, §7-S4):

- New frontend/src/client/fristenrechner-wizard.ts (711 LoC) — 5-row 'Geführt' wizard:
  - R1 event_kind (always asked, ~6 chips)
  - R2 forum (skipped when R1 narrows to a single forum)
  - R3 proceeding_type (auto-skipped when narrowed to a single candidate; EventKind EXISTS filter on the catalog)
  - R4 procedural_event (the landing question)
  - R5 perspective (async-probed after R4; only fires when the trigger event's follow-ups actually differ by primary_party)
- Row Filter/Qualifier badges per §11.Q3 (R1/R2 = Filter, R3/R5 = Qualifier).
- R5 has no 'Beide' option per §11.Q8 (qualifier mode in the file path).
- Pre-fill+collapse from project: proceeding_type → R3+R2 and our_side → R5 with 'aus Akte' tag.
- Backend ProceedingListOptions.EventKind added so R3's catalog query respects the chosen event_kind.
- 6 live-DB tests pass — including the kind=proceeding regression check (upc.cfi.interim filtered out as a phase row). 256 frontend tests pass + 7 new for followUpsDifferByParty.

Branch rebased on main (post-mig-153 + S3). S5 (flip ?overhaul=1 to default) next.
2026-05-27 10:15:14 +02:00
mAi
70985d88b0 feat(fristenrechner): Slice S4 — Mode B wizard (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Mode B "🧭 Geführt" — the guided 3-5 row wizard defined in
docs/design-fristenrechner-overhaul-2026-05-26.md §3.2. Lands the
user on a single procedural_event (the trigger), then transitions
to the shared §4 result view.

Frontend:
  * `fristenrechner-wizard.ts` — row stack with R1..R5:
      R1 Was ist passiert?           (event_kind, always asked)
      R2 Vor welchem Gericht?        (jurisdiction, skip if R1 narrows)
      R3 In welchem Verfahren?       (proceeding_type, auto-skip when
                                      narrowed pool has 1 option)
      R4 Welches Schriftstück?       (procedural_event, landing)
      R5 Welche Seite vertreten Sie? (party, only when follow-ups
                                      differ by primary_party)
    Row badges per §11.Q3: R1+R2 = Filter, R3+R4+R5 = Qualifier.
    R5 has NO "Beide" option per §11.Q8 — Mode B is the file-mode
    where perspective is a qualifier.
  * Project prefill — derives R3 + R2 jurisdiction from
    project.proceeding_type, R5 from project.our_side. Annotates
    pre-filled rows with "aus Akte" tag and implicit rows with
    "implizit" tag per §11.Q10 ("erhalten" annotation when a pick is
    carried across an upstream change).
  * R4-to-result transition — after R4 the wizard fetches /follow-
    ups (no dates) to inspect primary_party variance. If both
    claimant and defendant rules exist AND R5 isn't already set,
    swaps the loading row for the R5 chip picker. Otherwise jumps
    straight to mountResultView.
  * URL state — `?mode=wizard&kind=…&forum=…&pt=…&r4=…&party=…`
    keeps deep-link / back-nav consistent (the launchResult step
    sets `event=` so the result view picks up).
  * `fristenrechner-result.ts` mountModeShell now dispatches the
    "wizard" tab to the wizard module (was a coming-soon
    placeholder).
  * 18 i18n keys added (DE + EN parity), 145-line CSS block for the
    wizard row stack with Filter / Qualifier badge styling and
    "aus Akte" annotation chip.

Backend:
  * `ProceedingListOptions.EventKind` adds an EXISTS subquery
    filter on `paliad.sequencing_rules` ⨯ `paliad.procedural_events`
    so Mode B R3 chips only show proceedings whose event roster
    contains at least one event of the requested kind (design
    §6.3). Endpoint param: `event_kind=` on
    /api/tools/proceeding-types.

Test updates:
  * `TestListProceedings` switched from SKIP-when-column-missing to
    asserting the live filter — mig 153 has landed, `kind` column
    is in place. New subtests: kind=proceeding includes
    upc.inf.cfi and excludes the phase row upc.cfi.interim;
    event_kind=filing narrows to proceedings with filing events.
  * `fristenrechner-wizard.test.ts` covers
    `followUpsDifferByParty` — the R5 trigger predicate. 7 cases:
    asymmetric → true; uniform / both / court / empty → false.

Verified — bun build clean (2971 i18n keys), 256 frontend tests
pass (incl. 7 new), go build + vet clean, live-DB
TestListProceedings passes all 6 subtests against mig 153 data.
2026-05-27 10:14:37 +02:00
mAi
06d6c7540e Merge: t-paliad-323 Slice S3 — Fristenrechner Mode A direct search (m/paliad#146)
Some checks failed
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
knuth shipped S3 of the Fristenrechner overhaul (design §3.1, §7-S3):

- New frontend/src/client/fristenrechner-mode-a.ts (507 LoC) — 'Direkt suchen' UI per design §3.1: Filter strip (Forum · Verfahren · Was passierte · Partei) with section-split visual hierarchy per m §11.Q3, free-text search box, ranked result list of procedural_events with click-to-lock-as-trigger.
- Inbox channel as secondary 'Erweitert' chip per §3.3 with CMS→UPC / beA→DE forum nudge.
- Mode tabs pair (Direkt suchen / Geführt) under Step-0 per §11.Q2; wizard tab placeholder until S4.
- Backend ListProceedings(jurisdiction, kind) — kind='proceeding' filter targets mig 153's column (just merged in 3e55ff8). 4 tests pass + 1 SKIP that probes for column existence (graceful fallback prior to mig 153).
- 310 LoC CSS, 88 i18n keys for the new surface.
- bun build clean; 249 existing frontend tests + new pass; go vet clean.

Mode A live under ?overhaul=1. Mode B (S4 wizard) next on the same branch.
2026-05-27 10:10:57 +02:00
mAi
3e55ff8294 Merge: t-paliad-325 — mig 153 proceeding_types kind discriminator + ProjectService hardening (m/paliad#147)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
ritchie shipped atlas's design (docs/design-proceeding-types-taxonomy-2026-05-26.md):

- mig 153 additive: ADD COLUMN kind text NOT NULL DEFAULT 'proceeding' CHECK in {proceeding,phase,side_action,meta}; UPDATE 4 phase + 10 side_action + 9 meta; per m's Q9 flips is_active=false on the same 23 rows in the same TX. CHECK trigger projects_proceeding_type_kind_check blocks projects.proceeding_type_id from pointing at non-proceeding kinds. Snapshot to paliad.proceeding_types_pre_153 in the same TX. set_config('paliad.audit_reason', ...) defensively.
- ProjectService.SetProceedingType hardened: new ErrInvalidProceedingTypeKind, single-SELECT validator checks category + kind + is_active before assigning.
- 4-angle test (TestProjectService_ProceedingTypeKindGuard) covers happy-path proceeding, rejected phase, rejected inactive, rejected wrong category.
- cmd/gen-upc-snapshot/main.go gains the AND kind='proceeding' filter; embedded snapshot JSON regen flagged as follow-up (needs DATABASE_URL at runtime).

Mode B R3 query now becomes WHERE is_active=true AND kind='proceeding' for a 23-row clean primary list. Phase/side_action/meta rows survive in the table for taxonomic reference but never surface in pickers.
2026-05-27 10:10:39 +02:00
mAi
9d688459e3 feat(db): mig 153 — proceeding_types kind discriminator + ProjectService hardening
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Adds a `kind` column to paliad.proceeding_types (proceeding / phase /
side_action / meta) so the Mode B R3 Fristenrechner wizard, the
projects.proceeding_type_id binding, and the pkg/litigationplanner
snapshot can filter to primary proceedings only.

Implements the ratified design from docs/design-proceeding-types-
taxonomy-2026-05-26.md (m greenlit 2026-05-27 09:57 after the 11-question
AskUserQuestion round-trip).

Mig 153 is purely additive — ADD COLUMN with a safe DEFAULT, UPDATEs
reclassify 23 non-primary rows (4 phase + 10 side_action + 9 meta), and
a BEFORE INSERT/UPDATE trigger on paliad.projects backstops the new
invariant. Pre-mig audit (Supabase MCP, 2026-05-27) confirmed zero
downstream pressure on the 23 reclassified rows.

- internal/db/migrations/153_proceeding_types_kind.up.sql + .down.sql
  - snapshot to paliad.proceeding_types_pre_153 in the same TX
  - set_config('paliad.audit_reason', …) defensively
  - DO-block asserts 23 reclassified rows before the trigger ships
  - Q9 carve-out: is_active=false on every phase/side_action/meta row
  - new trigger paliad.projects_proceeding_type_kind_check on
    paliad.projects.proceeding_type_id

- internal/services/project_service.go
  - extend validateProceedingTypeCategory to also enforce
    kind='proceeding' AND is_active=true; new typed error
    ErrInvalidProceedingTypeKind
  - single SELECT picks up category + kind + is_active

- internal/services/project_service_test.go
  - TestProjectService_ProceedingTypeKindGuard covers service-layer
    rejection, the active-but-non-proceeding edge, mig 153 trigger
    backstop, and the kind='proceeding' happy path

- cmd/gen-upc-snapshot/main.go
  - filter proceeding_types query to kind='proceeding' for forward-
    compat (the embedded UPC snapshot JSON regen requires DATABASE_URL
    access and will land in a follow-up; the current placeholder is
    already empty of non-primary rows)

t-paliad-325 / m/paliad#147
2026-05-27 10:09:33 +02:00
mAi
2a2c5b8033 feat(fristenrechner): Slice S3 — Mode A direct search (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Mode A " Direkt suchen" — the power-user entry path defined in
docs/design-fristenrechner-overhaul-2026-05-26.md §3.1. Renders
above the §4 result view; clicking a result row locks the trigger
event and transitions to the shared result surface from S2.

Frontend:
  * `fristenrechner-mode-a.ts` — filter strip (Forum / Verfahren /
    Was passierte / Partei) + free-text search input + result list.
    Section-split visual hierarchy per m §11.Q3: filter chips in a
    bordered "Filter (eingrenzen)" strip on top, result list below.
    Inbox channel chip lives behind an "Erweitert" details summary
    per §3.3; picking CMS / beA auto-nudges the Forum chip. Party
    chip retains a "Beide" option (Mode A is filter mode per §11.Q8;
    Mode B drops it in S4).
  * `fristenrechner-result.ts` — new `mountModeShell(activeTab)`
    renders the two mode tabs per §11.Q2 and lazy-imports Mode A.
    Mode B tab is a placeholder until S4 lands.
  * `fristenrechner.ts` boot — when `?overhaul=1` is set and `?event`
    is empty, mountModeShell takes over (default tab = search; `?mode=
    wizard` opens the wizard tab when S4 ships). With `?event=` the
    flow still jumps straight to the result view. URL state syncs
    forum / pt / kind / party / q on every chip click.
  * 28 i18n keys added (DE + EN parity), 310-line CSS block for the
    mode tabs + Mode A surface.

Backend:
  * New `ProceedingListOptions { Jurisdiction, Kind }` + service
    method `ListProceedings(ctx, opts)`. Legacy
    `ListFristenrechnerTypes` keeps the no-filter signature for
    existing callers. Handler `/api/tools/proceeding-types` accepts
    `?jurisdiction=` and `?kind=` query params.
  * `kind=proceeding` filter targets the taxonomy column landed in
    mig 153 (parallel branch t-paliad-325, m/paliad#147). Sequenced
    per the taxonomy doc §7 option (c): mig 153 merges before S3
    ships to main, so the filter is never false-positive (no phase
    / side_action / meta rows leak into the chip strip).

Verified — bun build clean (2955 i18n keys, data-i18n attributes
clean), 249 frontend tests pass, go build + vet clean. New
TestListProceedings — 4 PASS (no-filter, jurisdiction=UPC,
jurisdiction=DE, ListFristenrechnerTypes alias) + 1 SKIP for the
kind=proceeding case that probes the column and skips when mig 153
hasn't landed yet. S1 + S2 live tests still green.
2026-05-27 10:07:27 +02:00
mAi
058a36976b Merge: t-paliad-324 — proceeding_types taxonomy design doc (docs only) (m/paliad#147)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
atlas shipped the 580-line design ratifying Model 1 (kind discriminator) for the proceeding_types cleanup. All 11 PRDs answered by m in §10.

Final categorisation (46 active rows):
- 23 kind='proceeding' (18 with corpus + 5 unloaded primaries incl. upc.costs.cfi per m's Q2 carve-out)
- 4 phase (upc.cfi.interim/oral/decision + upc.default.cfi)
- 10 side_action (evidence/experiments/security/intervention/parties/optout/inspection/freezing/withdrawal/rehearing)
- 9 meta (case.mgmt, general.rop, service, language, representation, fees, legalaid, special, reestablishment)

Mig 153 sketch (per §3): ADD COLUMN kind text NOT NULL DEFAULT 'proceeding' CHECK in {proceeding,phase,side_action,meta}; 4 UPDATEs setting kind for the non-primary IDs; optional CHECK trigger blocking projects.proceeding_type_id from referencing non-proceeding kinds. No row moves, no FK churn — 0 downstream rules / projects / spawn FKs / concepts point at non-primary rows today (verified live, §0.1).

Sequencing (m's Q10): parallel-land with knuth's S3 of the Fristenrechner overhaul. The kind column makes Mode B R3's WHERE filter trivial; no need to serialize.

Coder gate held — atlas parks; head dispatches a fresh Sonnet coder for mig 153 + ProjectService.SetProceedingType hardening + youpc-go snapshot regen.
2026-05-27 09:55:52 +02:00
mAi
3219bff4d4 design(taxonomy): proceeding_types kind discriminator + 11 m's decisions (t-paliad-324)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Live audit established that 28 of 46 active proceeding_types have zero
downstream pressure (0 rules, 0 projects, 0 spawn FKs, 0 concepts). Mig
plan is purely additive: ADD COLUMN kind text CHECK (...), four UPDATE
statements to tag phase/side_action/meta rows, deactivate them, and add
a BEFORE INSERT/UPDATE trigger on projects.proceeding_type_id to enforce
kind='proceeding'.

m's call on the 11 AskUserQuestion decisions:
- Model 1 (kind discriminator)
- Phases implicit via procedural_events.event_kind, EXCEPT upc.costs.cfi
  stays kind='proceeding' (standalone R.151 application)
- Side-actions: kind='side_action', rules anchor on parent primary
- Schutzschrift kind='proceeding' (own RoP filing)
- DE inf + DE null + DE-vs-upc.apl unification: all keep discrete
- upc.ccr.cfi: keep status quo per t-paliad-204 S1
- DB trigger on projects only (admin-only writes on sequencing_rules)
- Deactivate non-primary rows (23 active post-mig, all kind='proceeding')
- Parallel-land vs m/paliad#146 — knuth's S3 picks up the filter

Final categorisation: 23 proceeding / 4 phase / 10 side_action / 9 meta.

No code yet — coder gate held per inventor SKILL. Design only.

Closes the inventor pass on m/paliad#147.
2026-05-27 09:54:18 +02:00
mAi
081b66ebc8 Merge: t-paliad-323 Slice S2 — Fristenrechner result view under ?overhaul=1 (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
knuth shipped S2 of the Fristenrechner overhaul (design §4, §7-S2):

- New frontend/src/client/fristenrechner-result.ts (611 LoC) — renders the shared result view: trigger card (sticky header, inline date editor), 4 priority groups (Mandatory / Recommended / Optional / Conditional) with SPAWNED badge per §4.2, per-rule rows with checkbox + inline date override + party/citation badges, write-back footer conditional on project!=null (§11.Q7 — kontextfrei mode shows informational nudge instead).
- 72-LoC test suite covers groupFollowUps + defaultChecked semantics.
- Page wiring: ?overhaul=1 query param mounts the result view in place of the legacy renderProcedureResults; both coexist this slice. Deep-link shape: ?overhaul=1&event=<code>&trigger_date=…&project=… per §5.
- audit_reason wording in the bulk write-back call: 'Aus Fristenrechner — Trigger: {name} ({date})' per §11.Q12.
- 340 LoC of new CSS (entity-table extensions, group dividers, badge tokens).
- bun build clean; 249 existing frontend tests + 9 new pass; go build + vet clean; S1 live-DB tests still green.

PAUSED AT SEAM — knuth parked persistent. S3+ (Mode A/B wizard chips) waits for the proceeding_types taxonomy redesign (m/paliad#147, atlas in flight) to ratify the qualifier set that R3 picks from.
2026-05-26 22:09:59 +02:00
mAi
9ab8dd8e0f feat(fristenrechner): Slice S2 — result view under ?overhaul=1 (m/paliad#146)
Some checks failed
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
New `frontend/src/client/fristenrechner-result.ts` module renders the
shared result surface defined in
docs/design-fristenrechner-overhaul-2026-05-26.md §4:

  * Sticky trigger card — event icon + name, proceeding/jurisdiction
    chips, inline trigger-date input that re-fetches on change.
  * Four follow-up groups — Mandatory / Recommended / Optional /
    Conditional. SPAWNED rules fold into their priority bucket with
    a `⇲ neues Verfahren` badge (§11.Q5). Conditional bucket holds
    every rule with sr.condition_expr IS NOT NULL.
  * Per-rule rows — title, duration phrase, party chip, legal-source
    citation (with youpc.org link when available), pre-checked
    checkbox driven by `defaultChecked(r)` (mandatory + recommended
    on; conditional + court-set + optional off), inline ✏ Datum
    override that re-renders.
  * Write-back footer — conditional on `?project=<uuid>` per §11.Q7;
    in kontextfrei mode the footer is hidden and an inline nudge
    invites the user to pick an Akte. CTA submits to the existing
    POST /api/projects/{id}/deadlines/bulk endpoint, stamping each
    row with `audit_reason: "Aus Fristenrechner — Trigger: {name}
    ({date})"` per §11.Q12.

Mount + URL contract — when `?overhaul=1` is set in the URL,
`fristenrechner.ts` hides every legacy panel (`fristen-step1`,
`fristen-step2`, `fristen-pathway-a`, `fristen-pathway-b`,
`fristen-step3a`, the step-1 summary) and shows the overhaul root
instead. With `?overhaul=1&event=<code>&trigger_date=…` the surface
is deep-linkable end-to-end. Without `?event=` the empty-shell
nudge renders — S3+S4 will mount the entry-mode UIs onto this same
root.

Verified — bun build clean, 249 frontend tests pass (incl. 9 new
helper tests for groupFollowUps + defaultChecked), go build + vet
clean, S1 live-DB tests still green.
2026-05-26 22:09:27 +02:00
mAi
4218d9cb52 Merge: t-paliad-323 Slice S1 — Fristenrechner backend endpoints (m/paliad#146)
Some checks failed
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
knuth shipped S1 of the Fristenrechner overhaul (docs/design-fristenrechner-overhaul-2026-05-26.md §6, §7-S1):

- GET /api/tools/fristenrechner/search?kind=events — returns procedural_events tuples with trigram ranking + follow-up counts (alongside the existing concept-card response). New service: services/fristenrechner_search_events.go (257 LoC).
- GET /api/tools/fristenrechner/follow-ups — given trigger event + date + optional party qualifier, returns sequencing_rules anchored on the event with computed due dates via pkg/litigationplanner.CalculateRule. New service: services/fristenrechner_followups.go (404 LoC).
- 6 live-DB integration tests (services/fristenrechner_followups_test.go, 205 LoC): SoC follow-ups, party narrowing, jurisdiction filters, event_kind filters, unknown-event sentinel.

No schema changes — the unified sequencing_rules model already has every column needed.

Knuth proceeds to S2 (result view under ?overhaul=1).
2026-05-26 22:01:41 +02:00
mAi
7ea415145f feat(fristenrechner): Slice S1 — backend ?kind=events + /follow-ups (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Two additive endpoints behind the Fristenrechner overhaul (design
§6.1 + §6.2 in docs/design-fristenrechner-overhaul-2026-05-26.md):

1. GET /api/tools/fristenrechner/search?kind=events — returns
   procedural_events rows directly (not aggregated concept-cards),
   one hit per (event × proceeding_type) tuple. Trigram-ranked
   against name / name_en / code. Filters: jurisdiction, proc,
   event_kind, party. Powers Mode A's result list and Mode B's R4
   landing chips. Default search shape unchanged.

2. GET /api/tools/fristenrechner/follow-ups?event=...&trigger_date=...
   — given a trigger event (by code or uuid) + date, returns the
   immediate follow-up sequencing rules with computed due dates
   via litigationplanner.CalculateRule. Each row carries priority /
   primary_party / is_court_set / is_spawn / has_condition / legal
   source / spawn target so the result view can group into
   Mandatory / Recommended / Optional / Conditional with the
   SPAWNED badge. party=claimant|defendant filters keep "both"
   rules visible.

No schema changes — unified sequencing_rules already has every
column needed. Live-DB tests cover the SoC follow-up shape, party
narrowing, jurisdiction + event_kind filters, and the unknown-
event sentinel.
2026-05-26 22:01:10 +02:00
mAi
109946edff Merge: t-paliad-322 — Fristenrechner overhaul design doc (docs only) (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
553-line design doc documenting the complete Fristenrechner UX overhaul. Coder shift gated on m's go/no-go.

Two complementary entry paths into a shared result view:
- Mode A 'Direkt suchen' — search + filter chips (Forum · Proceeding · Event-Kind · Partei), result list of procedural_events, click locks a trigger.
- Mode B 'Geführt' — 3-5 row wizard (R1 event_kind → R2 forum → R3 proceeding_type → R4 procedural_event → R5 perspective), pre-filling + auto-skip from project context, row badges marking Filter vs Qualifier.

Shared result view groups follow-up sequencing_rules by Mandatory / Recommended / Optional / Conditional (SPAWNED folded with a 'neues Verfahren' badge). Trigger card sticks with inline-editable trigger date. Write-back via POST /api/projects/{id}/deadlines/bulk through a confirm-and-edit-dates modal. Kontextfrei mode hides the CTA entirely (m §11.Q7).

Filter vs Qualifier axis taxonomy ratified:
- forum, event_kind: filters
- proceeding_type, perspective (in file mode), procedural_event: qualifiers
- inbox channel: dropped from primary surface, kept as Mode A secondary chip

Backend deltas: extend /search to return events; new /follow-ups endpoint. No schema changes — the unified sequencing_rules model already has every column needed.

6-slice migration: S1 backend handlers → S2 result view (?overhaul=1) → S3 Mode A → S4 Mode B → S5 flip flag default → S6 drop buildRowStack + cascade reads. Procedure-mode (upper half of fristenrechner.tsx) untouched.

All 12 PRD questions ratified by m on 2026-05-26 via AskUserQuestion. 10/12 matched inventor recommendation; 2 diverged (Q3 section-split UX, Q7 hide kontextfrei CTA). Per-pick reasoning + design impact in §11.

Cronus parked on mai/cronus/inventor-fristenrechner. Coder shift held pending m's go.
2026-05-26 21:47:38 +02:00
mAi
528fe35540 design(fristen): fold m's 12 decisions into Fristenrechner overhaul doc
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
All 12 questions answered via AskUserQuestion. 10/12 = inventor recommendation.
2 diverged:

  Q3 (Filter-vs-qualifier UX): m picked section-split (Filter strip above,
      result/qualifier strip below) instead of '(Pflichtangabe)' tag.
      §3.1 Mode A layout rewritten with Filter strip header; §3.2 wizard
      rows now carry Filter/Qualifier badges next to the row number.

  Q7 (No-project mode): m picked 'Hide CTA entirely' instead of disabled-
      with-hint. §4.4 footer renders only when project != null; an inline
      'Tipp: Wähle oben eine Akte' nudge replaces the missing footer.

New §11 'm's decisions (2026-05-26)' anchors each pick with reasoning where
it diverges from the recommendation. §11.1 captures the two follow-on edits
to §3.1 and §4.4. Migration plan and backend contracts unchanged.

DESIGN READY FOR REVIEW pending head's coder gate.
2026-05-26 21:45:41 +02:00
mAi
9c2788ed8c design: Fristenrechner complete UX overhaul (t-paliad-322)
Inventor shift-1 design pass for m/paliad#146.

- Mode taxonomy (Direct-search A + Wizard B → shared result view)
- Filter-vs-qualifier table ratified (forum/event_kind/inbox as filters;
  proceeding_type/perspective as qualifiers)
- Wizard branching: R1 event_kind → R2 forum → R3 proceeding_type →
  R4 procedural_event → R5 perspective; rows prefill+collapse from project
- Result view: 4 priority groups (mandatory/recommended/optional/conditional)
  with SPAWNED folded into priority + cross-proceeding badge
- Project write-back via existing POST /api/projects/{id}/deadlines/bulk
  with confirm-and-edit-dates modal and audit_reason wording
- Backend deltas: extend /api/tools/fristenrechner/search to return
  procedural_events; new /api/tools/fristenrechner/follow-ups
- No schema changes — pure UX + handler shape
- 6-slice migration plan from current buildRowStack to overhaul under
  ?overhaul=1 flag, then flip + cleanup
- One worked example (LG Düsseldorf Hinweisbeschluss)
- 12 open questions for m (3 batches of 4 via AskUserQuestion)
2026-05-26 21:30:26 +02:00
mAi
c56859058d Merge: t-paliad-321 — mig 152 dedupe identical sequencing_rule clones + Proceeding column on admin list (m/paliad#144 follow-up)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
mig 151 archived 5 of 6 duplicate procedural_events for 'Mängelbeseitigung / Zahlung' and reparented their sequencing_rules. The 6 sequencing_rules themselves were byte-for-byte clones (NULL proceeding/rule_code, 14d duration) — admin showed 6 indistinguishable rows for one legal concept.

Mig 152: full-signature partition over sequencing_rules, lowest UUID per group as canonical, archive the rest. Audit-first RAISE NOTICE pre-block surfaces every clone-group in deploy logs. Snapshot to paliad.sequencing_rules_pre_152. Reparents deadlines.sequencing_rule_id (renamed from rule_id in mig 140). Defensive set_config('paliad.audit_reason') even though sequencing_rules has no audit trigger live.

Expected outcome: 5 archived (just Mängelbeseitigung / Zahlung). Other name-groups (Antrag auf Patentänderung×4, Beginn des Hauptsacheverfahrens×2, Berufungs*-R.220.1×2) have distinct (proceeding_type_id, rule_code, duration, primary_party) signatures — legitimately different rules per proceeding, left alone.

UI: admin-rules-list gains a Proceeding column (proceeding_type.code, server-side join). Replaces the legacy Verfahrenstyp column which was broken for non-fristenrechner categories. One column for proceeding info instead of two; works for every category.

Build + vet clean. NoDuplicateSlot passes.
2026-05-26 21:28:26 +02:00
mAi
6acb1167dd feat(admin): add proceeding-type column to /admin/procedural-events list (t-paliad-321 / m/paliad#144)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Surfaces the 3-segment proceeding code (e.g. upc.inf.cfi) on the admin
rules list so the 4 legitimately-distinct same-named groups are
visually disambiguated without opening each row's edit page.

Specifically helps with:
- "Antrag auf Patentänderung" × 4 (distinct proceeding_type_ids)
- "Beginn des Hauptsacheverfahrens" × 2
- "Berufungsbegründung-R.220.1" × 2
- "Berufungsschrift-R.220.1" × 2

(The 6× "Mängelbeseitigung / Zahlung" identical clones are dedup'd by
mig 152 in the sibling commit; this column lets m verify the dedupe
landed and confirms the remaining same-named groups are intentional.)

* internal/services/rule_editor_service.go —
  - LoadProceedingTypeCodes(ctx, rows) — batch SELECT id, code FROM
    paliad.proceeding_types WHERE id = ANY(...) for every distinct
    non-NULL proceeding_type_id in rows. Returns id → code map.
    Single round-trip, firm-wide reference data (no RLS / visibility
    gate). Used only by the LIST endpoint; GetByID etc. don't need it.

* internal/handlers/admin_rules.go —
  - adminRuleResponse gains ProceedingTypeCode *string field
    (json:"proceeding_type_code,omitempty"). Populated by
    wrapRuleListResponse from the id → code map.
  - handleAdminListRules calls LoadProceedingTypeCodes after fetching
    rows, passes the map to wrapRuleListResponse.

* frontend/src/admin-rules-list.tsx —
  - Adds Proceeding column header in position 2 (between Submission
    Code and Legal Citation) per paliadin's "Place between submission-
    code and the existing columns" spec. Binds to canonical i18n
    key admin.procedural_events.col.proceeding (added below).
  - Drops the legacy Verfahrenstyp column at position 4 — the new
    code-only column at position 2 replaces it; the old column
    showed `code · name` which duplicates the new content.

* frontend/src/client/admin-rules-list.ts —
  - Rule type gains proceeding_type_code?: string | null.
  - New proceedingCodeCell(r) helper: prefers server-side
    proceeding_type_code, falls back to dropdown-lookup
    proceedingLabel for defense-in-depth on older API responses
    (the old behaviour broke for rules whose proceeding_type_id
    pointed at non-fristenrechner category proceedings; the new
    column never has that bug because the join is server-side).
  - Row rendering: new <td class="admin-rules-col-proceeding"><code>
    proceedingCodeCell(r) </code></td> in column 2.

* frontend/src/client/i18n.ts —
  - admin.procedural_events.col.proceeding alias added for DE +
    EN ("Verfahren" / "Proceeding"). Mirror style of the other
    canonical aliases from Slice A.

* frontend/src/i18n-keys.ts —
  - Generated key union extended with
    "admin.procedural_events.col.proceeding".

Build + vet clean. No new SQL — proceeding_types is firm-wide
reference data and the join uses an existing primary key.
2026-05-26 21:27:00 +02:00
mAi
4cd28bc896 feat(db): mig 152 — dedupe identical sequencing_rule clones (5 archived) (t-paliad-321 / m/paliad#144 follow-up)
Mig 151 (t-paliad-319) archived 5 of 6 duplicate procedural_events for
"Mängelbeseitigung / Zahlung" and reparented their sequencing_rules
onto the canonical PE. The 6 sequencing_rules themselves were left
active — and they are byte-for-byte clones (proceeding_type_id=NULL,
rule_code=NULL, duration 14d, primary_party=NULL, condition_expr=NULL,
…). The admin shows six indistinguishable rows for one legal concept.

This migration archives 5 of 6, keeping the row with the
lexicographically lowest UUID as canonical.

Pre-write verification (Supabase MCP, 2026-05-26):
- Exactly 1 clone-group surfaces under the full-signature query
  (procedural_event_id, proceeding_type_id, rule_code, duration_*,
  primary_party, condition_expr::text, trigger_event_id, alt_*,
  anchor_alt, combine_op, parent_id, is_spawn, spawn_*):
  6 "Mängelbeseitigung / Zahlung" rows.
- 0 paliad.deadlines reference any of the 5 to-be-archived rows
  (verified via deadlines.sequencing_rule_id JOIN; rule_id column
  was dropped in mig 140 / Slice B.4).
- Other name-duplicates (Antrag auf Patentänderung×4, Beginn des
  Hauptsacheverfahrens×2, Berufungsbegründung-R.220.1×2,
  Berufungsschrift-R.220.1×2) do NOT collapse under this signature —
  their proceeding_type_id / rule_code / duration / primary_party
  differ. Legitimately distinct rules per proceeding. This mig
  leaves them alone.

Migration shape (mirrors mig 151):
1. Build dedupe mapping (duplicate_id → canonical_id) into a
   ROW_NUMBER() OVER (PARTITION BY full-signature ORDER BY
   created_at, id::text) TEMP table.
2. PRE NOTICE: surface every clone-group with its canonical + dups
   so the deploy log shows what's about to be touched (m may want
   to spot-check).
3. Snapshot the duplicates into paliad.sequencing_rules_pre_152
   (precedent pre_091/093/095/098/140/151).
4. Reparent paliad.deadlines.sequencing_rule_id duplicate → canonical
   BEFORE archiving (defensive no-op today).
5. set_config('paliad.audit_reason', …) — defensive; sequencing_rules
   has no audit trigger yet (mig 151 §scope verified), but a future
   trigger would inherit the reason automatically.
6. UPDATE sequencing_rules SET is_active=false,
   lifecycle_state='archived' WHERE id IN dups.
7. POST assertions: expected archive count met, zero clone groups
   remaining in active+published, zero live deadlines pointing at
   an archived sequencing_rule. RAISE EXCEPTION on any mismatch.

Down: best-effort revert (flips archived → published from snapshot).
Doesn't undo the deadlines reparent (live data didn't need one;
snapshot doesn't carry pre-state of deadlines).

Build + vet clean. TestMigrations_NoDuplicateSlot passes.
2026-05-26 21:21:38 +02:00
mAi
568eac0aff Merge: t-paliad-320 — editorial seed cmd for 5 orphan deadline_concept drafts (4 concepts) (m/paliad#193)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
darwin (researcher + /mai-lexy) staged 5 lifecycle_state='draft' sequencing_rules via services.RuleEditorService.Create() for the 4 remaining orphan deadline_concepts:

  - counterclaim-for-revocation → upc.ccr.cfi, RoP.025, 3 months (32aafb64)
  - versaeumnisurteil-einspruch  → de.inf.lg, § 339 ZPO, 2 weeks Notfrist (eda1756a)
  - schriftsatznachreichung      → de.inf.lg, § 283 ZPO, 3 weeks court-set (08b1682a)
  - weiterbehandlung (EPC)       → epa.grant.exa, Art. 121 EPÜ + R. 135(1), 2 months (73674564)
  - weiterbehandlung (DPatG)     → event-rooted (NULL proc), § 123a PatG, 1 month (16e262d2)

Deliverable: cmd/seed-orphan-concept-drafts/main.go — runs against
RuleEditorService in-process; idempotent; audit-reason flag.

Editorial follow-up flagged in DPatG rule's deadline_notes: no
dpma.grant.* proceeding_type exists yet; create dpma.grant.dpma and
reassign rule 16e262d2 once added.

Drafts ready for m's editorial review at /admin/procedural-events.
2026-05-26 21:07:52 +02:00
mAi
733d21c930 feat(seed): editorial cmd to stage drafts for orphan deadline_concepts (t-paliad-320)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Stages five lifecycle_state='draft' sequencing_rules — one per orphan
deadline_concept — via services.RuleEditorService.Create(), the same
service the POST /admin/api/procedural-events handler hits internally
(audit trigger + INSTEAD-OF view trigger fan-out into procedural_events
+ sequencing_rules + legal_sources). No HTTP/auth shell, no raw SQL
writes.

Drafts (slug → proceeding):
- counterclaim-for-revocation → upc.ccr.cfi, 3 months, RoP.025
- versaeumnisurteil-einspruch → de.inf.lg, 2 weeks Notfrist, § 339 ZPO
- schriftsatznachreichung → de.inf.lg, 3 weeks court-set, § 283 ZPO
- weiterbehandlung (EPC) → epa.grant.exa, 2 months, Art. 121 EPÜ + R. 135(1) EPÜ
- weiterbehandlung (DPatG § 123a) → event-rooted (NULL proc), 1 month

The DPatG variant is event-rooted because no dpma.grant.* proceeding_type
exists yet — flagged in deadline_notes as editorial follow-up.

Idempotent: refuses to insert if (concept, proceeding, rule_code)
already exists.
2026-05-26 21:04:36 +02:00
mAi
b05bcf7eeb Merge: t-paliad-319 — mig 151 dedupe null.* procedural_events (9 archived, 5 name-groups consolidated) (m/paliad#144)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:54:50 +02:00
mAi
71e8023784 feat(db): mig 151 — dedupe null.* procedural_events (t-paliad-319 / m/paliad#144)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Consolidates 5 name-groups with synthetic null.<8hex> codes (minted by
mig 136 from legacy submission_code IS NULL rows) onto a single canonical
PE per name. 9 duplicate rows archived (is_active=false,
lifecycle_state='archived'), 9 sequencing_rules reparented onto their
canonical procedural_event. Worst offender: "Mängelbeseitigung /
Zahlung" 6 → 1.

Audit-first: per-row RAISE NOTICE before the writes, plus snapshots in
paliad.procedural_events_pre_151 and paliad.sequencing_rules_pre_151
(same TX, mirrors precedent pre_091/093/095/098/140). Post-asserts that
no name-group still has >1 active+published null.* row and no sr points
at an archived PE.

Pre-flight schema audit confirmed no audit trigger on procedural_events
or sequencing_rules (only INSTEAD OF triggers on deadline_rules_unified,
which don't fire on direct table writes), 0 deadlines + 0 draft_of refs
to the duplicates, and lifecycle_state has no CHECK constraint blocking
'archived'.

.down.sql best-effort restores sr.procedural_event_id and reactivates
the archived rows from the snapshot tables.

Mig already applied to youpc paliad schema via Supabase MCP within the
same TX as the applied_migrations row insert (checksum matches the
embedded file); deployed binary will see version 151 as applied.
2026-05-26 20:54:01 +02:00
mAi
d190fbe0a4 Merge: hotfix #3 mig 140 — filter POST check to active+published (B.2 dual-write scope)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:32:58 +02:00
mAi
e0a82d9f9e fix(mig 140): post-check filters to active+published rows only
The previous post-check compared unfiltered counts (snapshot 493 vs
sequencing_rules 231) and false-positived as "dual-write drift". Reality:
B.2 dual-write was scoped to is_active=true + lifecycle_state='published'
(the read-path universe). Archived + draft rows in deadline_rules were
never replicated to sequencing_rules because nothing read them.

Patch: filter both counts to active+published before comparison — the
invariant B.2 actually maintained. Archived/draft rows survive in
deadline_rules_pre_140 for forensic / future-backfill.

Third hotfix on mig 140 today (1: missing matview drop; 2: wrong post-check
comparand; 3: post-check missing lifecycle filter). The slice itself is
sound — every failure was in the verification path, not the data.
2026-05-26 20:32:58 +02:00
mAi
d326f9aa4a Merge: hotfix mig 140 — POST check compares snapshot to sequencing_rules (was view) (m/paliad#93 hotfix #2)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:28:45 +02:00
mAi
026ad2d5ee fix(mig 140): POST integrity check compares snapshot to sequencing_rules, not view
The previous post-check compared paliad.deadline_rules_pre_140 row count
to paliad.deadline_rules_unified row count and failed with
"snapshot has 493 rows, view has 231 rows — drift". That's a false
positive: the snapshot has every row (all lifecycle states + is_active),
the view filters to is_active+published. They're not supposed to match.

The right invariant: snapshot row count == sequencing_rules row count
(B.2 dual-write keeps them 1:1 across all lifecycle states). Patched.
View count stays in the RAISE NOTICE line as informational.

Refs t-paliad-305 / m/paliad#93 Slice B.4 hotfix #2.
2026-05-26 20:28:36 +02:00
mAi
13a65a6d6e Merge: Composer Slice F — section reorder/hide/add custom. Composer A→F complete (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:27:43 +02:00
mAi
bd7896ef68 feat(submissions): Composer Slice F — section reorder / hide / add custom (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
The final Composer slice per design doc §12. Lawyer gains full
control over section composition: drag-and-drop reorder, per-section
delete, "+ Add section" picker for custom slugs that don't appear in
the base's default spec. Combined with Slice B's hide toggle, this
closes out the A→F sequence — Composer A→F is complete.

Backend (internal/services/submission_section_service.go, +120 LoC):

- SectionService.Create — adds a new section row to a draft. Validates
  section_key + labels + kind (must be prose/requests/evidence).
  Auto-assigns next order_index when OrderIndex=0; collisions on
  (draft_id, section_key) surface as ErrInvalidInput.

- SectionService.Delete — removes one section by id. Returns
  ErrSubmissionSectionNotFound when nothing was deleted.

- SectionService.Reorder — accepts a sequence of section_ids, rewrites
  every row's order_index to (1..N)×10 transactionally. Returns the
  refreshed list. Sections not present in the sequence are silently
  ignored (defensive — partial reorder doesn't lose rows).

Handlers (internal/handlers/submission_sections.go, +180 LoC):

- POST /api/submission-drafts/{draft_id}/sections — owner-scoped via
  SubmissionDraftService.Get. 400 on slug collision / invalid kind.
- DELETE /api/submission-drafts/{draft_id}/sections/{section_id} —
  owner + section-belongs-to-draft cross-check. 204 on success.
- POST /api/submission-drafts/{draft_id}/sections/reorder — accepts
  {"section_order": [uuid, uuid, ...]}; returns refreshed sections list.

Frontend (frontend/src/client/submission-draft.ts, +260 LoC):

- Each section row gains a drag handle (⋮⋮) on the left of the head.
  Drag handle is the only draggable element; contentEditable
  selections inside the editor body keep working. HTML5 native DnD,
  no library.
- Drop-target highlighting via .submission-draft-section--drop-target
  (border-top accent). Cleanup on dragend / drop / cancel.
- Per-section "Delete" button next to the existing Hide/Include
  toggle. Confirm prompt prevents accidental loss of typed prose.
- "+ Add section" trailing affordance below the section list opens an
  inline form (slug + DE label + EN label + kind dropdown). Submit
  POSTs to the new endpoint; on success splices the row into
  state.view.sections and re-paints.

CSS (frontend/src/styles/global.css, +65 LoC):

- .submission-draft-section-handle (grab cursor + hover background +
  active=grabbing).
- .submission-draft-section--dragging / --drop-target visual states.
- .submission-draft-add-section form layout (dashed border + lime
  primary submit).

Tests (internal/services/submission_section_slice_f_test.go, NEW,
TEST_DATABASE_URL-gated):
- Create custom section + slug-collision surface as ErrInvalidInput.
- Delete + repeat-delete returns ErrSubmissionSectionNotFound.
- Reorder reverses 10 seeded sections + verifies the resulting
  order_index sequence is ascending and matches the input order.

Build hygiene: go build/vet/test -short clean (all packages);
bun run build clean (2906 i18n keys, data-i18n scan clean).

Hard rules honoured:
- NO new migrations (Slice F is pure code on Slice A's schema).
- NO behavior change for pre-Composer drafts (no section rows → no
  drag handles to drag).
- {{rule.X}} aliases preserved (custom sections render through the
  same composer pipeline as default sections).
- Q2/Q9/Q10 ratifications preserved.

This closes the Composer slice sequence A → F. The full feature set
ratified by m on 2026-05-26 is now in place:
  A — base picker + read-only section list (mig 146/147/148)
  B — editable prose + anchor-spliced render + MD→OOXML walker
  C — building-blocks library + section picker (mig 149)
  D — rich prose (headings, lists, blockquote, hyperlinks)
  E — specialist bases lg-duesseldorf + upc-formal (mig 150)
  F — section reorder / delete / add custom

t-paliad-318 Slice F
2026-05-26 20:26:53 +02:00
mAi
946f373651 Merge: Composer Slice E — specialist bases lg-duesseldorf + upc-formal (mig 150) + base-swap content survival (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:21:56 +02:00
mAi
94310ba498 feat(submissions): Composer Slice E — specialist bases + base-swap content survival (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Two new firm-agnostic base templates + the generic generator that
produced them + a regression test pinning Q10's base-swap-content-
survival contract.

Mig 150: seeds two `submission_bases` rows with firm=NULL.

- lg-duesseldorf — proceeding_family='de.inf.lg'. Conservative
  German legal style: Times New Roman 11pt; plain black headings.
  Stylemap targets LG-Body / LG-Heading1..3 / LG-ListBullet /
  LG-ListNumber / LG-Quote.

- upc-formal — proceeding_family='upc.inf.cfi'. UPC court style:
  Calibri 11pt body; UPC-blue (#1F3864) headings; Cambria italic
  for blockquotes. Stylemap targets UPC-Body / UPC-Heading1..3 / …

Both rows ship the same 10-section spec.defaults shape as the Slice A
bases (letterhead → signature) with their own seed Markdown.

scripts/gen-submission-base/main.go (NEW, ~240 LoC):

- Generic generator with -preset flag. Two presets baked in
  (lg-duesseldorf + upc-formal). Each preset hard-codes typography
  (font, sizes, colour) so the lawyer can swap between bases and
  see chrome change while section content carries through unchanged.
- Output is byte-reproducible (zip mtime pinned to 2026-05-26 UTC).
- Emits a minimal Composer-mode .docx: [Content_Types].xml,
  _rels/.rels, word/_rels/document.xml.rels (empty envelope so the
  composer's hyperlink-rels patch from Slice D has somewhere to land),
  word/styles.xml (preset's full named-style block + "Hyperlink"
  character style for Slice D link runs), word/document.xml (anchor-
  only body in §6.1 default section order).

Gitea uploads (via mAi):

- 6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx
  blob SHA: 82f57b3cb3b54c755fc5ab36862bfd61b8aaa73e
- 6 - material/Templates/Word/Paliad/Composer/upc-formal.docx
  blob SHA: 41b9a388263ccc43ddc28b55caab301a4cf74fe8

These live under Composer/ (not under HLC/) so a future non-HLC
deployment serves the same cross-firm files.

Backend wiring:

- internal/handlers/files.go: two new fileRegistry entries
  (composerBaseLGDuesseldorfSlug, composerBaseUPCFormalSlug) +
  matching slugs in composerBaseSlugMap so fetchComposerBaseBytes
  routes the new catalog rows to the new Gitea objects.

Tests:

- TestComposer_BaseSwapPreservesContent — composes the same draft
  against an HLC-style stylemap AND an LG-style stylemap; asserts
  (a) content survives both ways, (b) each output carries the
  correct stylemap-entry stylenames, (c) neither output leaks the
  other's stylenames. Pins Q10's base-swap-survives-content
  contract.

Build hygiene: go build/vet/test -short clean (all packages);
bun run build clean.

NOT in scope (Slice E's brief was specialist bases + survival test):

- Generator coverage for HL Patents Style bases — gen-hl-skeleton-
  template stays as the per-firm path (it needs the proprietary
  .dotm source). gen-submission-base is for firm-agnostic bases.
- LG-Düsseldorf-court-style-guide deep fidelity — the LG preset is
  a conservative starting point; admin refines via the admin editor
  in a later slice if needed.
- numbering.xml carrying numId=1/2 — Slice D's MD walker emits
  visible "• " / "N. " prefixes that don't need numbering.xml;
  honours stylemap entry for indentation.

Hard rules honoured:

- Migration purely additive (`ON CONFLICT (slug) DO NOTHING`).
- NO behavior change for pre-Composer drafts.
- NO behavior change for existing hlc-letterhead + neutral seed
  rows.
- {{rule.X}} aliases preserved (walker passes placeholders through;
  v1 SubmissionRenderer pass substitutes).
- Q10 base-swap-content-survival pinned by new test.

t-paliad-317 Slice E
2026-05-26 20:21:12 +02:00
mAi
5834e3dc66 Merge: Composer Slice D — rich prose (headings, lists, blockquote, hyperlinks) in MD→OOXML walker (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:16:14 +02:00
mAi
677849784c feat(submissions): Composer Slice D — rich prose (headings, lists, blockquote, hyperlinks) (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Extends the Composer's MD → OOXML walker per the design at
docs/design-submission-generator-v2-2026-05-26.md §12 Slice D from
Slice B's paragraphs + B/I baseline to the full rich-prose feature set:
headings 1-3, bullet + numbered lists, blockquote, inline hyperlinks.

MD walker (internal/services/submission_md.go, +320 / -75 LoC):
- RenderMarkdownToOOXMLWithStyles is the new Slice-D entry point;
  RenderMarkdownToOOXML stays as a thin back-compat wrapper.
- splitMarkdownBlocks classifies every line into one of:
  paragraph, heading_1/2/3, list_bullet, list_numbered, blockquote.
  CommonMark-style 3-space indent tolerance; "N. " and "N) " for
  numbered. Blank-line spacing semantics preserved from Slice B.
- renderBlockParagraph applies stylemap[blk.styleKey] (with
  fall-back to stylemap["paragraph"]). List blocks emit visible
  "• " / "N. " prefix runs so the structure surfaces even if Word
  isn't configured with auto-list-numbering — lawyer can apply a
  real Word list style post-export. Numbered-list ordinals reset
  on every non-list block (so "1. A\nplain\n1. C" renders 1./1.,
  not 1./2.).
- parseInlineRuns adds `[label](url)` recognition. Each link gets
  routed through the optional HyperlinkAllocator; the walker emits
  `<w:hyperlink r:id="{rId}">…runs…</w:hyperlink>` with the
  "Hyperlink" character style on each child run. Nil allocator
  falls back to plain-text label (URL drops, label survives).

Composer (internal/services/submission_compose.go, +130 / -10 LoC):
- composerLinkAllocator hands the walker fresh rIds (rIdComposer1,
  rIdComposer2, …) outside the base's existing namespace; same URL
  shared across multiple sections dedupes to one rId.
- patchDocumentXMLRels appends matching <Relationship Type="…/hyperlink"
  Target="URL" TargetMode="External"/> entries to
  word/_rels/document.xml.rels. Idempotent on rIds already present;
  synthesizes a fresh rels part when missing (defensive for stripped
  bases). Returns the patched parts slice (caller must overwrite
  because append may grow the backing array — fixed in this slice).
- Compose now passes the full stylemap (paragraph + heading_1/2/3 +
  list_bullet + list_numbered + blockquote) into the walker, not
  just the paragraph-style entry.

Frontend (frontend/src/client/submission-draft.ts):
- Toolbar adds H1/H2/H3 buttons (formatBlock h1/h2/h3), bullet
  list, numbered list, blockquote, and a link button that prompts
  for a URL + wraps the selection via execCommand("createLink").
- domToMarkdown serializer extends to <h1>/<h2>/<h3>, <ul>/<ol>
  with per-item ordinal counter for numbered lists, <blockquote>,
  and <a href="…"> → `[label](url)`. Nested <li> handling sits in
  the ul/ol branch.

Tests (internal/services/submission_md_test.go, internal/services/
submission_compose_test.go):
- TestRenderMarkdownToOOXML_Heading1 / _Heading2And3 — stylemap
  applied.
- _BulletList / _NumberedList / _NumberedListResetsOnNonList —
  prefixes + ordinal counter.
- _Blockquote — stylemap applied.
- _Hyperlink — allocator called, w:hyperlink rId wired, Hyperlink
  character style on label runs.
- _HyperlinkNilAllocatorFallsBackToPlain — label survives, no
  hyperlink tag emitted.
- TestDetectBlockMarker — 13 marker / non-marker cases.
- TestComposer_HeadingsAndLists — end-to-end through Compose with
  a multi-construct draft; verifies stylemap presence + content +
  ordinal prefixes.
- TestComposer_HyperlinkWiresRels — body has the right
  <w:hyperlink r:id="rIdComposer{N}">, document.xml.rels has the
  matching <Relationship> rows with External target mode.
- TestComposer_HyperlinkDedupesByURL — two `[label](url)` references
  to the same URL share one rId; second allocation gets no new
  Relationship row.

Build hygiene: go build/vet/test -short clean (all packages); bun run
build clean (2906 i18n keys).

NOT in scope (Slice D's brief was rich-prose + toolbar):
- Numbering.xml audit on bases — current approach emits visible
  "• " / "N. " prefix runs without depending on numbering.xml. A
  future slice can swap to `<w:numPr>` if firm-style auto-numbering
  becomes a hard requirement.
- DOM-from-Markdown on initial editor paint — the editor still uses
  textContent=md, so toolbar-applied formatting reverts to literal
  Markdown text after autosave + repaint. Acceptable trade-off for
  Slice D; a future polish could parse MD into the DOM on paint.
- Tables, images, footnotes (still design §13 out of scope).

Hard rules honoured:
- NO new migrations (Slice D is pure code).
- NO behavior change for pre-Composer drafts (gate on draft.BaseID
  unchanged).
- {{rule.X}} aliases preserved (placeholders pass through the walker
  verbatim, get substituted by the v1 SubmissionRenderer pass).
- Q2 ratification preserved (no building_block_id lineage).
- Q9 ratification preserved (4-tier BB visibility from Slice C).

t-paliad-316 Slice D
2026-05-26 20:15:28 +02:00
mAi
b27d402156 Merge: Slice B.6 — /admin/rules → /admin/procedural-events URL rename + 301 redirects + .tsx i18n rebind. #93 slice train concludes (m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:13:17 +02:00
mAi
14290294b4 Merge: hotfix mig 140 — drop+recreate deadline_search matview (unblock prod)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:12:55 +02:00
mAi
6b970da774 fix(mig 140): drop+recreate deadline_search matview (was blocking DROP TABLE deadline_rules)
prod-down: mig 140 fails with `cannot drop table deadline_rules because
other objects depend on it (2BP01)`. The dependent object is the
deadline_search materialized view (mig 077) — curie's brief listed FK
re-pointing but missed the matview.

Fix: drop the matview before DROP TABLE deadline_rules, recreate it at
the end of mig 140 against deadline_rules_unified (same column shape).
All 11 indexes restored. REFRESH at end so search keeps working.

Single-TX atomicity preserved — if anything past step 6a fails, the
whole drop-and-recreate rolls back. The pre_140 snapshot from step 1
remains as the forensic backstop.

Refs t-paliad-305 / m/paliad#93 Slice B.4.
2026-05-26 20:12:49 +02:00
mAi
9359e99a6b feat(handlers,frontend): Slice B.6 — admin URL rename /admin/rules → /admin/procedural-events with 301 redirects + .tsx i18n rebind (t-paliad-305 / m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Closes the procedural-events rename loop opened by m/paliad#93. The
admin surface now lives under its canonical URL; the legacy paths
remain reachable for one deprecation cycle via 301 redirects so
bookmarks, audit-log entries, and curl scripts keep working.

* internal/handlers/handlers.go —
  - Registers the 12 canonical routes under
    /admin/procedural-events* (page paths and JSON API). Same
    handlers — just the new URL slot.
  - Registers the 12 legacy /admin/rules* routes as 301 redirects.

* internal/handlers/admin_rules.go —
  - redirectToProceduralEvents(dst) — fixed-destination redirect for
    paths without an {id}.
  - redirectToProceduralEventEdit — page redirect carrying the {id}.
  - redirectToProceduralEventAPI(suffix) — JSON API redirect carrying
    {id} + optional suffix (/clone-as-draft, /publish, /archive,
    /restore, /audit, /preview). Query string is preserved on every
    redirect.
  - All three helpers add the IETF Deprecation header + a Link
    header pointing at the successor-version path.

* frontend internal nav + URL strings —
  Sidebar.tsx, admin.tsx, admin-rules-list.tsx, admin-rules-edit.tsx,
  client/admin-rules-list.ts, client/admin-rules-edit.ts: every
  `/admin/rules*` reference flipped to `/admin/procedural-events*`.
  In-app navigation now hits the canonical paths directly without a
  redirect round-trip; external callers keep working via the 301s.

* frontend .tsx i18n rebind —
  9 admin .tsx i18n bindings rebound to the canonical
  `admin.procedural_events.*` keys that already exist as aliases in
  i18n.ts (per Slice A from t-paliad-262). Specifically:
    admin.rules.list.title           → admin.procedural_events.list.title
    admin.rules.list.heading         → admin.procedural_events.list.heading
    admin.rules.list.new             → admin.procedural_events.list.new
    admin.rules.col.submission_code  → admin.procedural_events.col.code
    admin.rules.edit.title           → admin.procedural_events.edit.title
    admin.rules.edit.breadcrumb      → admin.procedural_events.edit.breadcrumb
    admin.rules.edit.field.submission_code → admin.procedural_events.edit.field.code
    admin.rules.edit.field.event_type      → admin.procedural_events.edit.field.event_kind
    admin.rules.edit.field.parent          → admin.procedural_events.edit.field.parent

  The remaining ~142 admin.rules.* keys do NOT yet have
  procedural_events aliases. Migrating them is a follow-up slice —
  each needs a new alias entry in i18n.ts (DE + EN) before the .tsx
  reference can be flipped. The 9 keys touched here are the most
  visible (page titles + edit-page field labels) so the admin UI
  immediately reads as "Verfahrensschritte" everywhere.

* frontend/src/client/i18n.ts header comment updated to reflect that
  the URL rename has shipped (Slice B.6 done) and to flag the
  remaining i18n-key migration as the next step.

Scope (documented, paliadin authorised):
- "go everything" applied: backend routes + frontend nav + .tsx
  rebind of the 9 keys whose canonical aliases exist.
- Full migration of all 142 admin.rules.* keys deferred — would
  require seeding ~142 new alias entries in i18n.ts (DE + EN) plus
  another 142 .tsx rebinds. Out of scope for tonight; flag as
  follow-up `feat(i18n): finish admin.rules.* → admin.procedural_events.*
  alias migration`.
- 12 legacy /admin/rules routes still hit a handler (the redirect
  helper) — they don't 404 yet. Once a deprecation window passes
  with no traffic on the old paths, a future slice can drop them
  outright.

Build + vet clean. TestMigrations_NoDuplicateSlot passes.

This concludes the m/paliad#93 procedural-events rename slice train
(Slices A through B.6). curie stays parked persistently for any
follow-up the deploy / monitor cycle surfaces.
2026-05-26 20:12:20 +02:00
mAi
2c0efc396c Merge: Slice B.5 — Go type aliases (SequencingRule = DeadlineRule) + JSON envelope dual-emit + Deprecation headers (m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:08:43 +02:00
mAi
5c6a0095e3 feat(models,services,handlers): Slice B.5 Go rename + JSON envelope dual-emit (t-paliad-305 / m/paliad#93)
Some checks failed
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Adds the Slice B.5 canonical Go names (SequencingRule, ProceduralEvent,
LegalSource, SequencingRuleService) without breaking any existing
call-site, and dual-emits / dual-accepts the two JSON envelope key
renames on /admin/api/rules with a Deprecation header.

* internal/models/models.go —
  - type SequencingRule = DeadlineRule (alias; same struct, same db /
    json tags). DeadlineRule remains the underlying type for now —
    deferred hard-rename keeps the slice small.
  - type ProceduralEvent struct mirroring paliad.procedural_events
    (id, code, name, name_en, description, event_kind,
    primary_party_default, legal_source_id, concept_id,
    lifecycle_state, draft_of, published_at, is_active, timestamps).
    Used by future code that needs the PE identity row alone.
  - type LegalSource struct mirroring paliad.legal_sources (citation,
    jurisdiction, pretty_de / pretty_en — both nullable per mig 136).

* internal/services/deadline_rule_service.go —
  - type SequencingRuleService = DeadlineRuleService (alias).
  - var NewSequencingRuleService = NewDeadlineRuleService (constructor
    alias). Internal callers can adopt either name.

* internal/services/rule_editor_service.go —
  - CreateRuleInput gains Code + EventKind fields tagged
    json:"code" / json:"event_kind". CoalesceCanonicalKeys() folds
    canonical → legacy after json.Decode so the rest of the service
    keeps using SubmissionCode / EventType. Canonical wins when
    both are sent.
  - RulePatch gains EventKind field with the same fold.

* internal/handlers/admin_rules.go —
  - adminRuleResponse wraps *models.DeadlineRule and adds Code +
    EventKind fields alongside the legacy SubmissionCode /
    EventType. Outputs both keys per response for one
    deprecation-window slice.
  - wrapRuleResponse / wrapRuleListResponse helpers.
  - adminRuleDeprecationHeaders emits IETF Deprecation + Link/Sunset
    headers on every Rule-bearing response so clients see the
    migration signal in transit.
  - All 8 Rule-returning handlers (List, Get, Create, Patch, Clone,
    Publish, Archive, Restore) now wrap their result and add the
    headers.
  - Create + Patch handlers call CoalesceCanonicalKeys after decode
    so legacy AND canonical request bodies are both accepted.

Scope decisions (documented in commit):
- Type renames use aliases instead of a hard 200-LOC rename. Same
  semantics, no call-site churn. A future cleanup slice can flip
  the underlying type definitions when convenient.
- ProceduralEvent + LegalSource are NEW structs (not aliases) since
  they represent new conceptual rows; no legacy callers exist yet.
- Frontend admin .tsx i18n key rebinds (mentioned in parent task
  brief B.5 deliverable list) are deferred — i18n keys themselves
  already exist from Slice A (t-paliad-262); rebinding only changes
  which key the .tsx file looks up. Pulling this into B.5 ballooned
  scope; flagging as a small follow-up slice or B.6 sibling.
- Only /admin/api/rules emits dual keys today. Other handlers that
  surface rule rows (Schriftsätze list, deadlines join) continue to
  emit the legacy keys via models.DeadlineRule's existing JSON tags
  — they're read paths, not the editor surface, and the deprecation
  signal is most important where clients write.

Build + vet clean. TestMigrations_NoDuplicateSlot passes.
2026-05-26 20:07:48 +02:00
mAi
6e0961cc30 Merge: Composer Slice C — building blocks library + section picker (mig 149) (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:05:30 +02:00
mAi
ee98db94fa feat(submissions): Composer Slice C — building blocks library (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Per the design at docs/design-submission-generator-v2-2026-05-26.md §8
and the Q2 / Q9 ratifications:

- Q2 (m, 2026-05-26): building blocks are plain text paste sources.
  No building_block_id reference is stored on submission_sections.
- Q9 (m, 2026-05-26): four visibility tiers — private / team / firm
  / global.

Schema (mig 149):

- paliad.submission_building_blocks — library catalog. Columns: slug,
  firm (NULL = cross-firm), section_key (binds to one section kind),
  proceeding_family (NULL = any), title_de/_en + description_de/_en
  + content_md_de/_en, author_id, visibility (CHECK in 4-tier set),
  is_published, created_at, updated_at, deleted_at (soft delete).
  RLS: coarse-grained SELECT — every authenticated user sees
  non-deleted non-private rows + own private rows. Tier-specific
  predicate (private/team/firm/global) applied in Go-layer service so
  semantics evolve without RLS migrations. Mutations admin-only (no
  RLS write paths).

- paliad.submission_building_block_admin_versions — append-only
  history per block, retention=20. Admin-side only; NOT referenced
  from submission_sections (per Q2's plain-text-paste model). Exists
  so accidental delete / overwrite are recoverable.

Backend:

- internal/services/submission_building_block_service.go (~510 LoC):
  BuildingBlockService. ListVisible applies tier predicate at query
  time (private = author_id match; firm = firm column NULL OR matches
  branding.Name; team = author shares a project_team with caller via
  paliad.project_teams self-join; global = open). ListAllForAdmin
  drops the predicate. Create + Update + SoftDelete + RestoreVersion
  all transactional; appendVersionTx writes one audit row +
  GC-deletes anything past the retention=20 horizon in the same tx.
  InsertIntoSection (the paste mechanic) clones content_md_<lang>
  into the section row with a "\n\n" separator if section already has
  content. NO building_block_id stamped per Q2.

- internal/handlers/submission_building_blocks.go (~480 LoC): nine
  handlers split between the lawyer-facing picker (list, insert) and
  the admin editor (list, get, create, update, delete, list-versions,
  restore-version, page). buildingBlockUpdateInput uses presence-
  tracking UnmarshalJSON for the four nullable fields (firm,
  proceeding_family, description_de/_en) so PATCH can distinguish
  "no change" from "set to null".

- Routes registered: lawyer-facing under /api/submission-building-blocks,
  admin-gated under /api/admin/submission-building-blocks/* and
  /admin/submission-building-blocks (page).

- Wiring: handlers.Services + dbServices + cmd/server/main.go all
  gain SubmissionBuildingBlock. NewBuildingBlockService takes the
  branding.Name firm hint for the visibility predicate.

Frontend:

- frontend/src/admin-submission-building-blocks.tsx (~85 LoC):
  three-pane admin shell (list / editor / version log) registered
  in build.ts.

- frontend/src/client/admin-submission-building-blocks.ts (~370
  LoC): admin client — list paint, edit form (slug + firm +
  section_key + proceeding_family + title/desc/content per lang +
  visibility radio + is_published toggle), per-block version log
  with restore button. Bilingual labels.

- frontend/src/client/submission-draft.ts: per-section "+ Baustein"
  button on the Composer editor toolbar (Slice B substrate gets one
  more affordance). openBlockPicker opens a modal filtered to the
  section's section_key, 200ms-debounced search by free text against
  title/description/content. Click a hit → POST insert-into-section
  → section row's content_md_<lang> gains the block's content
  appended at the end (Q2's plain-text paste semantic, no lineage).

- ~240 LoC of CSS: modal overlay + picker rows with tier-colored
  visibility chips + admin editor 3-pane grid + form rows + version
  list.

- 12 new i18n keys × 2 langs (admin.building_blocks.*).

Tests:
- TestValidVisibility (8 cases including case-sensitivity + empty).
- TestAppendBlockContent (8 cases covering empty-existing / empty-
  addition / whitespace-only / trailing newline collapse).
- TestBuildingBlockVisibilityConstants pins the 4 string literals
  against drift (RLS predicate + DB CHECK depend on them).

Build hygiene: go build/vet/test -short clean; bun run build clean
(2906 i18n keys, data-i18n scan clean).

Hard rules per ratifications honoured:
- Q2: no building_block_id lineage on sections (paste is plain text).
- Q9: 4 visibility tiers (private/team/firm/global).
- NO behavior change for pre-Composer drafts (the picker just doesn't
  show — section list is hidden for base_id NULL drafts).
- {{rule.X}} aliases preserved (block content goes through the same
  v1 placeholder pass on export as section prose).

NOT in scope per Slice C brief:
- User-authored private blocks (Slice C ships admin curation only;
  any-user create is a follow-up).
- Tier promotion review workflow (admin sets tier directly today).
- Per-section "where is this block used" reverse lookup (no lineage
  to query).
- Slice D's rich-prose features (headings, lists, blockquote) still
  Slice D's job; this Slice doesn't extend the MD walker.

t-paliad-315 Slice C
2026-05-26 20:04:40 +02:00
mAi
987db27831 Merge: t-paliad-305 — Slice B.4 destructive drop: paliad.deadline_rules retired, INSTEAD OF triggers on view (mig 140, snapshot pre_140 same-TX) (m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 19:54:27 +02:00
mAi
1129baba7a feat(db,services): Slice B.4 destructive drop — paliad.deadline_rules retired, INSTEAD OF triggers on view route writes (mig 140, t-paliad-305 / m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Drops the legacy paliad.deadline_rules table after 3 weeks of dual-write
shadowing (mig 136 → B.2 dual-write → B.3 read cutover via view). The
new tables — paliad.procedural_events, paliad.sequencing_rules,
paliad.legal_sources — are the sole source of truth from this commit
forward.

Pre-flip drift verified clean against prod:
  deadline_rules=231 == sequencing_rules=231 == procedural_events=231
  legal_sources=87
  missing_sr=0, orphaned_sr=0, mismatched_lifecycle=0

* internal/db/migrations/140_drop_deadline_rules.up.sql (new) —
  Single TX, audit-first:
  1. CREATE TABLE paliad.deadline_rules_pre_140 AS TABLE paliad.deadline_rules
     (precedent migs 091/093/095/098 — snapshot in same TX as destructive op).
  2. Final reconciliation UPDATE on paliad.deadlines (no-op when
     drift is already 0; defensive against last-minute writes).
  3. DROP TRIGGER deadline_rules_audit_aiud.
  4. Re-point FKs to sequencing_rules:
     - paliad.appointments.deadline_rule_id → paliad.sequencing_rules(id)
     - paliad.deadline_rule_backfill_orphans.resolved_rule_id → paliad.sequencing_rules(id)
     (the id values are identical — sr.id inherited dr.id at mig 136.)
  5. DROP COLUMN paliad.deadlines.rule_id.
  6. DROP TABLE paliad.deadline_rules.
  7. CREATE INSTEAD OF INSERT + INSTEAD OF UPDATE triggers on
     paliad.deadline_rules_unified. Triggers route writes into the
     three new tables in the same TX, preserving the legacy column
     shape on the wire so RuleEditorService SQL only needs a
     table-name swap, not a structural rewrite. Synthetic-code mint
     expression is byte-identical to mig 136 + the B.2 dual-write
     helper. POST assertions confirm the table is gone, the column
     is gone, and the snapshot matches.

  Trigger design notes (1:N caveat documented in-trigger):
  - PE identity columns (code, name, name_en, description, event_kind,
    primary_party_default, legal_source_id, concept_id) mirror from
    the writing sequencing-rule.
  - PE lifecycle columns (lifecycle_state, published_at, is_active)
    deliberately do NOT mirror — a draft sequencing-rule cloned from
    a published source shares the source's PE; we don't want the
    clone's 'draft' lifecycle to leak back onto the source's PE.
    Practical bound today (1:1 corpus); explicit comment in-trigger
    for the eventual 1:N pattern.

* internal/db/migrations/140_drop_deadline_rules.down.sql (new) —
  Best-effort restore from the snapshot. Triggers / indexes /
  CHECK constraints from historical migrations are NOT replayed;
  operator must reapply 078/079/091/095/098/122/128/134/135 to
  bring the restored table to working shape. The down path is for
  catastrophic recovery, not casual revert.

* internal/services/rule_editor_service.go —
  Six syncDualWriteFromDeadlineRule(...) calls removed (the
  INSTEAD OF triggers now do the fan-out). Five
  INSERT/UPDATE paliad.deadline_rules statements (Create,
  UpdateDraft, CloneAsDraft INSERT+SELECT, Publish, peer-archive,
  flipLifecycle) renamed to paliad.deadline_rules_unified —
  trigger handles the routing.

* internal/services/rule_editor_orphans.go — ResolveOrphan no
  longer writes deadlines.rule_id (column dropped). Sets
  sequencing_rule_id directly + derives procedural_event_id from
  the matching sequencing_rules row in the same UPDATE statement.

* internal/services/deadline_service.go — deadlineColumns now
  lists sequencing_rule_id (Deadline.RuleID still binds to it via
  the db tag rename below). Update path's appendSet("rule_id",…)
  flipped to appendSet("sequencing_rule_id",…) and post-write
  derivation moved to the renamed syncDeadlineProceduralEventID
  helper.

* internal/services/projection_service.go,
  internal/services/submission_vars.go — `WHERE rule_id = $X`
  reads on paliad.deadlines flipped to sequencing_rule_id.

* internal/models/models.go — Deadline.RuleID db tag changed from
  "rule_id" to "sequencing_rule_id". Field name + JSON name kept
  for backward compat with the frontend and existing Go callers;
  semantic value is identical (same UUID).

* internal/services/dual_write.go — Massively trimmed.
  Removed: syncDualWriteFromDeadlineRule, syncDeadlineDualLinks,
  CheckDualWriteDrift, DualWriteDriftReport, HasDrift,
  StartDualWriteDriftCheckLoop. All referenced
  paliad.deadline_rules which no longer exists.
  Kept (renamed): syncDeadlineProceduralEventID — derives
  procedural_event_id from sequencing_rule_id after any
  DeadlineService.Update that touched the back-link.

* cmd/server/main.go — Removed the StartDualWriteDriftCheckLoop
  bootstrap call (and its `time` import that only that call
  needed). Comment notes the retirement.

* internal/services/dual_write_test.go — Removed the final
  CheckDualWriteDrift assertion in
  TestDualWrite_RuleEditorLifecycle (function deleted). The
  per-step asserts against procedural_events / sequencing_rules
  / legal_sources cover the same contract by direct query.

Hard rules followed:
- Audit-first: snapshot precedes destructive ops in the same TX.
- No silent data loss: pre-drop drift was zero; snapshot captures
  the final state; FK re-points use identical UUIDs.
- INSTEAD OF triggers documented in mig 140 — single source of
  truth for the legacy→new mapping.
- Down migration is honest about its scope (catastrophic recovery
  only).

Build + vet clean. TestMigrations_NoDuplicateSlot passes. Live-DB
tests skipped (no TEST_DATABASE_URL in this env) — they'll exercise
the full mig 140 + INSTEAD OF triggers in CI.
2026-05-26 19:53:24 +02:00
mAi
c20e935a4b Merge: t-paliad-313 — Composer Slice B: editable prose + anchor-spliced render + MD→OOXML walker (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 19:46:40 +02:00
mAi
f963b0df34 feat(submissions): Composer Slice B — editable prose sections + anchor-spliced render (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
The "Composer actually works" milestone per the design at
docs/design-submission-generator-v2-2026-05-26.md §12 Slice B. Builds on
Slice A's substrate (submission_bases, submission_sections, base_id on
drafts); no new migrations needed.

Backend additions:

- internal/services/submission_md.go (~240 LoC): Markdown → OOXML
  walker. Per the head's Slice B brief, scope is paragraphs +
  bold/italic + blank-line spacing. Placeholders pass through
  unchanged for the v1 substitution pass. CRLF normalisation; nested
  formatting (***bold-italic***); two delimiter forms (* and _);
  XML-escaping for &/</>; explicit empty-paragraph emit so blank
  lines round-trip. 12 unit tests.

- internal/services/submission_compose.go (~470 LoC): SubmissionComposer
  service. Pipeline: ConvertDotmToDocx pre-pass → extract
  word/document.xml → render each included section's content_md_<lang>
  → splice via {{#section:KEY}}/{{/section:KEY}} anchor pairs in
  the body → strip anchors for excluded sections → append unanchored
  sections before <w:sectPr> → repack zip → run v1 placeholder pass.
  RE2-friendly anchor scanner walks markers in body-order and matches
  open/close pairs with a stack (handles unbalanced anchors
  defensively). 6 unit tests covering anchor-mode splice,
  append-mode-no-anchors, excluded-section drop, placeholder
  resolution, lang column pick, order_index ASC.

- internal/services/submission_section_service.go: SectionPatch +
  Update method. Six optional fields (content_md_de/en, included,
  label_de/en, order_index). Sentinel ErrSubmissionSectionNotFound on
  RLS-filtered miss.

- internal/handlers/submission_sections.go (NEW, ~150 LoC):
  PATCH /api/submission-drafts/{draft_id}/sections/{section_id}.
  Owner-scoped via SubmissionDraftService.Get; section-belongs-to-draft
  cross-check. 404 on both missing-draft and section-belongs-elsewhere
  paths.

- internal/handlers/files.go: fetchComposerBaseBytes + composerBaseSlugMap
  reuse the existing Gitea proxy cache for base .docx bytes. hlc-letterhead
  → existing firmSkeletonSubmissionSlug, neutral → existing
  skeletonSubmissionSlug.

- internal/handlers/submission_drafts.go: exportSubmissionDraft helper
  branches on draft.BaseID. When set AND base + bytes + sections all
  resolve → Composer pipeline. Else v1 fallback render path stays.
  Audit metadata jsonb gains "composer": true + "base_id" flag when
  composer was used.

Wiring:
- handlers.Services gains SubmissionComposer.
- dbServices.submissionComposer wired from svc.SubmissionComposer.
- main.go instantiates NewSubmissionComposer with the existing
  SubmissionRenderer (so the {{rule.X}} alias contract stays preserved
  inside section content).

Frontend additions (~400 LoC):
- client/submission-draft.ts: paintSectionList rewritten to render a
  contentEditable per included section with a per-section B/I
  toolbar. Per-section autosave debounced 500ms; mousedown handlers on
  toolbar buttons preserve editor focus mid-command. domToMarkdown
  walks the contentEditable's DOM tree back to Markdown source-of-
  truth (b/strong → **…**, i/em → *…*, div/p → paragraph break, br
  → newline). Updated state.view.sections in-place on PATCH success
  without re-painting (avoids focus-stealing on every keystroke);
  re-paints only on structural changes (included toggle, label edits,
  order changes).

- client/submission-draft.ts: onSectionToggleIncluded hides/shows a
  section via PATCH. flushSectionAutosave on blur force-flushes
  pending edits so leaving an editor doesn't strand unsynced changes.

- styles/global.css: editor surface (contentEditable area with focus
  ring + placeholder), toolbar buttons (B/I 1.8rem squares),
  per-section "Hide"/"Include" toggle in the head row.

- Updated i18n hint copy: "Inhalt pro Abschnitt — Autosave nach
  500ms. Letztes Layout in Word."

Templates regenerated on Gitea:
- _skeleton.docx → composer-mode body (anchors only): blob SHA
  ac0cdeaf49f7cd417ec143e2319ffbb02ec65644.
- _firm-skeleton.docx → composer-mode body (anchors only, preserves
  sectPr → firm header/footer rIds): blob SHA
  f1e9a9fb9a29ca01bf7bee709a45c5dda2a8e317.
- Both uploaded as mAi via --netrc-file ~/.netrc-mai.
- gen-skeleton-submission-template script gains an -anchors flag
  (default true) so future regens emit composer-ready bodies. The
  _firm-skeleton.docx regen was done via a one-off /tmp helper since
  the gen-hl-skeleton-template script requires the proprietary .dotm
  source which lives in HL/mWorkRepo; extending that script to accept
  an existing .docx as input is a follow-up cleanup.

Build hygiene: go build/vet/test -short ./internal/... ./cmd/... all
clean; bun run build clean (2900 i18n keys, data-i18n scan clean).

NO behavior change for pre-Composer drafts (base_id NULL → v1
fallback render path stays compiled in). NO migrations needed in this
slice — sections were already in the schema from Slice A; only
content_md_de/en UPDATEs happen via the new PATCH endpoint.

Hard rules per Q2/Q10 ratification still honoured:
- No building_block_id lineage (Slice C territory; Q2).
- Caption/letterhead/signature are regular prose sections, seeded from
  base spec; lawyer can edit/hide freely (Q10).
- {{rule.X}} aliases preserved (renderer pass unchanged).

NOT in scope per Slice B brief:
- Headings 1–3, lists, blockquote (Slice D's MD walker extension).
- Building blocks library (Slice C).
- Reorder / add-custom-section (Slice F).
- Auto-upgrade of pre-Composer drafts (Slice C — explicitly NOT in
  this slice per head's brief msg #2393).

t-paliad-313 Slice B
2026-05-26 19:45:29 +02:00
mAi
6cd340300b Merge: t-paliad-313 — Composer Slice A: base picker + read-only section list (migs 146/147/148) (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 19:25:08 +02:00
mAi
557f9a4cce Merge: fix(paliadin): one-shot fallback when persona lacks streaming (unblock chat)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 19:24:42 +02:00
mAi
3af71e772b fix(paliadin): fall back to one-shot when aichat persona lacks streaming
Symptom: paliadin chat returns "Verbindung verloren" because aichat's
paliadin persona is not configured with streaming support — every
RunTurnStream() call gets back HTTP 400 unsupported_streaming and the
SSE stream closes empty.

Fix: when RunTurnStream() detects "unsupported_streaming" in the
upstream error, transparently retry against /chat/turn (non-streaming)
with the same body. The full response gets emitted as a single
StreamChunk + StreamMeta so the SSE relay sees identical event
ordering. Persistence (completeTurn + markPrimed) mirrors the one-shot
RunTurn() path.

No real-time chunking until the persona is reconfigured upstream, but
the chat works end-to-end. Once the paliadin persona supports streaming
on aichat, this code path goes dormant — the unsupported_streaming
branch is only entered when the upstream actually returns that error.

Diagnostic logs from commit 937ff13 made this visible:
  paliadin: backend returned error err=aichat: HTTP 400 (bad_request):
  unsupported_streaming: persona paliadin does not support streaming

Refs m/paliad demo path.
2026-05-26 19:24:41 +02:00
mAi
e2969fc358 feat(submissions): Composer Slice A — base picker + read-only section list (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
The first slice of the Submission generator v2 ("Composer") per the
design at docs/design-submission-generator-v2-2026-05-26.md §12 Slice A.
Ships the base concept + per-draft section seeding end-to-end with NO
change to the .docx render path — v1 export still works exactly as
today.

Schema (mig 146/147/148):
- paliad.submission_bases — catalog table; one row per template base
  (slug, firm, proceeding_family, label_de/en, gitea_path, section_spec
  jsonb, is_default_for[]). RLS: wide-open SELECT for authenticated
  users, mutations admin-only (handler-enforced, no RLS write paths).
  Seeded with 2 rows: hlc-letterhead → _firm-skeleton.docx; neutral →
  _skeleton.docx. Each section_spec carries the 10-section default
  (letterhead, caption, introduction, requests, facts, legal_argument,
  evidence, exhibits, closing, signature) with bilingual labels +
  bag-driven seed Markdown for caption/letterhead/signature.
- paliad.submission_drafts gains base_id (FK SET NULL, optional) +
  composer_meta jsonb (default '{}'). Purely additive; pre-Composer
  drafts keep base_id NULL → v1 fallback render path stays active.
- paliad.submission_sections — per-draft section rows (draft_id,
  section_key, order_index, kind ∈ {prose,requests,evidence},
  label_de/en, included, content_md_de/en). RLS mirrors
  submission_drafts (owner-scoped + can_see_project, four policies).

Backend:
- BaseService (read-only Slice A): List + GetByID + GetBySlug +
  GetDefaultForCode (firm/family fallback chain).
- SectionService: ListForDraft + Get + SeedFromSpec (transactional
  multi-INSERT).
- SubmissionDraftService.AttachComposer wires both; Create resolves
  the firm default base and seeds base_id + section rows in one tx.
  Composer wiring is additive — when bases==nil the service stays
  v1-shaped.
- Update accepts BaseID **uuid.UUID (set / clear / no-change).
- submissionDraftView gains BaseID, ComposerMeta, Sections fields.
- Routes: GET /api/submission-bases (catalog list). PATCH endpoints
  on both project-scoped and global drafts accept "base_id".

Frontend:
- submission-draft.tsx: base picker dropdown above language toggle
  (hidden until catalog loads); section-list pane above the preview
  (hidden when no rows).
- client/submission-draft.ts: loadBases() parallel-fetches on boot;
  paintBasePicker rebuilds <option> list on every paint; onBaseChange
  PATCHes base_id and repaints; paintSectionList renders each section
  read-only (label + kind chip + excluded badge + Markdown body).
- Per the brief: NO auto-upgrade of existing 11 drafts (that's Slice C).
  Pre-Composer drafts get the picker (catalog still loads) but the
  section pane stays hidden until they pick a base on a new draft.

Tests:
- TestFamilyOfCode + TestBaseSectionSpec_DecodeShape + _EmptyDecode
  (pure unit, no DB).
- TestComposerSeedFlow (live, TEST_DATABASE_URL-gated): asserts mig 146
  seeded 10 default sections on both bases; GetDefaultForCode picks
  hlc-letterhead for HLC/de.inf.lg.erwidg; new draft via Create seeds
  base_id + 10 section rows in tx with ascending order_index and
  bilingual labels populated.

NO behavior change to .docx export — the v1 path stays sole render
path this slice. Composer's anchor-based assembly engine + MD→OOXML
walker land in Slice B.

Build hygiene: go build/vet/test -short clean; bun run build clean
(2900 i18n keys, data-i18n scan clean).

t-paliad-313
2026-05-26 19:23:40 +02:00
mAi
85d0cedd22 Merge: t-paliad-312 — PRD for submission generator v2 (Composer); 12 questions ratified (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 19:05:04 +02:00
mAi
0e1691f00e docs: ratify Q1-Q12 — submission generator v2 design final (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
All 12 §11 design questions ratified by m on 2026-05-26 via
AskUserQuestion (paliadin-authorised override per instruction msg #2391).

Picks matching inventor recommendations (9 of 12):
 Q1 separate submission_sections table
 Q3 Gitea-backed body + thin DB row
 Q4 contentEditable + Markdown + in-house serializer
 Q5 section anchors + in-house MD->OOXML walker
 Q7 split content_md_de + content_md_en from day 1
 Q8 Go map for per-submission_code section defaults
 Q9 4 visibility tiers (private/team/firm/global)
 Q11 collapsed preview pane by default
 Q12 moot (superseded by Q2 simplification)

Deviations from recommendation (3 of 12):
 Q2 SIMPLIFY further — m: "sounds overengineered". Building blocks
    become plain text paste sources. No building_block_id column on
    sections, no _versions table referenced from sections, no
    refresh-from-library affordance. Slice G dropped.
 Q6 Auto-upgrade all 11 existing drafts at mig-148 apply time (not
    opt-in per draft). v1 fallback render path stays compiled in.
 Q10 *_auto kind removed. Caption/letterhead/signature sections are
    regular prose rows seeded with bag-driven Markdown; lawyer can
    edit/hide. Untouched drafts export identically to v1.

Body sections updated inline (§4.3 schema, §4.4 BB tables, §6.3
seeding, §8.3+8.4 BB insert, slice plan A/C/G, §11 ratification notes,
§14 risks #8+11, §17+18 acceptance + gate). §11 retains the historical
recommendation matrix.

Status: ALL DESIGN QUESTIONS RATIFIED — design doc final, ready for
Slice A coder shift. Inventor parks per hard gate. Head decides hire.

t-paliad-312
2026-05-26 19:04:21 +02:00
mAi
05ad43aa46 Merge: t-paliad-308 — Verfahrensablauf URL state hybrid (chips in URL, scenario in localStorage) (m/paliad#137)
Some checks failed
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
2026-05-26 18:46:32 +02:00
mAi
43de8f9c7b feat(verfahrensablauf): URL state hybrid — filter chips in URL, scenario in localStorage (t-paliad-308, m/paliad#137)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Splits /tools/verfahrensablauf persisted state into two namespaces:

URL params (timeline kind — paste-able, shareable, refresh-resistant):
  proceeding, side, target, trigger_date

localStorage `paliad.verfahrensablauf.scenario.*` (per-user tweaks
that should never leak into a shared link):
  event_choices, court_id, ccr, inf_amend, rev_amend, rev_cci,
  show_hidden

Hydration order: URL wins. localStorage fills the rest. A shared link
reproduces the timeline kind but each user sees their own scenario
state.

Added trigger_date and proceeding to URL (previously DOM-only — a
refresh lost the date and the proceeding tile). Moved event_choices
and show_hidden from URL to localStorage (verbose, per-user). Added
court_id + flag persistence to localStorage (previously DOM-only).

New pure module `views/verfahrensablauf-state.ts` owns the URL +
localStorage contract: URL parsers + encoder (`applyFiltersToSearch`),
scenario read/write helpers, and a `hydrate()` orchestrator that
documents the URL→localStorage order. 31 unit tests pin the contract,
including the "shared link doesn't leak scenario state" invariant.

Anti-patterns explicitly avoided:
- No ?appellant= resurrection (#132 removed it; engine reads from
  the single side picker for role-swap proceedings).
- trigger_date in URL not localStorage (a shared link must reproduce
  the same dated timeline).
- URL→localStorage hydration order is contract; localStorage never
  overrides an explicit URL value.

Project-driven side-fill chip (?project=<id>) still overrides as
before — parseSideFromSearch is called before the project's our_side
is applied so an explicit ?side= still wins.

Build clean: `bun run build`, `bun test` (240 pass / 594 expect calls),
`go test ./...`, `go vet ./...`.
2026-05-26 18:45:00 +02:00
mAi
635457474a docs: PRD/design — submission generator v2 ("Composer") (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Sectioned composition, swappable base templates, in-app prose editing,
building-blocks library. Deepens t-paliad-215 + t-paliad-238 without
replacing them — v1 contracts (submission_drafts shape, {{rule.X}}
aliases, audit shape) preserved.

7 slices A→G; Slice B is the smallest "Composer works" milestone.
Existing 11 v1 drafts continue via v1 path; opt-in upgrade per draft.

12 open design questions with recommended defaults + alternatives for m
to ratify via head escalation (no AskUserQuestion per task brief).

Flags two issue-body inaccuracies: no submission_drafts.audit_log column
(audit lives in system_audit_log + project_events); live row count is
11, not 7.

t-paliad-312
2026-05-26 18:37:52 +02:00
mAi
235e68496b Merge: t-paliad-311 — backup exporter drift-resistant + 4 broken ORDER BY cols fixed (m/paliad#140)
Some checks failed
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
2026-05-26 18:20:42 +02:00
mAi
8125caf49a test(backup): add TEST_DATABASE_URL-gated live smokes for org export
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Two complementary live tests (both skipped without TEST_DATABASE_URL):

- TestResolveOrgSheets_LiveSchemaSnapshot — runs the schema probe + SQL
  composer the way the backup runner does at the start of every run,
  then executes each resolved SELECT against the live DB (wrapped in
  LIMIT 1 to keep table reads cheap). A future column rename in a
  table our spec still names triggers this test and surfaces in CI
  before /admin/backups breaks.

- TestWriteOrg_LiveSmoke — end-to-end pipeline against a real DB:
  schema probe, REPEATABLE READ tx, every sheet query, xlsx + JSON +
  per-sheet CSV assembly, outer zip framing. Spot-checks meta.RowCounts
  and the zip magic bytes; doesn't materialise the full bundle to
  disk.

Both tests exercise the exact failure mode m/paliad#140 reproduced
(hardcoded ORDER BY against a renamed column) so CI catches regressions
once TEST_DATABASE_URL is wired.

m/paliad#140
2026-05-26 18:19:55 +02:00
mAi
937ff13470 Merge: footer 'by' + paliadin diagnostic logs (unblock 'Verbindung verloren' diagnosis)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 18:17:39 +02:00
mAi
b97f170c1d chore: footer "by" + paliadin diagnostic logs
- Footer: "© 2026 Paliad — ein Werkzeug von / a tool by" → "© 2026 Paliad — by" (both DE + EN).
- Paliadin streaming handler now log.Printf on every error path (StreamError, silence_timeout, backend nil/err) so the next "Verbindung verloren" failure produces a server-side trace. Previous behaviour: silent SSE close + empty paliad logs, impossible to diagnose.
2026-05-26 18:17:33 +02:00
mAi
935ea23038 refactor(backup): make orgSheetQueries drift-resistant
Refactor orgSheetQueries() into orgSheetSpecs() returning declarative
(SheetName, Table, OrderBy []string) triples instead of free-form SQL,
with composeOrgSheetSQL() as a pure builder and resolveOrgSheets() as
the DB-touching orchestrator.

At backup time the resolver:
  1. probes information_schema.columns once for every spec table,
  2. composes SELECT * FROM <table> ORDER BY <columns-that-exist>,
  3. logs WARN per ORDER BY column dropped because it's gone.

A future column rename or removal can no longer break /admin/backups:
the worst case is one sheet temporarily losing sort stability, and the
WARN log surfaces which spec needs updating.

Sheets needing custom projections (documents drops ai_extracted) keep
the SQL override path. All other org-scope sheets — entity + ref__ —
declare their ORDER BY as a column list.

Tests:
  - 6 composeOrgSheetSQL unit tests cover the drift behaviour with no
    DB needed (missing column, all-missing, override bypass, declared
    order preserved, unknown table)
  - Existing registry-shape tests (no duplicates, no paliadin leakage,
    ref__ prefix, ORDER BY-for-determinism) updated to the spec API
  - Full internal/services suite green

m/paliad#140
2026-05-26 18:17:21 +02:00
mAi
f8e5be5f7a Merge: fix(submissions): order Schriftsätze catalog by sequence_order (was alphabetic — Berufungsbegründung ahead of Klageerhebung)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 18:15:07 +02:00
mAi
ee0a9ea6cb fix(submissions): order catalog by sequence_order, not alphabetic submission_code
The Schriftsätze list rendered procedurally meaningless: Berufungsbegründung
ahead of Klageerhebung etc. because the ORDER BY was alphabetic by
submission_code within each proceeding. Add dr.sequence_order ASC as the
primary intra-proceeding sort; submission_code stays as the deterministic
tiebreaker for rules sharing a sequence_order.

deadline_rules.sequence_order is already populated for every published
filing rule (verified via paliad.deadline_rules_unified). Pure read-side
fix; no schema or data change.
2026-05-26 18:15:01 +02:00
mAi
da464813b7 fix(backup): repair 4 broken ORDER BY columns in orgSheetQueries
Backup export was 100% broken because four sheets referenced columns
that no longer exist (or never did) in their target tables:

- email_templates: ORDER BY id → key, lang (composite PK)
- policy_audit_log: ORDER BY changed_at → created_at
- ref__deadline_event_types: ORDER BY rule_id → deadline_id (post-rename)
- ref__event_category_concepts: ORDER BY category_id → event_category_id

Audited every entry in orgSheetQueries() against information_schema.columns;
these were the only mismatches. Patch unblocks /admin/backups → Generate.
Drift-resistant refactor (m/paliad#140 Part B) follows in a separate commit.

m/paliad#140
2026-05-26 18:13:27 +02:00
mAi
6d24fb8931 Merge: t-paliad-310 — dark-mode CSS: repoint 12 var(--color-surface-alt) sites to defined tokens (m/paliad#138)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 18:07:45 +02:00
mAi
446c46e5c5 fix(css): repoint 12 var(--color-surface-alt, hex) sites to defined tokens (t-paliad-310, m/paliad#138)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
The --color-surface-alt token was never defined in :root or :root[data-theme="dark"],
so the var() fallback hex literal always won — leaving 12 surface sites with
zero dark-mode treatment. Same pattern as t-paliad-087 / t-paliad-150 / t-paliad-291.

Issue #138 surfaced four panels visibly broken in dark mode:
1. submission-draft no-project banner ("Kein Projekt zugeordnet…") — white-on-white
2. submission-draft preview header ("Vorschau / Read-only Vorschau…") — white-on-white
3. smart-timeline rule-chip (e.g. de.null.bpatg.berufung in Vorhersage rows) — grey-on-grey
4. submission-draft addparty manual form (Manuell / Aus DB / Name / …) — white-on-white

Eight more latent sites with the same root cause are fixed in the same pass:
.submissions-new-chip:hover, .submissions-new-project-item:hover,
.submission-draft-import-row, .submission-draft-addparty-search-projref,
.collab-invite-hint, .smart-timeline-status-icon,
.smart-timeline-kind-chip--projected, .smart-timeline-add-choice:hover.

Each site repointed to the semantically correct existing token
(--color-surface-2 for #fafafa, --color-surface-muted for #f4f4f4,
--color-bg-subtle for #f7f7f0, --color-bg-lime-tint for the lime-tinted
collab-invite-hint). All four target tokens are defined in both :root
and :root[data-theme="dark"]. No new tokens introduced.

Light-mode hex values are functionally identical (#fafafa==#fafafa,
#f4f4f4≈#f3f4f6, #f7f7f0≈#f7f3f0).

Verified: bun run build clean; Playwright screenshots of the four panels
in both light + dark modes show correct rendering.
2026-05-26 18:07:02 +02:00
mAi
d1aa0f72c0 Merge: t-paliad-305 — Slice B.3: read cutover via paliad.deadline_rules_unified view (mig 139); legacy writes retire in B.4 (m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 18:01:25 +02:00
mAi
94f2831f3f Merge: fix(backup): export ORDER BY uses binding_id (was calendar_binding_id) — unblocks /admin/backups
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 18:00:37 +02:00
mAi
83be122b19 fix(backup): export ORDER BY uses binding_id, not calendar_binding_id
paliad.appointment_caldav_targets's join column is named binding_id
(mig 101). The backup sheet exporter referenced calendar_binding_id
which doesn't exist, so /admin/backups generate failed with 42703.

Single-char fix. Also flags follow-up: hardcoded ORDER BY columns on
every sheet in orgSheetQueries() are fragile under schema renames —
a separate slice (m/paliad#140) tracks making the exporter flexible
to drift (e.g. probe information_schema or use NULLS LAST id-only).
2026-05-26 18:00:17 +02:00
mAi
df592f9fc4 feat(db,services): Slice B.3 read cutover — flip reads to paliad.deadline_rules_unified view backed by sr+pe+ls (t-paliad-305 / m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
The new tables (mig 136) and the dual-write that keeps them in sync
(B.2) have been steady-state in prod since mig 136 deployed at
13:24 UTC today. Drift verified clean before this commit:
deadline_rules=231, sequencing_rules=231, procedural_events=231 (153
codes + 78 synthetic), legal_sources=87, zero mismatches across
counts, FK integrity, lifecycle, is_active.

This commit flips READ paths to source data from the new tables via
a backwards-compatible view, leaving the dual-write WRITE paths
untouched for B.4 to retire alongside the destructive drop.

* internal/db/migrations/139_deadline_rules_unified_view.up.sql (new) —
  CREATE VIEW paliad.deadline_rules_unified projecting sr+pe+ls
  back into the legacy paliad.deadline_rules column shape. Same
  column names + types so the Go-side change is a 1-token
  substitution per query with no struct or scanner edits.
  Post-apply DO block asserts view row count = sequencing_rules row
  count (FK NOT NULL on procedural_event_id guarantees they match).

* 10 service / handler files — every SELECT FROM paliad.deadline_rules
  (or JOIN paliad.deadline_rules) flipped to use the view:
  - internal/handlers/submissions.go            (Schriftsätze list)
  - internal/services/deadline_rule_service.go  (8 read sites)
  - internal/services/rule_editor_service.go    (3 read sites — ListRules, getByID, validateSpawnNoCycle)
  - internal/services/rule_editor_orphans.go    (candidate-rule lookup)
  - internal/services/submission_vars.go        (loadPublishedRule)
  - internal/services/deadline_service.go       (deadlines list join)
  - internal/services/fristenrechner.go         (calculator reads)
  - internal/services/projection_service.go     (projection reads)
  - internal/services/event_deadline_service.go (event→rule join)
  - internal/services/export_service.go         (3 export sites — ref__deadline_rules)

Verified semantically safe on live (read-only smoke):
- 231 rows in view match 231 in legacy.
- name + event_type pair: 231/231 match.
- legal_source: 231/231 match (NULL on both sides treated as match).
- submission_code: 153 non-NULL codes match exactly; the 78
  synthetic 'null.<8hex>' codes diverge from legacy NULL but no
  reader filters on NULL submission_code (verified
  handlers/submissions.go: synthetic-code rules all have NULL
  event_type so the WHERE event_type = 'filing' filter excludes
  them; the Schriftsätze surface returns the same 105 rows).

Scope decisions documented (deviation from design §5.3):
- B.3 ships the READ flip only. WRITE paths (RuleEditorService
  Create / UpdateDraft / CloneAsDraft / Publish / flipLifecycle)
  retain the dual-write from B.2 — they write to both legacy and
  new tables. B.4 (destructive drop) will retire the legacy writes
  in the same slice that drops the table, avoiding a transient
  state where the legacy writes have no purpose.
- The B.2 drift-check ticker (StartDualWriteDriftCheckLoop) stays
  active for the same reason: dual-write continues, so the
  invariants the loop checks remain meaningful.

This shape is paliadin-approvable on a "good solution > strict
phase boundary" reading of m's greenlight. If paliadin pushes back
and wants the legacy writes removed in B.3, the refactor is ~300
LOC across the 5 RuleEditorService write methods + buildPatchSets
split into PE/SR sets — schedulable as B.3.5 before B.4.

Build + vet clean. TestMigrations_NoDuplicateSlot passes.
2026-05-26 17:59:58 +02:00
mAi
b6c2df95cc Merge: t-paliad-307 — Verfahrensablauf appeal mode fixes (side filter + synthetic trigger row + duration label + notes dedup) (m/paliad#136)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 17:57:39 +02:00
mAi
367627af0d fix(verfahrensablauf): appeal side filter + parent in duration label + notes dedup (t-paliad-307, m/paliad#136)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Frontend half of the four Verfahrensablauf appeal bugs.

Bug 1 (frontend half) — Side selector dead on appeal. The column
bucketer now reads dl.appealRole (engine-stamped under
appeal_target) and routes each "both" appeal rule via the user
side: side=claimant maps the user to the appellant, so appellant
filings land in 'ours' and appellee filings in 'opponent';
side=defendant mirrors. side=null keeps the legacy mirror so every
appeal rule renders in both columns (every-rule-visible behaviour
the brief calls out). The new appealAware opt gates the path so
non-appeal proceedings keep their existing bucketing untouched.

Removed upc.apl.unified from APPELLANT_AXIS_PROCEEDINGS — appeal
routing is now per-rule via appealRole, not a page-level appellant
collapse. Other role-swap proceedings (EPA opp, DE/DPMA appeals)
keep the appellant axis since they have no appeal_target metadata.

Bug 3 — Duration label appends parent name. formatDurationLabel now
takes an optional parent fallback and renders "<n> <unit> <timing>
<parent>". deadlineCardHtml resolves the parent per-rule
(dl.parentRuleName / EN variant), falling back to opts.trigger
EventLabel for root rules with a non-zero duration (e.g.
Berufungseinlegung 2 mo. after the Endentscheidung). renderColumns
Body + renderTimelineBody auto-derive the trigger event label from
the response via the new pickTriggerEventLabel helper unless the
caller passes one explicitly.

Bug 4 — Duration prefix stripped from deadline_notes. New
stripLeadingDurationFromNotes regex peels off leading
"Frist N <unit> <vor|nach|ab|seit> …. " (DE) and
"<N>-<unit> period from …" / "N <unit> BEFORE …" / "Period is N
<unit> from …" (EN) up to the first sentence boundary. Wired into
deadlineCardHtml so noteHint + notesBlock both render the deduped
text. Per the brief's option (a): conservative regex, composite
durations with "ODER" / "whichever is the longer" stay untouched
as a follow-up editorial cleanup. deadline_rules DB untouched.

Tests: 22 new test cases across appeal-aware bucketing,
formatDurationLabel parent append, deadlineCardHtml duration
tooltip resolution, and stripLeadingDurationFromNotes regex
(positive + negative + composite + EN/DE variants). All 209
frontend tests pass.

Engine wire fields added in the preceding commit (AppealRole,
IsTriggerEvent). Reads them from CalculatedDeadline without
breaking the wire contract for non-appeal callers.
2026-05-26 17:56:32 +02:00
mAi
7d7b20651d feat(litigationplanner): appeal-target synthetic trigger row + appeal-role stamping (t-paliad-307, m/paliad#136)
Engine side of the four Verfahrensablauf appeal bugs in m/paliad#136.

Bug 2 — Missing trigger event row. When CalcOptions.AppealTarget is set,
Calculate now prepends a synthetic TimelineEntry to the deadlines slice
dated to the trigger date, carrying the per-appeal-target label from
TriggerEventLabelForAppealTarget (Endentscheidung (R.118), Kosten-
entscheidung, Anordnung, Schadensbemessung, Bucheinsicht). Marked
IsRootEvent + IsTriggerEvent + party=court + priority=informational
so the frontend renders it as a dimmed anchor card without a save
button / choices caret / click-to-edit affordance. Empty Code so it
doesn't collide with real rule UUIDs downstream.

Bug 1 (engine half) — Side selector dead on appeal. Every appeal
filing rule carries primary_party='both' in the catalog, so the
column bucketer couldn't distinguish Berufungskläger vs Berufungs-
beklagter filings from primary_party alone. Engine now stamps the
new TimelineEntry.AppealRole field with appellant/appellee from the
rule-semantic AppealFilerRole mapping (appeal_role.go) when an
appeal_target is in scope. The frontend half of the fix (next commit)
consumes this to route each "both" rule into the user-perspective
column once the user picks a side.

Mapping covers all 12 appeal filing rules across the three
applies_to_target tracks (endentscheidung/schadensbemessung,
kostenentscheidung, anordnung/bucheinsicht). Court-issued events
(merits.decision, merits.oral, cost.decision, order.order) stay
empty — they continue to route on Party='court'. Unmapped
submission_codes return empty so a new appeal rule we forgot to map
falls through to the bucketer's legacy path rather than silently
picking a side.

Tests: TestAppealFilerRole pins the mapping; TestCalculate_Appeal
SyntheticTriggerRow covers (a) synthetic row prepended + AppealRole
stamped when target is set, (b) no synthetic row + no AppealRole
when target is unset (regression guard), (c) unknown target
short-circuits to no-op. Existing tests untouched — both behaviours
gate on opts.AppealTarget != "".

No DB migration — the bugs are calc-side. deadline_rules untouched.
2026-05-26 17:56:12 +02:00
mAi
8f1a287549 Merge: t-paliad-305 — Slice B.2: dual-write to deadline_rules + procedural_events/sequencing_rules/legal_sources (m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 17:50:57 +02:00
mAi
38ebccc907 feat(services): Slice B.2 dual-write — RuleEditorService writes deadline_rules AND procedural_events / sequencing_rules / legal_sources (t-paliad-305 / m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Keeps the parallel new tables (mig 136, Slice B.1) in lock-step with
the legacy paliad.deadline_rules table through every write path on
RuleEditorService. Read paths stay on deadline_rules in B.2 — B.3
flips them and stops legacy writes.

* internal/services/dual_write.go (new) —
  - syncDualWriteFromDeadlineRule(ctx, tx, id): idempotent UPSERT of
    legal_sources + procedural_events + sequencing_rules from the
    just-written deadline_rules row. Pure SQL projection, no Go-side
    struct mapping. Synthetic-code mint expression is byte-identical
    to mig 136 ('null.' || first 8 hex of stripped uuid).
  - syncDeadlineDualLinks(ctx, tx, deadlineID): mirrors a deadline's
    legacy rule_id back-link onto deadlines.procedural_event_id +
    sequencing_rule_id. Handles NULL rule_id naturally (collapses both
    new columns to NULL).
  - CheckDualWriteDrift(ctx, conn): nine read-only count queries +
    integrity joins. Returns DualWriteDriftReport. HasDrift() bool for
    log routing.
  - StartDualWriteDriftCheckLoop(ctx, conn, interval): goroutine ticker
    that runs CheckDualWriteDrift every `interval` (default 6h) for
    the lifetime of ctx. Clean run logs at INFO; drift at WARN with
    full report.

* internal/services/rule_editor_service.go —
  - Create / UpdateDraft / CloneAsDraft / Publish / flipLifecycle
    each call syncDualWriteFromDeadlineRule(ctx, tx, id) after the
    deadline_rules mutation, before tx.Commit. Publish syncs BOTH the
    published draft AND the cloned-from peer it just archived as a
    cascade. The audit_reason already set via setAuditReasonTx
    propagates to the new-table writes (same TX, same session).

* internal/services/rule_editor_orphans.go —
  - ResolveOrphan calls syncDeadlineDualLinks after UPDATE
    paliad.deadlines SET rule_id = $1, so the parallel new columns
    follow the legacy back-link.

* internal/services/deadline_service.go —
  - DeadlineService.Update calls syncDeadlineDualLinks when
    input.RuleSet is true (auto/custom rule swap from t-paliad-258).

* cmd/server/main.go —
  - Spawns StartDualWriteDriftCheckLoop alongside CalDAV sync and
    reminder scanner. Inherits bgCtx so the goroutine stops on
    SIGTERM. Interval 6h.

* internal/services/dual_write_test.go (new) —
  - TestDualWrite_RuleEditorLifecycle: Create → UpdateDraft → Publish
    → Archive, asserts the new tables mirror at each step. Final
    CheckDualWriteDrift returns zero drift.
  - TestDualWrite_SyntheticCodeForNullSubmission: rule created with
    submission_code=NULL gets a 'null.<8hex>' procedural_events row
    matching mig 136's mint expression byte-for-byte.

Scope decisions documented in the commit:

- B.2 keeps read paths on deadline_rules. paliadin's "Read paths fall
  back to legacy" reads as "reads stay on legacy as the safety net
  while drift-check validates the new tables". B.3 swaps reads to
  new tables only AND stops writing to deadline_rules — that's a
  separate slice per the design's §5.2/§5.3 split.

- B.2 does NOT modify submission_drafts, projection_service, the
  Fristenrechner calculator, the SubmissionVarsService, the
  Schriftsätze list query, or any other reader. They keep reading
  deadline_rules unchanged. The new tables are populated in parallel
  for B.3's cutover.

- Audit triggers on deadline_rules continue to fire as before. The
  new tables have no audit triggers yet (a later slice can add
  parallel audit rows once the new tables are authoritative).

- Drift-check uses default 6h interval — short enough that a broken
  dual-write surfaces within the same business day, long enough that
  the count-COUNTs don't churn the pool. Override via the caller in
  cmd/server.

Hard rules followed:
- audit_reason set on every TX before any deadline_rules mutation
  (existing pattern; new-table writes share the same reason).
- No destructive op (B.2 is strictly additive in behaviour).
- New helpers idempotent (UPSERT ON CONFLICT DO UPDATE) — safe to
  call twice, safe to re-run after a partial failure.

Build + vet clean. TestMigrations_NoDuplicateSlot passes.
2026-05-26 17:49:48 +02:00
mAi
3b601f156b Merge: t-paliad-306 — Slice D: paliad.scenarios + Catalog API + engine adapter (mig 145) (m/paliad#124 §5)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 17:49:36 +02:00
mAi
cd5f752a0e feat(litigationplanner): scenarios — paliad.scenarios jsonb table + Catalog API + engine adapter (Slice D, t-paliad-306, m/paliad#124 §5)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
A scenario is a named composition of existing proceedings + flags +
per-card choices + anchor dates. Users compose, they don't author —
spec references existing rules by submission_code; never creates new
rules. Per m's 2026-05-26 AskUserQuestion picks (doc commit 6e58595):
  Q1 composition: primary + spawned (v1); multi-proceeding peer
                  compose is the v2 goal (spec.proceedings[] array)
  Q2 scope:       per-project + abstract (project_id NULL = abstract)
  Q3 trigger:     per-anchor overrides over one base date
  Q4 storage:     NEW paliad.scenarios table with jsonb spec
                  (NOT a project_event_choices column extension)

Migration 145 — additive only. Pre-flight coordination check:
  - On-disk max: 138 (Berufung backfill, just merged).
  - Live DB tracker: 106 (significantly behind — many migs pending
    deploy).
  - curie's #93 B.2-B.6 migs not pushed yet — reserved 139-143 + 144
    as buffer; claimed 145 as the safe minimum that won't collide.
  - paliad.scenarios has audit_reason NOT applicable (no audit
    trigger on the table); updated_at trigger added on the table
    itself.
  - paliad.projects gains active_scenario_id uuid NULL FK with ON
    DELETE SET NULL (mig 134 lesson — no updated_at clauses on
    proceeding_types-style assumptions).

Schema:
  paliad.scenarios (
    id uuid pk,
    project_id uuid NULL FK → projects(id) ON DELETE CASCADE,
    name text NOT NULL CHECK char_length > 0,
    description text NULL,
    spec jsonb NOT NULL CHECK jsonb_typeof = 'object',
    created_by uuid NULL FK → users(id) ON DELETE SET NULL,
    created_at + updated_at timestamptz,
    UNIQUE NULLS NOT DISTINCT (project_id, created_by, name)
  );
  paliad.projects.active_scenario_id uuid NULL FK;
  RLS: project-scoped → can_see_project; abstract → created_by = auth.uid();
  Trigger: scenarios_touch_updated_at_trg.

pkg/litigationplanner additions:
  - Scenario struct (db + json tags)
  - ScenarioSpec / ScenarioProceeding / ScenarioCardChoice — parsed
    view of the jsonb (version-1 today, v2 multi-peer-ready)
  - ParseSpec(raw) + ScenarioSpec.PrimaryProceeding() + CalcOptionsFromSpec()
  - ScenarioFilter + Catalog.LoadScenarios + Catalog.MatchScenario
  - CalculateFromScenario(scenario, catalog, holidays, courts) — high-
    level engine entry: parses spec → builds CalcOptions → delegates
    to Calculate
  - Sentinel errors: ErrUnknownScenario, ErrInvalidScenario,
    ErrScenarioNoPrimary

paliadCatalog impl:
  - LoadScenarios with progressively-built WHERE clauses (project-id
    filter, abstract-for-user filter, or all)
  - MatchScenario by id — returns ErrUnknownScenario on not-found
  - Services connection bypasses RLS; ScenarioService enforces
    visibility at the application layer (mirrors EventChoiceService
    pattern from t-paliad-265)

SnapshotCatalog impl (embedded/upc):
  - LoadScenarios returns empty slice (no scenarios in the snapshot)
  - MatchScenario returns ErrUnknownScenario

internal/services/scenario_service.go:
  - Create / Get / ListForProject / ListAbstractForUser / Patch /
    SetActive / Delete with visibility checks
  - validateSpec checks version, base_trigger_date format, every
    proceedings[*].code resolves to an active paliad.proceeding_types
    row, every appeal_target is valid, every anchor_overrides date
    parses, every role ∈ {primary, peer}
  - SetActive validates the scenario belongs to the requested project
    (a scenario from a different project can't be active here)
  - Returns ErrScenarioNotVisible for failed visibility checks

REST endpoints (registered in handlers.go):
  GET    /api/scenarios?project=<id>             — list project's
  GET    /api/scenarios?abstract=true            — list user's abstract
  GET    /api/scenarios/{id}                     — one
  POST   /api/scenarios                          — create
  PATCH  /api/scenarios/{id}                     — partial update
  DELETE /api/scenarios/{id}                     — remove
  PUT    /api/projects/{id}/active-scenario      — set / clear active

Handler error mapping:
  - ErrUnknownScenario / ErrScenarioNotVisible → 404
  - ErrInvalidInput / ErrInvalidScenario / ErrScenarioNoPrimary → 400
  - everything else → 500

Tests:
  - pkg/litigationplanner/scenarios_test.go: ParseSpec roundtrip
    (well-formed + unknown version + malformed json),
    PrimaryProceeding zero/multi/single, CalcOptionsFromSpec full
    unpack, trigger_date_override path, no-base-trigger safety check.
    8 cases total, all DB-free.

Wired in cmd/server/main.go alongside EventChoice — same pattern,
nil-safe when DATABASE_URL is unset (handlers 503 in that mode).

Acceptance:
  - go build ./... clean
  - go test ./... all green (incl. new scenarios tests)
  - Pre-flight audit confirmed mig 145 number is safe vs curie's
    pending B.2-B.6 range
2026-05-26 17:48:56 +02:00
mAi
2377f08bd7 Merge: t-paliad-304 — R.109 anchor + columns-view duplicate fix (topo walk + 'both'→ours collapse) (m/paliad#135)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 15:54:39 +02:00
mAi
1d704f6e04 fix(litigationplanner): R.109.1/R.109.4 mis-anchor + duplicate 'both' row in columns view (t-paliad-304, m/paliad#135)
Two bugs surfaced on /tools/verfahrensablauf?side=defendant for upc.inf.cfi:

1. Anchor regression for timing='before' children of court-set parents.
   Rules R.109.1 (translation_request) and R.109.4 (interpreter_cost)
   anchor on the oral hearing (parent_id=upc.inf.cfi.oral, IsCourtSet)
   but were computing dates BEFORE the Statement of Claim — 1 month
   resp. 2 weeks before the SoC instead of before the oral hearing.

   Root cause: engine walked rules in sequence_order, and the two
   "before"-timed children carry sequence_order 45/46 (their chronological
   position, before the oral hearing at 50). Their parent had therefore
   not been processed yet when the children were, so courtSet[oral.ID]
   was still empty → parentIsCourtSet=false → the engine fell back to
   the trigger date as the base.

   Fix: walk rules in topological order (parent-first) during the
   compute pass, then restore sequence_order on the output slice so
   the wire shape and the linear timeline view's render order stay
   identical to the legacy behaviour modulo the bug fix.

2. Duplicate "Antrag auf Simultanübersetzung" row in columns view.
   With primary_party='both' and an explicit side pick (?side=defendant),
   the bucketing mirrored the card into both 'Unsere Seite' and
   'Gegnerseite' — the same card on the same row, visible as a
   duplicate.

   Fix: when the user has committed to a perspective (side picked)
   but no appellant axis applies, collapse 'both' rows into ours.
   The '↔ beide Seiten' indicator is suppressed in that path to match
   the existing appellant-collapse semantics (no sibling row to mirror
   to). Legacy mirror behaviour is preserved when side is null.

DB audit ruled out a data-level duplicate: exactly one published+active
row per submission_code in paliad.deadline_rules.

Tests:
  - pkg/litigationplanner/before_court_set_anchor_test.go: synthetic
    rules pinning the conditional-on-court-set-parent contract plus
    the override path (1mo before user-pinned oral).
  - frontend/src/client/views/verfahrensablauf-core.test.ts: two new
    cases pinning the side-collapse routing for party='both'.
2026-05-26 15:54:02 +02:00
mAi
a75731a902 Merge: t-paliad-302 — Verfahrensablauf duration indicator (hover + toggle, +3 lp.TimelineEntry fields) (m/paliad#133)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 15:45:15 +02:00
mAi
727e01c6c9 Merge: t-paliad-303 — backfill applies_to_target: Schadensbemessung (merits) + Bucheinsicht (order) (mig 138) (m/paliad#134)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 15:44:19 +02:00
mAi
5cff38ff3c feat(deadlines): mig 138 backfill applies_to_target — Schadensbemessung (merits) + Bucheinsicht (order)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
After Slice B1's Berufung unification (mig 134), the picker exposed
five appeal targets but only three carried rules. Schadensbemessung and
Bucheinsicht returned empty timelines.

m's 2026-05-26 decision (#134): R.224 is uniform across substantive
R.118 decisions, and R.220.2 / R.224.2.b / R.235.2 / R.237 / R.238.2 are
uniform across the orders they appeal — so the existing merits-track
and order-track rules can carry the missing targets via a non-destructive
applies_to_target extension.

Audit of live `paliad.deadline_rules` for upc.apl.unified (proceeding_type_id=160):
- 7 endentscheidung rules → extend with 'schadensbemessung'
- 7 anordnung rules        → extend with 'bucheinsicht'
- 2 kostenentscheidung rules — untouched (distinct leave-to-appeal track)

Migration:
- set_config('paliad.audit_reason', …) at top of UP and DOWN — required
  by the mig 079 deadline_rule_audit_trigger on every UPDATE.
- Audit-first DO block lists every row to be touched (pre/post state)
  and RAISE EXCEPTIONs on pre-condition drift (missing proceeding_type,
  wrong rule counts, partial-run carry-over of the new targets).
- Two narrow UPDATEs keyed off upc.apl.unified + existing target +
  absence of new target.
- Post-sanity asserts schad=7, buch=7, end=7, anord=7, cost=2 — hard
  RAISE EXCEPTION on any drift.
- DOWN strips both new targets via array_remove with the same WHERE.
- No deadline_rules.updated_at writes; column exists but the migration
  is single-purpose and leaves it as-is.

Dry-run via Supabase MCP confirmed:
- UP yields {schad:7, buch:7, end:7, anord:7, cost:2} on prod.
- DOWN restores {schad:0, buch:0, end:7, anord:7, cost:2}.
- DB returned to pre-state; the real golang-migrate boot path will
  apply 138 cleanly at next deploy.

Version bump 137→138: cronus's mig 137 (proceeding_role_labels, #132)
merged to main while this branch was in flight. Rebased onto current
main, renamed files, rewrote all "mig 137" references inside the SQL +
test code.

Test:
- lookup_events_test.go: the schadensbemessung empty-result assertion
  becomes the inverse (rules expected). Adds a parallel bucheinsicht
  assertion. Same anchor-row shape check as the existing endentscheidung
  case (DepthFromAnchor=1, target ∈ AppliesToTarget, proceeding_type
  = upc.apl.unified).
- `go test ./...` green post-rebase, including pkg/litigationplanner/
  appeal_target_label_test.go added by cronus's mig 137.

Refs: m/paliad#134, t-paliad-303.
Lessons applied from mig 134 hotfixes: audit_reason set_config, no
updated_at writes, audit live DB before drafting, RAISE EXCEPTION on
integrity violations.
2026-05-26 15:43:36 +02:00
mAi
3097df3918 mAi: #133 — Verfahrensablauf duration affordance (hover + toggle)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
t-paliad-302 / m/paliad#133. Surface each event card's rule duration
("2 Mo. nach") on /tools/verfahrensablauf — by default as a hover
tooltip on the date span, and optionally inline via a new
"Dauern anzeigen" header toggle (localStorage key
paliad.verfahrensablauf.durations-show).

The issue scoped this as pure-frontend on the assumption that the
duration fields were already on the /api/tools/fristenrechner payload.
They were not: lp.TimelineEntry exposed only the computed dueDate, not
the rule's (duration_value, duration_unit, timing) tuple. Added these
as three additive optional fields and populated them in both engine
emission sites (Calculate + CalculateByTriggerEvent) from the rule
row directly. Source values are the base rule fields, not the
post-alt-swap arithmetic — the tooltip reads as a property of the
rule rather than a recap of which branch fired.

Frontend wiring:
- formatDurationLabel() in verfahrensablauf-core builds the
  "<value> <unit> <timing>" string from the existing
  deadlines.event.unit.<unit>.{one,many} + deadlines.event.timing.*
  i18n keys, reused from /tools/fristenrechner's event-mode renderer.
- deadlineCardHtml attaches the label as title= on the date span
  (hover, default) and, when CardOpts.showDurations is on, emits an
  inline <span class="timeline-duration"> in the meta row.
- Court-set / zero-duration rules (trigger event, hearings) skip the
  affordance — durationValue <= 0 short-circuits in
  formatDurationLabel.
- Toggle persisted in localStorage under
  paliad.verfahrensablauf.durations-show, default off; sits next to
  the existing "Hinweise anzeigen" toggle.

bun run build clean, go test ./pkg/litigationplanner/... and
./internal/... clean, bun test src/client/views clean (89/89).
2026-05-26 15:43:30 +02:00
mAi
46b58dcf41 Merge: t-paliad-301 — Berufung tile UX: collapse side selectors + appeal-target trigger labels (mig 137) (m/paliad#132)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 15:37:51 +02:00
mAi
9da4715137 feat(litigationplanner): Berufung tile UX — collapse side selectors + appeal-target trigger label (t-paliad-301, m/paliad#132)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Two bugs from the Slice B1 Berufung rollout, one fix surface:

Bug A — duplicate side selectors collapse into ONE proactive-side
picker with per-proceeding role labels. The Verfahrensablauf used to
show both ?side= (Klägerseite/Beklagtenseite) AND ?appellant= (same
labels in case-form) on the Berufung tile. Now: one side picker, with
labels that swap to Berufungskläger/Berufungsbeklagter on the unified
upc.apl.unified tile (and Antragsteller/Antragsgegner Nichtigkeit on
upc.rev.cfi, Einsprechende(r)/Patentinhaber(in) on epa.opp.*).

Bug B — 'Auslösendes Ereignis' label derives from appeal_target on
the unified Berufung tile (5 target-specific strings) instead of the
proceeding's own trigger_event_label. Endentscheidung (R.118) /
Kostenentscheidung / Anordnung / Entscheidung im
Schadensbemessungsverfahren / Anordnung der Bucheinsicht.

Migration 137 (additive, no triggers on proceeding_types — verified
via mcp__supabase__execute_sql before drafting; no updated_at on the
table — lesson from mig 134 HOTFIX 3; no audit_reason setup needed):
  - ADD COLUMN role_proactive_label_de  (text NULL)
  - ADD COLUMN role_proactive_label_en  (text NULL)
  - ADD COLUMN role_reactive_label_de   (text NULL)
  - ADD COLUMN role_reactive_label_en   (text NULL)
  - Audit-first DO block lists the rows the UPDATE will touch.
  - Backfill 4 proceedings (upc.apl.unified + upc.rev.cfi +
    epa.opp.opd + epa.opp.boa); every other proceeding stays NULL
    and the renderer falls back to default labels.
  - Down drops the 4 columns.

Package additions (pkg/litigationplanner):
  - ProceedingType gains 4 *string fields (RoleProactive/Reactive
    LabelDE/EN) — db tags match the new columns; existing scans pick
    them up via the proceedingTypeColumns extension.
  - TriggerEventLabelForAppealTarget(target, lang) — Go-side map of
    the 5 appeal-target slugs to their DE/EN trigger-event labels.
    Empty result on unknown target signals "fall back to proceeding's
    own trigger_event_label".
  - Engine override: when CalcOptions.AppealTarget is set, the
    resulting Timeline.TriggerEventLabel/EN are replaced from the
    per-target map.

Frontend:
  - Removed #appellant-row div (was a separate 3-radio selector
    duplicating side).
  - Dropped ?appellant= URL state + the change handler + the init
    readback. The engine still consumes "appellant" — sourced from
    currentSide for role-swap proceedings; null otherwise.
  - applyRoleLabels(proceedingType) swaps the side-row radio labels
    from a hardcoded ROLE_LABELS map mirroring mig 137's backfill.
    Falls back to deadlines.side.claimant/defendant i18n keys for
    proceedings without overrides.
  - syncTriggerEventLabel reads data.triggerEventLabel from the calc
    response — which the engine override now sets per appeal_target,
    so no client-side mapping needed.
  - i18n cleanup: removed orphan deadlines.appellant.* keys (label /
    claimant / defendant / none) in both DE + EN.

Tests:
  - pkg/litigationplanner/appeal_target_label_test.go pins the 5×2
    label matrix + a coverage test that fails if a new entry in
    AppealTargets is added without populating the label switch.

Acceptance:
  - go build + go test all green (incl. new lp test).
  - bun run build clean (i18n codegen drops 4 keys, regenerates).
  - Live-DB audit before drafting confirmed: 4 target columns don't
    exist on proceeding_types, zero triggers on the table, exact
    column inventory matches the design.
2026-05-26 15:37:10 +02:00
mAi
16ec8c490a Merge: t-paliad-273 — Slice B.1: additive procedural_events / sequencing_rules / legal_sources (mig 136) (m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 15:22:23 +02:00
mAi
f49c804ddd Merge: HOTFIX 3 — mig 134 remove non-existent updated_at column reference (t-paliad-292)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 15:19:58 +02:00
mAi
5901d40b79 fix(mig 134): remove non-existent updated_at column reference (HOTFIX 3)
paliad.proceeding_types has no updated_at column. Removing the
UPDATE ... SET ..., updated_at = now() clause from both up and down
migrations. Third bug in cronus's Slice B1 mig 134 — production
still down.

Verified columns on paliad.proceeding_types via prod-snapshot.sql:
id, code, name, description, jurisdiction, category, default_color,
sort_order, is_active, name_en, display_order, trigger_event_label_de,
trigger_event_label_en, appeal_target (added by this mig).

Refs t-paliad-292, m/paliad#124. No new issue filed — single-line
emergency fix during head's incident response.
2026-05-26 15:19:54 +02:00
mAi
c767b61a8a Merge: t-paliad-300 — HOTFIX 2: mig 134 set_config('paliad.audit_reason') (m/paliad#131)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 15:15:40 +02:00
mAi
4f94697377 fix(litigationplanner): mig 134 set_config('paliad.audit_reason') (HOTFIX 2, t-paliad-300, m/paliad#131)
Some checks failed
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Mig 134's step 4 UPDATEs paliad.deadline_rules to reassign 16 rules
to the unified upc.apl.unified proceeding_type. The mig-079 audit
trigger requires set_config('paliad.audit_reason', …, true) before
any mutation — mig 134 missed it, causing the migration runner to
abort with P0001 "audit reason required for UPDATE" on every boot
after #130 landed.

Adds the canonical set_config call at the top of both up + down,
matching the pattern from mig 082, 099, 100, 103, 106, 110, 127, 129.
2026-05-26 15:15:01 +02:00
mAi
2a56b7817c Merge: t-paliad-292 — Slice C: embedded UPC snapshot + generator (m/paliad#124 §19)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 15:13:45 +02:00
mAi
75833082fc feat(db): mig 136 — additive procedural_events / sequencing_rules / legal_sources tables (Slice B.1, t-paliad-273 / m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Creates the three new tables that split today's paliad.deadline_rules
into its three latent concepts, plus two nullable link columns on
paliad.deadlines for B.2 dual-write.

ADDITIVE ONLY. paliad.deadline_rules is untouched. deadlines.rule_id
stays in place — it remains the authoritative deadline → rule link
until B.3 cutover flips reads and B.4 drops the legacy table.

* paliad.legal_sources        — distinct citations (87 rows backfilled).
                                pretty_de/pretty_en deferred (Go
                                legalSourcePretty still computes them
                                on read; future slice backfills).
* paliad.procedural_events    — 153 rows from distinct submission_codes
                                + 78 synthetic-code rows for the
                                NULL-submission_code branch (m's pick
                                via paliadin 2026-05-26: mint
                                'null.<8hex>' codes so every rule row
                                has a procedural event, preserving the
                                NOT NULL FK on sequencing_rules).
* paliad.sequencing_rules     — 1:1 with deadline_rules (231 rows). id
                                inherited from deadline_rules.id so any
                                existing deadlines.rule_id FK resolves
                                transitively to the new sequencing_rule
                                during the dual-write window.
* paliad.deadlines.procedural_event_id, sequencing_rule_id (nullable,
                                backfilled by JOIN on the inherited id).

Audit-first pattern (mirrors mig 135): PRE pass counts what we're about
to backfill + refuses to run if multi-row submission_codes have crept
back in (B.0 found zero; the assertion guards against a future
re-archival or rule-editor bug). POST pass asserts the four
invariants — procedural_events count, sequencing_rules 1:1,
legal_sources distinct-citation match, FK integrity — and RAISE
EXCEPTIONs on any mismatch so the transaction rolls back cleanly.

Design deviations from §4.1 (documented in the migration header):
- procedural_events.event_kind is NULLABLE. 89 live rules have NULL
  event_type today (structural / parent-only rows in the proceeding
  tree). Tightening to NOT NULL with 'other' fallback would lose
  semantics; a later slice can do it after reclassification.
- legal_sources.pretty_de / pretty_en are NULLABLE. Materialising them
  requires the Go-side legalSourcePretty(); deferred to a Go-driven
  slice. Read path keeps computing them from the citation in the
  meantime.
- submission_drafts is NOT modified (instruction scope is explicit:
  tables + deadlines columns only).

Down migration: drops the two deadlines columns first, then
sequencing_rules → procedural_events → legal_sources in FK-safe
order. No data loss possible (deadline_rules is the source of truth
through B.3).

Test: internal/db/migration_136_test.go restates the four
invariants in Go so they survive PL/pgSQL refactors. Skipped without
TEST_DATABASE_URL.

Verified on live (read-only): 153 distinct codes + 78 distinct
synthetic-code candidates = 231 = deadline_rules row count. 87
distinct legal_sources. Zero 8-hex synthetic-code collisions in the
live UUIDs.

Hard-stop: B.2 dual-write requires explicit m greenlight before
RuleEditorService starts writing to the new tables. B.4 destructive
drop additionally requires m's downtime window + a
paliad.deadline_rules_pre_<N> snapshot in the same migration.
2026-05-26 15:12:12 +02:00
mAi
ce28ea972e feat(litigationplanner): embedded UPC snapshot + generator (Slice C, m/paliad#124 §19)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Lays the foundation for youpc.org's cross-repo integration: an
in-package UPC subset of paliad's deadline corpus, embedded as JSON,
that any consumer can use to run the litigationplanner engine without
DB access.

Generator (cmd/gen-upc-snapshot):
  - Reads paliad's live DB (DATABASE_URL), applies pending migrations
    to match schema HEAD, SELECTs the UPC subset
    (proceeding_types WHERE jurisdiction='UPC' AND is_active=true,
    deadline_rules WHERE lifecycle_state='published' AND is_active=true
    on those proceedings, referenced trigger_events, DE+UPC holidays,
    UPC courts).
  - Writes pretty-printed JSON to
    pkg/litigationplanner/embedded/upc/{proceeding_types, rules,
    trigger_events, holidays, courts, meta}.json.
  - Idempotent — same DB state → same output (modulo
    meta.generated_at + auto-versioned suffix).
  - Date-stamped versioning (YYYY-MM-DD-N) with same-day suffix bump.
  - Operator runbook in cmd/gen-upc-snapshot/README.md.

Embedded subpackage (pkg/litigationplanner/embedded/upc/):
  - embed.go    — //go:embed *.json + LoadMeta()
  - snapshot.go — SnapshotCatalog (full lp.Catalog impl: LoadProceeding
    / LoadProceedingByID / LoadRuleByID / LoadRuleByCode /
    LoadRulesByTriggerEvent / LoadTriggerEventsByIDs / LookupEvents);
    O(1) map lookups; LookupEvents linear over the < 100-row UPC corpus.
  - holidays.go — SnapshotHolidayCalendar implementing lp.HolidayCalendar
    (IsNonWorkingDay / Adjust* with structured AdjustmentReason).
  - courts.go   — SnapshotCourtRegistry implementing lp.CourtRegistry.
  - Compile-time assertions (_ lp.X = (*Snapshot*)(nil)) catch
    interface drift.

Wire-up for consumers:
  cat, _ := upc.NewCatalog()
  hc, _  := upc.NewHolidayCalendar()
  cr, _  := upc.NewCourtRegistry()
  timeline, _ := lp.Calculate(ctx, "upc.inf.cfi", "2026-05-26",
                              lp.CalcOptions{}, cat, hc, cr)

Tests (snapshot_test.go, all DB-free):
  - meta parses cleanly, non-zero counts
  - LoadProceeding(upc.inf.cfi) returns expected proc + rules
  - LoadProceeding(unknown) returns ErrUnknownProceedingType
  - LookupEvents(Jurisdiction:UPC, all-following) covers corpus
  - LookupEvents(party=defendant, next) scopes anchors correctly
  - engine end-to-end via lp.Calculate against the embedded snapshot
  - holiday calendar (weekends, DE closures, UPC vacation block)
  - court registry (empty courtID fallback, known + unknown court)

Placeholder data shipped (2 proceedings, 2 rules, 5 holidays, 2
courts) so tests run without a live DB. Operator regenerates against
prod via `make snapshot-upc` once migrations 134 (B1) and 135 (B3)
have landed on prod — see cmd/gen-upc-snapshot/README.md for the
runbook. The placeholder's meta.version is suffixed `-placeholder`
to make the regeneration delta obvious.

Makefile target:
  make snapshot-upc — wraps the generator + reruns the snapshot tests

Design (§19 of docs/design-litigation-planner-2026-05-26.md):
  - Embedding format: go:embed JSON (diff-friendly, no compile coupling)
  - Generator entry: cmd/gen-upc-snapshot/main.go (idiomatic Go cmd path)
  - Versioning: meta.json carries semver + generated_at + paliad_commit
  - Regeneration: manual via Make target or `go generate`; no CI cron in v1
  - Out of scope: snapshot signing, DE/EPA/DPMA snapshots, snapshot
    diff tooling

Acceptance:
  - go build clean, go test all green (incl. 6 new tests in
    pkg/litigationplanner/embedded/upc, all DB-free)
  - SnapshotCatalog passes the compile-time lp.Catalog assertion
  - Generator binary builds + runs (Idempotence verified by re-running
    against the same source data)
2026-05-26 15:11:07 +02:00
mAi
6f8b4eabb1 Merge: t-paliad-299 — HOTFIX: rename upc.apl → upc.apl.unified (unblock mig 134, restore paliad.de) (m/paliad#130)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 15:09:46 +02:00
mAi
e2d75c391d fix(litigationplanner): rename upc.apl → upc.apl.unified (HOTFIX, t-paliad-299, m/paliad#130)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
mig 134 was inserting code='upc.apl' (2 segments) into paliad.proceeding_types,
which carries paliad_proceeding_code_shape CHECK requiring 3 dot-segments OR
'^_archived_'. Every container restart hit the constraint, rolled the migration
TXN back, and crash-looped paliad.de.

Rename the unified Berufung code to 'upc.apl.unified' (3 segments, satisfies the
constraint, preserves design intent). The pre-existing constraint is a useful
jurisdiction.category.specific invariant — keep it, fix the new row.

Touched only string literals:
- mig 134 up.sql + down.sql (insert, lookups, post-checks)
- frontend/src/verfahrensablauf.tsx (UPC_TYPES code + i18nKey)
- frontend/src/client/verfahrensablauf.ts (APPELLANT_AXIS + APPEAL_TARGET sets)
- frontend/src/client/i18n.ts (DE + EN translation rows)
- frontend/src/i18n-keys.ts (auto-regen via bun build)
- internal/services/lookup_events_test.go (anchor-row assertion)

Verified: `grep -rn "'upc\.apl'\|\"upc\.apl\""` returns zero hits.
go build, bun run build, go test ./... all green.
2026-05-26 15:09:12 +02:00
mAi
932b177779 Merge: t-paliad-292 — Slice B3: primary_party CHECK constraint + IsValidPrimaryParty helper (mig 135 audit-first) (m/paliad#124 §18.3)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 13:59:06 +02:00
mAi
989941c648 feat(litigationplanner): primary_party CHECK constraint + IsValidPrimaryParty helper (Slice B3, m/paliad#124 §18.3)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Tightens paliad.deadline_rules.primary_party from free-text to a CHECK
constraint over the canonical four-value vocab (claimant / defendant /
court / both). NULL stays valid for the 78 cross-cutting orphan
concept seeds (Wiedereinsetzung, Versäumnisurteil-Einspruch,
Schriftsatznachreichung, Weiterbehandlung) — they have no
proceeding_type_id binding so they're outside the calculator's path;
loosening the CHECK to "IS NULL OR IN (…)" keeps them valid without
backfill gymnastics.

Migration 135 (audit-first):
  - DO block RAISEs NOTICE for every non-conforming row + RAISEs
    EXCEPTION if any dirty rows exist (manual cleanup required).
    Live audit (Supabase, 2026-05-26 §18.0) confirmed zero dirty rows
    on the current corpus; the audit pass stays in the migration as
    safety against future drift.
  - ALTER TABLE … ADD CONSTRAINT deadline_rules_primary_party_chk
    CHECK (primary_party IS NULL OR primary_party IN
           ('claimant', 'defendant', 'court', 'both'))
  - Post-migration distribution NOTICE so the operator sees the
    final per-value count.
  - Down = DROP CONSTRAINT. No data revert needed.

Package additions (pkg/litigationplanner):
  - PrimaryParty* constants (PrimaryPartyClaimant / Defendant / Court
    / Both) + PrimaryParties[] ordered list + IsValidPrimaryParty(s)
    predicate. Empty string is "no value supplied" = valid (NULL maps
    to empty on the wire); non-empty must match one of the four
    canonical values.
  - Sibling unit tests (primary_party_test.go) pin the four-value
    vocab + the chip order + IsValidAppealTarget's matching shape.

Rule-editor validation hook (rule_editor_service.go):
  - Create() validates input.PrimaryParty before INSERT.
  - UpdateDraft() validates patch.PrimaryParty before UPDATE.
  - Both surface a user-friendly 400 with the canonical vocab listed
    instead of leaking the raw PG CHECK constraint-violation message.
  - Uses errors.Is(err, ErrInvalidInput) so handler 400 routing
    continues to work.

services/fristenrechner.go cleanup:
  - The B2-inlined isValidPartyForLookup helper is replaced with the
    canonical lp.IsValidPrimaryParty. No behaviour change.

No frontend changes — the rule-editor's primary_party UI already
constrains to the four values via a select; the validation hook is
defense-in-depth.

Audit:
  - go build + go test (incl. new lp unit tests) all green
  - Pre-migration audit confirmed: 26 claimant + 26 defendant + 38
    court + 63 both + 78 NULL = 231 total, all in canonical vocab
  - event_categories.party (text[] array, narrower semantic) is
    NOT touched in this migration per the design doc's
    "out of scope, separate follow-up" decision
2026-05-26 13:58:33 +02:00
mAi
db8e8ba6fd Merge: t-paliad-292 — Slice B2: multi-axis catalog query API (LookupEvents, 5-axis AND filter, depth toggle) (m/paliad#124 §18.2)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 13:55:24 +02:00
mAi
d5bf82314a feat(litigationplanner): multi-axis catalog query API (Slice B2, m/paliad#124 §18.2)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
New Catalog.LookupEvents(ctx, axes, depth) method exposes a unified
graph query over paliad.deadline_rules + paliad.proceeding_types + the
deadline_concept_event_types junction. Used by the Determinator
cascade, the scenarios surface (Slice D), and any future "show me
events matching X" query — centralises a fan-out that today is
duplicated across multiple client-side paths.

Package additions (pkg/litigationplanner):
  - EventLookupAxes: optional Jurisdiction / *ProceedingTypeID / Party
    / *EventCategoryID / AppealTarget. All fields optional; the empty
    value (or nil pointer) is "no filter on this axis". Multiple
    non-zero axes apply as AND.
  - EventLookupDepth: "next" (1 hop downstream) or "all-following"
    (full chain).
  - EventMatch: Rule + ProceedingType + Priority + DepthFromAnchor +
    *ParentRuleID (populated only when the parent itself is in the
    returned set, so the frontend can render a tree).
  - Catalog interface gains LookupEvents.

paliad-side implementation (internal/services/fristenrechner.go):
  - SQL pass with progressively-built WHERE clauses (one $N
    placeholder per non-zero axis). EventCategoryID uses an EXISTS
    subquery against paliad.event_category_concepts joined via
    concept_id.
  - Post-fetch parent_id graph walk in Go for depth control. Loads
    the per-proceeding rule corpus via DeadlineRuleService.List so
    children whose parent_id is in the anchor set can be added even
    when those children don't match the axes themselves. AllFollowing
    iterates to fixpoint; Next stops after one pass.
  - DepthFromAnchor computed by walking each result row up the
    parent_id chain until it hits an anchor (iteration-bounded to
    prevent infinite loops on hypothetical cycles).
  - Unknown axis values (jurisdiction="XX", party="foo",
    appealTarget="invalid") silently fall through as "no filter on
    this axis" — a stale frontend chip should not drop the entire
    result set.
  - "published + active" gate (lifecycle_state='published' AND
    is_active=true) matches LoadProceeding's WHERE clause.
  - Results ordered by (proceeding_type_id, sequence_order) so the
    frontend can render without re-sorting.

Tests (internal/services/lookup_events_test.go):
  - Live-DB driven (skipped without TEST_DATABASE_URL, matches the
    existing TestCalculateRule pattern).
  - Cases: UPC-jurisdiction returns the UPC corpus only;
    party=defendant scopes anchor matches to defendant rules;
    unknown jurisdiction falls through; appeal_target=endentscheidung
    returns the merits rules from B1 mig 134;
    appeal_target=schadensbemessung returns empty (no rules seeded).

No schema delta. No frontend wiring (the new HTTP endpoint at
GET /api/tools/lookup-events can land in a follow-up slice — the
package + paliad-side impl are the deliverable here).
2026-05-26 13:54:57 +02:00
mAi
426b90bb88 Merge: t-paliad-292 — Slice B1: Berufung unification (one upc.apl + 5 appeal_target chips, mig 134 additive) (m/paliad#124 §18.1)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 13:49:52 +02:00
mAi
07acf7b4a2 feat(litigationplanner): Berufung unification — one upc.apl + 5 appeal_target chips (Slice B1, m/paliad#124 §18.1)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Collapses the 3 UPC appeal proceeding_types (upc.apl.merits 7 rules,
upc.apl.cost 2, upc.apl.order 7 = 16 total across 3 codes) into ONE
unified upc.apl proceeding type + a per-rule applies_to_target[]
discriminator. The verfahrensablauf picker now shows one "Berufung"
tile; after picking it, the user selects which decision the appeal is
directed AT via a 5-chip group (Endentscheidung / Kostenentscheidung /
Anordnung / Schadensbemessung / Bucheinsicht) and the engine filters
rules whose applies_to_target contains the picked slug.

m's 2026-05-26 decision: Schadensbemessung-as-appeal is a NEW first-
class target with its OWN rule set (no shared inheritance from
merits). The 5 enum values are all defined + addressable; for now
schadensbemessung and bucheinsicht return empty timelines until rules
are seeded in a follow-up slice (likely via /admin/rules or pairing
with t-paliad-193 orphan-concept-seed).

Migration 134 (additive only):
  - ADD proceeding_types.appeal_target text (CHECK on 5 slugs OR NULL)
  - ADD deadline_rules.applies_to_target text[] (CHECK each element
    in the 5 slugs)
  - INSERT the unified upc.apl row (inherits sort/color from
    upc.apl.merits)
  - Audit-first RAISE NOTICE pass listing every row about to be
    touched + a post-migration sanity check
  - Reassign rule rows: merits → applies_to_target={endentscheidung},
    cost → {kostenentscheidung}, order → {anordnung}
  - Archive (is_active=false, NOT DELETE) the 3 old proceeding_types
    so historical FKs stay intact
  - Down migration restores is_active=true on the 3 old types, points
    rules back by their applies_to_target stamp, drops the unified
    row, drops both columns. Safe.

Package additions (pkg/litigationplanner):
  - AppealTarget* constants + AppealTargets[] ordered list +
    IsValidAppealTarget(s) predicate (silent no-op on unknown slugs
    so a stale frontend chip doesn't break the render)
  - ProceedingType.AppealTarget *string field (top-level marker;
    NULL on non-appeal proceedings)
  - Rule.AppliesToTarget pq.StringArray field (per-row applies-to set)
  - CalcOptions.AppealTarget string (engine filter — when set,
    keeps only rules whose AppliesToTarget contains the slug)

Engine filter runs after ApplyRuleOverrides but before the rule walk
so the existing condition_expr / spawn / appellant-context machinery
operates on the filtered subset transparently.

paliad-side wiring:
  - deadline_rule_service.go: ruleColumns + proceedingTypeColumns
    extended to scan the new columns
  - handlers/fristenrechner.go: AppealTarget JSON field on the
    request payload, threaded into CalcOptions

Frontend (verfahrensablauf surface only):
  - Single "Berufung" tile replaces the 3 separate Berufung tiles
  - New 5-chip appeal-target row, shown only when upc.apl is picked
  - URL state ?target=<slug>; default endentscheidung when none set
  - APPELLANT_AXIS_PROCEEDINGS updated: upc.apl.* (3 entries) →
    upc.apl (1 entry)
  - i18n keys (DE + EN) for the new tile + the 5 chip labels +
    the "Worauf richtet sich die Berufung?" / "Appeal against:" prompt
  - calculateDeadlines threads appealTarget through to the API

Acceptance:
  - go build clean, go test all green (existing test suite — no new
    tests on the engine filter as a follow-up; the migration's
    sanity-check DO block guards the rule-reassignment count)
  - Live audit before drafting confirmed: 3 active UPC appeal
    proceeding_types, 16 rules total, primary_party already conforms
    to 4-value vocab on all proceeding-bound rules
2026-05-26 13:49:03 +02:00
mAi
3e1644820a Merge: t-paliad-273 — B.0 procedural-events design doc + live-DB re-validation findings (m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 13:46:19 +02:00
mAi
c4c0a82abb docs(procedural-events): B.0 live-DB re-validation findings + design doc bug fix (t-paliad-273 / m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Slice B.0 — read-only re-validation of cronus's procedural-events design
against the live youpc Supabase paliad schema, 24 h after the design was
authored.

* Adds docs/design-procedural-events-b0-findings-2026-05-26.md with the
  drift table, per-check confirmations, and a tightened B.1 brief.
* Annotates the cronus design doc's status header to point at the B.0
  findings file so future readers see both together.
* Fixes the self-contradictory sentence in §1 that referenced
  `deadline_rule_id` on both sides of a "not" — the live column is
  `paliad.deadlines.rule_id`, renamed directly to
  `paliad.deadlines.procedural_event_id` under Slice B (no intermediate
  step). Matching fix patched into the m/paliad#93 issue body via
  Gitea API (curl --netrc-file ~/.netrc-mai PATCH).

Key drift surfaced (vs design 2026-05-25):
- deadline_rules rows 254 → 231
- distinct submission_codes 158 → 153 (10 _archived_litigation.* codes
  gone — Q5 multi-row collapse premise is now MOOT)
- distinct legal_sources 70 → 87 (+17)
- concept-linked rules 125 → 129
- paliad.deadlines rows 1 → 5
- submission_drafts rows 4 → 7
- live mig head 123 → 133; next available = 134 (not 124)

No migration SQL written. No writes to paliad.deadline_rules. Researcher
stays parked pending m's B.1 greenlight.

Note: this commit also cherry-picks the original inventor design doc
(5bb6df6) onto the B.0 branch, because the design was never merged to
main and the doc bug fix needed somewhere to land.
2026-05-26 13:44:30 +02:00
mAi
5ab14f8b37 docs: t-paliad-262 — procedural-events data-model design (inventor)
Slice A (cosmetic rename) + Slice B (structural rework) for the
deadline_rules → procedural_events / sequencing_rules / legal_sources
split. Recommendation (R)=C (cosmetic now, structural follow-up).
Umbrella-term lock: procedural event / Verfahrensschritt.

Read-only design phase. No code or schema changes here. m/paliad#93.
2026-05-26 13:38:08 +02:00
mAi
acf5743fa3 docs(litigation-planner): Slice B design — Berufung unification + multi-axis catalog query + primary_party CHECK (m/paliad#124)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Adds §18 to the design doc folding in m's three 2026-05-26 decisions:

§18.1 Berufung unification — collapse 3 active UPC appeal proceeding_types
(upc.apl.merits / upc.apl.cost / upc.apl.order, 16 rules total) into ONE
upc.apl + appeal_target discriminator. 5 targets: Endentscheidung,
Kostenentscheidung, Anordnung, Schadensbemessung, Bucheinsicht. Adds
proceeding_types.appeal_target + deadline_rules.applies_to_target[]
columns; archives the 3 old codes; CalcOptions gains AppealTarget filter.
Migration 134 with pre-migration audit pass. Q to m on whether
Schadensbemessung-as-appeal shares the merits rule set (R) or has its own.

§18.2 Multi-axis catalog query API — new Catalog.LookupEvents method
taking optional {jurisdiction, proceeding_type_id, party,
event_category_id, appeal_target} axes + EventLookupDepth control
("next" / "all-following"). No schema delta — reuses existing parent_id
+ sequence_order graph. Returns EventMatch with priority + depth metadata.

§18.3 primary_party enum tightening — CHECK constraint on
deadline_rules.primary_party against canonical four-value vocab
(claimant/defendant/court/both, plus NULL for orphan concept seeds).
Live audit confirmed all 26+26+38+63 proceeding-bound rows already
conform; the 78 NULL rows are all proceeding_type_id IS NULL orphans
(cross-cutting concepts) and stay NULL. Migration 135 with audit-first
RAISE NOTICE pass. Package exposes PrimaryParties[] + IsValidPrimaryParty().

§18.4 revises §10 slice plan: B1 (Berufung), B2 (catalog query), B3
(enum tightening). Independent + parallel-friendly.

Branch: mai/cronus/inventor-litigation-slice-b (off main d1d0cf9).
NOT reusing the merged Slice A branch.
2026-05-26 13:37:26 +02:00
mAi
d1d0cf9c1d Merge: t-paliad-298 — Slice A: extract Fristen/Verfahrensablauf calc into pkg/litigationplanner (m/paliad#124)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 13:01:50 +02:00
mAi
5f0a85fa83 refactor(litigationplanner): extract Fristen/Verfahrensablauf calc into pkg/litigationplanner (Slice A, t-paliad-298 / m/paliad#124)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Atomic extraction of the deadline-rule compute engine + types from
internal/services into a new pkg/litigationplanner package that paliad
+ youpc.org can both import. No behaviour change — every existing test
passes against the post-move shape.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Lime hue and non-active states untouched.

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

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

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

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

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

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

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

Regenerated .docx files uploaded to HL/mWorkRepo:
- 6 - material/Templates/Word/Paliad/HLC/_skeleton.docx → d0ecc0e
- 6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx → 25954c9
2026-05-26 09:41:01 +02:00
mAi
c3eaa9b1d4 Merge: t-paliad-290 — show-hidden toggle + un-hide chip on Verfahrensablauf (m/paliad#122)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 09:39:52 +02:00
mAi
80883eaac5 feat(verfahrensablauf): re-surface hidden optional events — show-hidden toggle + un-hide chip (t-paliad-290)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
m/paliad#122. atlas's #96 Slice A added per-card 'Überspringen' but no
un-skip path — hidden cards just disappeared from the timeline. This
adds the missing return path:

- CalcOptions.IncludeHidden (default false) tells the calculator to
  re-surface skipRules entries as faded rows instead of dropping them.
  When true, the rule renders with UIDeadline.IsHidden=true and the
  descendant-suppression cascade is bypassed so children compute their
  dates off the un-suppressed parent.
- UIResponse.HiddenCount always reflects the projection's hide count
  (gate-passed rules whose submission_code is in skipRules) so the
  "Ausgeblendete (N)" badge stays accurate regardless of toggle state.
- /tools/verfahrensablauf gets a "Ausgeblendete anzeigen" checkbox next
  to the perspective + appellant selectors. URL-driven (?show_hidden=1)
  so the state is shareable and survives reload. The row hides itself
  on projections with zero hidden cards.
- Hidden cards render via .timeline-item--hidden / .fr-col-item--hidden
  (opacity 0.55 + dotted border, mirroring the existing
  --skipped fade) and carry an inline "Wieder einblenden" chip. Clicking
  the chip removes the skip choice via the page's existing
  attachEventCardChoices remove callback (URL state + recalc included)
  and runs through a new delegated handler in event-card-choices.ts.
- 3 new i18n keys (DE+EN): choices.show_hidden.label,
  choices.show_hidden.count, choices.unhide.chip.

The skip-choice storage shape (paliad.project_event_choices, atlas's
table) is unchanged — un-hide is just a delete of the skip row.

Tests: 3 new bun-test cases pin the chip contract (emits on isHidden=
true with submission_code, suppressed otherwise); go test ./internal/...
+ bun run build clean.
2026-05-26 09:38:31 +02:00
mAi
5e17de6e07 Merge: t-paliad-288 — Verfahrensablauf 'Beide' → 'Nicht festgelegt' (m/paliad#120)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 09:35:19 +02:00
mAi
0e1f62e375 feat(verfahrensablauf): replace 'Beide' chip with 'Nicht festgelegt' (t-paliad-288)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
The Verfahrensablauf side selector offered Klägerseite / Beklagtenseite /
Beide. 'Beide' is legally impossible (no party is on both sides) — the
state being modelled is "perspective not yet picked", not "both sides".
Rename the chip to 'Nicht festgelegt' (DE) / 'Undefined' (EN) without
changing the underlying state value or projection behaviour.

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

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

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

m/paliad#120
2026-05-26 09:33:00 +02:00
mAi
cca5e72c57 ci: trigger workflow run to verify Slice A pre-deploy gate (post DOKPLOY_TOKEN setup)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 09:19:27 +02:00
mAi
4d923562f5 Merge: t-paliad-283 — /views/any filter-bar Predicates flatten fix (m/paliad#115)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-25 17:47:57 +02:00
mAi
c70914c2a0 fix(filter-bar): flatten FilterSpec.Predicates wire shape (t-paliad-283)
The bar's chip clicks POST a payload shaped as `predicates: {<source>:
<per-source>}` — flat, one entry per data source. Go declared
`Predicates map[DataSource]Predicates` — a doubled-nested wrapper where
each map value was itself a Predicates struct with named per-source
fields. The JSON shape Go expected was
`{"deadline": {"deadline": {"status": [...]}}}`; the shape the bar
emitted was `{"deadline": {"status": [...]}}`. Go silently unmarshalled
the bar's payload as `Predicates{}` (all source fields nil), so every
chip click on /views/any was a server-side no-op — the regression in
#115.

The latent contract bug was present since t-paliad-144 A1 (b516201) but
only surfaced now: /inbox uses the InboxSystemView's code-resident
predicates (built in Go directly, doubled shape works) and saved views
never carried predicates in the DB, so chip-click overlays were the
only path that exercised the wire-format wrong way. /views/any made
that path visible because all four sources need narrowing.

Fix: align Go to the flat shape the frontend already emits.

- FilterSpec.Predicates: `map[DataSource]Predicates` → `*Predicates`.
- All `spec.Predicates[SourceX]` access sites in view_service.go +
  approvalStatusMatches + allowed* helpers + system_views literals
  + tests rewritten to `spec.Predicates.X` with a nil-spec.Predicates
  guard.
- Frontend FilterSpec.predicates type tightened from
  `Partial<Record<DataSource, Predicates>>` (which silently allowed
  the wrong runtime write) to `Predicates`.

Regression coverage:

- `filter_spec_predicates_test.go` (new, Go) pins three contracts:
  the bar's exact wire payload unmarshals into a non-nil per-source
  predicate; marshalling a Go-constructed spec produces the same flat
  shape; the "Erledigt" chip's request narrows to completed deadlines.
- `compute-effective.test.ts` (new, bun:test) pins 12 chip-overlay
  cases for /views/any (every axis the saved view's sources expose).

Build hygiene:
- `go build ./...` clean.
- `go test ./... -count 1` clean (existing inbox + filter_spec tests
  updated for the new struct shape; new tests pass).
- `cd frontend && bun run build` clean.
- `cd frontend && bun test src/` — 169 pass, 0 fail.

No migration: paliad.user_views.filter_spec jsonb rows live with
`predicates: {}` or no predicates field; both unmarshal as nil
*Predicates under the new type, identical to the no-narrowing behaviour
the old map type produced for the same rows.
2026-05-25 17:46:58 +02:00
mAi
016ac2532a Merge: t-paliad-282 Slice A — CI/CD pre-deploy gate + snapshot-based migration smoke (m/paliad#114)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-25 17:42:51 +02:00
mAi
c901293c9c feat(cicd): Slice A — pre-deploy gate + role-split migration smoke
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Adds .gitea/workflows/test.yaml that gates every push on `go build`,
`bun run build`, `go vet`, the migration coordination check, and the
role-split end-to-end migration smoke. On push to main + green, calls
Dokploy's compose.deploy API and polls /health/ready until 200.

t-paliad-282 / m/paliad#114. Design: docs/design-cicd-pre-deploy-gate-2026-05-25.md
(inventor shift on mai/cronus/inventor-ci-cd-pre).

Catches all three of today's outage classes:

  brunel (~13:20) slot collision     -> TestMigrations_NoDuplicateSlot
  hermes (~16:05) dropped-col refs   -> TestBootSmoke
  mig 129 (~14:56) 42501 ownership   -> TestMigrations_EndToEndAsAppRole

Snapshot approach. internal/db/testdata/prod-snapshot.sql is a pg_dump
of youpc-supabase paliad schema + applied_migrations rows. CI restores
this into a fresh `supabase/postgres:15.8.1.060` (same image, same role
topology as prod) and runs ApplyMigrations as the `postgres` role
(which is NOT a superuser on supabase/postgres, matching prod). Existing
migrations are skipped (already in applied_migrations); only NEW migs
from the PR run end-to-end. This sidesteps the fresh-DB idempotence
debt in some historical migrations (mig 037 missing pg_trgm, mig 051
inner COMMIT) — those are tracked separately and don't block the gate.

Sub-changes:

- internal/handlers/handlers.go — new /health/ready endpoint distinct
  from /healthz. /healthz stays liveness (process alive, no DB); /ready
  is readiness (DB pool pings within 2 s). Returns 503 when svc or pool
  is nil (DB-less deploys are intentionally not-ready). svc.Pool added
  to handlers.Services, wired in cmd/server/main.go.

- internal/db/migrate_test.go — TestMigrations_NoDuplicateSlot (pure
  unit, catches brunel) and TestMigrations_EndToEndAsAppRole (snapshot-
  gated, catches the 42501 class).

- cmd/server/main_smoke_test.go — TestBootSmoke now also asserts
  /health/ready returns 503 with a nil svc. New TestHealthReady_Live
  asserts 200 against a live pool.

- internal/db/migrations/024_rename_department_columns.up.sql and
  027_rename_to_partner_units.up.sql — ALTER INDEX / ALTER POLICY
  exception handlers now catch undefined_object OR undefined_table OR
  duplicate_object. Old handler only caught undefined_object; Postgres
  raises undefined_table when source object never existed, and
  duplicate_object when destination already exists. The expanded
  handlers make these migrations truly idempotent across all plausible
  starting states.

- Makefile — verify-mig-app, test-frontend, refresh-snapshot targets.
  refresh-snapshot pg_dumps youpc-supabase prod (needs PALIAD_PROD_DATABASE_URL),
  strips pg16 \restrict commands for pg15 restore compat, and filters
  applied_migrations rows to this branch's max on-disk version.

- internal/db/testdata/README.md — explains the snapshot's purpose,
  refresh procedure, and how to verify locally.

- docs/cicd-runner-setup-2026-05-25.md — one-time admin steps for
  registering a Gitea Actions runner on mriver and wiring DOKPLOY_TOKEN
  as a repo secret. Documents soft-launch plan per m's Q11.4 (keep
  Dokploy's autoDeploy=true webhook alive for one week, disable after
  the workflow has gated 5 successful deploys).

Build clean. Full go test ./internal/... ./cmd/... green without
TEST_DATABASE_URL. With TEST_DATABASE_URL + TEST_APP_DATABASE_URL set
to a supabase/postgres scratch + snapshot restored:
TestMigrations_NoDuplicateSlot, TestMigrations_EndToEndAsAppRole,
TestBootSmoke, TestHealthReady_Live all pass. Live-DB service tests in
internal/services/* fail under supabase/postgres 15.8 with a 42P08
parameter-binding error (unrelated to Slice A — tracked as a follow-up).
2026-05-25 17:42:06 +02:00
mAi
0b1653c2bf Merge: t-paliad-284 — Wave 1 Tier 1 rule additions + Q6 archived cleanup + audit FK fix (mig 132) (m/paliad#116) 2026-05-25 17:38:42 +02:00
mAi
a6cf6ff4c9 feat: t-paliad-284 Wave 1 Tier 1 deadline-rule additions (mig 132)
Add 12 Tier 1 procedural deadline rules from curie's audit §10
(docs/research-deadlines-completeness-2026-05-25.md), backfill the
UPC R.104/R.105 Interim Conference citation on upc.inf.cfi.interim
(m/paliad#116 / m's 2026-05-25 report), and fold in the audit Q6
cleanup of the 40 _archived_litigation.* rows.

New rules:
  T1.1  upc.inf.cfi.cmo_review           15d / R.333.2
  T1.2  upc.inf.cfi.confidentiality_response 14d / R.262.2 (trigger 25)
  T1.3  upc.apl.order.grounds_orders     15d / R.224.2(b)
  T1.4  upc.apl.order.response_orders    15d / R.235.2
  T1.5  upc.inf.cfi.cons_orders          2mo / R.118.4
  T1.6  upc.inf.cfi.rectification        1mo / R.353
  T1.7  upc.pi.cfi.deficiency            14d / R.207.6(a)
  T1.8  upc.pi.cfi.merits_start          31d OR 20wd (max) / R.213 + R.198.1
  T1.9  upc.inf.cfi.translation_request  1mo BEFORE oral / R.109.1
  T1.10 upc.inf.cfi.interpreter_cost     2wk BEFORE oral / R.109.4
  T1.11 upc.inf.cfi.translations_lodge   2wk / R.109.5 (trigger 113)
  T1.12 upc.pi.cfi.response              UPDATE: re-anchor on .app, court-set

T1.8 uses Wave 2 Slice A primitives (mig 128: working_days unit +
combine_op='max'). T1.9/T1.10 use timing='before' with the
backward-snap path in deadline_calculator.go.

Also drops the deadline_rule_audit.rule_id FK constraint. The mig 079
audit trigger had a latent bug — it could not log DELETEs because the
FK rejected the post-delete INSERT (count(*) WHERE action='delete'
was 0 across the entire history). Audit tables are append-only
history and should not FK-constrain on live entity tables; before_json
preserves the full row state. Unblocking this also unblocks the §13b
Q6 cleanup.

Verified on Supabase: 13 rows present in post-fix shape, all
assertions in the DO-block pass, audit log now records 11 creates +
2 updates + 40 deletes for this migration.
2026-05-25 17:29:13 +02:00
mAi
191d8e7268 Merge: t-paliad-285+286 — UPC Damages + PM appeal route deadlines (mig 133) (m/paliad#117, m/paliad#118) 2026-05-25 17:26:04 +02:00
mAi
cb44b3b8cc mAi: #117 + #118 - t-paliad-285/-286 UPC dmgs+pi court followup (mig 133)
Adds the post-submission court phase to upc.dmgs.cfi and the appeal
route to upc.pi.cfi. The Verfahrensablauf timeline currently stops at
the last party submission (dmgs.rejoin / pi.order); without these rows
the interim conference / oral hearing / decision / appeal sub-tree
never renders, even though atlas's #96 spawn mechanism is in place.

Migration 133 (single slot, coordinated with knuth's #116 on 132):

Section A — UPC Damages tree end (#117):
- upc.dmgs.cfi.interim       court-set, R.105
- upc.dmgs.cfi.oral          court-set, R.118 / R.250
- upc.dmgs.cfi.decision      court-set, R.118 / R.144
- upc.dmgs.cfi.appeal_spawn  2mo, R.220.1(a) / R.224.1(a), spawn → upc.apl.merits

Section B — UPC PI appeal route (#118):
- upc.pi.cfi.appeal_spawn    2mo, R.220.1(a) / R.224.1(a), spawn → upc.apl.merits
  PI orders under R.211 dispose of the urgent question and ride the
  main 2-month track; the 15-day R.220.1(c) order track does not apply.

Same shape as mig 095 inf.appeal_spawn and the upc.inf.cfi
interim/oral/decision rows from mig 012. Court-set rows reuse the
shared interim-conference / oral-hearing / decision concepts.

Citations: docs/research-deadlines-completeness-2026-05-25.md §D + Tier 4 (R.144), docs/audit-upc-rop-deadlines-2026-05-08.md §D R.144 + §F R.220.1(a)/R.224.1(a). Per-row RoP citation in the migration header.

Idempotent INSERT NOT EXISTS guards per row + post-insert DO block that RAISEs EXCEPTION if any expected row is missing or the spawn shape (is_spawn / spawn_proceeding_type_id / parent_id) is wrong.

go build ./... clean, go test ./internal/... clean, bun run build clean.
2026-05-25 17:25:19 +02:00
mAi
c4e9875ff4 Merge: t-paliad-276 — submission generator DE/EN language selector, post-rebase (mig 130) (m/paliad#108) 2026-05-25 17:04:23 +02:00
mAi
e4c694e01c mAi: #108 - t-paliad-276 submission generator language selector (DE/EN)
Per-draft `language` column drives the .docx output language for the
submission generator. The lawyer picks DE or EN on the draft editor's
sidebar; the generator selects the language-matched template variant
(falling back through {code}.{lang} → {code} → _skeleton.{lang} →
_skeleton → letterhead) and resolves language-aware variables
({{procedural_event.name}} → name_de vs name_en).

Schema (mig 130 — bumped from 129 to deconflict with atlas's #96):
- paliad.submission_drafts.language text NOT NULL DEFAULT 'de'
  CHECK IN ('de','en'). Existing rows inherit 'de' via the default,
  preserving every legacy draft's behaviour byte-for-byte.

Backend (Go):
- SubmissionVarsContext.Lang overrides the user's UI lang. Build()
  uses it when set; falls back to user.Lang otherwise — Slice 1's
  format-only /generate path keeps working unchanged.
- SubmissionDraftService.BuildRenderBag now threads draft.Language
  through. Create/EnsureLatest seed from the UI lang (DE default).
- DraftPatch.Language landed; Update validates and rejects values
  outside {de,en}. Project-scoped + global PATCH endpoints both
  surface the field.
- resolveSubmissionTemplate(ctx, code, lang) replaces the lang-less
  predecessor. Returns the matched tier (per_code_lang / per_code /
  skeleton_lang / skeleton / letterhead) so the editor knows whether
  to surface the "Fallback: universelles Skelett" notice.
- fileRegistry registers the EN skeleton sibling (`_skeleton.en.docx`)
  alongside the DE one; per-code EN variants land in a parallel
  submissionTemplateENRegistry (empty for now — EN templates land per
  HLC authoring). 404s from Gitea fall through silently.
- /api/projects/{id}/submissions/{code}/generate accepts
  `?language=de|en` query override (one-shot path, no draft row to
  pull the column from); defaults to the user's UI lang.

Frontend (TS/JSX):
- DE/EN radio above the variables list in the draft editor sidebar.
  Switching the radio PATCHes `language` and the server returns the
  freshly-resolved bag + preview HTML so the lawyer sees EN values
  immediately.
- Fallback notice ("Fallback: universelles Skelett (keine
  sprachspezifische Vorlage)") shows when the resolved tier doesn't
  match the requested language.
- 4 new i18n keys (DE + EN) + CSS for the toggle.

Tests:
- normalizeDraftLanguage covers DE/EN/case/whitespace/unknown.
- addRuleVars language-pick test pins procedural_event.name and the
  rule.name alias to the language-matched value.
- languageFallback truth table covers all 10 (lang × tier) combos.

Build hygiene: go build/vet/test clean; bun run build clean.
2026-05-25 17:03:34 +02:00
mAi
5efb9f5098 Merge: t-paliad-281 — admin rules list 'undefined' proceeding name fix (m/paliad#113) 2026-05-25 16:54:39 +02:00
mAi
c6267e4e6d Merge: t-paliad-277 — submission party selector + import-from-project (mig 131) (m/paliad#109) 2026-05-25 16:53:50 +02:00
mAi
8e696487e0 Merge: t-paliad-279 — Verfahrensablauf form reorder, party-after-proceeding-type (m/paliad#111)
# Conflicts:
#	frontend/src/client/verfahrensablauf.ts
2026-05-25 16:53:32 +02:00
mAi
001542a3ce mAi: #113 - fix admin rules list 'undefined' proceeding name
ProceedingType TS interface in admin-rules-list.ts and admin-rules-edit.ts
declared `name_de` but the Go ProceedingType model serialises `db:"name"`
as JSON key `name` (DE is the primary on the wire). Result: `pt.name_de`
was undefined for every row, so `${pt.code} · ${pt.name_de}` produced the
literal "upc.apl.cost · undefined" in the list (and the same in proceeding
selects of the edit page).

Frontend-only fix:
- Rename the field to `name` to match the API contract.
- Guard the label builder: if the active-language name is missing, fall
  back to just the proceeding code rather than rendering "code · " (or
  worse, the original "code · undefined" string).

Other admin pages that fetch /api/proceeding-types-db (deadlines-new,
deadlines-detail, project-form, fristenrechner) already read `pt.name`
correctly, so the bug was scoped to these two files. TriggerEvent's
`name_de` field is real and stays untouched.
2026-05-25 16:52:07 +02:00
mAi
4fc3005db8 mAi: #109 - t-paliad-277 submission generator party selector + import-from-project
Multi-select party picker on the dedicated submission draft editor —
lawyer picks which of the project's parties to mention in this
specific submission. Adds the t-paliad-277 variable-bag multi-party
shape ({{parties.claimants}}, {{parties.claimant.0.name}}) while
keeping the legacy flat aliases ({{parties.claimant.name}}) for every
existing .docx template authored before the rename.

Surfaces an explicit "Aus Projekt importieren" button + last-imported
timestamp at the top of the variable sidebar so the lawyer can re-pull
project-derived variables (project.*, parties.*, deadline.*,
procedural_event.*, rule.*) when the project data drifts away from the
saved draft overrides. firm.*, today.*, user.* overrides survive the
import — those values aren't sourced from the project record.

Schema: mig 131 adds two columns to paliad.submission_drafts:
  - selected_parties uuid[] DEFAULT '{}'::uuid[]
    Empty = include every party (legacy default).
    Non-empty = restrict to the subset, grouped by role at substitution.
  - last_imported_at timestamptz NULL
    Bumped each "Aus Projekt importieren" click; surfaced in UI.

Backend:
  - SubmissionVarsContext gains SelectedParties — filterPartiesBySelection
    restricts the resolved bag before role bucketing.
  - addPartyVars emits THREE coexisting forms per role: comma-joined
    (parties.claimants), indexed (parties.claimant.0.name), and flat
    legacy (parties.claimant.name → first selected claimant). Flat
    aliases are kept forever per the issue's backward-compat contract.
  - SubmissionDraftService.ImportFromProject strips overrides for
    project-derived prefixes and bumps last_imported_at; rejects
    project-less drafts (nothing to import from).
  - New endpoint POST /api/submission-drafts/{id}/import-from-project.
  - DraftPatch + PATCH handlers accept selected_parties.
  - submissionDraftView now ships available_parties so the editor can
    render the picker without an extra round-trip.

Frontend:
  - submission-draft.tsx: new import-row + parties block in the sidebar.
  - client/submission-draft.ts: paintImportRow / paintPartyPicker /
    onPartySelectionChange / onImportFromProject; group parties by
    role bucket (claimant / defendant / other) with DE+EN role-string
    matching to mirror the backend bucketing.
  - 3 new i18n keys (DE+EN): import.button, parties.title, parties.hint.
  - CSS for the picker + import row in global.css.

Tests: 6 new unit tests in submission_vars_parties_test.go covering
the multi-party bag emission, German role-string bucketing, flat-alias
first-of-role resolution, empty-selection-means-all default, non-empty
restriction, and the isProjectDerivedKey policy that powers the
import path.

Build hygiene: go build/vet clean; go test -short ./internal/... pass;
bun run build clean (2876 i18n keys, scan clean).
2026-05-25 16:51:35 +02:00
mAi
a6d0acbcb4 mAi: #111 - t-paliad-279 — Verfahrensablauf form reorder + project auto-fill chip
Reorder Verfahrensablauf 'Browse a proceeding' so the user-input flow
matches the importance hierarchy: proceeding-type → side → appellant →
date / court / flags. Side was previously below the date input; it is
the most-defining input after proceeding-type, so it belongs above.

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

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

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

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

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

No backend / projection logic change.
2026-05-25 16:51:27 +02:00
mAi
96eab90044 Merge: t-paliad-280 — search input icon-text padding fix (m/paliad#112) 2026-05-25 16:51:26 +02:00
mAi
5348cb548f mAi: #112 - fix Fristenrechner Akte-picker icon overlap
The Akte-picker (Step 1) wraps its magnifying-glass icon + input in a
flexbox row (`.fristen-step1-search-row`) with `gap: 0.5rem`, expecting
the icon to participate in the flex layout. But the shared
`.fristen-search-icon` rule (used by the B2 search input) sets
`position: absolute; left: 0.875rem;` — and the step1-scoped override
only tweaked color + flex-shrink without resetting `position`.

Result: the icon was absolutely-positioned out of the flex flow and
overlapped the input text (since `.fristen-akte-search` has no
padding-left). Resetting `position: static` for the step1 context lets
flexbox + gap handle the spacing naturally — same pattern as
`.fristen-row-search-panel-input-wrap`, which already works.

Audited other search inputs with leading magnifying-glass icons:

- `.glossar-search` (Glossary, Courts, Links, Team, AdminTeam,
  AdminEventTypes) — wrap `.glossar-search-wrap` is `position: relative`,
  input has `padding: 0.65rem 4.5rem 0.65rem 2.5rem`. Fine.
- `.projects-search-input` (/projects index) — wrap is
  `position: relative`, input has `padding: 0.5rem 0.75rem 0.5rem 2.4rem`.
  Fine.
- `.fristen-search-input` (Fristenrechner B2) — wrap `.fristen-search-row`
  is `position: relative`, input has
  `padding: 0.75rem 2.5rem 0.75rem 2.6rem`. Fine.
- `.fristen-row-search-panel-input` (Fristenrechner row-search panel) —
  pure flex layout with `gap`, icon non-positioned. Fine.
- `.sidebar-search-input` (global sidebar search) — pure flex layout.
  Fine.
- Other search inputs (`event-search-input`, `event-type-search`,
  `submissions-new-search`, submissions index) have no leading icon.
  N/A.
2026-05-25 16:50:05 +02:00
mAi
b1340e2be4 Merge: t-paliad-278 — date-range picker 3-column layout Past/NOW/Future (m/paliad#110) 2026-05-25 16:46:59 +02:00
mAi
1292aa575d Merge: t-paliad-265 — per-event-card choices Slice A+B (popover + CCR + projection engine, mig 129) (m/paliad#96) 2026-05-25 16:46:15 +02:00
mAi
87c200a47e feat(t-paliad-265): caret + popover + chip on Verfahrensablauf cards
m/paliad#96 — frontend wiring of the per-event-card choice flow on
both consumer surfaces.

Shared rendering core (verfahrensablauf-core.ts):
- CalculatedDeadline gains choicesOffered + appellantContext (mirror
  the new server fields).
- deadlineCardHtml emits a ▾ caret next to the date when a rule
  carries a non-empty choicesOffered, plus an inert chip span next to
  the title that the popover module rehydrates after every render.
- bucketDeadlinesIntoColumns prefers appellantContext over the
  page-level appellant for "both" rows when the per-card context is
  set to claimant or defendant. "both" / "none" / "" all fall back to
  the existing collapse logic. New test cases cover all three paths.
- CalcParams + calculateDeadlines pass projectId / perCardChoices
  through to the backend.

New module (client/views/event-card-choices.ts):
- attachEventCardChoices wires a delegated click handler on the
  result container; the caret opens a body-anchored popover with one
  block per choice-kind the rule offers (appellant: 4 radio-style
  buttons; include_ccr + skip: 2-way toggle).
- Active picks render as small chips on the card title; reseedChips()
  repaints them after every renderResults() innerHTML rewrite.
- Skipped rows fade to 55% opacity via the timeline-item--skipped
  class.

Page wiring:
- /tools/verfahrensablauf (unbound): commits mutate an in-memory list
  + the ?event_choices= URL param, then schedule a recalc. Shareable
  via link, no persistence — same idiom as ?side= / ?appellant=.
- /tools/fristenrechner (project-bound): commits POST/DELETE to
  /api/projects/{id}/event-choices. The next calculate() call sends
  projectId so the server folds the persisted choices in.

i18n: 17 new keys under choices.* (DE primary + EN secondary). Caret
title, appellant/include_ccr/skip block titles + value labels, chip
labels, reset action, commit error toast.

CSS: caret, popover, options, chip parts, skipped-row fade.

Tests: 3 new bucketer cases covering AppellantContext propagation
(157 frontend tests pass).
2026-05-25 16:45:39 +02:00
mAi
4f910e31ea mAi: #110 - t-paliad-278 — 3-column date-range picker (Past/NOW/Future, closeness-to-NOW sort)
Restructures atlas's #79 horizontal row into 3 vertical columns: Past
(left), NOW (middle), Future (right). Each column sorts by closeness
to NOW (closest at top, farthest at bottom) — the picker now reads as
a spatial map of time around the current moment instead of a flat
horizontal fan.

Layout

  Vergangenheit          ⌖              Zukunft
  Letzte 7 Tage          Heute          Nächste 7 Tage
  Letzte 30 Tage         Alles          Nächste 30 Tage
  Letzte 90 Tage                        Nächste 90 Tage
  Ganze Vergangenheit                   Ganze Zukunft

Changes

- date-range-picker.ts — renderPanel builds .date-range-grid with
  three vertical .date-range-col children. Past column iterates
  PAST_HORIZONS reversed (past_1d → past_all top-to-bottom). NOW
  column hosts next_1d ("Heute") + any ("Alles") plus a ⌖ glyph
  header. Future column iterates NEXT_HORIZONS minus next_1d (which
  moved to NOW). Legacy "all" horizon still lights up the Alles chip
  for saved-Custom-View back-compat.
- global.css — replace .date-range-row/.date-range-fan/.date-range-
  center{,-btn,-glyph,-label} with .date-range-grid + .date-range-col
  + .date-range-col-heading. Chips stretch to 100% column width for a
  clean vertical stack. Panel widened from 32rem to 34rem so "Ganze
  Vergangenheit" never wraps. Mobile (max-width 540px) collapses the
  grid to a single column, preserving in-column sort.
- i18n.ts — next_1d label fixed from "Morgen"/"Tomorrow" to "Heute"/
  "Today". next_1d's bounds are [today, tomorrow) = single-day today,
  so the prior label was semantically wrong; renaming aligns the
  label with the bounds and matches m's "Heute" spec for the NOW
  column.
- axes.ts — DEFAULT_TIME_PRESETS updated to match m's spec (4 past +
  Heute + Alles + 4 future + custom). projects-detail.ts continues
  to override via timePresets for its past-only Verlauf surface.

12 horizon values in the union remain unchanged — PAST_HORIZONS /
NEXT_HORIZONS registries and parseURL still accept past_1d / past_14d
/ next_14d for back-compat with saved URLs; the default picker UI
just no longer surfaces chips for them. Surfaces that want the
finer granularity can opt back in via timePresets.

Verification

- bun test src/client/date-range-picker-pure.test.ts — 38 pass
- bun run build — i18n + branding + bundle clean
- go build ./... — clean
- go test ./internal/... — pass
2026-05-25 16:45:30 +02:00
mAi
bf60fc1400 feat(t-paliad-265): projection engine + HTTP handlers for per-card choices
m/paliad#96 — slice A engine + slice B engine wired together (per
m's Q4 bundling decision in §11 of the design doc).

Engine (internal/services/fristenrechner.go):
- CalcOptions gains PerCardAppellant map, SkipRules set, IncludeCCRFor
  set. All three keyed by paliad.deadline_rules.submission_code (same
  key AnchorOverrides uses).
- UIDeadline gains AppellantContext (per-decision pick that propagates
  to descendants via parent_id chain) + ChoicesOffered (passes the
  jsonb through to the frontend so the caret renders).
- Calculate honours all three:
  * IncludeCCRFor non-empty → append with_ccr to flag set before gate
    evaluation (v1 simplification documented in CalcOptions comment;
    correct for single-CCR-entry-point proceedings).
  * SkipRules suppression via submission_code match AND parent_id
    cascade (descendants suppress too — one-pass walk in sequence_order).
  * AppellantContext: each rule with its own per-card pick stamps its
    UUID; descendants inherit via parent_id lookup; "" = no override.

HTTP:
- /api/projects/{id}/event-choices GET / PUT / DELETE — full CRUD
  with visibility gate, audit-logged via paliad.system_audit_log.
- POST /api/tools/fristenrechner accepts either projectId (server
  pulls choices from project_event_choices) OR inline perCardChoices
  (unbound /tools/verfahrensablauf surface). Inline wins when both.

Services wiring:
- EventChoiceService instantiated in cmd/server/main.go; threaded into
  handlers.dbServices.eventChoice.
2026-05-25 16:45:21 +02:00
mAi
dc47ea7f43 feat(t-paliad-265): migration 129 + EventChoiceService (Slice A foundation)
m/paliad#96 — per-event-card optional choices on the Verfahrensablauf
timeline. This commit lands the schema + service layer.

Migration 129:
- paliad.project_event_choices table (project_id, submission_code,
  choice_kind ∈ {appellant, include_ccr, skip}, choice_value) with
  UNIQUE(project_id, submission_code, choice_kind) for idempotent
  re-pick, RLS via paliad.can_see_project.
- paliad.deadline_rules.choices_offered jsonb — opt-in declaration of
  which choice-kinds each rule offers. Seeded for every decision rule
  (appellant), every priority='optional' rule (skip), and the two
  Klageerwiderung rules (upc.inf.cfi.sod + de.inf.lg.erwidg) with
  include_ccr.

Live verification before authoring:
- rule_code is NULL on every decision row → submission_code is the
  join key (matches AnchorOverrides plumbing in fristenrechner.go).
- upc.inf.cfi.sod is the UPC Klageerwiderung, not upc.inf.cfi.def
  (rejected the design doc's first guess; SELECT name ILIKE
  'Klageerwiderung' confirmed).

Go service:
- models.ProjectEventChoice + DeadlineRule.ChoicesOffered.
- EventChoiceService: ListForProject / Upsert (with audit-log row to
  paliad.system_audit_log) / Delete. Pure-helper ToCalcOptionsAddendum
  + per-kind value validation + unit tests.

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

Resolved fallback chain after this CL:

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

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

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

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

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

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

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

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

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

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

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

Files

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

Hard rules

  - .docx export path unchanged (Render passes nil wrap, covered by
    existing TestRender_DocxOutputUnchangedByPreviewWrap).
  - Both directions survive autosave-driven preview re-renders (see
    paintPreview re-apply + paintVariables re-apply).
  - go build ./... && go test ./internal/... && bun run build all clean.
2026-05-25 16:32:45 +02:00
mAi
169ace5d26 design(t-paliad-265): fold m's decisions into the design doc
Four open questions answered via AskUserQuestion (2026-05-25):

  Q1 State location       → persisted table          (matches (R))
  Q2 Affordance           → caret + popover          (matches (R))
  Q3 Appellant layer      → per-card overrides       (matches (R))
  Q4 Slice order          → bundle A + B             (over (R) of "A first")

Q4 captured with rationale: cohesive PR, single user-visible release,
no half-shipped state where the include-CCR popover would exist
without the engine wire-through. Coder still organises commits per
slice internally; one branch, one ship.
2026-05-25 16:27:30 +02:00
mAi
ac7bc27fb7 design(t-paliad-265): per-event-card optional choices on Verfahrensablauf
Draft inventor design for m/paliad#96 — per-card affordances driving
projection state: appellant per decision, include-CCR on Klageerwiderung,
skip optional events.

Persisted choices in new paliad.project_event_choices table; opt-in
declared via choices_offered jsonb on paliad.deadline_rules. Caret +
popover affordance; chip indicators on cards with non-default picks.
Two-slice plan: A=appellant+skip (engine-stable), B=include-CCR.

m's decisions section to be filled after the AskUserQuestion round.
2026-05-25 16:27:30 +02:00
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
aa2f4aacc6 mAi: #98 - move Auto-rule resolved name to its own row
The Auto-mode resolved rule name was rendered as an inline-flex pill
that sat visually crammed next to the [Eigene Regel eingeben] toggle.
Promote .rule-mode-auto to a full-width block-level flex row (width:
100%, margin-top: 0.35rem) so it sits cleanly on its own line beneath
the toggle, and render the rule label via the canonical
formatRuleLabelHTML helper so the citation gets the muted-secondary
styling from rule-label.ts.

Applies to both /deadlines/new and /deadlines/:id edit form. Custom
mode (free-text input) is unaffected — the input already filled the
column.

Refs: m/paliad#98 (t-paliad-267), addendum to t-paliad-258 / m/paliad#89.
2026-05-25 16:01:15 +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
mAi
e0c8401482 Merge: t-paliad-266 — event-type modal cross-cutting filter by court system (mig 125) (m/paliad#97) 2026-05-25 15:53:50 +02:00
mAi
247e9005db Merge: t-paliad-248 Slice A — symmetric date-range picker + filter-bar wiring (m/paliad#79)
# Conflicts:
#	frontend/src/client/filter-bar/axes.ts
2026-05-25 15:51:36 +02:00
mAi
e68b800d52 Merge: t-paliad-249 Slice A — inbox overhaul (project_event feed + read cursor + dispatch) (m/paliad#80) 2026-05-25 15:50:27 +02:00
mAi
bcfde73815 feat(inbox): t-paliad-249 Slice A frontend — inbox dispatch + UI axes (m/paliad#80)
The /inbox surface drops "Genehmigungen" framing in favour of "Inbox"
and renders the unified feed.

- shape-list.ts: factor renderApprovalRow out of renderApprovalList so
  it can be reused alongside renderProjectEventInboxRow inside the new
  renderInboxList (row_action="inbox"). Project_event rows show a
  compact stream layout with an Öffnen link pointing at the right
  project tab (deadlines / appointments / notes).
- filter-bar gets two new axes: unread_only (binary chip cluster) +
  inbox_focus (4-chip coarse cluster: Alles / Genehmigungen / +Termine
  / +Fristen). Both round-trip via url-codec; inbox_focus translates
  to (sources, project_event.event_types, approval_request.entity_types)
  at the bar's resolve step (applyInboxFocusOverlay).
- FilterSpec gains a top-level unread_only flag; the bar writes it
  when the user toggles the chip; the server overlays the cursor.
- /inbox header: new "Alles als gelesen markieren" button POSTs
  /api/inbox/mark-all-seen with up_to=<newest visible row> for
  race-safety against a second tab.
- INBOX_AXES adds project + project_event_kind as advanced override
  chips so power users can still narrow per kind.
- i18n: inbox.title.feed / inbox.heading.feed / inbox.action.mark_all_seen
  / inbox.action.open / inbox.empty.feed / views.bar.unread_only.*  /
  views.bar.inbox_focus.* (DE + EN).
- url-codec round-trip tests for the two new axes.
2026-05-25 15:49:54 +02:00
mAi
4ead2d08c1 feat(inbox): t-paliad-249 Slice A backend — project_event feed + read cursor (m/paliad#80)
Substrate changes that turn /inbox from approvals-only into the
unified notification surface m asked for.

- Migration 126: paliad.users.inbox_seen_at (high-watermark read cursor;
  pending approval_requests bypass it per design §3).
- KnownProjectEventKinds gains note_created, our_side_changed,
  deadline_updated/deleted, deadlines_imported. New
  InboxProjectEventKinds curated subset (head's Q1=A lock).
- InboxSystemView spans [approval_request, project_event]; defaults to
  past 30 days, newest first, row_action="inbox".
- view_service.allowedProjectEventKinds drops *_approval_* audits when
  ApprovalRequest is also in spec.Sources (no double-count).
- RunSpec resolves the caller's inbox_seen_at once and threads it
  through viewSpecBounds; runProjectEvents excludes self-authored
  events and rows older than the cursor when unread_only is set.
  Decided approval_requests follow the cursor; pending always survives.
- ApprovalService.UnseenInboxCountForUser (unified badge count) +
  MarkInboxSeen + InboxSeenAt service methods.
- GET /api/inbox/count returns the unified count; new
  POST /api/inbox/mark-all-seen advances the cursor (optional up_to=).

Tests cover the InboxSystemView shape, the audit-dedup helper, the
isApprovalAuditKind matcher, and the no-narrow-no-approvals nil path.
2026-05-25 15:49:39 +02:00
mAi
31d78526cf feat(date-range-picker): t-paliad-248 — symmetric picker + filter-bar wiring
Slice A complete. Builds on the additive backend constants (commit
34e3d71) by shipping the user-facing surface.

# Pure helpers (no DOM)

frontend/src/client/date-range-picker-pure.ts (190 LoC) — TimeSpec
shape, ALL_HORIZONS / PAST_HORIZONS / NEXT_HORIZONS registries,
horizonBounds (mirrors view_service.go), isValidHorizon, isValidISODate
(strict — rejects 2026-02-30 etc.), validateCustomRange, parseURL /
serializeURL (canonical ?horizon=...&horizon_from=...&horizon_to=...
with default-omission), isDefault.

frontend/src/client/date-range-picker-pure.test.ts (38 bun tests,
118 expect calls): registries, horizon bounds for all 14 values,
ISO-date validity rejects calendar-impossible dates, validateCustomRange
on every error branch, parseURL fallback to default, serializeURL
default-omission + key-override + custom-bounds, full round-trip.

# DOM mount

frontend/src/client/date-range-picker.ts (290 LoC) — mountDateRangePicker
returns {element, getValue, setValue, close, destroy}. Trigger button
in a .multi-anchor wrapper, popover panel reusing .multi-panel
positioning. Symmetric chip row: past fan (right-aligned) | ALLES
centre (target glyph U+2316) | next fan. 'Anpassen' chip toggles an
inline date-pair editor with Apply / Cancel + a live validation
message that surfaces only the meaningful 'inverted range' error
during typing (empty/format errors are visible via the disabled
Apply button). Outside-click + Esc close the popover, focus returns
to the trigger. setValue lets the host sync from URL changes.

# Filter-bar wiring

frontend/src/client/filter-bar/axes.ts:renderTimeAxis — the disabled
'Anpassen' stub (t-paliad-163 Phase 2 placeholder) is gone; the axis
mounts the picker instead. New default presets surface 6 chips +
ALLES centre + Anpassen, plus the per-surface timePresets override
filters down to whatever subset the surface declares. 'any' still
maps to BarState.time = undefined to keep the canonical URL short
and preserve the existing 'no overlay' semantics.

frontend/src/client/filter-bar/types.ts — TimeOverlay.horizon union
extended with next_1d / next_14d / next_all / past_1d / past_14d /
past_all.

frontend/src/client/filter-bar/url-codec.ts — parseHorizon accepts
the six new values; existing 9 values continue to round-trip.

frontend/src/client/filter-bar/url-codec.test.ts — round-trip
iteration extended to all 14 horizons.

frontend/src/client/views/types.ts — TimeHorizon TS mirror extended.

frontend/src/client/projects-detail.ts — horizonBounds covers the
six new values (open-ended for next_all/past_all so the upstream
filter treats nil bounds as 'no narrowing in that direction').

# i18n + retired legacy keys

frontend/src/client/i18n.ts — 30 new keys per language (date_range.*
namespace for the picker + 6 missing views.horizon.* labels for
existing dynamic-key composition in views.ts:317). Legacy
views.bar.time.* keys (10 per language) retired with a one-line
breadcrumb comment pointing at the date_range.* namespace.

frontend/src/i18n-keys.ts — regenerated by build.ts.

# CSS

frontend/src/styles/global.css — date-range-* class block (256 LoC).
Trigger button, popover panel, past/centre/next groups, custom-range
editor, mobile stack at <540px. Reuses --color-accent /
--color-accent-light / --color-bg-lime-tint / --color-border /
--color-text + .agenda-chip / .agenda-chip-active for chip styling
so every active state lights up with the same lime accent as every
other paliad filter chip — no new tokens, no fresh dark-mode
contrast risk (t-paliad-150 / fritz lesson held).

# Surfaces lit up by this single change

- /projects/:id Verlauf (filter-bar consumer)
- /views runtime
- /views/:id Custom-Views editor
- /inbox InboxFilterBar

All four pick up the picker on their next page load. Per-surface
presets (timePresets MountOpt) preserved exactly; Verlauf still
shows the past-only subset, /inbox the forward-leaning subset etc.
The custom chip that's been disabled-with-coming_soon since
t-paliad-163 now works.

# Tests + build hygiene

- go build ./... clean
- go test ./internal/services/ clean (filter_spec + new bounds test)
- bun test passes (150 tests, 8 files, 377 expect calls)
- bun run build clean (2848 i18n keys, data-i18n scan clean)

# What's NOT in this slice

- /agenda chip-row migration (Slice B).
- /admin/audit-log + /projects/:id/chart migration (Slice C).
- upckommentar-style range slicer for custom mode (Slice D, separate
  task).
2026-05-25 15:47:51 +02:00
mAi
a8e2bd8350 Merge: t-paliad-264 — de.inf.lg Replik/Duplik sequencing fix (mig 124, idempotent) (m/paliad#95) 2026-05-25 15:47:40 +02:00
mAi
8c94dccf83 mAi: #95 - t-paliad-264 - fix de.inf.lg Replik/Duplik sequencing
Replik and Duplik had parent_id = NULL with a 4-week placeholder
duration, so the projection anchored both off the proceeding's
trigger date (Klageerhebung) - both rows rendered at the same
calendar date AND before Klageerwiderung.

Migration 124 anchors Replik on Klageerwiderung
(de.inf.lg.erwidg) and Duplik on Replik, and marks both
is_court_set = true with legal_source DE.ZPO.273. The 4-week
placeholder duration is retained so the timeline gives a sane
notional date for each row; the lawyer overrides it with "Datum
setzen" once the court issues the actual period.

Each UPDATE is guarded by parent_id IS NULL so a re-apply against
a DB that already carries the fix no-ops cleanly (mig 095
convention). No new audit-log rows on idempotent re-runs.

Slot note: originally landed as 123 in an earlier iteration;
cronus's t-paliad-246 Backup-Mode migration won slot 123 in the
parallel merge race, so this migration shifted to slot 124.

ZPO citations in the migration comment per the t-paliad-264 brief:
  - Klageerhebung           - section 253 ZPO
  - Anzeige Verteidigungsbereitschaft - section 276 Abs. 1 S. 1 ZPO
  - Klageerwiderung         - section 276 Abs. 1 S. 2 + section 277 ZPO
  - Replik / Duplik         - vom Gericht bestimmte Frist
    (section 273 ZPO Anordnungskompetenz; section 282 ZPO
    prozessuale Foerderungspflicht)

Verified ordering for trigger 2026-05-25:
  Klage         2026-05-25 Mon
  Anzeige       2026-06-08 Mon
  Klageerwidg   2026-07-06 Mon
  Replik        2026-08-03 Mon
  Duplik        2026-08-31 Mon

Each row strictly later than the previous; Replik and Duplik no
longer collide on the same date and no longer precede the
Klageerwiderung.
2026-05-25 15:46:09 +02:00
mAi
90f5dd4b1b fix: t-paliad-266 — bump migration to slot 125 (123 taken by cronus #77 backups) 2026-05-25 15:40:24 +02:00
mAi
34e3d7188e feat(filter_spec): t-paliad-248 — symmetric date-range horizons
Slice A backend, fully additive. Adds six new TimeHorizon constants
to make the past/future fan symmetric for the date-range picker:

  next_1d, next_14d, next_all,
  past_1d, past_14d, past_all

Each one-sided 'all' is distinct from the existing HorizonAll
(bidirectional unbounded, Q26-gated) and HorizonAny (no time filter
at all). next_all keeps from=today + to=nil; past_all keeps to=tomorrow
+ from=nil — half-open intervals, never crossing the boundary.

computeViewSpecBounds gets twelve explicit fan arms plus the
pre-existing any/all/custom paths. validate() accepts the six new
horizons against any scope (none of them is the unbounded substrate
scan that triggers Q26 on HorizonAll).

New tests:
- TestFilterSpec_NewSymmetricHorizonsValidate — round-trip
- TestComputeViewSpecBounds_Horizons       — table of 14 cases
- TestComputeViewSpecBounds_NewHorizonsAreOneSided
- TestComputeViewSpecBounds_CustomRoundTrips
2026-05-25 15:37:00 +02:00
mAi
24f3baf61f mAi: #97 - t-paliad-266 — event-type modal: narrow cross-cutting trigger pills by court system
Cross-cutting Wiedereinsetzung sub-rows (PatG §123 / ZPO §233 /
EPC Art.122 / DPMA PatG §123 / UPC R.320) used to bypass the
forum-bucket chip selection by design — every chip combination
returned all five rows. m/paliad#97: chip the chips through
to triggers via legal_source inference.

  - mig 123 backfills the missing deadline_rules row for trigger
    207 (UPC R.320 Wiedereinsetzung, orphaned by mig 063 because
    mig 092 dropped event_deadlines before that path was seeded)
    and rebuilds paliad.deadline_search with a LEFT JOIN on
    deadline_rules so cross-cutting trigger pills carry their
    structured legal_source.
  - DeadlineSearchService gains ForumToLegalSourcePrefixes (10
    buckets → UPC. / DE.ZPO. / DE.PatG. / EU.EPC + EU.EPÜ)
    paralleling ForumToProceedingCodes. Rule pills still narrow
    by proceeding_code; trigger pills now narrow by legal_source
    LIKE prefix. Multiple chips union the prefix allow-list as
    expected.
  - Live golden-table test gains a Wiedereinsetzung×forum matrix
    plus a multi-chip union case, and the existing 4-pill assertion
    is updated to the now-5-pill state (mig 063 added trigger 207).

Branch: mai/hermes/gitster-event-type-modal.
2026-05-25 15:36:08 +02:00
mAi
0f2f3e3ea1 docs(date-range-picker): inventor design — symmetric past/future fan + ALL center
t-paliad-248 / m/paliad#79.

§0 TL;DR + §1 audit of every paliad date-range affordance today
(/agenda chip row, /admin/audit-log select, /projects/:id/chart
symmetric range, /views editor, filter-bar time axis with stubbed
Anpassen chip, projects-detail Verlauf horizonBounds).

§2 upckommentar slicer pattern — read DateRangeSlider.svelte +
date-range-slider-pure.ts end-to-end. Borrow worth: anchor rail with
click-to-snap left/right halves, granularity zoom, epoch-day pure
math. Defer the actual slicer to Slice D.

§3 component design — <DateRangePicker> emits TimeSpec, extends
TimeHorizon with past_14d / next_14d / past_all / next_all
(additive; no migration). Symmetric chip fan layout, lime accent
for active, target glyph ⌖ for ALLES center button.

§4 URL contract — canonical ?horizon=…&from=…&to=…, surface-level
alias adapters for back-compat with existing ?range=N parsers.

§5 slice plan — A: filter-bar time axis (lights up 4 surfaces) /
B: /agenda / C: /admin/audit-log + /projects/:id/chart (sibling
SymmetricRangePicker for chart) / D (optional): slicer port.

§6 visual decisions, §7 edge cases, §8 open questions w/ (R)
defaults. 3 material picks escalated separately via mai instruct
head: chart migration shape, popover-vs-modal, Slice A first call
site.

§9 implementer notes + acceptance criteria for Slice A. §10
escalation-message summary.
2026-05-25 15:34:03 +02:00
mAi
2683c5f9cf docs(inbox): t-paliad-249 — inbox overhaul inventor design (m/paliad#80)
LOCKED design with head decisions (Q1=A) folded in §12. Slice plan
A/B/C reuses existing FilterSpec + RunSpec engine; no new aggregation
service. Slice A adds inbox_seen_at cursor + project_event source on
InboxSystemView + RowActionInbox dispatch in shape-list; Slice B adds
shape toggle (list/cards/calendar) + member_role_changed narrowing;
Slice C upgrades the badge + per-item dismiss.
2026-05-25 15:33:36 +02:00
mAi
51fca9383f Merge: t-paliad-246 — Backup Mode Slice A (on-demand admin org export, local disk, .zip bundle, mig 123) (m/paliad#77) 2026-05-25 15:29:48 +02:00
mAi
99c9d89daa feat(backups): t-paliad-246 — Backup Mode Slice A (on-demand admin org export)
m/paliad#77 Slice A. Folds the unbuilt t-paliad-214 Slice 3 (org async
export) into a new "Backup Mode" surface gated by adminGate.

m's calls (all 4 material picks per design §2):
- Storage: local disk PALIAD_EXPORT_DIR (LocalDiskStore only)
- Format: .zip bundle (xlsx + JSON + CSV + README) — no-lock-in preserved
- paliadin_turns + paliadin_aichat_conversation: EXCLUDE structurally
- Scheduler (Slice B): nightly 03:00 UTC, env-tunable

Wiring:
- mig 123 adds paliad.backups catalog table (kind/status/storage_uri/
  size/row_counts/warnings/error/deleted_at + admin-only RLS).
- ExportService.WriteOrg + orgSheetQueries enumerate 37 entity sheets
  + 12 ref sheets; REPEATABLE READ READ ONLY tx wraps the dump for
  snapshot consistency (design §3.3).
- writeBundle + runSheetQuery refactored to take a sqlx.QueryerContext
  so both *sqlx.DB (personal/project paths, unchanged) and *sqlx.Tx
  (org snapshot path) work.
- BackupRunner orchestrates: catalog INSERT → audit INSERT
  (event_type='backup_created') → WriteOrg → ArtifactStore.Put → patch
  catalog + audit on success/failure.
- ArtifactStore interface + LocalDiskStore impl (defense-in-depth key
  validation + URI-outside-dir guard).
- Sentinel actor for scheduled runs: actor_email='system@paliad',
  actor_id=NULL — no phantom user in paliad.users.
- Admin handlers POST /api/admin/backups/run + GET list/get/download
  behind adminGate(users, …); /admin/backups page + sidebar entry +
  bilingual i18n keys.
- BackupRunner only wired when PALIAD_EXPORT_DIR is set; routes return
  503 otherwise (same shape as requireDB).

Tests: 8 pure-function tests cover registry shape (no dups, paliadin
absent both as sheet name and SQL substring, ref__* sheets unscoped,
every sheet has ORDER BY) and LocalDiskStore (round-trip, bad-key
rejection, URI-traversal rejection, mkdir on construction).

go build ./... + go test ./internal/... clean. bun run build clean.

Slice B (BackupScheduler + retention cleanup) and Slice C (UI polish)
are separate follow-ups per head's instruction.
2026-05-25 15:28:37 +02:00
mAi
7bc6fdb18a Merge: t-paliad-263 — bulletproof deadline-rules completeness audit (m/paliad#94) 2026-05-25 15:24:57 +02:00
mAi
94a9e7e5fb docs: t-paliad-263 bulletproof deadline-rules completeness audit
Read-only audit of paliad.deadline_rules against UPC RoP + EPC +
PatG/ZPO/GebrMG statutory sources, with verbatim verification of
all citations against youpc data.laws_contents (UPC RoP + EPC) and
gesetze-im-internet.de (PatG/ZPO).

Headline findings:
- 5 hard user-visible bugs: 2 UPC_REV duration bugs (R.49.1 3mo->2mo,
  R.52 2mo->1mo), 1 UPC appeal-response duration bug (R.235.1 2mo->3mo),
  2 DE-LG-Verletzung sequencing bugs (beruf_begr anchor + replik/duplik
  parent_id NULL).
- 11 citation drift bugs (rule_code/legal_source point at wrong rule).
- 6 court-set-mismodelled-as-fixed (DPMA + DE + EPA richterliche Fristen
  carrying made-up statutory citations).
- ~30 statutory deadlines unmodelled (12 high-frequency in Tier 1).
- 13 ambiguity questions for m's judgement (court-set policy,
  working-days arithmetic, Wiedereinsetzung modelling).

Slices into Wave 0 (16 Tier-0 fixes) and Wave 1-6 (Tier 1-4 + spikes).
No DB writes; findings only.

Refs: m/paliad#94, t-paliad-263
2026-05-25 15:23:39 +02:00
mAi
f55648944c Merge: t-paliad-261 — submission-draft autosave focus + click-variable-in-preview jump (m/paliad#92) 2026-05-25 15:13:35 +02:00
mAi
7e66da8def mAi: #92 - t-paliad-261 — submission-draft autosave focus + click-variable-in-preview jump
Two related editor polish fixes.

(A) Autosave-refresh focus preservation
  paintVariables() replaces every input via innerHTML, blowing away
  the focused-input reference and dropping the cursor mid-edit. Fix:
  capture the active variable input's data-var key + selectionStart/
  End/Direction before the repaint, restore on the new element after
  (by data-var lookup + setSelectionRange). Cursor stays put across
  autosave, rename, and reset cycles. Works for <input> and
  <textarea> via the shared selectionRange contract.

(B) Click variable in preview → jump to sidebar input
  Go renderer wraps every substituted placeholder value in the HTML
  preview with <span class="draft-var" data-var="key">…</span>.
  Implemented via a valueWrapperFn plumbed through
  substituteInDocumentXML → substituteInTextNodes /
  substituteAcrossRuns → replacePlaceholders. RenderHTML passes
  htmlPreviewWrapper which marks values with three PUA sentinels
  (U+E100/U+E101/U+E102) that emitTextWithDraftVars converts to the
  span pair inside docXMLToHTML. Missing-marker text is wrapped too
  so a clicked [KEIN WERT: foo] jumps to the empty field.

  Render() (.docx export) passes nil for wrap → output is byte-
  identical to pre-261. New test
  TestRender_DocxOutputUnchangedByPreviewWrap asserts the .docx never
  carries draft-var/data-var markup or PUA sentinels.

  Client wireDraftVars() adds .draft-var--has-input only to spans
  whose key resolves to a sidebar input — derived variables (e.g.
  today.iso) stay non-clickable. Click handler:
    scrollIntoView(smooth, center) → focus + select after 50ms →
    1.2s lime flash on the row.
  Keyboard accessible (Enter / Space) with role=button + aria-label.

CSS adds a subtle lime tint to every .draft-var so the user sees
what was substituted; --has-input layers cursor: pointer + brighter
hover background. Flash animation respects prefers-reduced-motion
via a steps(1, end) fallback.

Tests: TestRenderHTML_ExtractsParagraphsAndFormatting updated to
assert the new span wrap. New tests for missing-marker wrap +
.docx-path-untouched. Go + frontend builds clean.
2026-05-25 15:12:10 +02:00
mAi
ef21e43375 Merge: t-paliad-260 — submission-draft mobile layout (m/paliad#91) 2026-05-25 14:59:53 +02:00
mAi
4cb99fb627 mAi: #91 - t-paliad-260 — submission-draft mobile layout: drop sticky on sidebar at ≤900px
Approach A: stack vertically. At single-column widths the variable
editor was sticky + max-height: calc(100vh - 2rem), so it stayed
pinned at the top of the viewport while the user scrolled down to
read the preview, visually overlaying the preview pane.

Add a media-query override that switches the sidebar to position:
static, max-height: none, overflow-y: visible at the same ≤900px
breakpoint where the grid already collapses to one column. The
sidebar now reflows above the preview, takes its natural height,
and scrolls away as the user moves down — no overlay, no
horizontal scroll. Desktop (≥901px) layout unchanged: sidebar
keeps its sticky behavior side-by-side with the preview.

Verified at 375 / 414 / 768 / 1280 px in Playwright on the
populated editor body — same renderer serves both URL shapes
(/submissions/draft/{id} and
/projects/{id}/submissions/{code}/draft/{id}).
2026-05-25 14:58:21 +02:00
mAi
452ccdf127 Merge: t-paliad-258 — Deadline form Auto/Custom rule field + canonical rule-label display (m/paliad#89) 2026-05-25 14:56:18 +02:00
mAi
045accc6d9 mAi: #89 - deadline rule field binary Auto/Custom + canonical rule-label display
t-paliad-258. m's verdict on t-paliad-251's rule UI: "too many options"
(4 'Oral hearings' across courts, etc.). Replace the full deadline_rules
catalog dropdown + sort selector with a binary model and unify the rule
display contract across every surface that prints a rule label.

Binary Rule field on the deadline form
- Auto (default): rule_id is derived from the chosen Type. The resolved
  rule renders read-only as 'Auto | <Name · Citation>' next to the
  field. No catalog picker, no sort options.
- Custom: free-text input. Stored as deadlines.custom_rule_text (new
  nullable column, migration 122). Mutually exclusive with rule_id at
  the persistence boundary.
- Toggle link flips between modes. Re-toggling to Auto re-resolves from
  the current Type — no stale state.

Schema + service (additive)
- migration 122 adds paliad.deadlines.custom_rule_text (nullable).
  Existing rows: empty custom_rule_text + non-null rule_id = Auto-
  equivalent. Both NULL = "keine Regel" (consistent with today).
- models.Deadline.CustomRuleText + service SELECTs include the column.
- CreateDeadlineInput accepts custom_rule_text; the service drops it
  when rule_id is set (catalog wins; simple invariant at the boundary).
- UpdateDeadlineInput grows a {RuleSet, RuleID, CustomRuleText} triple.
  RuleSet=true is the discriminator so absent fields don't overwrite
  the row (PATCH semantics). RuleID and CustomRuleText are mutually
  exclusive in one request; service rejects "both set".
- EventListItem (the /api/events union) carries CustomRuleText so list
  surfaces can render it.

Frontend: deadlines-new
- Drop the rule <select>, the by_proceeding/by_court/alpha sort
  dropdown, the override-warning slot, and the collapsed-by-Regel Typ
  view. Strip the (Rule→Type) auto-fill machinery — direction is now
  one-way (Type → Auto-resolved Rule).
- Keep Type→Rule resolution: resolveAutoRuleForType picks the canonical
  rule by project's proceeding, then jurisdiction match, then first
  candidate. Same logic, just re-aimed at the read-only display.
- Standardtitel preserves the chain (event type → Auto rule label →
  Custom text → proceeding → fallback) so the recipe still produces a
  sensible title even when Custom is used.

Frontend: deadlines-detail
- Read-only display: catalog rule → Name · Citation, else
  custom_rule_text + Custom badge, else legacy rule_code, else "—".
- Edit mode: mirror the create form with the Auto/Custom toggle.
  enterEdit initialises the mode from the persisted deadline; Save
  PATCHes with rule_set:true + the chosen rule pointer.

Rule-label addendum (m's 14:31 follow-up)
- Canonical contract everywhere: Name primary, Citation muted secondary
  ("Notice of Appeal · UPC.RoP.220.1"). Custom rules render the text
  with a "Custom" pill.
- New frontend/src/client/rule-label.ts exports formatRuleLabel /
  formatRuleLabelHTML / formatCustomRuleLabelHTML — one helper per
  shape (plain text vs muted-citation HTML).
- Wired into: deadlines-new Auto display, deadlines-detail read +
  Standardtitel, events.ts ruleDisplay (REGEL column on /events),
  projects-detail.ts Fristen table, views/shape-list.ts generic
  rule column.
- Verfahrensablauf (views/verfahrensablauf-core.ts) already renders
  name + citation chip separately and matches the canonical pattern;
  no change needed. Schriftsätze table is column-shaped (name + code
  in distinct columns) and out of scope per the addendum.

CSS
- New .rule-mode-auto / .rule-mode-custom / .rule-label-* family.
- Drop the dead .rule-sort-select rule and the .event-type-collapsed*
  family (retired with the catalog dropdown).

i18n
- DE+EN. Remove 10 stale keys (rule.none, autofill, autofill_inline,
  mismatch, override, override_warn, sort.*). Add 6 (auto_no_match,
  auto_pick_type, custom_badge, custom_placeholder,
  mode.toggle_to_auto, mode.toggle_to_custom).

Build hygiene
- go build + go test ./internal/... clean.
- frontend bun build clean (2803 keys, scan clean).

Out of scope (per issue)
- Promoting Custom entries back to the catalog ("save as new rule").
- Filtering/searching custom_rule_text in deadline lists.
- Touching the event-type browse modal (Part 1 of #82 — that stays).

Files
- internal/db/migrations/122_deadlines_custom_rule_text.{up,down}.sql
- internal/models/models.go
- internal/services/deadline_service.go (Create+Update+SELECT)
- internal/services/event_service.go (union projection)
- frontend/src/client/rule-label.ts (new helper)
- frontend/src/client/deadlines-new.ts (rewrite)
- frontend/src/client/deadlines-detail.ts (Auto/Custom editor + display)
- frontend/src/client/events.ts (REGEL column)
- frontend/src/client/projects-detail.ts (Fristen table cell)
- frontend/src/client/views/shape-list.ts (generic rule column)
- frontend/src/client/i18n.ts + i18n-keys.ts (DE+EN delta)
- frontend/src/deadlines-new.tsx (strip dropdown+sort, add toggle)
- frontend/src/deadlines-detail.tsx (Auto/Custom edit slots)
- frontend/src/styles/global.css (rule-mode + rule-label families)
2026-05-25 14:54:51 +02:00
mAi
e6b61b4d2e Merge: t-paliad-259 — universal _skeleton.docx fallback for submission preview/generate (m/paliad#90) 2026-05-25 14:45:50 +02:00
mAi
940df95418 fix(submissions): t-paliad-259 — universal _skeleton.docx for fallback chain
Issue: m noticed the submission generator's preview still shows the raw
HL Patents Style .dotm letterhead for every submission_code that has no
per-firm template. Confirmed live: paliad.de's /healthz is green, the
preview path and /generate path both flow through resolveSubmissionTemplate,
and the only code wired in submissionTemplateRegistry is de.inf.lg.erwidg
(t-paliad-241). For every other code, the fallback was the bare letterhead
with zero placeholders — exactly what m observed.

Fix: slot a universal _skeleton.docx between the per-firm code-specific
template and the macro-only HL Patents Style:

  per-firm/{code}.docx → _skeleton.docx → HL Patents Style.dotm

The skeleton carries every placeholder SubmissionVarsService resolves
(all 48 keys across firm.*, today.*, user.*, project.*, parties.*, rule.*,
deadline.*) without baking in submission_code-specific prose, so any
code lands with variables substituted instead of the bare letterhead.

Changes:
- scripts/gen-skeleton-submission-template/main.go: byte-reproducible
  .docx generator mirroring gen-demo-submission-template but with a
  code-agnostic body (no Klageerwiderung "I./II./III." structure, a
  single [Schriftsatztext] block the lawyer replaces). One run per
  placeholder so the renderer's pass-1 substitution catches every token.
- internal/handlers/files.go: register slug submission/_skeleton.docx +
  fetchSubmissionSkeletonBytes helper (same stale-while-revalidate
  semantics as the existing per-code and HL-Patents-Style fetchers).
- internal/handlers/submission_drafts.go: insert the skeleton lookup
  between fetchSubmissionTemplateBytes (per-firm code) and
  fetchHLPatentsStyleBytes (bare letterhead). HL Patents Style remains
  the final fallback for resilience if mWorkRepo is unreachable.

The companion _skeleton.docx is committed to m/mWorkRepo at
6 - material/Templates/Word/Paliad/HLC/_skeleton.docx (commit f2659e4)
so the file proxy can fetch it on first request.

Build hygiene: go build ./... clean, go test ./internal/... clean,
bun run build clean.
2026-05-25 14:44:58 +02:00
mAi
538c2d2da9 Merge: t-paliad-257 — Verfahrensablauf user-perspective column axis (Unsere Seite / Gericht / Gegnerseite) (m/paliad#88) 2026-05-25 14:34:38 +02:00
mAi
a9a9adbd2a mAi: #88 - Verfahrensablauf: column axis reframed to user-perspective
Replaces the misleading Proaktiv/Reaktiv column pair with a static
"Unsere Seite" / "Gericht" / "Gegnerseite" axis ("WE always on the
left", per m's t-paliad-257 ask). The side toggle now drives row
PLACEMENT into the ours/opponent buckets — the column labels stay
truthful regardless of which physical party occupies them.

Old framing lied half the time: Klägerseite is sometimes proactive
(filing the claim) and sometimes reactive (responding to a CCR),
so "Proaktiv (Klägerseite)" was wrong whenever the user's perspective
flipped. New axis is purely positional with semantic labels.

Changes:

- frontend/src/client/views/verfahrensablauf-core.ts:
  • ColumnsRow fields proactive/reactive → ours/opponent.
  • renderColumnsBody picks static "Unsere Seite" / "Gegnerseite"
    labels — no more variant-by-side label keys.
  • bucketDeadlinesIntoColumns routes the user's party into `ours`
    when opts.side ∈ {"defendant"}; default (null) keeps the legacy
    "we are claimant" fallback so claimant-on-left layout survives.

- verfahrensablauf-core.test.ts: rewritten expectations on the new
  ours/opponent fields. Added two new tests pinning the WE-on-left
  semantics and the side+appellant interaction (side=defendant +
  appellant=claimant → "both" collapses into opponent).

- fristenrechner.ts: wires currentPerspective into renderColumnsBody
  as `side` so the columns honour the chip-strip perspective.
  Without this, a defendant-perspective user would see claimant
  filings under the "Unsere Seite" header — the old code didn't
  need the wire-up because the labels weren't perspective-aware.

- i18n.ts: replaces deadlines.col.proactive(.defendant) +
  deadlines.col.reactive(.claimant) with deadlines.col.ours +
  deadlines.col.opponent ("Unsere Seite"/"Client Side",
  "Gegnerseite"/"Opponent Side"). Court key unchanged.

- i18n-keys.ts: regenerated key union.

- global.css: .fr-col-proactive/.fr-col-reactive renamed to
  .fr-col-ours/.fr-col-opponent.

Out of scope (kept intact):
- Side and appellant URL-state plumbing.
- Appellant selector for Appeal-type proceedings (separate axis).
- Project-default side-from-our_side wiring — /tools/verfahrensablauf
  has no project context, and /tools/fristenrechner already does this
  via applyOurSidePredefine().

Build: bun run build clean (2794 keys), go build ./... clean.
Tests: 112 frontend tests pass (was 110, +2 new); all Go tests
cached green.
2026-05-25 14:32:57 +02:00
mAi
f24a90b722 Merge: t-paliad-252 — Approval withdraw warning modal + edit-instead path (m/paliad#83) 2026-05-25 14:26:20 +02:00
mAi
55bfe439f2 Merge: t-paliad-256 — test-data reset + Example Projects seed exercising chain codes (m/paliad#87) 2026-05-25 14:26:03 +02:00
mAi
0ac26fe0ee chore(seed): t-paliad-256 — wipe + seed Example Projects exercising chain code
Re-runnable Go script under scripts/seed-example-projects/ that wipes
every paliad.projects row (FK CASCADE handles dependents) and seeds 18
realistic patent-litigation projects across 3 example clients:

  SIEMENS  — UPC + LG cases incl. CCR (Widerklage) on EP3456789
  BAYER    — EPA Einspruch + BPatG Nichtigkeit on EP2222333
  BEISPL   — sparse DPMA demo on DE10987654

Every node carries the chain-code-driving fields (reference on client,
opponent_code on litigation, patent_number on patent, proceeding_type_id
on case), producing codes like SIEMENS.HUAW.789.INF.CFI and
SIEMENS.HUAW.789.CCR.CFI via services.BuildProjectCode.

One transaction wraps both wipe and seed; -dry-run rolls back so the
script can be sanity-checked before commit. Reference tables
(proceeding_types, deadline_rules, event_types, gerichte, checklists
templates, firms) are untouched.

Ran live against youpc Postgres 2026-05-25: 12 rows wiped, 18 seeded.
2026-05-25 14:25:16 +02:00
mAi
72b64140e9 mAi: #83 - approval withdraw warning modal + edit-instead path
t-paliad-252. Replace the silent confirm()-then-DELETE with a three-path
warning modal: Cancel / Edit event (primary) / Withdraw and delete
(destructive). The edit-instead path lets the requester revise the
in-flight entity without withdrawing the approval request.

Backend — new service method + endpoint
- ApprovalService.EditPendingEntity(requestID, callerID, fields):
  - validates caller == requested_by AND status = pending
  - reuses the existing wider counter-allowlist (buildCounterSetClauses
    from SuggestChanges) — every editable field on the entity, not just
    the date triggers
  - applies the field updates to the entity row via applyEntityUpdate
    (including the event_type_ids junction rewrite for deadlines)
  - merges new fields into approval_requests.payload (jsonb) so the
    approver inbox sees what was revised
  - emits a distinct *_approval_edited_by_requester project_event so the
    Verlauf surfaces the revision separately from the original *_requested
    row and any decision row
  - request stays pending; entity.approval_status stays pending
- POST /api/approval-requests/{id}/edit-entity
  - Body: {"fields": {<entity-shape>}}
  - Errors reuse the existing mapApprovalError mapping:
    400 suggestion_requires_change, 403 not_authorized,
    404, 409 request_not_pending
- Distinguishing audit event types per the spec:
  - destructive Withdraw path: existing <entity>_approval_revoked
    (no behaviour change — for CREATE deletes the entity, for UPDATE /
    COMPLETE reverts to pre_image, for DELETE cancels the delete request)
  - edit-instead path: new <entity>_approval_edited_by_requester

Frontend — shared withdraw warning modal
- frontend/src/client/components/withdraw-warning-modal.ts
  - Built on the unified openModal() primitive (t-paliad-217 Slice A)
  - Primary CTA "Termin bearbeiten" highlights the non-destructive path
  - Secondary defaults to "Abbrechen" (handled by openModal)
  - Destructive button "Endgültig zurückziehen und löschen" lives inside
    the body (red, separated by a dashed border) so the safe path stays
    visually primary in the footer
  - Copy adapts per lifecycle:
    CREATE   → "Wenn Sie zurückziehen, wird die Frist/der Termin gelöscht."
    UPDATE   → "Ihre vorgeschlagenen Änderungen werden verworfen."
    DELETE   → "Der Eintrag bleibt bestehen."

Frontend — wiring on both detail pages
- deadlines-detail.ts + appointments-detail.ts:
  - Replace confirm() in withdraw flow with openWithdrawWarningModal()
  - Edit path: set module-level pendingEditMode = true + enter edit mode
    (override existing pending-state freeze on appointments; expose
    enterEdit() via late-bound pendingEnterEdit on deadlines)
  - Save handler in pendingEditMode routes to /edit-entity instead of
    PATCH /api/<entity>/{id} (which still 409s on pending state)
  - Destructive Withdraw path: existing /revoke endpoint unchanged
  - For CREATE-lifecycle revokes the entity is gone — bounce to the
    /events list instead of trying to re-fetch (was reload() before)

i18n: +14 keys DE+EN under approvals.withdraw.* (modal title, primary,
destructive, cancel, lead.create.{deadline,appointment}, lead.update,
lead.delete, sub.create, sub.update, sub.delete)

CSS: .withdraw-warning-body + .withdraw-warning-{intro,sub,
destructive-row,destructive-btn} — lime-tint sibling palette consistent
with the existing form-hint pattern; destructive button uses .btn-danger.

Build hygiene:
- go build + go vet + go test ./internal/... clean
- frontend bun run build clean (2807 keys, +14 new, scan clean)

Files of note:
- internal/services/approval_service.go (EditPendingEntity + sortedKeys
  helper; maps.Copy for the payload merge)
- internal/handlers/approvals.go (handleEditPendingEntity)
- internal/handlers/handlers.go (route registration)
- frontend/src/client/components/withdraw-warning-modal.ts (new shared
  component)
- frontend/src/client/deadlines-detail.ts (initWithdraw rewrite + Save
  pending-edit branch)
- frontend/src/client/appointments-detail.ts (withdrawAppointmentRequest
  rewrite + Save pending-edit branch + form-freeze respects
  pendingEditMode)

Out of scope (intentionally):
- Reopening already-deleted approval requests (the destructive path
  stays final).
- Approval-request analytics / metrics.
- Notifying the original approval-requester via channel.
2026-05-25 14:24:55 +02:00
mAi
50cd80a4a6 Merge: t-paliad-255 — kill /events horizontal scroll on mobile (m/paliad#86) 2026-05-25 14:10:07 +02:00
mAi
716f6d7ece fix(events): t-paliad-255 — kill /events horizontal scroll on mobile
A native <select> sizes itself to the widest <option> text. With long
project titles in the matters filter, the select grew wider than the
viewport and the /events page scrolled horizontally on mobile.

The existing 480px media query forced .entity-select to width:100% on
phones, but the 481-1000px range (tablet portrait + landscape phones)
had no constraint at all and inherited the intrinsic select width.

Fix: cap .filter-group and .entity-select at max-width:100% with
min-width:0 so the cell can shrink to fit its flex container at every
viewport. Desktop layout is preserved — normal-length options still
sit in one row across the page; only pathological content (a single
title wider than the row) wraps onto its own line.

Approach: A — let the trigger respect its container at every width.

Verified: zero horizontal scroll at 320 / 375 / 414 / 768 px with a
realistic 130-character project title injected into the matters
selector. Desktop (1280px) keeps all four filter-groups in one row.
2026-05-25 14:08:44 +02:00
mAi
1bf62c78e3 Merge: t-paliad-251 — Deadline form overhaul (m/paliad#82) 2026-05-25 14:05:06 +02:00
mAi
9a774ba3ad Merge: t-paliad-254 — Sidebar scroll position persists across nav (m/paliad#85) 2026-05-25 14:04:39 +02:00
mAi
8caaf6a631 mAi: #82 - deadline form overhaul: type-modal filter chips, type→rule autofill, Auto mode, Standardtitel
t-paliad-251. Four bundled concerns from m's 2026-05-25 reports, one
worker, one branch.

Part 1 — Event-type browse modal (search + filters)
- Modal already had a search input; added court-type filter chips
  (UPC / EPA / DPMA / DE / Allgemein) under the search.
- Chips render only the jurisdictions actually present in the data;
  any future flavour lands at the end of the row.
- Active chip uses the lime-tint chip palette already established by
  the .event-type-collapsed* family (t-paliad-165).
- Search input keeps autofocus; chip + search filters intersect.

Part 2 — Type → Rule auto-fill + sort options
- Inverted the existing rule.concept_default_event_type_id mapping
  client-side: given a chosen event_type X, candidate rules are
  those with concept_default_event_type_id === X.
- Resolution picks (1) exact match on the project's
  proceeding_type_id, (2) jurisdiction match on the rule's
  proceeding (EPA→EPO canonicalised), (3) first candidate.
- Sort dropdown next to the Rule label: by proceeding sequence,
  by court (jurisdiction grouping with optgroup), alphabetical.
  Defaults to "by court"; localStorage-persisted per browser.
- All sorts are client-side over the existing /api/deadline-rules
  payload — no new endpoint.

Part 3 — Auto rule mode + clearer override warning
- Auto badge (.form-hint--auto, lime-tint pill + " — <rule name>")
  surfaces whenever the Rule was derived from the chosen Type.
  Disappears the moment the user manually picks a different rule.
- Override warning names BOTH sides + the actually-applied rule:
  "Typ ergibt Regel: X. Gewählte Regel: Y. Es wird Y angewendet."
- Symmetric `lastAutoFilledRuleID` sticky-replace flag mirrors the
  existing `lastAutoFilledEventTypeID` (t-paliad-165) so the auto-
  fill only replaces its own previous suggestion, never a manual
  pick.
- Collapsed Typ view (t-paliad-165) is suppressed when the rule was
  auto-derived from the type — the "vorgegeben durch Regel" copy
  reads backwards in that case; show picker + Auto badge instead.

Part 4 — Standardtitel button (create + edit)
- Button rendered next to the Title field on both /deadlines/new
  and /deadlines/{id} (edit mode only).
- Recipe (recipe-docs-here-so-future-templates-can-mirror-it):
    head =
      1. event_type label (if exactly one Typ chip is set)
      2. rule code+name (when a Rule is set — "RoP.023 — Klageerwiderung")
      3. proceeding type name from project (create form only)
      4. fallback: t("deadlines.field.title.default_fallback")
    suffix = " — <project.reference>" when ref is set and not
             already in head.
  Examples:
    Klageerwiderung — C-UPC-0042       (type known)
    RoP.023 — Klageerwiderung — REF    (rule known, no type)
    UPC — Verletzungsverfahren — REF   (only proceeding type)
    Neue Frist — REF                   (fallback)
- Click REPLACES current title; no destructive confirmation
  because the user invoked it explicitly. Focus moves into the
  title input afterwards so the user can fine-tune.

Build hygiene:
- go build + go vet + go test ./internal/... clean.
- frontend/build.ts clean (2786 keys, +10 new DE+EN, scan clean).
- All changes client-side / CSS / i18n + 2 small TSX edits; no
  schema, no service, no migration.

Files touched:
- frontend/src/client/event-types.ts (browse-modal chips)
- frontend/src/client/deadlines-new.ts (rewrite — Type→Rule, sort,
  Auto badge, override warn, Standardtitel)
- frontend/src/client/deadlines-detail.ts (edit-mode Standardtitel
  + show/hide on enter/exit edit)
- frontend/src/deadlines-new.tsx (label-row + sort dropdown + Auto
  badge slot + override-warn slot + Standardtitel button)
- frontend/src/deadlines-detail.tsx (Standardtitel button)
- frontend/src/styles/global.css (.event-type-browse-chip*,
  .form-hint--auto, .form-hint-badge, .form-field-label-row,
  .btn-link-action, .rule-sort-select)
- frontend/src/client/i18n.ts (+10 keys DE+EN)
2026-05-25 14:03:04 +02:00
mAi
228ae1b263 mAi: #85 - sidebar scroll position persists across nav
Sidebar nav clicks trigger a full page reload, which rebuilds the
sidebar from scratch and snaps .sidebar-nav back to scrollTop=0.
Persist scrollTop to sessionStorage (paliad.sidebar.scroll) on every
scroll and restore on initSidebar(). Re-apply once after
/api/user-views resolves so the async layout shift doesn't leave the
user a few rows off.

sessionStorage scopes the value to the tab: Cmd-click / right-click
"open in new tab" still produces a fresh tab that starts at the top.
2026-05-25 14:03:03 +02:00
mAi
cdd3747c2b Merge: t-paliad-250 — Browse-a-proceeding side+appellant selectors + 'appealable decision' trigger label (m/paliad#81) 2026-05-25 14:00:02 +02:00
mAi
02255c4234 mAi: #81 - verfahrensablauf side+appellant selectors + UPC Appeal trigger label
Concerns A + B + C from m/paliad#81:

A. Browse-a-proceeding (/tools/verfahrensablauf) gains a side selector
   (Kläger/Beklagter/Beide) and an appellant selector. The side selector
   swaps which column labels which user-side; the appellant selector
   collapses party='both' rules into the appellant's column (no mirror)
   so role-swap proceedings (Appeal, etc.) stop showing every row
   twice in the timeline. Both selectors are URL-driven (?side= +
   ?appellant=) and re-render without a backend round-trip.

   The appellant row hides itself for proceedings without an appellant
   axis (first-instance Inf/Rev/Opp) via a small allowlist.

B. UPC Appeal trigger-event caption now reads "Anfechtbare Entscheidung"
   / "Appealable Decision" instead of falling back to the proceeding
   name ("Berufungsverfahren" / "Appeal"). Implemented as an optional
   trigger_event_label_{de,en} column on paliad.proceeding_types (mig
   121); the frontend prefers it over the proceedingName fallback that
   fires when no rule has IsRootEvent=true. No new deadline rules, no
   slug changes (hard rule from the issue).

C. Parameter contract for the column projection is unified in
   bucketDeadlinesIntoColumns(deadlines, {side, appellant}) — a pure
   helper extracted from renderColumnsBody so the routing behaviour
   stays unit-testable without a DOM. Tests cover the default mirror,
   appellant-collapse for both sides, side-swap of column ownership,
   the combined case, and row alignment by dueDate.

Verification

- go build ./...                        clean
- go test ./...                         all green
- bun run build (frontend)              clean
- bun test (frontend/src)               110/110 pass (12 new + 98 prior)
- Migration 121 applied to paliad schema; UPC Appeal proceeding now
  carries the curated trigger label pair.

Out of scope (filed for follow-up): per-rule role tagging so
respondent-side filings (Response to Appeal, Cross-Appeal) land in
the respondent's column when an appellant is selected. The current
issue scope (one-row-per-deadline collapse) is delivered; the
realistic-per-row routing needs a deadline_rules schema bump that
the hard rules of #81 excluded.
2026-05-25 13:57:38 +02:00
mAi
206f2917ea Merge: t-paliad-253 — Submissions /generate runs the merge engine (m/paliad#84) 2026-05-25 13:55:14 +02:00
mAi
5df87f4129 fix(submissions): t-paliad-253 — /generate runs the merge engine
The "Generieren" button on the project Schriftsätze tab posts to
/api/projects/{id}/submissions/{code}/generate. Pre-fix that handler
called `fetchHLPatentsStyleBytes` unconditionally and streamed the
result after a format-only .dotm→.docx convert — it never touched
`submissionTemplateRegistry` (added in t-paliad-241 for the draft
editor) and never ran the SubmissionRenderer merge. m's report on
m/paliad#84 ("the document generator still has no variables in the
template") was the lawyer-facing manifestation: HL Patents Style has
no {{…}} placeholders, so the downloaded .docx had nothing to
substitute and looked like a generic firm-style fixture.

The "Bearbeiten" path (/projects/{id}/submissions/{code}/draft) was
unaffected — it uses `resolveSubmissionTemplate` + the renderer
already, which is why the editor preview shows the 48 placeholders
resolved correctly. Only the one-click /generate side missed the
wire-up.

Fix:

- `internal/services/submission_draft_service.go` — add
  `RenderProjectSubmission(ctx, userID, projectID, submissionCode,
  templateBytes)` that wraps `vars.Build` + `renderer.Render` for the
  no-saved-draft path. Returns the merged bytes plus the resolved
  SubmissionVarsResult (rule, project, user, lang) so the handler can
  derive filename + audit metadata without a second DB round-trip.

- `internal/handlers/submissions.go` — rewrite
  `handleGenerateProjectSubmission` to resolve the template via
  `resolveSubmissionTemplate` (per-firm slug → HL Patents Style
  fallback, same as the editor draft) and run the new service method.
  Visibility / rule-not-found semantics route through
  `SubmissionVarsService` errors so the gate behavior matches every
  other project endpoint. Removed `loadPublishedRuleByCode` and
  `errRuleNotFound` — both were only used by the old handler.

- `scripts/gen-demo-submission-template/main.go` + the regenerated
  `de.inf.lg.erwidg.docx` on mWorkRepo (HL/mWorkRepo @ 3e3e828f) now
  exercise the bare `{{today}}` alias too. The demo template covers
  every one of the 48 keys SubmissionVarsService can resolve (firm 2,
  today 4, user 3, project 18, parties 6, rule 8, deadline 7).

The renderer is a no-op on placeholder substitution when the
fallback HL Patents Style is fetched (it has none) — but it still
runs the .dotm→.docx pre-pass via `ConvertDotmToDocx`, so the
non-per-firm code path streams a byte-for-byte equivalent download.

Build + vet + tests clean (go test ./internal/...; bun run build).
2026-05-25 13:51:45 +02:00
mAi
898348a64a Merge: t-paliad-245 — Daten Exportieren demoted into Verwaltung tab (m/paliad#76) 2026-05-25 13:34:53 +02:00
mAi
1714b788d2 feat(projects-detail): t-paliad-245 — demote Daten Export into Verwaltung tab
m/paliad#76. The export button no longer pokes out of the tabs nav with a
non-tab styling — instead it lives inside a new "Verwaltung" tab (last in
the project tab list) as a normal section with heading, description, and a
plain btn-secondary trigger. Same gate as before (canExportProject).

Archive co-locates in the same tab as a pointer to the Edit-modal danger
zone: click "Bearbeiten öffnen" → modal opens scrolled to the archive
button. Single source of truth for the destructive action stays in the
modal; the Verwaltung pointer just gives it discoverability.

If neither sub-section is visible to the caller (no export entitlement,
not global_admin), the Verwaltung tab hides itself — an empty tab is
worse UX than no tab.
2026-05-25 13:33:14 +02:00
mAi
db8335253b Merge: t-paliad-244 — Team View mailto: link for non-admin members (m/paliad#75) 2026-05-25 13:31:52 +02:00
mAi
5589cbb477 mAi: #75 - team view mailto: link for non-admin members
t-paliad-244 / m/paliad#75. Both "E-Mail an Auswahl senden" actions on
/team (filter-bar + bottom selection footer) now branch on canBroadcast():
- Admin path keeps the in-app compose modal (POST /api/team/broadcast).
- Non-admin path renders a native <a href="mailto:..."> with the
  recipient list pre-filled, comma-joined and URL-encoded via
  buildMailtoHref (already exported from broadcast.ts).

Filter-bar button used to hide for non-admins; it now shows as the
mailto: anchor and its href refreshes on every filter change so the link
always matches what's visible. Empty visible set disables the affordance
visually (aria-disabled + pointer-events:none) so a click can't open an
empty composer. Bottom selection footer mirrors the same shape.

No new i18n keys, no backend changes, admin compose flow untouched.
2026-05-25 13:30:32 +02:00
mAi
0059e3f15b Merge: t-paliad-243 — project-optional Schriftsätze drafts + /submissions/new picker 2026-05-23 02:20:26 +02:00
mAi
a911a2d0ee feat(submissions): t-paliad-243 — global Schriftsätze drafts without project
Adds an end-to-end project-optional path for Schriftsatz drafts:

- Migration 120 drops NOT NULL on paliad.submission_drafts.project_id
  and rewrites the four RLS policies to gate purely on user_id when
  project_id IS NULL, otherwise on paliad.can_see_project. Down
  refuses to run if project-less rows exist (safer than silent
  data corruption).

- SubmissionDraft.ProjectID becomes *uuid.UUID end-to-end. Service
  layer skips project/parties/deadline lookups when nil and exposes
  DraftPatch.ProjectID for the "Projekt zuweisen" affordance.
  ListAllForUser LEFT JOINs paliad.projects so project-less drafts
  surface in the global index next to project-scoped ones.

- New HTTP surface:
    GET  /submissions/new                 (picker page)
    GET  /submissions/draft/{draft_id}    (editor for any draft)
    GET  /api/submissions/catalog         (catalog without project)
    POST /api/submission-drafts           (project-less or attached)
    GET/PATCH/DELETE /api/submission-drafts/{draft_id}
    POST /api/submission-drafts/{draft_id}/export
  Existing /api/projects/{id}/submissions/... routes remain bit-
  identical so the project-scoped flow keeps working unchanged.

- Frontend: /submissions/new lists the full cross-proceeding catalog
  grouped by proceeding, filterable by text + chip. Each row offers
  "Ohne Projekt" (instant draft) or "Mit Projekt…" (modal picker
  with autocomplete over visible projects). /submissions index gains
  a prominent "Neuer Entwurf" CTA and an empty-state CTA pointing at
  the picker. The editor renders a banner + "Projekt zuweisen"
  action when project_id is null; assigning persists project_id and
  redirects to the project-scoped URL.

Audit + project-event writes detect d.ProjectID == nil; the audit
row's scope flips to 'user' (scope_root = user_id) and the
project_events row is skipped entirely.
2026-05-23 02:19:55 +02:00
mAi
b26f04ffe0 Merge: t-paliad-242 — Schriftsätze tab full catalog grouped by proceeding 2026-05-23 01:56:44 +02:00
mAi
8e195cb497 feat(submissions): t-paliad-242 — Schriftsätze tab shows full catalog grouped by proceeding
Per m's 2026-05-23 ask: from any project, surface every available
template/generator instead of just the project's own proceeding.

Backend (GET /api/projects/{id}/submissions):
- drop the proceeding_type_id filter; JOIN deadline_rules with
  proceeding_types to return every active+published filing rule
  across every active proceeding
- response gains proceeding_code, proceeding_name, proceeding_name_en
  per row plus project_proceeding_code at the top so the frontend
  can pin the project's own group
- has_template now reflects "per-submission .docx wired in
  submissionTemplateRegistry"; the editor still falls back to the
  universal HL Patents Style for everything else (t-paliad-238)
- can_see_project gate unchanged; rules are static reference data
- sorted by (proceeding_code, submission_code)

Frontend:
- client/submissions.ts renders a grouped table: project's own
  proceeding pinned to the top with a lime border + "(dieses
  Projekt)" suffix, every other proceeding alphabetised below
- "Generieren" + "Bearbeiten" buttons stay on every row (editor
  handles missing variables via [KEIN WERT: …])
- "universell"/"universal" badge surfaces for rules without a
  per-submission template — informational, not blocking
- soften the no_proceeding hint so the catalog still renders below
- entity-table-group-header CSS, including --own modifier and a
  read-only override so group rows don't pretend to be clickable

Verified: 103 filing rules across 19 proceedings surface (de.inf.lg,
upc.inf.cfi, epa.opp.opd, etc.). go build + go vet + go test
./internal/... + bun run build clean.
2026-05-23 01:55:32 +02:00
mAi
1f7de99493 Merge: t-paliad-241 — demo Klageerwiderung template + placeholder wiring 2026-05-23 01:33:22 +02:00
mAi
0adcc2c826 Merge: t-paliad-240 — Schriftsätze sidebar + global drafts index 2026-05-23 01:30:32 +02:00
mAi
2c7ac6423f feat(submissions): t-paliad-241 — demo Klageerwiderung template wired
Authored a per-submission-code .docx template for `de.inf.lg.erwidg`
exercising every placeholder SubmissionVarsService resolves (45 keys
across firm/today/user/project/parties/rule/deadline namespaces), so
the Submissions draft editor has variables to substitute and the
sidebar/preview feature can be demonstrated end-to-end.

Pieces:

- `scripts/gen-demo-submission-template/` — one-shot Go authoring tool
  that emits a minimal but Word-compatible .docx zip with a fake
  Klageerwiderung skeleton in German. Each placeholder lives in its own
  <w:r> run so the renderer's pass-1 (format-preserving) substitution
  catches it without falling into the cross-run merge path. Output is
  byte-reproducible (fixed mtime).

- `internal/handlers/files.go` — added `submissionTemplateRegistry`
  (submission_code → fileRegistry slug) plus
  `fetchSubmissionTemplateBytes` helper that reuses the Gitea proxy
  cache infra. Registered one entry for `de.inf.lg.erwidg`. The file
  itself was uploaded to mWorkRepo at
  `6 - material/Templates/Word/Paliad/HLC/de.inf.lg.erwidg.docx`
  (mWorkRepo commit 9633524).

- `internal/handlers/submission_drafts.go` —
  `resolveSubmissionTemplate` now tries the per-code lookup first;
  falls back to the universal HL Patents Style for any code that
  doesn't have a per-firm template registered, matching the cronus
  design fallback chain §8.

The existing HL Patents Style .dotm is untouched (still the universal
fallback and still the source for the format-only /generate path).
Future per-submission templates register one fileRegistry entry +
one submissionTemplateRegistry row.
2026-05-23 01:30:24 +02:00
mAi
436c1b41bb feat(submissions): t-paliad-240 — Schriftsätze sidebar + global drafts index
Add a top-level Schriftsätze entry under the Werkzeuge sidebar group
plus a new /submissions page that lists every draft the caller owns
across visible projects. Each row links to the per-project editor at
/projects/{id}/submissions/{code}/draft/{draft_id}.

Backend: SubmissionDraftService.ListAllForUser joins paliad.submission_drafts
with paliad.projects, gated by paliad.can_see_project for visibility. New
GET /api/user/submission-drafts endpoint exposes the rows; the page route
GET /submissions is gateOnboarded'd alongside the other project surfaces.

Frontend: submissions-index.tsx renders an entity-table; submissions-index.ts
hydrates from /api/user/submission-drafts and wires the row-click contract
(skip clicks on inner a/button). DE primary, EN secondary i18n.
2026-05-23 01:29:56 +02:00
mAi
2c5f85b802 Merge: t-paliad-238 Slice A — dedicated Submissions draft editor + merge engine 2026-05-23 00:06:50 +02:00
mAi
d3aade5aac feat(submissions): t-paliad-238 Slice A — dedicated draft editor page
Adds the dedicated Submissions/Schriftsätze editor at
/projects/{id}/submissions/{code}/draft (and …/draft/{draft_id}) per
docs/design-submission-page-2026-05-22.md.

Lawyer picks (or creates) a named draft, edits placeholder variables
in a sticky sidebar, sees a read-only HTML preview of the merged
document body, and exports a .docx with project state + lawyer
overrides resolved. Drafts persist in paliad.submission_drafts
keyed on (project_id, submission_code, user_id, name) with RLS via
can_see_project; updates and deletes additionally gated on owner-only
(Q-E4 owner-scoped pick, m-confirmed).

Resurrected from git history per the design's "no rewrite" plan:
  SubmissionVarsService    ← commit 1765d5e (Slice 2 with patent_number_upc)
  SubmissionRenderer       ← commit 8ea3509 (in-house merge engine — the
                             lukasjarosch/go-docx library refuses sibling
                             placeholders in one run, which patent submissions
                             use routinely)
  ConvertDotmToDocx        ← existing format-only convert (kept; reused as
                             pre-pass so .dotm inputs strip macros before
                             merge)

New code:
  paliad.submission_drafts  migration 119 (idempotent — DROP POLICY IF EXISTS
                            + CREATE; CREATE OR REPLACE for the shared trigger
                            function). Applied to live DB.
  SubmissionDraftService    CRUD + autosave-friendly Update + Export/RenderPreview
                            entry points
  RenderHTML method         new on the renderer; walks the same merged
                            document.xml as Render but emits HTML for the
                            preview pane (Q-E3 server-side pick)
  7 API handlers            list / create / get / patch / delete / preview / export
  2 page routes             /draft and /draft/{draft_id}
  submission-draft.tsx      stand-alone editor page (header / sidebar /
                            preview / export button); served via
                            dist/submission-draft.html
  submission-draft.ts       client bundle — autosave (500ms debounce),
                            draft switcher, rename, delete, export with
                            blob download

Tab integration: existing /projects/{id}/#tab-submissions rows get
[Bearbeiten] alongside the existing [Generieren] one-click format-only
path — additive, no removal.

Slice A template: universal HL Patents Style .dotm (same path
t-paliad-230 uses). resolveSubmissionTemplate carries the
submission_code parameter so Slice B's TemplateRegistry wiring (per-
code .docx fallback chain) is a one-function swap.

Audit trail: paliad.system_audit_log row per export
(event_type='submission.exported') + paliad.project_events row
(event_type='submission_exported', timeline_kind='custom_milestone')
so the export surfaces on the project's Verlauf / SmartTimeline. No
paliad.documents write (Q-E2 inventor pick, head-ratified).

Tests: TestRender_* / TestPlaceholderRegex_* / TestRenderHTML_* +
TestLegalSourcePretty / TestOurSide* / TestPatentNumberUPC — all
green. go build / go vet / go test ./internal/... / bun run build all
clean.

Migration slot taken: 119.
2026-05-23 00:06:08 +02:00
mAi
17d2fff661 Merge: t-paliad-239 — Add Checklist button on project Checklists tab 2026-05-22 23:56:50 +02:00
mAi
c6a5416611 feat(projects): t-paliad-239 — add Checklist button on project Checklists tab
The project-detail Checklists tab now exposes an "Add Checklist"
button that opens a template picker modal. Picking a template POSTs
to /api/checklists/{slug}/instances with the current project_id and
the template title as the instance name; the table refreshes and a
transient success banner confirms the add. Reuses the catalog cache
across the tab renderer and modal so the second open doesn't refetch.

Closes the UX cul-de-sac in the previous empty-state copy that told
users to leave the page and create instances on the Vorlagen-Seite.
2026-05-22 23:56:14 +02:00
mAi
d590be4bb7 Merge: t-paliad-237 — anchor lookup traverses linked proceedings 2026-05-22 23:43:59 +02:00
mAi
f7374a67cd docs(submissions): t-paliad-238 design — dedicated Submissions/Schriftsätze page
Adds docs/design-submission-page-2026-05-22.md (~600 lines) — a
dedicated submission-draft page at /projects/{id}/submissions/{code}/draft
with sidebar variable editor + read-only HTML preview + .docx export.

Reuses (resurrects from git) the deleted Slice 1 backend that t-paliad-230
ripped out — SubmissionVarsService (3677c81 + 1765d5e), in-house
SubmissionRenderer (8ea3509), TemplateRegistry (3677c81). All four
compile against today's services with zero API drift.

New schema: paliad.submission_drafts keyed on
(project_id, submission_code, user_id, name) with RLS via can_see_project.

Three slices: A = schema + page + variables-only export against universal
.docx; B = per-submission_code templates with fallback-chain registry;
C = toggleable passages.

Four material picks escalated to head in §11 (template authoring effort,
paliad.documents row, server-vs-client preview, inter-user draft
visibility). All other open questions defaulted to inventor (R)
recommendations from task brief.

No code. Read-only design phase per inventor → coder gate.
2026-05-22 23:43:51 +02:00
mAi
3ff1b23238 fix(timeline): t-paliad-237 — anchor lookup must traverse linked proceedings
On a CCR sub-project the SmartTimeline renders the parent inf project's
rules in the parent_context lane (correct — the CCR depends on the inf
schedule). Clicking "Datum setzen" on those rows bubbled up as a
generic "Konnte das Datum nicht setzen." because RecordAnchor only
looked up the rule under the CCR's own proceeding_type_id; for an
inf rule like upc.inf.cfi.soc that returned sql.ErrNoRows and dropped
into the catch-all error.

The anchor handler now mirrors the read view's broader rule scope: on
sql.ErrNoRows for a CCR project, we retry the lookup against the
parent project's proceeding_type_id. If the rule is found there, we
reject with a new CrossProceedingAnchorError carrying the parent
project's id + title so the frontend can render a clear DE/EN message
and a clickable link back to the parent ("anchor it on the
infringement proceeding, not the counterclaim"). We deliberately do
NOT auto-route the write across projects — that would silently mutate
the inf project's actuals and is out of scope per the brief.

Genuine "unknown submission_code" failures still surface as
ErrInvalidInput; the predecessor_missing 409 path keeps its existing
shape (the two errors discriminate on the response's `error` field).

Adds a Live-DB integration test that seeds an inf-only rule + a CCR
under a real inf project and verifies all three paths: CCR rejects
cross-proceeding, parent inf project accepts the same code, unknown
codes still report unknown_submission_code.
2026-05-22 23:43:15 +02:00
mAi
a88269c7c1 Merge: hotfix checklist detail page authored slugs 2026-05-22 23:32:12 +02:00
mAi
3d85ce5444 hotfix(checklists): serve detail page for authored slugs (u-a-…)
m's 2026-05-22 report: user-authored checklists appear in the overview
list but clicking through to /checklists/u-a-<id> 404s.

Root cause: handleChecklistDetailPage only consulted
checklists.Find(slug), which is the STATIC compile-time catalog.
Authored checklists (t-paliad-225) live in the DB and never appear
there, so every authored slug fell into the http.NotFound branch even
though /api/checklists returned them in the overview.

Fix: when the static lookup misses AND a DB-backed catalog is wired,
ask checklistCatalog.Find(ctx, uid, slug). The catalog enforces
visibility — slugs the caller can't see still return 404 (via
ErrNotVisible), so this doesn't open a leak. The static path is
unchanged.
2026-05-22 23:32:12 +02:00
mAi
903225b593 Merge: tesla — dashboard widget size clamp on read + write 2026-05-22 15:53:59 +02:00
mAi
4cd2f05d33 fix(dashboard): t-paliad-238 — hidden widgets render at proper size in edit mode
Symptom (m, 2026-05-22): "super slim columns which I can move but not
resize - and they seem greyed out." Hidden widgets in edit mode were
rendering as 1×1 slivers because applyLayout left their inline grid-
column empty — placeWidgets skipped non-visible entries entirely, so
CSS Grid auto-flowed them into the next free cell at 1/12th width.
The greyed-out + no-resize-handle parts were correct UX signalling
that the widget is hidden; the slim rendering was the bug.

Fix:
- placeWidgets() gains a {includeHidden} option. When true, a second
  pass places hidden widgets after the visible pass — collision-aware
  + cursor-aware so the hidden tray stacks below the active layout
  without ever displacing a visible widget. applyLayout() passes
  includeHidden:true in edit mode.
- materializePositions() keeps the default (hidden widgets retain
  their stored coordinates so un-hiding restores them in place).

Server-side recovery (belt-and-braces):
- SanitizeForRead now also clamps each widget's W/H/X against the
  catalog Min/Max + grid bounds on load. Stale rows with W below MinW
  (or above MaxW, or X+W overflowing the grid) heal on the next
  /api/me/dashboard-layout GET and the cleaned spec is persisted
  back. W=0 stays 0 (auto/default sentinel — the placer expands it).
- The validator stays strict on write; the read-path sanitiser only
  exists to recover users who got into a bad state under the old
  rules.

Tests:
- bun: 4 new cases in dashboard-grid.test.ts pin includeHidden
  behaviour (hidden skipped by default, two-pass ordering, multi-
  hidden, no-overlap invariant).
- go: 7 sub-tests in dashboard_layout_spec_test.go cover each
  SanitizeForRead clamp (MinW, MaxW, grid-width, MaxH, X+W overflow,
  W=0 sentinel, negative X) plus a round-trip Validate guarantee.
2026-05-22 15:53:19 +02:00
mAi
b4f5af7f70 Merge: t-paliad-236 — demote Projekt archivieren into Edit modal 2026-05-22 15:52:35 +02:00
mAi
83b00d13fe feat(projects): demote "Projekt archivieren" into Edit modal danger zone (t-paliad-236)
Archive is a rare, deliberate action — it doesn't deserve real estate next
to navigation / common actions on the project view. Move it from the
prominent entity-detail-footer button into the bottom of the Edit Project
modal as a tertiary btn-link-danger inside a visually separated
.modal-danger-zone (top-bordered section, right-aligned).

The visibility wiring (project-delete-wrap, admin-gated in renderHeader)
and click handler (project-delete-btn → delete-modal confirm flow) keep
the same DOM ids, so the existing confirm-modal and POST behaviour are
unchanged. Backend (/api/projects/{id} status=archived) untouched.
2026-05-22 15:51:34 +02:00
mAi
34372ca4c8 Merge: project detail trio — partner-units scan + submissions 404 + client_number nil-coercion 2026-05-22 15:48:47 +02:00
mAi
65308651dd fix(projects): three project-detail page hotfixes
m hit a cluster of three bugs on /projects/{id}/submissions:

1. 500 on /api/projects/{id}/partner-units — DerivationService.AttachedUnit
   scanned derive_unit_roles (text[]) into a plain []string. sqlx returns
   []uint8 for array columns without an adapter. Swap to pq.StringArray
   (same shape as the other array-scanned types in the codebase).

2. 404 on /projects/{id}/submissions — every other project-tab path
   (history, deadlines, team, checklists, …) is registered in handlers.go
   routing all to handleProjectsDetailPage so deep links work, but the
   submissions tab added in t-paliad-230 never got the matching route.
   Result: m navigates to the share-able URL and gets the 404 chrome.
   Add the missing route entry.

3. Create / update project rejected by projekte_client_number_check —
   the CHECK is `client_number IS NULL OR matches '^[0-9]{6}$'`, but the
   form sends empty string "" for an unset field. The Create path passed
   `*input.ClientNumber` raw; the Update path's appendSetSkippable did
   the same. Both now route through a new nullableTrimmed helper that
   coerces empty/whitespace to nil → SQL NULL → constraint accepts.
   matter_number gets the same treatment for symmetry.

Verified the SQL by EXPLAIN against the live DB on the today-filter
hotfix (becf4f0). These three fixes only change Go-side type / nil-
coercion, so no SQL-syntax exposure.
2026-05-22 15:48:47 +02:00
mAi
d088de95eb Merge: hotfix today filter SQL syntax 2026-05-22 15:29:15 +02:00
mAi
becf4f0ce3 hotfix(deadlines): use date(completed_at) not ::date — sqlx named-param bug
Production down (Termin/Fristen list returning 503) since 13:28 UTC on
my own 6c40823 deploy:

  ERROR service: prepare list deadlines:
  pq: syntax error at or near ":" at position 24:68 (42601)

Root cause: sqlx's named-parameter parser scans for `:identifier`
tokens and rewrites them into positional placeholders. The Postgres
cast operator `::` is two consecutive `:`. So `completed_at::date`
gets read as `completed_at:` + `:date` placeholder; the parser strips
the cast and leaves a bare `:` next to `date`, which Postgres rejects.

Same query rewritten with the function form `date(completed_at)`
avoids the lexer collision. Verified against the live DB via EXPLAIN
before pushing.

Lesson for future: tests in internal/services/ don't exercise the SQL
path because there's no DB fixture in the service-layer unit tests.
EXPLAIN-against-live or a real DB integration test is the only way to
catch this kind of SQL regression. Filing as architecture follow-up.
2026-05-22 15:29:15 +02:00
mAi
924dbd9768 Merge branch 'mai/knuth/coder-paliadin-chat' 2026-05-22 15:19:32 +02:00
mAi
6c40823038 Merge: Heute filter retains done-today deadlines 2026-05-22 15:18:59 +02:00
mAi
007ebc2794 fix(deadlines): "Heute" filter retains deadlines completed today
m's 2026-05-22 observation: checking off a deadline on the Heute view
made it disappear immediately — no sense of progress, no record that
the day's work is getting done.

Root cause: filterDeadlines was filtering on `status = 'pending'` even
for the Heute bucket. The bucket should be a date-scoped view of the
day's deadlines, not a pending-only queue.

Fix: include items where `due_date = today` AND either still pending
OR completed today (`completed_at::date = :today`). Items completed on
earlier dates still drop out — the bucket stays "today's work" rather
than "everything that was ever due today".

Frontend already renders completed deadlines as strikethrough/green
via `frist-urgency-done` (see frontend/src/client/events.ts:254), so
no client change needed.

Dashboard counter (`SELECT … COUNT(*) FILTER WHERE status='pending'`)
intentionally unchanged — keeps the lime card a "remaining to do"
indicator, like an unread-mail badge.
2026-05-22 15:18:59 +02:00
mAi
cdd27d674e feat(paliadin): stream + honest late-recovery (t-paliad-235)
m's 14:56 observation: long Paliadin turns showed "Verbindung verloren —
Antwort wird nachgereicht …" but never delivered. The aichat backend
finished the turn upstream; paliad's HTTP client had given up at 130 s
and the legacy filesystem janitor never ran for the aichat path.

Three intertwined fixes, all shipped together because they share the
same wire shape and the same UI states:

1. Switch the aichat backend to /chat/turn/stream
   - new AichatPaliadinService.RunTurnStream relays incremental chunks
   - SSE parser handles default `data:` frames (chunk/meta/done/error)
     and named `event: heartbeat` frames per the upstream contract
   - no more 130 s hard ceiling — stream stays open as long as data or
     heartbeats flow; silenceTimeout (90 s) catches a true upstream
     stall instead

2. Proof-of-life thinking events
   - handler emits `event: thinking` every 5 s while the upstream is
     silent (synthesised locally) AND relays aichat's `heartbeat`
     events as thinking pings
   - frontend renders a lime-dot pulse + monospace counter inside the
     assistant bubble — the user can SEE the chat is still working

3. Honest disconnect copy + real late-recovery
   - new dispatching endpoint GET /api/paliadin/turns/{id}/recover
   - aichat backend: asks aichat via GET /chat/conversations and
     /chat/conversations/{id}/turns whether the turn actually finished
   - legacy backend: falls through to the local row read (janitor)
   - frontend swaps "wird nachgereicht" → "Lade frische Antwort …"
     while the recovery polls; on confirmed "lost" swaps to
     "Antwort konnte nicht zugestellt werden — bitte erneut stellen"
   - migration 118 adds aichat_conversation_id to paliadin_turns so
     the recovery has a fast path when the done frame arrived before
     the drop

Streaming + recovery are a no-op for PALIADIN_BACKEND=legacy: the
StreamingPaliadin interface is detected via type assertion, the
LocalPaliadinService stays on the one-shot RunTurn + filesystem
janitor path.

13 new unit tests cover the SSE parser, the conversation-API client,
and the match-assistant-response helper.

go build ./... + go test ./internal/... + go test ./cmd/server/...
+ bun run build all clean.
2026-05-22 15:17:24 +02:00
mAi
28de2e56d0 Merge: t-paliad-233 — print views default portrait + landscape opt-ins 2026-05-21 22:03:18 +02:00
mAi
af073f87da fix(print): default to portrait, opt-in landscape for wide surfaces (t-paliad-233)
The smart-timeline-chart block in global.css declared @page { size: A4
landscape } inside @media print. @page rules are global even when nested
in selectors, so this leaked landscape onto every printed surface in
paliad — not just the chart.

Switch to named-page strategy:

- Default @page { size: A4 portrait; margin: 1.5cm 1.2cm }
- @page paliad-landscape { size: A4 landscape; margin: 1.5cm }
- @media print: body.<surface> { page: paliad-landscape } opts surfaces
  that need width into landscape via per-page body classes

Landscape opt-ins:
- body.page-kostenrechner — wide fee-tier tables
- body.page-projects-chart — horizontal Smart Timeline chart
- body.events-view-calendar — /events Kalender tab (month grid)
- body.views-shape-active-calendar / -timeline — Custom Views shapes
- body.verfahrensablauf-view-timeline — horizontal procedure timeline

Body classes:
- kostenrechner.tsx, projects-chart.tsx, verfahrensablauf.tsx now set
  page-<slug> on body
- verfahrensablauf.ts toggles verfahrensablauf-view-(timeline|columns)
  in initViewToggle
- views.ts toggles views-shape-active-<shape> in setActiveShape (mirrors
  the existing events.ts events-view-* pattern)

General print polish in the universal block (the catch-all at the bottom
of global.css):
- Hide .fab / .fab-button / .edit-mode-handle / .paliadin-widget /
  [data-print-hide] in print
- thead { display: table-header-group } so headers repeat across pages
- tr/th/td page-break-inside: avoid so rows don't split mid-cell
- h1-h6 page-break-after: avoid, orphans/widows: 3 for p/h*/li
- print-color-adjust: exact on brand-coloured headers + status pills
- a[href^="http"]::after content: " (" attr(href) ")" prints external
  URLs after their link text (opt-out via data-print-url="hide")
- body font-size: 11pt for print readability

Verified via Playwright on static dist build that:
- Default surfaces (dashboard, projects, fristenrechner, agenda, admin)
  match no page: rule → portrait
- kostenrechner, projects-chart match the landscape rule
- verfahrensablauf-view-columns → portrait, -view-timeline → landscape
- views-shape-active-list/-cards → portrait, -calendar/-timeline →
  landscape
- /events default (events-view-cards) → portrait, calendar toggle →
  landscape

go build ./... + go test ./internal/... + bun test (99 pass) + bun
run build all clean.
2026-05-21 22:01:46 +02:00
mAi
f22e918048 Merge: hotfix compose env — SUPABASE_SERVICE_ROLE_KEY 2026-05-21 21:50:43 +02:00
mAi
79d98cfeb8 hotfix(compose): declare SUPABASE_SERVICE_ROLE_KEY in web env block
m reported "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY
fehlt am Server)" when trying to add a user account on /admin/team.

Root cause: the value was provisioned in Dokploy's compose env block
(I confirmed it via compose.one API), but docker-compose.yml's
`environment:` section never declared the variable. Docker compose
only forwards env vars that are listed in `environment:` — Dokploy's
project-level env is just a source of `${…}` interpolation, not an
automatic injection.

Fix: add `- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY:-}`
alongside the other Supabase keys. The `:-` default keeps the compose
parseable on deployments that haven't provisioned the key (those still
get the existing /admin/team 503 fallback log line).

After the auto-deploy, cmd/server/main.go:139 will log
"supabase admin API configured — /admin/team Add-User path active"
instead of "SUPABASE_SERVICE_ROLE_KEY not set".
2026-05-21 21:50:43 +02:00
mAi
19d95d6f5b Merge: hotfix paliadin /chat/turn user_id 2026-05-21 21:21:37 +02:00
mAi
17d149c09e hotfix(paliadin): ship user_id on /chat/turn (aichat tenant-DB requirement)
m reported "ai chat seems not to be wired anymore" + the frontend
showed "Verbindung verloren. Antwort wird nachgereicht…".

Root cause: aichat on mRiver added a tenant-DB layer that demands
`user_id` on every /chat/turn request:

  {"error":{"code":"bad_request",
            "message":"user_id is required when a tenant DB is
                       configured","retryable":false}}

aichat itself is healthy (/chat/health 200, paliadin session ok:true,
last successful turn was ~2.6h ago). The paliad side built and shipped
an aichatTurnRequest without user_id, so every turn since the tenant-DB
flip 400s; paliad's SSE relay receives no upstream data and closes
empty, producing the user-visible "Verbindung verloren".

Fix: add UserID to aichatTurnRequest (json: user_id, mandatory now),
populate from req.UserID.String() at the call site. The userID was
already in scope (used for JWT mint + username lookup); the struct just
wasn't shipping it.

Regression test in TestRunTurn_HappyPath_ViaCallHTTP asserts
captured.UserID == request UUID so a future struct edit that drops the
field fails CI instead of production.
2026-05-21 21:21:32 +02:00
mAi
7c7030c5bf Merge: t-paliad-232 — Verfahrenstyp picker + Schriftsätze CTA 2026-05-21 15:45:59 +02:00
mAi
da8389b6e3 feat(projects): t-paliad-232 Verfahrenstyp picker + Schriftsätze CTA
Two-part fix from m's 2026-05-21 finding that the Schriftsätze tab
told users "Bitte zuerst einen Verfahrenstyp setzen" while the
project form had no field to set it. The `proceeding_type_id`
column was already on `paliad.projects` and accepted by the API.

  Part 1 — Verfahrenstyp picker on the case-fields block

    * frontend/src/components/ProjectFormFields.tsx — new optional
      <select id="project-proceeding-type-id"> rendered between
      Aktenzeichen and Mandantenrolle inside the type=case block.
      First option is "(nicht gesetzt)" / "(unset)".
    * frontend/src/client/project-form.ts — shared
      loadProceedingTypes() + populateProceedingTypeSelect()
      helpers. Options sorted by `code` (de.* → dpma.* → epa.* →
      upc.*). readPayload sends `proceeding_type_id` only when the
      user picked a value; prefillForm restores the saved id via
      dataset.preselect to survive the async populate race.
    * frontend/src/client/projects-new.ts — kicks off populate on
      DOMContentLoaded.
    * frontend/src/client/projects-detail.ts — edit-modal preload
      now awaits populate; the local loadProceedingTypes duplicate
      (used by the counterclaim modal) is replaced by the shared
      helper so both surfaces hit the same cache.

  Part 2 — Actionable empty-state on the Schriftsätze tab

    * frontend/src/projects-detail.tsx — the static <p> empty-state
      becomes a div with a "Projekt bearbeiten" button.
    * frontend/src/client/projects-detail.ts — openEditModal now
      accepts an optional focusFieldID; the new
      #project-submissions-edit-cta click handler calls it with
      "project-proceeding-type-id" so the picker is scrolled into
      view and focused right after the modal opens.

  i18n: new keys projects.field.proceeding_type{,.unset,.hint} and
  projects.detail.submissions.empty.no_proceeding.cta; reworded
  no_proceeding copy to match the new "edit the project" CTA.

  Backend already validates via validateProceedingTypeCategory
  (mig 087/088 fristenrechner-category guard). Added
  TestProjectService_CaseProceedingTypePicker exercising both the
  happy and reject paths through a `case`-typed Create.

Manual test path: open any case project → Edit → the Verfahrenstyp
picker shows below Aktenzeichen → save → the Schriftsätze tab now
lists the submission codes. Clicking the empty-state CTA jumps
straight to the picker.
2026-05-21 15:45:19 +02:00
mAi
7967839f78 Merge: t-paliad-230 — submission generator format-only convert (.dotm → .docx) 2026-05-21 15:26:31 +02:00
mAi
d86cac0b53 feat(submissions): t-paliad-230 format-only .dotm→.docx convert
m's 2026-05-21 scope reduction of the t-paliad-215 submission generator:
ship a demo that hands the lawyer the firm style template as a clean
.docx. No variable-merge engine, no per-submission template registry,
no fallback chain — the merge slice is deferred to a future task.

Replaces the previous engine (template registry + variable bag +
{{placeholder}} renderer + dual project_events/documents writes) with:

* services.ConvertDotmToDocx — single-function .dotm/.docm/.dotx → .docx
  format converter that strips word/vbaProject.bin, word/vbaData.xml,
  word/customizations.xml, and word/_rels/vbaProject.bin.rels, rewrites
  [Content_Types].xml (demotes the macro/template main type to plain
  docx, drops the .bin Default Extension and the macro Overrides), and
  rewrites word/_rels/document.xml.rels to drop the vbaProject +
  keyMapCustomizations relationships. Idempotent on a plain .docx.
  archive/zip + regex stdlib only — no new third-party dependencies.

* handlers/submissions.go — POST /api/projects/{id}/submissions/{code}
  /generate fetches the cached HL Patents Style .dotm (via a new
  fetchHLPatentsStyleBytes accessor on files.go that shares the same
  cache as /files/{slug}), converts, writes one paliad.system_audit_log
  row (event_type='submission.generated', metadata={submission_code,
  rule_name, filename}), and streams the .docx as an attachment. GET
  /api/projects/{id}/submissions still lists filing rules but
  has_template is unconditionally true (one universal template).

* Filename per design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}
  .docx, with Umlauts ASCII-folded and slashes → underscores.

Drops services/submission_templates.go, services/submission_vars.go,
and the wiring in cmd/server/main.go + handlers/handlers.go that bound
them together. Frontend client switched to POST.

Verified the converter against the real HL Patents Style.dotm (361 KB
input → 243 KB output, 46 parts in output zip):

  unzip -tq /tmp/hl-patents-style.converted.docx   → No errors
  python3 -c "import zipfile, xml.etree.ElementTree as ET; \
              z=zipfile.ZipFile('/tmp/hl-patents-style.converted.docx'); \
              [ET.fromstring(z.read(p)) for p in z.namelist() if p.endswith('.xml')]"
  uv run --with python-docx python3 -c "import docx; \
              d=docx.Document('/tmp/hl-patents-style.converted.docx'); \
              print(len(d.paragraphs), 'paragraphs', len(d.styles), 'styles')"
              → 236 paragraphs, 168 styles, 1 section

All assertions passed: every Override in [Content_Types].xml resolves
to a real part, every internal Target in document.xml.rels resolves,
zero macro-related residue, and the document body + styles + theme
survive untouched.

go test -run TestBootSmoke ./cmd/server/... clean (route additions
register without conflict on the Go ServeMux).
2026-05-21 15:23:24 +02:00
mAi
69f45893a3 Merge: t-paliad-231 — mailto: team selection on project Team tab 2026-05-21 15:18:42 +02:00
mAi
9f339747e5 feat(team): mailto: selection on project-detail Team tab (t-paliad-231)
Non-admins can now select team members directly on the project detail
Team tab and open a mailto: link in their local mail client with every
selected member queued in the To: line. No server call, no audit row —
the existing /admin/team server-SMTP broadcast (t-paliad-147) stays
admin-only and untouched.

Behaviour:
- Checkbox column on every team-body row (direct + ancestor-inherited).
  Rows for users without an email render a disabled checkbox so the
  column geometry stays uniform.
- Tri-state master checkbox in the header row toggles every visible,
  email-bearing row.
- Single "Mail an Auswahl" button above the table, disabled while the
  selection is empty. When one or more rows are selected the label
  picks up "(N)" and the title attribute spells out the count.
- Click composes mailto:a@x,b@y via the existing buildMailtoHref
  helper from broadcast.ts (RFC 6068 comma join + encodeURIComponent
  per address) and sets window.location.href. Pure client side.
- Selection is pruned to currently-rendered, email-bearing user_ids
  on every renderTeam call so removed members or members who lose
  their email drop out automatically.
2026-05-21 15:17:52 +02:00
mAi
7c3c84454d Merge: t-paliad-229 — changelog catch-up May 2026 2026-05-21 15:03:44 +02:00
mAi
61210943d9 content(changelog): drop submission-generator entry per task scope
t-paliad-229 hard rule: "Don't write release notes for things still
in design phase (submission generator, etc.)". Klageerwiderung
shipped end-to-end via t-paliad-215 Slice 1, but m flagged the whole
submission generator as too early for the public changelog — only
one template, more to follow. Removing the 2026-05-19 entry; the 9
other entries remain unchanged.
2026-05-21 15:01:54 +02:00
mAi
74783e7a89 content(changelog): t-paliad-229 — catch up changelog for May 2026
Adds 10 user-visible entries covering everything shipped since the
2026-04-30 entries. Newest first, voice and length match the
established pattern.

- 2026-05-21 Configurable dashboard (drag/drop edit mode, resize,
  per-widget options, widget catalog, firm-wide admin default,
  collision-aware placement — bundles t-paliad-219 Slice A+B+C +
  m/paliad#69 + #70)
- 2026-05-20 User-authored checklists (Wizard + explicit sharing +
  admin firm-wide promotion + template versioning — t-paliad-225
  Slice A+B+C)
- 2026-05-20 Approvals: suggest changes (third inbox action,
  counter-proposal modal, Verlauf integration — t-paliad-216 +
  t-paliad-217)
- 2026-05-20 Client role + auto-derived project codes (t-paliad-222 =
  m/paliad#47 + #50)
- 2026-05-19 Submissions: Klageerwiderung als Word-Datei (Schriftsätze
  tab + first .docx template — t-paliad-215)
- 2026-05-19 Personal data export (xlsx/csv/json on /settings +
  per-project subtree export — t-paliad-214)
- 2026-05-15 Custom Views (Meine Sichten + list/cards/calendar/timeline
  + exports — t-paliad-144 + t-paliad-177 + t-paliad-211)
- 2026-05-07 Projects page redesign (tree + chips + pin + search + Cards
  view — t-paliad-149 PR 1+2)
- 2026-05-06 Four-eyes approvals (dual-control on deadline/appointment
  CRUD + admin policies UI — t-paliad-138 + t-paliad-154)
- 2026-05-05 Fristenrechner v3 (Pathway A/B + decision tree + concept
  layer + DE/EPA/DPMA expansion — t-paliad-131 / 133 / 134 / 136)

go build ./... + go test ./internal/changelog/... clean.
2026-05-21 15:00:53 +02:00
mAi
062afb6cc5 Merge: hotfix project tree ltree-on-text outage 2026-05-21 14:52:56 +02:00
mAi
47b869dddf hotfix(projects): drop ltree operators on text path — production outage
Production-down: project tree returned the
"Projektverwaltung zurzeit nicht verfügbar" message because every
PopulateProjectCodes call raised:

  ERROR service: populate project codes: bulk fetch:
  pq: operator does not exist: text @> text at position 13:38 (42883)

Root cause: paliad.projects.path is stored as TEXT (dot-separated
UUIDs), not as the ltree extension type. The rest of the codebase
treats it accordingly — can_see_project uses
string_to_array(path, '.')::uuid[]; export_service.go uses LIKE
patterns; export_service.go even spells it out:
"Subtree-aware queries via paliad.projects.path (ltree as text)."

The new project-code helper (t-paliad-222 / m/paliad#50) was the only
caller using ltree operators (@>, nlevel) against this text column.
Postgres correctly rejected text @> text — no such operator exists.

Fix: rewrite both queries (BuildProjectCode + PopulateProjectCodes) to
walk ancestors via string_to_array(path, '.')::uuid[], consistent with
the existing visibility predicate. Ordering uses array_position
instead of nlevel. Query shape validated against the live DB.

Pure-function tests (assemble + segment) untouched and passing. The
gap that let this ship: no integration test exercises the actual SQL
— it only tests the pure assembler. Filing a follow-up issue for a
real-DB regression test.
2026-05-21 14:52:50 +02:00
mAi
c4c4fa267f Merge: fix dashboard deadline link query preservation 2026-05-21 14:23:07 +02:00
mAi
d555d5f679 fix(dashboard): preserve query string on /deadlines → /events redirect
m's 2026-05-21 14:20 report: dashboard "Diese Woche" card linked to
/deadlines?status=this_week but the 301 to /events?type=deadline dropped
the query string, landing on the default Pending filter instead of the
This-Week bucket.

Two-part fix:

1. handleDeadlinesListRedirect now appends r.URL.RawQuery to the
   target so any filter (status, project_id, event_type, …) survives
   the redirect. Regression test pins all three shapes (no query,
   single param, multi param).

2. Dashboard summary cards point at the canonical
   /events?type=deadline&status=… URL directly — saves the 301 bounce
   and matches the URL the events page itself reads on load.

The five card values (overdue/today/this_week/next_week/later) are all
in STATUS_OPTIONS_DEADLINE in frontend/src/client/events.ts, so the
events page filter chip picks them up natively.
2026-05-21 14:23:04 +02:00
mAi
875d0c149a Merge: m/paliad#70 — collision-aware widget placement (dashboard overlap fix)
Follow-up to m/paliad#69. Mixed-size rows (e.g. 2-col widget next to 1-col)
no longer visually overlap because:

- Grid occupancy map now accounts for each widget's full colspan footprint,
  not just its origin cell.
- Drop-target hit detection excludes cells covered by another widget's
  colspan.
- Resize-grow shifts conflicting siblings to the next free cell (m's
  recommended behaviour per the issue body).

Tesla stays persistent on mai/tesla/dashboard-overlap for follow-up
dashboard tweaks per m's continuity ask.
2026-05-21 10:49:45 +02:00
mAi
92d0340d74 fix(dashboard): t-paliad-228 — collision-aware widget placement (m/paliad#70)
After m/paliad#69's edit-mode overhaul, widgets visually overlapped on
mixed-size rows: a 12-col + 6-col swap, an auto-flow widget landing on
an explicit blocker, or a resize-grow into a sibling all produced
layouts that ignored colspan footprints when computing occupancy.

Extracts placement math from dashboard.ts into a pure ./dashboard-grid
module and adds an occupancy bitmap. Every visible widget is placed
once; explicit-position collisions are resolved by searching downward
from the requested row for the first w×h block that fits, preferring
the requested column. Resize-grow + drag-drop swap now reliably
produce no-overlap layouts because the placer cleans up after them.

x+w > GRID_COLUMNS is clamped in the placer instead of rendered as an
overflow — matches the validator's hard rule on the wire.

Adds 14 dashboard-grid.test.ts regressions covering the mixed-width
swap, resize-grow shifting siblings, multi-row widgets, and the
overflow clamp. Pure tests — no DOM.
2026-05-21 10:48:10 +02:00
mAi
f8c6206afe Merge: m/paliad#69 — dashboard edit-mode overhaul (drag/drop + resize + per-widget options)
Three regressions / gaps on newton's just-shipped Slice B+C addressed.

- **Drag/drop reorder**: rebuilt on a single proper 12-col grid (newton's
  implementation had per-row containers which blocked cross-row drops + the
  swap heuristic only handled adjacent same-size cells). Drop hit detection
  now works across the entire grid; recalc step uses real grid coordinates;
  any widget moves anywhere, autosaves.
- **Resize**: bottom-right resize handle added (visible only in edit mode).
  Snaps to valid 1x1 / 2x1 / 2x2 grid sizes; sibling widgets reflow on
  resize; autosave via the same PUT /api/user/dashboard path.
- **Per-widget options expansion**: widget catalog entries now carry an
  option schema (limits, position, content/view-type). Settings pane
  renders the right controls dynamically per schema. Deadlines widget
  exposes list / calendar / timeline-strip view picker; activity widget
  full / compact toggle; etc.

No schema migration — option schema rides on the existing user_dashboard_layouts
jsonb. Backward-compat: legacy layouts (without per-widget options) hydrate
with catalog defaults.
2026-05-21 09:56:08 +02:00
mAi
f8245a06a6 fix(dashboard): t-paliad-227 — rebuild edit mode on a single 12-col grid (m/paliad#69)
Three issues from Slice B were entangled in the same root cause:

1. **Drag/drop reorder only swapped the first two same-size widgets.**
   Widgets lived in two parents (.container + .dashboard-columns); the
   old applyLayout used parent.appendChild per widget which physically
   moved every .container widget to the END of .container — past the
   .dashboard-columns row, edit-footer, and save-toast. Only the two
   columns inside .dashboard-columns swapped visibly because they
   shared a parent. Cross-row drags appeared to silently no-op.

2. **No resize affordance** — the design's per-widget sizing existed
   only on paper.

3. **Per-widget options were thin** — count + horizon dropdowns only.

This change rebuilds the whole layout primitive on a single 12-column
CSS grid:

Backend (internal/services/):
- DashboardWidgetRef gains x/y/w/h grid coordinates. Validator clamps
  against catalog MinW/MaxW/MinH/MaxH and rejects x+w > 12.
- WidgetDef gains DefaultW/H + MinW/MaxW/MinH/MaxH for the resize clamps.
- WidgetSettingsSchema gains Views ([{id,label_de,label_en}]), CountMax,
  HorizonMax. Validator accepts free-form ints inside [1,CountMax] in
  addition to dropdown presets, plus view-id against schema.
- WidgetCatalog wires views for upcoming-deadlines/-appointments (list,
  calendar), inline-agenda (timeline, list), recent-activity (full,
  compact), plus default sizes per widget.
- FactoryDefaultLayout greedy-packs visible widgets onto the grid,
  tracking row-max height so taller previous neighbours never overlap.

Frontend:
- dashboard.tsx: every widget moved into a single .dashboard-grid
  wrapper; matter-summary converted to a CollapsibleSection so it
  participates in the grid like everything else.
- applyLayout rewritten — never moves DOM nodes; writes inline
  grid-column / grid-row from computed placements. computePlacements
  trusts explicit positions and auto-flows the rest with the same
  rowMaxH-aware packer the backend uses.
- reorderViaDnd swaps (x, y) instead of array order; layout re-sorted
  by (y, x) so the persisted array matches visual order.
- Resize handles in edit mode: bottom-right pointer-drag, cellW/cellH
  derived from live grid metrics, snaps to grid + clamps to schema,
  autosaves on pointerup. Native HTML5 DnD suppressed during resize.
- afterLayoutMutation now materialises every visible widget's
  (x,y,w,h) so the spec stays self-describing — no mixed
  explicit/auto-flow on next render.
- Gear popover expanded: view segmented control, custom count/horizon
  numeric inputs alongside preset dropdowns, size (W/H) + position
  (X/Y) spinners. Every visible widget gets a gear in edit mode.
- View-aware renderers:
  - upcoming-deadlines / -appointments: list (default) or mini-month
    calendar with item dots.
  - inline-agenda: timeline (default) or flat list.
  - recent-activity: full (default) or compact (one-line per row).

CSS:
- .dashboard-grid (12 cols, dense auto-flow); collapses to single
  stack on narrow viewports.
- .dashboard-widget__resize handle (bottom-right diagonal stripes).
- .dashboard-widget__view-group segmented control.
- .dashboard-cal-* mini-calendar.
- .dashboard-activity-list--compact one-line variant.
- Grid items get card chrome via .dashboard-grid > .dashboard-section.

Tests:
- New: AcceptsCustomCountWithinMax, AcceptsValidView,
  RejectsUnknownView, RejectsViewOnNoViewWidget, GridPosition,
  GridSizeOutsideClamps, NoOverlap (greedy packer regression),
  AssignsPositions.
- Updated: BadSettings now asserts a value above CountMax (free-form
  values inside [1,CountMax] are valid; presets stay valid too).

Backwards-compatible: a stored layout without x/y/w/h still loads — the
client's auto-flow placer puts widgets into a clean single column until
the user customises. The first drag / resize / settings tweak
materialises all positions so subsequent renders are deterministic.
2026-05-21 09:54:23 +02:00
mAi
ca71162543 Merge: t-paliad-219 Slice C — catalog expansion + firm-wide admin default (m/paliad#46)
Final slice of the configurable dashboard. Catalog expansion + firm-wide
default propagation.

- mig 117 paliad.firm_dashboard_default — single-row firm-wide factory
  layout, editable by global_admin. New users hydrate from this; existing
  users get 'reset to firm default' option alongside the existing
  'reset to factory'.
- Catalog expansion: pinned-projects widget brought live (C0 pin-machinery
  prerequisite shipped inline); plus 2-3 high-value adds per design
  catalog (recent-deadlines-by-type, my-open-approvals, etc.).
- Frontend: admin '/admin/dashboard-default' page to edit the firm shape;
  user-side 'Reset auf Firmenstandard' link in the dashboard reset flow.

m/paliad#46 fully shipped (Slices A + B + C).
2026-05-20 19:30:20 +02:00
mAi
6b565be830 feat(dashboard): t-paliad-219 Slice C — catalog expansion + firm-wide admin default
Three additions on top of Slice B's edit-mode chrome.

**Catalog expansion (2 new widgets, default-hidden — opt-in via picker):**

- pinned-projects: surfaces a list of the user's pinned matters via the
  pre-existing PinService (mig 062/063, pre-dates t-paliad-219). New
  DashboardService.loadPinnedProjects joins paliad.user_pinned_projects
  to paliad.projects under the standard visibility predicate, preserves
  pinned-at-DESC order, capped at PinnedProjectsCap=20. PinnedProjects
  []PinnedProjectRef grows DashboardData; SetPinService wired
  post-construction to mirror the SetApprovalService pattern.

- quick-actions: pure UI affordance with three buttons linking to the
  existing /projects/new, /deadlines/new, /appointments/new routes. No
  backend payload, no settings schema.

Both default-hidden — m's brief asked for "high-value adds"; injecting
new widgets into every user's dashboard unannounced would be loud.
Factory test relaxed: visibility now matches catalog.DefaultVisible
instead of the previous "all-visible" invariant.

**Firm-wide admin default (mig 117 + new service + 4 endpoints):**

- paliad.firm_dashboard_default: single-row table (id smallint PK CHECK
  id=1) with layout_json + updated_by + updated_at. RLS: SELECT
  authenticated, no INSERT/UPDATE policy (writes go through the
  service-role connection behind the adminGate).
- FirmDashboardDefaultService Get/Set/Clear. Validates against the
  catalog on Set so an admin can't seed an invalid layout.
- DashboardLayoutService.SetFirmDefaultService wires in the firm
  source. Both GetOrSeed and ResetToDefault now prefer the firm
  default over the code-resident FactoryDefaultLayout when one is set.
  Nil-safe — empty firm row falls back to the factory layout, transient
  DB errors fall back too (a blip can't strand a user without a
  dashboard).
- HTTP: GET / PUT / DELETE /api/admin/firm-dashboard-default (admin-
  gated). POST /api/me/dashboard-layout/promote: admin convenience —
  reads the admin's own current layout and stashes it as the firm
  default (saves the JSON-editor step; admins edit via /dashboard's
  normal editor, then click Promote).

**Frontend (Slice B's edit-mode footer grew an admin button):**

- "Als Firmen-Standard speichern" button in the edit footer; hidden via
  CSS-inline until syncPromoteButtonVisibility unhides for
  global_admin. Confirm() → POST /promote → toast.
- The existing "Auf Standard zurücksetzen" copy stays the same — the
  semantics now "firm default if set, else factory", which is the
  desired surface: users see one canonical "Standard" link.

i18n: 13 new keys × DE+EN (dashboard.pinned.*, dashboard.quick.*,
dashboard.edit.promote*). i18n-keys.ts regenerated by build.

m/paliad#46.

go build ./... clean; go vet ./... clean
go test ./internal/... clean (Slice C catalog test + factory-default
   test relaxation; FirmDashboardDefault round-trip tests gated on
   TEST_DATABASE_URL)
Migration 117 dry-run: PASS (other dry-run failures are pre-existing
   local-DB collisions on origin/main; mig 117 itself clean)
bun run build clean: dashboard.html carries new section markup + admin
   button; dashboard.js bundles renderPinnedProjects + promote handler
   + all new i18n keys
2026-05-20 19:15:32 +02:00
mAi
0857c1c078 Merge: t-paliad-219 Slice B — dashboard edit mode (m/paliad#46)
Second slice of the configurable dashboard. Adds the user-facing edit-mode
on top of Slice A's storage + factory render.

- 'Anpassen' toggle button in the dashboard header — off by default.
- Drag handles + x + + buttons appear on widgets when edit mode is on;
  invisible otherwise so the reading-only path stays clean.
- Per-widget settings (counts + horizon dropdowns) per widget catalog.
- 12-col grid drag/drop reorder; mobile fallback to single column with
  drag-by-handle.
- Autosave 400ms debounced via PUT /api/user/dashboard.
- Reset-to-default link to revert layout to the factory shape.

Frontend-only slice. Net 5 files, +1027/-3 LoC (most of it in
client/dashboard.ts + the new CSS block).

Slice C (catalog expansion + admin firm-wide default) remaining.
2026-05-20 19:00:11 +02:00
mAi
4bf0a719b0 feat(dashboard): t-paliad-219 Slice B — edit mode + drag/drop + autosave
Adds the user-facing dashboard customization UI on top of Slice A's
backend (already shipped). Off by default — view-mode DOM and behavior
are byte-identical to the factory render.

Anpassen toggle in the dashboard header flips body.dashboard-editing.
When on, every [data-widget-key] grows a chrome strip with drag handle,
↑/↓ keyboard reorder buttons, hide/show button, and ⚙ gear for widgets
with a settings schema. An edit footer below the activity widget
surfaces "+ Widget hinzufügen" and "Auf Standard zurücksetzen".

Drag-and-drop uses native HTML5 DnD (dragstart / dragover / drop) on
the widget element itself. ↑/↓ buttons are the keyboard + touch
fallback. Hide flips Visible:false in the layout draft; re-showing via
the picker either un-hides in place or appends to the end if the
widget was never added.

Picker modal uses the unified openModal() helper (t-paliad-217). Each
catalog entry shows title + description + active/hidden/absent pill;
tapping an inactive entry mutates the layout and the list re-renders
in place so the user can multi-add.

Gear popover anchors absolutely inside the widget. Per-widget knobs
follow the catalog's WidgetSettingsSchema: count {1,3,5,10,20} for
list widgets, horizon_days {7,14,30,60} for upcoming-deadlines/-appoint-
ments, horizon-only {14,30,60} for inline-agenda, count {1,3,5,10} for
inbox. Selecting a value scheduleSave()s; close on outside-click / Esc.

Autosave: every layout mutation → snapshot rollback target +
400ms-debounced PUT /api/me/dashboard-layout. Success flashes a
"Gespeichert" toast (1.5s); failure rolls back, re-renders, and shows
"Speichern fehlgeschlagen". Reset link → confirm() → POST /api/me/
dashboard-layout/reset, replacing currentLayout with the factory
default returned by the service.

Mobile (≤32rem): toggle becomes full-width tappable, drag handle
hides in favor of ↑/↓ buttons (touch DnD is unreliable), picker uses
the existing modal full-screen breakpoint, toast spans the row.

Frontend-only — Slice A already shipped GET/PUT/POST /api/me/dashboard-
layout, GET /api/dashboard-widget-catalog, and the three-blob shell
hydration (data, layout, catalog). The client reads __PALIAD_DASHBOARD
_CATALOG__ inline; fetch fallback on hydration miss.

i18n: 23 new keys × 2 langs (DE + EN) for the toggle, picker, gear,
toast, and reset confirm. The i18n-keys.ts regenerates on every build.

m/paliad#46.

go build ./... clean
go vet ./... clean
go test ./internal/... clean (24 dashboard-layout/widget-catalog unit tests pass)
go test ./cmd/server/ -run TestBootSmoke: SKIPS without TEST_DATABASE_URL
   (CI's clean test DB runs the boot-smoke gate)
bun run build clean: dashboard.html still carries the three placeholder
   tokens; dashboard.js bundles the edit-mode code + i18n keys
2026-05-20 18:42:41 +02:00
mAi
15ce176ebd Merge: t-paliad-225 Slice C — checklist gallery + versioning (m/paliad#61)
Final slice. Discoverability + versioning on user-authored checklists.

- mig 116 paliad.checklists.version int NOT NULL DEFAULT 1 +
  paliad.checklist_instances.template_version int (snapshot column).
  Version bumps on template UPDATE; instance carries the version it was
  created from.
- 'Geteilte Vorlagen' tab on /tools/checklists surfacing templates the
  user can see via firm/global visibility + checklist_shares. Filter by
  author / tag / visibility level. Popularity sort optional (deferred).
- Outdated-template badge on instance detail when
  instance.template_version < template.version. Click → modal showing
  the diff (template's new sections / items vs the snapshot).
- audit events: checklist_template_versioned emitted on each UPDATE.

t-paliad-225 / m/paliad#61 fully shipped (Slices A + B + C).
2026-05-20 15:51:43 +02:00
mAi
e56cb3b210 feat(checklists): t-paliad-225 Slice C frontend — Geteilte Vorlagen tab + outdated-template badge
m/paliad#61 Slice C frontend pass.

Discovery (Geteilte Vorlagen):
- New 4th tab on /checklists between "Meine Vorlagen" and "Vorhandene
  Instanzen". Filters the merged catalog response to authored entries
  not owned by the caller (firm-visible OR globally-promoted OR
  share-recipient). Tab state round-trips via ?tab=gallery.
- Regime filter pills (UPC / DE / EPA / OTHER) operate independently
  from the main Vorlagen tab.
- Cards show regime badge, item count, author line, visibility chip.
- Self-filter relies on /api/me email match — loadMe() fires once on
  page boot and is idempotent.

Versioning UI on /checklists/instances/{id}:
- "Vorlage aktualisiert" badge appears when the instance's
  template_version is known AND lags the live template version (only
  for authored templates; static templates never bump). Shows "v{from}
  → v{to}" delta.
- "Änderungen anzeigen" button opens a diff modal that compares the
  instance's template_snapshot against the live template body.
  Item-level grouping by (section title, item label). Surfaces added /
  removed / changed items with localised section labels. Empty state
  when only metadata changed.

i18n: 13 new keys per language (DE + EN) under
checklisten.tab.gallery, checklisten.gallery.*, checklisten.filter.other,
and checklisten.instance.{outdated,diff}.*. Total 2666 keys.

Build hygiene: bun run build clean; i18n scan clean. Go build/vet/test
+ TestBootSmoke ./cmd/server/ all green.
2026-05-20 15:50:38 +02:00
mAi
fffddcc71a feat(checklists): t-paliad-225 Slice C backend — template versioning + catalog Version
m/paliad#61 Slice C backend.

Schema (mig 116, idempotent):
- ALTER paliad.checklists ADD COLUMN version int NOT NULL DEFAULT 1.
  Pre-Slice-C rows default to 1 (the column was added with DEFAULT
  so the UPDATE clause is a no-op safety net).
- ALTER paliad.checklist_instances ADD COLUMN template_version int.
  NULL on existing rows — instance detail page leaves the "outdated"
  badge off when the snapshot version is unknown.

Services:
- ChecklistTemplateService.Update — version bumps on title/body
  changes (the meaningful edits that warrant notifying instance
  owners). Pure metadata tweaks (description/court/reference/deadline)
  update updated_at without bumping. Emits the new 'checklist.versioned'
  audit event with prior_version + new_version metadata.
- ChecklistInstanceService.Create — captures snapshot_version
  alongside the body snapshot.
- ChecklistCatalogService — CatalogEntry grew a Version field
  (1 for static; live column for authored). ListVisible / Find
  populate it.
- Models — Checklist.Version int; ChecklistInstance.TemplateVersion *int.
- /api/checklists/{slug} response now includes version so the
  instance detail page can compare against the snapshot.

Migration verified live via BEGIN..ROLLBACK against paliad.checklists
and paliad.checklist_instances.

Build hygiene: go build/vet/test ./internal/... + TestBootSmoke
./cmd/server/ all green.
2026-05-20 15:50:21 +02:00
mAi
b850eb755c Merge: t-paliad-225 Slice B — checklist sharing + admin promotion (m/paliad#61)
Second slice. Explicit sharing of personal checklists to user / office /
partner_unit / project + global_admin promote-to-firm / demote.

- mig 115 paliad.checklist_shares (FK to user_id / office_key / partner_unit_id
  / project_id; granted_by; granted_at). Partial indexes per share kind.
- Backend: ListShares / GrantShare / RevokeShare on ChecklistService.
  Promote/Demote on AdminChecklistService — flips visibility to/from 'global'
  and emits checklist_promoted_global / checklist_demoted audit events.
- HTTP routes (under /api/checklists/templates/ + /api/checklists/shares/ +
  /api/admin/checklists/ — all literal-prefixed to avoid the route-collision
  class the hotfix 6b63420 just shipped to address).
- Frontend: 'Teilen' modal on a checklist detail page (recipient picker:
  user / office / partner-unit / project); 'Als global markieren' / 'Aus
  global entfernen' admin buttons (global_admin only).
- RLS extended: select policy allows owner + visibility='firm' + visibility='global'
  + rows present in checklist_shares matching caller's ancestry.

Slice C (discoverability gallery + versioning) follows.
2026-05-20 15:39:56 +02:00
mAi
a93277a072 feat(checklists): t-paliad-225 Slice B frontend — share modal + admin promote/demote on detail page
m/paliad#61 Slice B frontend pass.

Detail page (/checklists/{slug}) gains:
- Provenance line ("Erstellt von <author>") for authored templates,
  populated from the catalog response's owner_display_name.
- Owner action buttons: Bearbeiten (links to
  /checklists/templates/{slug}/edit per the Slice A hotfix), Teilen,
  Löschen. Reveal driven by /api/me email match against the catalog
  response's owner_email.
- global_admin action buttons: "Als Firmen-Vorlage hinterlegen"
  (promote) when visibility != 'global'; "Aus Katalog entfernen"
  (demote) when visibility == 'global'. Reveal driven by /api/me
  global_role.

Share modal:
- Single modal with a kind-picker (Kollege / Office / Dezernat /
  Projekt) and a matching select per kind — sections toggle on the
  active kind.
- Recipient pickers populated from /api/users, /api/partner-units,
  /api/projects (loaded in parallel on open). Office options use the
  canonical 8-key set from internal/offices.
- Existing grants surface in a list under the form with per-row
  Entfernen buttons; Revoke confirms before DELETE.
- Errors surface inline (recipient-required, generic share failure).

i18n: 32 new keys per language (DE+EN) under checklisten.share.*
and checklisten.detail.promote/demote/delete.*. Total 2653 keys.

Build hygiene: go build/vet/test ./internal/... + ./cmd/server/ all
green; bun run build clean.
2026-05-20 15:38:43 +02:00
mAi
c3cd51eb85 feat(checklists): t-paliad-225 Slice B backend — explicit sharing + admin promotion
m/paliad#61 Slice B backend. Implements the explicit-share path
(checklist_shares + visibility predicate extension) and the
global_admin-only promotion / demotion of authored templates to and
from the firm catalog.

Schema (mig 115, idempotent):
- paliad.checklist_shares (uuid id, checklist_id FK, polymorphic
  recipient via xor-check: recipient_kind in {user, office,
  partner_unit, project} with exactly one matching recipient_* column
  populated; granted_by FK; granted_at)
- Hot-path lookup index + per-kind partial UNIQUE indexes prevent
  duplicate grants
- RLS: SELECT owner OR self-recipient (user-kind) OR global_admin;
  INSERT owner-only with granted_by=self; DELETE owner OR global_admin;
  no UPDATE (revoke = DELETE)
- can_see_checklist CREATE OR REPLACE — adds 4 share branches; project-
  share branch uses inline ltree walk over projects.path because
  can_see_project reads auth.uid() (NULL on service-role connection,
  same pattern as visibility.go)
- xor-check verified live: rejects kind='user' with recipient_office
  set; accepts the matching kind/recipient pair

Services:
- ChecklistShareService — Grant (owner-only, validates recipient kind +
  required FK target, friendly 409 on partial-unique-index conflict),
  Revoke (owner or global_admin), ListGrants (owner or global_admin;
  enriches recipient_label via LEFT JOINs)
- ChecklistPromotionService — Promote (global_admin → visibility=global
  + promoted_at/by + audit), Demote (global_admin → target visibility,
  default 'firm', clears promoted_at/by; rejects demote of non-global
  rows)
- ChecklistCatalogService.checklistVisibilityPredicate extended to
  include all 5 share branches; service-role-friendly (no auth.uid())
- ChecklistTemplateService.normaliseSliceAVisibility now accepts
  'shared' as an author-set value; 'global' stays admin-only

Endpoints:
- GET    /api/checklists/templates/{slug}/shares  — list grants (owner/admin)
- POST   /api/checklists/templates/{slug}/shares  — grant
- DELETE /api/checklists/shares/{id}              — revoke
- POST   /api/admin/checklists/{slug}/promote     — promote to global
- POST   /api/admin/checklists/{slug}/demote      — demote (body.target default 'firm')

Audit (paliad.system_audit_log):
- checklist.shared      — recipient_kind + recipient_id in metadata
- checklist.unshared    — same shape, captured pre-DELETE
- checklist.promoted_global — prior_visibility + owner_id
- checklist.demoted     — target_visibility

Tests: validateShareInput covers all 4 kinds (happy + missing-id);
predicate-shape test asserts all 6 visibility branches present;
pqUniqueViolation regex sniff; nullableString helper; SliceB visibility
opens 'shared' but keeps 'global' admin-only.

Hotfix-merge note: head shipped 794617c after Slice A — the
template-edit page route moved from /checklists/{slug}/edit to
/checklists/templates/{slug}/edit to disambiguate from
/checklists/instances/{id}. Slice B routes follow the safe
/<resource>/<noun>/{id} pattern (no new {slug}-then-verb endpoints).
2026-05-20 15:38:30 +02:00
mAi
6b634207c2 Merge: hotfix — disambiguate checklists route conflict (production-down) 2026-05-20 15:34:00 +02:00
mAi
794617cbfd hotfix(checklists): disambiguate /checklists/{slug}/edit → /checklists/templates/{slug}/edit (production-down route conflict)
Go ServeMux refused to register patterns 'GET /checklists/{slug}/edit' (from
dirac's Slice A merge b418705) and 'GET /checklists/instances/{id}' (existing)
because both match '/checklists/instances/edit'. Container crash-looped on
boot since 13:32 UTC; paliad.de returned 404 from Traefik because no app was
listening.

Renaming the new template-edit route to /checklists/templates/{slug}/edit
disambiguates — '/templates/...' is a literal segment so the {slug} is now
strictly under a fixed prefix that can't collide with 'instances'.

Touches:
- internal/handlers/handlers.go:257 — route pattern
- frontend/src/client/checklists.ts:290 — Bearbeiten link
- frontend/src/client/checklists-author.ts:52 — URL parser regex
- frontend/src/checklists-author.tsx — doc comment

go build + bun run build clean.
2026-05-20 15:34:00 +02:00
mAi
b418705775 Merge: t-paliad-225 Slice A — user-authored checklists (m/paliad#61)
First slice of the user-checklist feature. Personal templates + 'Meine Vorlagen'
authoring; private + firm visibility only (explicit sharing to specific
users/offices/units/projects + admin-promotion ship in Slices B + C).

- mig 114 paliad.user_checklists table (owner_id, visibility text, name, sections
  jsonb, created_at). RLS scoped to owner + 'firm' visibility = visible to
  all authenticated users. Verified-via-gap-tolerant-runner.
- ChecklistService — Create/List/Get/Update/Delete + RLS-aware queries.
- HTTP layer — GET/POST /api/checklists, PATCH/DELETE /api/checklists/{id}.
- 'Meine Vorlagen' surface on /tools/checklists with authoring wizard
  (sections + items + visibility radio).

Slice B (share-to-individual + promotion to global) and Slice C (gallery +
versioning) come in follow-up shifts.
2026-05-20 15:24:28 +02:00
mAi
7a1fd81d23 feat(checklists): t-paliad-225 Slice A frontend — Meine Vorlagen + authoring wizard
m/paliad#61 Slice A frontend pass.

Pages:
- /checklists gets a third tab "Meine Vorlagen" between Vorlagen and
  Vorhandene Instanzen — lists owned authored templates with regime
  badge, visibility chip, Bearbeiten / Löschen actions, "Neue Vorlage"
  CTA. Tab state round-trips via ?tab=mine.
- /checklists/new and /checklists/{slug}/edit serve a shared bundle
  (checklists-author.html). Client reads location.pathname to decide
  create vs edit mode; edit mode prefills from /api/checklists/templates/mine.

Wizard:
- Metadata form (title, description, regime UPC/DE/EPA/OTHER, court,
  reference, deadline, language de/en, visibility private/firm).
- Repeating section + item editor — add/remove sections, add/remove
  items per section, label + optional note + optional rule per item.
- Single-language authoring (lang column on paliad.checklists). The
  catalog read layer mirrors the title/description onto both DE and EN
  sides so the existing bilingual frontend renders without a special
  case for authored entries.
- Save POSTs (create) or PATCHes (edit) the template; visibility flip
  on edit goes through its own endpoint so the audit row captures the
  transition.

Merged catalog:
- /api/checklists now returns the merged list (static + DB visible);
  the Summary shape gained origin / visibility / owner_email /
  owner_display_name fields.

i18n: 55 new keys per language (110 total) under
checklisten.tab.mine.*, checklisten.mine.*, checklisten.author.*,
checklisten.detail.* (Bearbeiten/Löschen labels for Slice B). i18n
codegen total: 2621 keys.

Build hygiene: bun run build clean, go build clean, go vet clean,
go test ./internal/... + ./cmd/server/ all green.
2026-05-20 15:24:07 +02:00
mAi
a4e2f3526d feat(checklists): t-paliad-225 Slice A backend — user-authored templates
m/paliad#61 Slice A. Introduces paliad.checklists (mig 114) as the
DB-backed companion to the static Go catalog. ChecklistCatalogService
unifies both sources at read time; ChecklistTemplateService handles
authoring CRUD + visibility toggle (private↔firm; Slice B opens
'shared' and 'global').

Schema (mig 114, idempotent):
- paliad.checklists (uuid, slug UNIQUE, owner_id FK, title/description
  /regime/court/reference/deadline/lang, body jsonb, visibility CHECK
  ('private','shared','firm','global'), promoted_at/_by, timestamps)
- paliad.can_see_checklist(uuid, uuid) STABLE SECURITY DEFINER —
  owner OR firm/global. Slice B extends with the explicit-share branch.
- RLS: select via can_see_checklist; insert owner=self; update/delete
  owner OR global_admin
- ALTER paliad.checklist_instances ADD COLUMN template_snapshot jsonb
  (snapshot semantics so per-Akte instances stay decoupled from
  subsequent template edits)

Services:
- ChecklistCatalogService — ListVisible, Find, SnapshotBody, IsStaticSlug.
  Reapplies visibility application-side (service-role bypasses RLS, per
  visibility.go pattern). Static-slug map computed once at boot for
  collision detection.
- ChecklistTemplateService — Create (auto-generates u-<slug>-<hex> with
  retry), Update (changed_fields[] in audit), SetVisibility, Delete,
  ListOwnedBy, GetBySlug. Owner-or-global_admin gate.
- SystemAuditLogService.WriteChecklistEvent — thin helper writing into
  paliad.system_audit_log with scope='org'.
- ChecklistInstanceService.Create now captures template_snapshot via
  the catalog; GetByID returns it inline so the frontend can render
  the captured body even after the upstream template is mutated.

Endpoints (all owner-gated where mutating):
- GET    /api/checklists                 — merged catalog (static + DB visible)
- GET    /api/checklists/{slug}          — single template; static-first lookup
- GET    /api/checklists/templates/mine  — caller's authored templates
- POST   /api/checklists/templates       — create
- PATCH  /api/checklists/templates/{slug}            — edit
- PATCH  /api/checklists/templates/{slug}/visibility — private↔firm
- DELETE /api/checklists/templates/{slug}            — delete
- GET    /checklists/new, /checklists/{slug}/edit    — author wizard pages

Tests: pure-helper unit tests cover slugifyTitle (umlaut → ae/oe/ue/ss
normalisation + clamp), regime/lang/visibility validation, body-shape
enforcement, static-slug detection, predicate shape, clamp.
2026-05-20 15:24:06 +02:00
mAi
1c8cdd3079 docs(checklists): t-paliad-225 inventor design — user-authored checklists (#61)
918-line design doc covering all three capabilities from m/paliad#61:
authoring, multi-axis sharing, admin-promotion to global.

Load-bearing premise correction: the issue body claims `paliad.checklists`
is an existing table that gets new columns. It is NOT — checklists today
are static Go data in `internal/checklists/templates.go`. Design
introduces `paliad.checklists` from scratch and keeps the static catalog
as a parallel source via a hybrid catalog read layer.

Schema (mig 112): `paliad.checklists` (owner + visibility enum), `paliad.checklist_shares`
(polymorphic recipient: user/office/partner_unit/project),
`paliad.can_see_checklist` predicate, `paliad.checklist_instances.template_snapshot`
column for instance integrity under template edits.

12 decisions ledgered, all defaulted to (R) per task brief (no AskUserQuestion).
Three slices (A foundation, B sharing+promotion, C gallery+backfill).
2026-05-20 15:24:06 +02:00
mAi
82ecbe3b8e Merge: t-paliad-224 — calendar-view alignment (m/paliad#55)
Three calendar implementations consolidated into one. Custom Views' shape-calendar.ts
becomes the canonical renderer; /events Kalender tab and the orphaned
/deadlines/calendar + /appointments/calendar pages now use the same module.

- frontend/src/client/calendar/mount-calendar.ts — new canon module extracted
  from shape-calendar.ts. Month/week/day, URL state via ?cal_view/?cal_date,
  drill-down day view, kind-coded pills.
- /events Kalender tab folded onto mountCalendar(); the old modal popup
  replaced with day-view drill-down (Q2/(R)).
- /deadlines/calendar + /appointments/calendar become 301 redirects to
  /events?type=…&view=calendar (handlers test added to pin the targets).
- .frist-cal-* CSS block dropped (~180 lines). Dead i18n keys removed.

Net: ~700 LOC removed, ~100 added. Zero schema/endpoint changes. Same data-loader
shared across all surfaces. Single PR per Q7(R).
2026-05-20 15:23:50 +02:00
mAi
badbffa6e0 test(handlers): t-paliad-224 — pin /deadlines/calendar + /appointments/calendar redirect targets
Adds TestStandaloneCalendarHandlers_RedirectToEventsKalender to
internal/handlers/redirects_test.go covering both standalone-
calendar handlers. Each must 301 to the canonical Kalender-tab URL
on /events, preserving the bookmark contract called out in the
handler doc comments. Sister of the existing sub-projects redirect
test.
2026-05-20 15:23:28 +02:00
mAi
0f98d2cd39 refactor(calendar): t-paliad-224 — retire standalone calendar pages + prune dead code
Delete the four orphan files behind /deadlines/calendar +
/appointments/calendar:
- frontend/src/{deadlines,appointments}-calendar.tsx
- frontend/src/client/{deadlines,appointments}-calendar.ts
The standalone pages were unreachable from the UI since t-paliad-110
(Sidebar/BottomNav point at /events?type=…); their only role was as
bookmark targets.

Handlers in internal/handlers/{deadlines,appointments}_pages.go now
301-redirect to /events?type=…&view=calendar so bookmarks still
work. Route registrations in handlers.go remain unchanged — the
gate + redirect pair gives us the same URL surface with one canonical
renderer.

build.ts: drop the renderDeadlinesCalendar / renderAppointmentsCalendar
imports + entry-point bundle paths + dist HTML writes.

frontend/src/client/paliadin-context.ts: drop the two route-key
matches for the standalone URLs (the client never sees those
pathnames any more — 301 fires server-side).

Dead CSS pruned in frontend/src/styles/global.css (~180 lines):
- .frist-calendar, .frist-cal-{controls,month-label,grid,cell,…}
  block (lines 7464-7613 pre-refactor)
- @media (max-width: 700px) { .frist-cal-cell { min-height: 64px; } }
- .termin-cal-legend{,-item}
- .frist-cal-popup-time
- .frist-cal-dot.events-cal-dot-appointment

All verified by grep across frontend/ + internal/ to have no
non-calendar consumers before deletion.

Dead i18n keys removed (DE + EN + i18n-keys.ts union type):
- deadlines.kalender.{title,heading,subtitle,list,today,empty}
- appointments.kalender.{title,heading,subtitle,list,empty}
- deadlines.list.calendar, appointments.list.calendar (button labels
  on the deleted standalone routes)
- events.calendar.empty (replaced by cal.day.no_entries inside
  mountCalendar's day view)

Per head decisions §11 Q1 + Q8 (drop standalone pages as 301s; drop
dead i18n now).

Tests: go build ./... clean; go test ./internal/... 9 packages pass;
cd frontend && bun run build clean (2535 i18n keys); bun test
frontend/src/client/{calendar,views}/ all 73/73 pass.
2026-05-20 15:23:28 +02:00
mAi
d0f732d0ec refactor(events): t-paliad-224 — fold Kalender tab into mountCalendar()
The /events Kalender view now mounts the canonical mountCalendar()
module from frontend/src/client/calendar/ — same renderer Custom
Views uses for shape=calendar. Drops the events-page-specific
month-grid + popup code path entirely.

What replaces what
- renderCalendar() / openCalPopup() / calDotClass / fmtMonthYear /
  isoDate / itemDateISO and the calYear/calMonth module state →
  one mountCalendar() handle (lazy, urlState=true).
- events-cal-prev / events-cal-next / events-cal-today buttons →
  toolbar in mountCalendar (includes its own 'Heute' button).
- modal popup on cell click → drill-down to day view (matches
  /views; head decision §11 Q2).
- @media min-height shrink on .frist-cal-cell → views-calendar-*
  responsive surface (CSS unchanged from /views).

Behavioural deltas vs pre-refactor
- /events Kalender now persists view+anchor in ?cal_view + ?cal_date
  (head decision §11 Q3) — refresh / share-link safe.
- Pills are kind-coded (deadline / appointment) rather than urgency-
  coded; matches /views (head decision §11 Q4 — drop subtype dot
  colouring, file as follow-up).
- Empty-month message gone; the per-day no-entries state from the
  day-view replaces it (head decision §11 Q8 — drop dead i18n).

Adapter: toCalendarItem() preserves the pre-refactor bucketing rule
— deadlines bucket on due_date, appointments on start_at, both fall
back to event_date.

events.tsx: 31-line calendar subtree (toolbar + grid + modal +
empty hint) reduces to a single host div. mountCalendar fills it
when the user picks Kalender.
2026-05-20 15:23:28 +02:00
mAi
e83b150eda refactor(calendar): t-paliad-224 — extract mountCalendar() canon module
Lift the month/week/day renderer out of shape-calendar.ts into a new
frontend/src/client/calendar/mount-calendar.ts module so /events
Kalender (next commit) and Custom Views shape=calendar both go
through the same code path.

shape-calendar.ts becomes a thin adapter (ViewRow → CalendarItem +
defaultView=render.calendar.default_view, urlState=true). The
extracted module adds:

- update(items) on the returned handle so /events can re-mount on
  filter changes without rebuilding state.
- destroy() for clean teardown when /events switches shapes.
- A 'Heute' button in the toolbar (cal.today, DE+EN added to i18n.ts
  + i18n-keys.ts).
- Optional opts.urlPrefix for surfaces that may share a URL with
  another calendar.

mountCalendar reads ?cal_view / ?cal_date when opts.urlState=true.
/events will mount with urlState=true after the next commit so the
Kalender tab + day-view drill remain refresh-stable (per §11 Q3 in
the design doc).

Pure-helper test suite (mount-calendar.test.ts) covers isoDate,
startOfDay, startOfWeek, shift, bucketByDate, filterByDay, isToday —
12 assertions, all green. DOM rendering covered by manual smoke (no
jsdom in this repo's bun test setup; see verfahrensablauf-core.test.
ts comment for the convention).
2026-05-20 15:23:28 +02:00
mAi
2320cb765d docs(design): t-paliad-224 — head accepted all 8 (R) defaults
Decisions section §12 filled in per head msg #2087. Status → ACCEPTED.
Coder shift proceeds on same branch per Q7(R): single PR.
2026-05-20 15:23:28 +02:00
mAi
668558380d docs(design): t-paliad-224 — align calendar views (m/paliad#55)
Audit + refactor plan: three calendar implementations live today —
/events tab, standalone /deadlines|appointments/calendar pages, and
Custom Views shape-calendar.ts. Canonicalise on shape-calendar.ts by
extracting a shared mount-calendar.ts module, fold /events into it,
retire the standalone pages as 301 redirects, delete ~180 lines of
duplicated CSS.

Net: ~700 LOC removed, ~100 added, zero schema/endpoint changes.

8 open questions for head in §11; AskUserQuestion is disabled for this
task per role brief, so head answers via mai instruct and decisions
land in §12.
2026-05-20 15:23:28 +02:00
mAi
9dd47a0591 Merge: t-paliad-223 Slice B — Add User on /admin/team (m/paliad#49)
Completes t-paliad-223 (team & admin surface). Slice A (Project Admin role
+ inheritable role-edit) and Slice C (click-to-select) already merged at
111c7c3.

- SupabaseAdminService + AdminCreateUserFull — auth.users create via the
  Supabase Admin API (requires SUPABASE_SERVICE_ROLE_KEY env, provisioned
  on paliad's Dokploy compose by head 2026-05-20). Best-effort rollback
  on paliad.users insert failure: deletes the auth row to keep state
  clean.
- Welcome email with magic link sent on create when 'Send welcome email'
  checkbox is on (default per Q2).
- POST /api/admin/users/full endpoint, gated on global_admin.
- Frontend modal on /admin/team — 'Add user' button alongside the
  existing 'Invite colleague' / 'Onboard existing' actions.
- i18n keys for the new modal and toast feedback.
- Tests: happy path, duplicate-email refusal, paliad.users insert failure
  with best-effort auth rollback.

t-paliad-223 fully shipped.
2026-05-20 15:20:13 +02:00
mAi
3d3a4fa36d feat(team-admin): t-paliad-223 Slice B — Add User via Supabase Admin API
#49 — adds a third "Konto direkt anlegen" path on /admin/team alongside
"Onboard existing" and "Invite colleague". Creates both auth.users (via
Supabase Admin API) and paliad.users in one click; new user is visible in
dropdowns immediately and receives a paliad-branded magic-link email.

- internal/services/supabase_admin.go: new SupabaseAdminClient — thin net/http shim. 3 methods (CreateAuthUser, GenerateRecoveryLink, DeleteAuthUser). 10s timeout. ErrSupabaseAdminUnavailable when key unset, ErrSupabaseEmailExists when 422-with-"already" returned. apikey + Bearer headers on every call. Sentinel errors for handler mapping.
- internal/services/supabase_admin_test.go: 5 tests pin wire-shape (disabled mode, happy-path POST + headers + body, email-exists mapping, both action-link response shapes, DELETE-by-id route).
- internal/services/user_service.go: UserService grows optional supabase + mail + baseURL dependencies via SetAddUserDeps. AdminCreateFullInput (email/display_name/office/job_title/profession/lang/send_welcome_mail + inviter fields). AdminCreateUserFull validates input → calls supabase.CreateAuthUser → inserts paliad.users (best-effort DeleteAuthUser rollback on insert fail) → writes paliad.system_audit_log row (event_type='user.added_by_admin') → sends welcome mail with magic-link (best-effort).
- internal/templates/email/add_user_welcome.{de,en}.html: new template with magic-link CTA + base-URL fallback + firm-name placeholder. Editable through the existing /admin/email-templates editor (admin-overridable via DB).
- internal/services/email_template_*.go: register 'add_user_welcome' as a fourth canonical key, defaultSubjects entry, sample data, variable contract (6 vars).
- internal/services/mail_service_test.go: TestRenderTemplateAddUserWelcome pins both langs render with magic-link + firm + matching subject.
- internal/handlers/admin_users.go: handleAdminCreateFullUser POST /api/admin/users/full. Fills inviter fields from auth.uid() server-side (never trusts the request body). Error map: 503 (unavailable), 409 (email exists / already onboarded), 400 (invalid input), 403 (domain not on whitelist), 500 (other).
- internal/handlers/handlers.go: route registered behind adminGate.
- cmd/server/main.go: LoadSupabaseAdminClient + users.SetAddUserDeps + boot-log line so the deployer knows whether the path is active.
- frontend/src/admin-team.tsx: "Konto direkt anlegen" button + admin-add-full-modal with email/name/office/profession/job_title/lang fields + send-welcome checkbox (default on).
- frontend/src/client/admin-team.ts: initAddFullModal — POST to /api/admin/users/full, inline error handling for 503 / 409 / generic, optimistic insert into users[] on success, name auto-fills from email local-part on blur.
- i18n: +20 keys (admin.team.add.full + admin.team.add_full.*) × DE + EN.

Design picks honoured: Supabase Admin API path (Q1), welcome email default on (Q2), two-step with best-effort rollback (Q3), job_title default 'Associate' (Q4), profession default 'associate' (Q5). Trade-off #3 from §6 (privileged credential broadens trust surface) accepted by m via head.

go build && go test -short ./internal/... + bun run build all green.
2026-05-20 15:19:48 +02:00
mAi
1c021ed515 Merge: t-paliad-222 — project metadata rework (m/paliad#47 Client Role + m/paliad#50 auto-derived project codes)
Two related issues bundled in one PR.

## #47 Client Role
- mig 112 widens projects.our_side CHECK with new sub-roles: Active
  (claimant/applicant/appellant), Reactive (defendant/respondent),
  third_party/other. Drops 'court' + 'both' (semantically odd; backfilled
  to NULL).
- ProjectFormFields.tsx hides the field on type='client', 'litigation',
  'patent'; shows 'Client Role' on type='case' with 7 grouped options.
- Submission template variable bag — ourSideDE / ourSideEN updated for
  the new values. Determinator perspective inference: Active →
  claimant-perspective, Reactive → defendant-perspective.

## #50 Auto-derived project codes
- mig 113 adds paliad.projects.opponent_code text on litigations (vs
  brittle regex on title).
- New Go helper services/project_code.go: BuildProjectCode(ctx, projectID)
  walks the ltree ancestor chain, derives <CLIENT>.<OPPONENT>.<PATENT>.<TYPE>.<COURT>
  (each segment optional). Custom override via projects.reference still wins.
- Project JSON gets an eager 'code' field populated by the service (no
  per-render lookups; one DB round-trip per list page).
- Rendered as a second header badge on /projects/{id} + in the parent-picker
  typeahead so users see the auto code while organising the tree.

Both migrations land cleanly via the new gap-tolerant runner (boltzmann
c85c382). 376-line project_code_test.go covers the segment-derivation
matrix.
2026-05-20 14:56:25 +02:00
mAi
35217fab4f feat(project-picker): show auto-derived project code in parent typeahead
t-paliad-222 follow-up — wire .code into the parent-project picker so
two same-titled projects in different trees can be disambiguated by
their auto-derived dotted code. Search includes the code; the badge
renders only when distinct from the manual reference.

Excel __meta sheet still pending — the JSON code field is populated
by PopulateProjectCodes for every list payload, so the export
generator only needs to add one row in a follow-up shift.
2026-05-20 14:55:55 +02:00
mAi
225204cf1c feat(projects-detail): render auto-derived project code as a second header badge
t-paliad-222 follow-up — wire the .code field populated by
PopulateProjectCodes into the project-detail header. Shows next to
the manual reference when distinct, hidden when they match (avoid
duplication) or when no segments resolved. CSS `.entity-ref-code`
adds bracket-styling so the user knows the value is derived rather
than typed.

Also extends the frontend Project interface with code + opponent_code
to make TypeScript surface the new fields cleanly across consumers.
2026-05-20 14:55:55 +02:00
mAi
ea0715a8c7 feat(projects): t-paliad-222 — Client Role + auto-derived project codes
Implements m/paliad#47 (Client Role rework) + m/paliad#50 (auto-derived
project codes from the ancestor tree) in one shift.

Migrations:
- mig 112_client_role_rework: widen paliad.projects.our_side CHECK to
  seven sub-roles (claimant / defendant / applicant / appellant /
  respondent / third_party / other); drop legacy 'court' / 'both'
  and backfill rows to NULL (no-op on prod, defensive on staging).
- mig 113_projects_opponent_code: add paliad.projects.opponent_code
  text on litigation rows (slug pattern [A-Z0-9-]{1,16}); used as
  the middle segment when assembling auto-derived project codes.

Backend:
- internal/services/project_code.go — new package-level helpers
  BuildProjectCode (single row) + PopulateProjectCodes (bulk, one
  CTE-based round-trip). Walks the existing paliad.projects.path
  ltree; custom paliad.projects.reference on the target wins.
- Wired into ProjectService.List, GetByID, ListAncestors, GetTree,
  LoadCounterclaimChildrenVisible, BuildTreeWithOptions — every
  service entry-point that returns []models.Project / *models.Project
  populates .Code before returning.
- Models: Project.OurSide doc widened; new Project.OpponentCode
  (db:"opponent_code") and Project.Code (db:"-", projection-only).
- CreateProjectInput / UpdateProjectInput accept OpponentCode;
  validateOpponentCode + nullableOpponentCode mirror our_side helpers.
- validateOurSide widens to the seven sub-roles; legacy 'court' /
  'both' rejected at the service layer with a clear error before
  the DB CHECK fires.
- derivedCounterclaimOurSide CCR flip widened: applicant ↔ respondent,
  appellant → respondent; third_party / other / NULL pass through.
- submission_vars: project.code added to the placeholder bag.
  ourSideDE / ourSideEN now use the gender-neutral "-Seite" /
  "-Partei" suffix shape (Klägerseite / Antragstellerseite / ...);
  better legal-prose default for a B2B patent practice, matches the
  form labels which already used this shape (cf. head's soft-note on
  Q4).

Frontend:
- ProjectFormFields: opponent_code on a new projekt-fields-litigation
  block (hidden by default, shown when type=litigation); our_side
  moved into projekt-fields-case and re-labelled "Client Role" /
  "Mandantenrolle" with three <optgroup>s + seven options.
- project-form.ts: showFieldsForType toggles the new litigation
  block; readPayload / prefillForm wire opponent_code; our_side
  is now only emitted for type=case.
- fristenrechner: ourSideToPerspective widened to the seven sub-roles
  (Active→claimant, Reactive→defendant, Other→null). ProjectOption
  type literal updated.
- i18n.ts: new projects.field.client_role.* and
  projects.field.opponent_code.* keys (DE+EN). Legacy
  projects.field.our_side.* keys stay one release for cached
  bundles + Verlauf event-history rendering of the new sub-roles.

Tests:
- TestProjectCodeSegment, TestAssembleProjectCode, TestPatentLast3,
  TestSanitizeClientShort, TestProceedingTail, TestValidateOpponentCode,
  TestValidateOurSideSubRoles pin the new pure helpers.
- TestOurSideTranslations widened to the seven sub-roles + new
  prose shape; 'court'/'both' arms now return "" (legacy rejected).
- TestDerivedCounterclaimOurSide widened to the new flip map.

Migration slot history (this branch was rebumped twice on 2026-05-20):
mig 110 was claimed by m/paliad#51 (project_type_other, euler);
mig 111 was claimed by m/paliad#48 (project_admin_and_select, gauss).
Final slots 112 / 113.

go build && go test ./internal/... && cd frontend && bun run build
all clean.
2026-05-20 14:55:55 +02:00
mAi
3fdc969902 wip(projects): bump migrations 110→111, 111→112 (euler claimed 110) 2026-05-20 14:55:55 +02:00
mAi
5dea0a703b wip(projects): t-paliad-222 — backend + frontend changes (pre-merge checkpoint)
Backend: mig 110/111 (will be renumbered after merging main),
validators + helpers widened, BuildProjectCode helper + projection
populator wired into List/GetByID/ListAncestors/GetTree/CCR. All
internal Go tests pass.

Frontend: ProjectFormFields conditional render — opponent_code on
litigation, our_side renamed to Client Role on case with grouped
optgroups. i18n keys for both DE and EN. fristenrechner perspective
mapping widened. project-form.ts payload reader/writer + showFieldsForType
toggle for new litigation block.

Migration slots about to be bumped (mig 110 was claimed by euler's
project_type_other on main).
2026-05-20 14:55:55 +02:00
mAi
cc23e9e537 design(projects): t-paliad-222 — Client Role + auto-derived project codes
Design doc for paired m/paliad#47 (Client Role rework) + m/paliad#50
(auto-derived project codes from the ancestor tree). Two migrations
(110 widen our_side CHECK + backfill court/both → NULL; 111 add
opponent_code on litigations), one new BuildProjectCode helper that
walks the existing ltree path, plus form / submission-template /
Determinator wiring.

9 open design questions surfaced for the head; recommendations
default to the issue-body (R) picks unless a material concern is
flagged in §2.2 / §3.2.

Verified against live data (2026-05-20): all 12 projects have
our_side=NULL, so the backfill is a no-op on production today.
No 'opponent' field exists yet.
2026-05-20 14:55:55 +02:00
mAi
ca770636f7 Merge: m/paliad#58 — UPC CCR Procedure Roadmap (EN label + spawn-as-standalone renderer)
m's 2-part feedback on the UPC Counterclaim-for-Revocation roadmap surface.

1. Backfill missing English label on the trigger event 'Widerklage auf Nichtigkeit'
   → 'Counterclaim for Revocation'. Handled in services/proceeding_mapping.go
   (application layer; no corpus migration needed).
2. Generic 'spawn-as-standalone' renderer for sub-track proceeding types that
   have no native rules but spawn under a parent flag (CCR under
   upc.inf.cfi+with_ccr is the canonical case; the same pattern applies to
   R.46 Amendment etc.). When picked standalone, the timeline now renders the
   spawned rules with a contextual note explaining the normal parent context.

40 new unit tests in proceeding_mapping_test.go pin the renderer's standalone
detection + the EN label coverage.
2026-05-20 14:53:48 +02:00
mAi
ea9823db80 fix(verfahrensablauf): m/paliad#58 — UPC CCR roadmap (EN label + spawn-as-standalone)
m's 2026-05-20 14:08 reports on /tools/verfahrensablauf:

  1. "There seems to be a lacking english term here" — picking
     UPC CCR shows "Trigger event: Widerklage auf Nichtigkeit" on EN.
  2. "Nothing shows in the roadmap" — the timeline is empty because
     upc.ccr.cfi has no native rules (it's an illustrative peer that
     normally runs as a sub-track of upc.inf.cfi with with_ccr).

Root cause for (1): UIResponse.proceedingName was DE-only. When a
proceeding had no root rule the frontend fell back to that field, so
EN users saw the DE label. The DB already has bilingual names; this
was pure plumbing.

Root cause for (2): the upc.ccr.cfi proceeding-type row exists for
the picker (mig 096) but ResolveCounterclaimRouting — the helper
that maps it to upc.inf.cfi with the with_ccr flag — was defined
but never called. Calculate queried rules directly off upc.ccr.cfi
and got an empty list.

Fix:

  * Add ProceedingNameEN, ContextualNote, ContextualNoteEN to
    UIResponse. Frontend triggerEventLabelFor now consults the EN
    name on EN, falling back to DE only if the EN field is empty.
  * New SubTrackRouting registry in proceeding_mapping.go and a
    LookupSubTrackRouting lookup — single source of truth for the
    "this proceeding has no native rules, route to a parent with
    flags + show a contextual note" pattern. Today's only entry is
    upc.ccr.cfi → upc.inf.cfi + with_ccr; the pattern generalises
    to other sub-tracks via data-only additions.
  * Calculate consults the registry at the top: when a hit, the
    proceeding type is re-resolved to the parent for rule lookup, the
    default flags are merged into the user's flag set (user flags win
    on conflict), and the response identity (Code/Name/NameEN) stays
    on the user-picked proceeding so the page header still reads
    "Counterclaim for Revocation". The bilingual note surfaces in
    ContextualNote{,EN}.
  * Frontend renderResults paints a lime-accent banner above the
    timeline body when the response carries a note
    (.timeline-context-note). escHtml already exported from
    views/verfahrensablauf-core — imported here for the banner.

No DB migration: SELECTs against paliad.proceeding_types,
paliad.deadline_rules, and paliad.trigger_events confirm every
active row already has a non-empty name_en / name. The bug was
the API + frontend never reading the EN columns through the
proceedingName fallback path.

Tests: TestSubTrackRoutings pins the registry shape (every entry
has matching key/value, non-empty parent+flags, bilingual notes;
CCR's exact shape is asserted; non-sub-tracks miss). The existing
TestResolveCounterclaimRouting continues to pass because the
helper now consults the registry but the CCR semantics are
unchanged.
2026-05-20 14:53:22 +02:00
mAi
111c7c39e8 Merge: t-paliad-223 — team & admin Slices A + C (m/paliad#48 Project Admin role + #53 click-to-select)
Two slices on the team/admin surface. Slice B (Add User, m/paliad#49) is
parked pending m's go-ahead on the SUPABASE_SERVICE_ROLE_KEY credential.

## Slice A — Project Admin role (#48)
- mig 111 (renumbered from author's 110 to avoid collision with euler's
  project_type_other mig 110 merged immediately prior).
- 'admin' added to project_teams.responsibility CHECK.
- New paliad.effective_project_admin(user_id, project_id) SQL function
  walks the ltree path; sees admin on the row, on any ancestor, or
  global_admin status.
- ChangeResponsibility service method + last-admin-on-tree safeguard.
- Frontend inline-select on the project team panel, gated on
  effective_project_admin for the calling user.

## Slice C — Click-to-select (#53)
- /team gains a checkbox column + selection Set + sticky-footer
  broadcast action.
- Selection survives filter changes; drop-out rows de-select; navigation
  wipes selection.
- Empty-selection action falls back to the filtered set (no regression
  vs. existing broadcast).
- No backend changes; pure frontend.

All builds + tests green.
2026-05-20 14:47:13 +02:00
mAi
25cee32d01 feat(team): t-paliad-223 Slice C — click-to-select on /team with sticky-footer broadcast
#53 — adds an explicit selection layer ON TOP of the existing filter
pills on /team. Frontend-only; no backend changes, no migration.

- frontend/src/team.tsx: master "Alle sichtbaren auswählen" checkbox row above the team-list.
- frontend/src/client/team.ts:
  - Module-scoped selectedUserIDs Set + renderedUserIDs DOM-order snapshot + lastToggledUserID for Shift-click range expansion.
  - renderUserCard gains a per-row checkbox + data-selected attribute mirroring the Set.
  - pruneSelectionToVisible(): every render() drops user_ids that no longer match the filter — invariant "selection ⊆ visible".
  - wireSelectionCheckboxes() + applyRangeSelection() + refreshCardSelectedAttribute(): plain-click toggles one row, Shift-click extends a contiguous range using renderedUserIDs as the order reference.
  - renderSelectionFooter(): fixed-position bar that mounts when selection > 0, hides when empty. Hosts the live "{n} ausgewählt" counter, a "Auswahl aufheben" reset, and an "E-Mail an Auswahl" button that calls openBroadcastModal with selectedRecipients() — reuses the existing modal verbatim.
  - syncMasterCheckbox() + onMasterToggle(): tri-state master checkbox (empty / partial / full) for "select all visible".
- frontend/src/styles/global.css: .team-card[data-selected="true"] highlight, .team-card-select checkbox cell, .team-select-master-row, .team-selection-footer (z-index 150 — above mobile bottom-nav at 100, well below modal overlays at 1000+).
- i18n: +10 keys (team.selection.{count,clear,send,select_all,toggle_card}) × DE + EN.

Design picks honoured: surface=/team not /admin/team (Q1), checkbox column not modifier-key (Q2), sticky footer not always-visible (Q3), drop-out de-selects on filter change (Q4), fallback to filtered set when selection empty preserved by leaving the existing top-bar broadcast button intact (Q5), wipe on navigation since the Set is module-scoped in-memory only (Q6).

bun run build clean (2543 i18n keys, data-i18n scan clean). go build + go test -short ./internal/... unchanged (no backend touched).
2026-05-20 14:46:52 +02:00
mAi
2ed0ef3177 feat(team-admin): t-paliad-223 Slice A — Project Admin role + inheritable role-edit gate
#48 — adds 'admin' as fifth project_teams.responsibility value, plumbs an
inheritable role-edit gate via the materialised ltree path.

- migration 110: ALTER responsibility CHECK, CREATE paliad.effective_project_admin(uuid,uuid) STABLE SECURITY DEFINER (mirrors can_see_project shape), REPLACE project_teams_update / _insert / _delete RLS policies. Idempotent + down-mig provided. Dry-run BEGIN..ROLLBACK clean on live supabase.
- services/approval_levels.go: ResponsibilityAdmin const + IsValidResponsibility extension. responsibilityOpensGate UNCHANGED — admin is orthogonal to the 4-Augen approval gate.
- services/team_service.go: ChangeResponsibility() with last-admin guard inside tx (counts admins on project + ancestor chain, excludes the row being changed). RemoveMember() also runs the guard when removing an admin row. New IsEffectiveProjectAdmin() driving the frontend affordance. legacyRoleFromResponsibility: admin → 'lead' (deprecated shadow column).
- services/project_service.go: ErrLastProjectAdmin sentinel mapped to 409 in writeServiceError.
- handlers/teams.go: new PATCH /api/projects/{id}/team/{user_id}. RLS-enforced; non-admins get 404 to avoid existence leakage.
- handlers/projects.go: GET /api/projects/{id} now wraps the payload with effective_admin bool so the frontend drives the inline-select affordance without a second round-trip.
- frontend/src/projects-detail.tsx + client/projects-detail.ts: admin appears as 5th option in 'Mitglied hinzufügen' dropdown. Team-list Rolle cell switches to an inline <select> for callers with effective_admin (read-only span otherwise). Optimistic PATCH with rollback on error (last-admin guard / 403 from RLS / etc.) surfaced as transient toast in #team-msg.
- i18n: +6 keys (admin label + admin.hint + 3 error toasts × 2 langs).
- tests: TestIsValidResponsibility now covers admin; new TestLegacyRoleFromResponsibility pins the mapping table.

go build && go test -short ./internal/... && bun run build all clean.
2026-05-20 14:46:36 +02:00
mAi
a5ae2148fa Merge: small UX polish batch — m/paliad#51, #52, #54, #56, #60
5 small cleanups bundled in one batch per m's 'group sensibly' guidance.

- #51 (projects): 'other' added as real project type via CHECK extension;
  synthetic 'Empty' option dropped from the type filter. No NULL-typed rows
  in prod today; backfill is a no-op.
- #52 (approvals): density-picker (Compact/Comfortable) active state uses
  brand accent #c6f41c. Sourced from a CSS variable so future surfaces
  inherit.
- #54 (events): broken 'From Today' appointment filter dropped (frontend
  was sending status=upcoming with no matching backend case). Default for
  appointments is now today's bucket; 'Alle (auch vergangene)' stays as
  the explicit opt-in.
- #56 (deadlines): event type renders before rule; the two are bundled as
  a single 'Verfahrenshandlung' visual block on display (Event Type — Rule).
  Form-level keeps separate inputs but visually grouped.
- #60 (a11y): label htmlFor=trigger-event dropped — the target was a
  <span>, which isn't labelable; the warning surfaced in Chrome Issues tab.
2026-05-20 14:44:01 +02:00
mAi
5a0674a2cf fix(a11y): drop label htmlFor=trigger-event — span isn't labelable
m/paliad#60 (t-paliad-221) — Chrome's Issues tab flagged a label/for
violation on the timeline wizard: <label for="trigger-event"> pointed
at a <span> showing the selected trigger event name. <label for=…>
must target a labelable form control (input/select/textarea/…), never
a span; the browser strips the association and a11y tooling sees a
dangling reference.

Audit found two occurrences — verfahrensablauf.tsx and fristenrechner.tsx
both use the same wizard markup. Switch both captions to plain
<span class="date-label">; the .date-label rule already targets by
class only, so visual styling is unchanged. No other label-for
mismatches surfaced (194 label-fors scanned across frontend/src).
2026-05-20 14:43:42 +02:00
mAi
13bb01ec96 fix(deadlines): event type renders before rule; bundle as Verfahrenshandlung
m/paliad#56 (t-paliad-221) — the deadlines editor read Title → Rule →
Event Type, which inverted the conceptual hierarchy (rule is the
citation under an event type, not its peer). Reorder all three
surfaces so the event-type parent comes first and the rule sits
directly beneath it.

- deadlines-new.tsx: pull the Regel select out of the Due-date row and
  drop it directly under the Typ picker; Due becomes its own row below.
- deadlines-detail.tsx: swap the Typ and Regel <dt>/<dd> rows in the
  detail list.
- approval-edit-modal.ts: remove rule_code from the generic
  DEADLINE_FIELDS list and render it inside a new
  "Verfahrenshandlung (Typ + Regel)" section beneath the event-type
  picker. The shared per-field renderer is extracted so the bundled
  section reuses the same dirty-tracking / pre_image-hint wiring.
- New i18n key approvals.suggest.section.event_type_rule (DE/EN).

Form-level inputs stay independent (some rules attach to multiple
event types and vice versa) — the change is purely about visual
grouping and reading order.
2026-05-20 14:43:42 +02:00
mAi
072b3d0c3d fix(events): drop broken 'From Today' appointment filter; default to today
m/paliad#54 (t-paliad-221) — fix 92780cf added a status=upcoming option
for appointments and made it the default, but DeadlineFilterUpcoming
only narrowed deadlines. The appointment query had no matching case, so
the bucket fell through to the unfiltered path and past events leaked
into "Ab heute" / "From today".

- Drop the 'upcoming' option from STATUS_OPTIONS_APPOINTMENT — confusing
  label that never delivered.
- Default appointments to the 'today' bucket (matches the dashboard
  tile; sane lawyer-relevant view).
- Keep 'Alle (auch vergangene)' as the explicit opt-in at the bottom
  of the list.
- Defensive backend fix: map DeadlineFilterUpcoming to start_at >= today
  in bucketAppointmentWindow so any persisted ?status=upcoming bookmarks
  stop leaking past events.
2026-05-20 14:43:42 +02:00
mAi
e39c4eb62d fix(approvals): density-picker active state uses brand accent
m/paliad#52 (t-paliad-221) — the Compact/Comfortable segmented control
on /approvals was rendering its active pill in plain --color-surface
(white in light mode, midnight-tinted in dark). Switch to the brand
lime so the segmented controls speak the same primary-action language
as the rest of Paliad.

Introduces three semantic tokens (--color-segment-active-bg / fg /
border) so any future surface that adopts .filter-bar-segment
inherits the same accent treatment without a CSS rewrite. The tokens
resolve to --color-accent / --color-accent-dark in both themes,
keeping the midnight foreground WCAG-AA on lime.
2026-05-20 14:43:42 +02:00
mAi
dc5f11ddef feat(projects): add 'other' as a real type; drop synthetic Empty filter
m/paliad#51 (t-paliad-221) — the type chip filter on /projects used to
treat unclassified projects as a synthetic "Empty" bucket. Make 'other'
a first-class projects.type value so every row carries a meaningful
label and the filter UI stops needing a NULL/Empty shim.

- mig 110: extend projects.type CHECK to include 'other'; backfill any
  NULL rows defensively (production query confirmed zero, but the
  NOT NULL constraint isn't load-bearing once the IN-list changes).
- Go: add ProjectTypeOther constant; isValidProjectType + humanProjectType
  recognise it; handler doc lists 'other' in the ?type whitelist.
- Frontend: new chip in the projects.tsx type filter, new option in the
  Create-Project form, DE "Sonstiges" / EN "Other" labels for the
  projects.type and projects.chip.type i18n families.

Also drops a stray data-i18n-text attribute on the existing 'project'
chip checkbox (it had no consumer in i18n.ts and the surrounding markup
was nesting a <span> inside an <input>).
2026-05-20 14:43:42 +02:00
mAi
e343b759da Merge: m/paliad#57 — Fristenrechner cleanup (Custom labels + forward-workflow + same-context-twice + Add prefill)
m's 4-part feedback bundled in one PR:

1. Pre-selected project carries through 'Add' on both Pathway A (Save modal) and
   Pathway B (card-calc Add).
2. 'Custom' prefix stripped from all four adhoc proceeding-type chips (DE + EN).
3. 'Ich möchte etwas einreichen' option removed from 'Was ist passiert?' picker
   via HIDDEN_CASCADE_ROOTS; future forward-workflow tool tracked in m/paliad#65.
4. Same-context-asked-twice on Statement-of-Defence picker: pill-click now locks
   context inline (no duplicate 'Which context?' picker on top of the info list).
2026-05-20 14:42:27 +02:00
mAi
7288cf3c9c fix(fristenrechner): m/paliad#57 — cleanup (Custom labels, forward-workflow root, same-context-twice, Add prefill)
Four UX cleanups on /tools/fristenrechner per m's 2026-05-20 14:02–14:04
report:

1. **Pre-fill project on 'Add'** — when Step 1 binds an Akte, both the
   Pathway A "Save to Project" modal and the Pathway B card-calc inline
   'Add' picker now default their <select> to that project. Override
   still allowed; the picker lists all projects. New helper
   `preselectedProjectId()` reads `currentStep1Context` once so both
   surfaces stay in sync.

2. **Drop 'Custom' prefix from UPC/DE/EPA/DPMA adhoc chips** — the
   chip context already reads "oder ad-hoc, ohne Akte"; 'Custom' was
   redundant signaling. Labels become "UPC-Verfahren" /
   "UPC proceeding" (and the three sister jurisdictions).

3. **Remove 'Ich möchte etwas einreichen' from 'Was ist passiert?'** —
   the Fristenrechner is a backward-looking calc ("event happened, what
   spawns?"); the forward-workflow framing ("I want to file X") needs a
   different tool. Filter the `ich-moechte-einreichen` root subtree out
   in `loadEventCategoryTree()` (HIDDEN_CASCADE_ROOTS set) so the picker
   never offers it. DB rows preserved for the future forward-workflow
   tool, tracked in m/paliad#65.

4. **Same-context-asked-twice on Statement-of-Defence picker** —
   when the user clicks a specific rule pill on a concept card, the
   calc panel now renders a locked "Kontext: <proceeding — rule>"
   caption with an "ändern" affordance instead of re-showing the same
   five proceedings as a radio fieldset. When the user clicks the card
   body (no specific pill), the picker is still the primary surface, but
   the card's rule-pill section hides via CSS while expanded
   (`fristen-card-pills-section--rules`) so the same options aren't
   listed twice. Cross-cutting trigger pills (Wiedereinsetzung,
   Weiterbehandlung etc.) stay visible — they're conceptually
   different siblings, not the same proceeding context.
2026-05-20 14:42:14 +02:00
mAi
7f9e2ce7ed Merge: m/paliad#59 — restore click-to-edit on Procedure Roadmap timeline dates
m's priority bug 2026-05-20: 'we cannot change the dates in that anymore!
the timeline dates — they seem to be fix, nothing happens when I click on a date.'

Regression introduced when the verfahrensablauf-core renderer was extracted
as the shared source of truth for both /tools/verfahrensablauf and
/tools/fristenrechner — the delegated click handler that opens the inline
date-edit modal was wired on the Fristenrechner side but never re-attached
on the Verfahrensablauf side. Anchor overrides + editable:true flag were
not threading through.

Fix: thread anchorOverrides + editable:true through CardOpts into the
shared renderer; wire the delegated click handler on
/tools/verfahrensablauf; pin the editable → data-rule-code contract with
5 regression tests so this can't re-break silently.
2026-05-20 14:31:19 +02:00
mAi
bbb8c962a1 fix(verfahrensablauf): m/paliad#59 — restore click-to-edit on timeline dates
Per-rule due dates on /tools/verfahrensablauf were rendered as plain
spans with no `frist-date-edit` attrs and no delegated click handler,
so clicking a date did nothing (m's "the timeline dates seem to be fix,
nothing happens when I click on a date"). The wiring existed on
/tools/fristenrechner but had never been mirrored onto the abstract-
browse surface introduced in t-paliad-179.

Fix: lift the inline date editor + delegated click wiring out of
fristenrechner.ts into views/verfahrensablauf-core.ts so both pages
share one implementation:

  - openInlineDateEditor(span, onCommit) — swaps the date span for
    a `<input type=date>`, commits on blur/Enter, cancels on Escape,
    fires `onCommit(ruleCode, newValue)` ("" = revert).
  - wireDateEditClicks(container, onCommit) — idempotent delegated
    click + keyboard handler that resolves `.frist-date-edit
    [data-rule-code]` and opens the editor. Survives innerHTML
    rewrites because the listener lives on the container.

verfahrensablauf.ts now:
  - Owns its own anchorOverrides Map (cleared when proceeding-type
    changes — overrides for one proceeding don't apply to another).
  - Forwards overrides in calculateDeadlines() so downstream rules
    re-anchor on the user's date.
  - Passes `editable: true` to renderColumnsBody + renderTimelineBody.
  - Calls wireDateEditClicks() once on #timeline-container in
    DOMContentLoaded.

fristenrechner.ts shrinks: openInlineDateEditor + the inline click /
keydown blocks are replaced by an `onDateEditCommit` callback handed
to the shared wireDateEditClicks(). No behaviour change there.

Regression test: views/verfahrensablauf-core.test.ts pins the
editable→`data-rule-code` contract on `deadlineCardHtml` so a future
refactor that drops the attrs fails loudly instead of silently
breaking click-to-edit on both pages.
2026-05-20 14:31:06 +02:00
496 changed files with 105691 additions and 11910 deletions

242
.gitea/workflows/test.yaml Normal file
View File

@@ -0,0 +1,242 @@
# Paliad CI gate (t-paliad-282 / m/paliad#114).
#
# Single workflow, two purposes:
#
# - On every push: gate tier — build + unit + migration smoke. Red gate
# means no further work and (on main) no deploy.
# - On push to main with gate green: deploy step — calls the Dokploy
# compose-deploy API for paliad's compose Zx147ycurfYagKRl_Zzyo, then
# polls /health/ready until the new container reports 200.
#
# The deploy step REPLACES the previous Gitea-push → Dokploy webhook path
# (per m's Q11.4 pick: soft-launch with both alive for ~1 week, then
# disable the Dokploy auto-deploy toggle). Soft-launch leaves Dokploy's
# autoDeploy=true intact today — the workflow's deploy step is additive
# and idempotent (Dokploy's deploy is itself idempotent).
#
# Catches the three failure classes from 2026-05-25:
#
# - brunel slot collision (~13:20) — TestMigrations_NoDuplicateSlot,
# pure unit, no DB needed.
# - hermes dropped-col refs (~16:05) — TestBootSmoke, applies all NEW
# migrations (those not in the snapshot) end-to-end against a
# scratch DB restored from internal/db/testdata/prod-snapshot.sql.
# - mig 129 42501 ownership (~14:56→) — TestMigrations_EndToEndAsAppRole,
# applies new migrations as the prod-shaped `postgres` role (which
# is NOT a superuser on supabase/postgres — same shape as
# youpc-supabase prod, see internal/db/testdata/README.md).
#
# Snapshot approach: dump paliad schema + applied_migrations rows from
# prod, commit them. CI restores → ApplyMigrations sees existing migs as
# applied, only runs NEW migs (the ones this PR adds). This sidesteps the
# fresh-DB idempotence requirement on historical migrations (some of
# which use raw COMMIT or pre-installed extensions and can't be replayed
# from scratch). To refresh: `make refresh-snapshot`.
#
# Design: docs/design-cicd-pre-deploy-gate-2026-05-25.md (cronus inventor
# shift, t-paliad-282).
name: Paliad CI gate
on:
push:
branches:
- main
- 'mai/**'
pull_request:
branches: [main]
env:
GO_VERSION: '1.24'
BUN_VERSION: '1.2'
jobs:
# Gate job 1 — pure build. Catches go/bun build breakage that local
# `go build` would catch but which a worker might have skipped before
# pushing. Fast (~60 s) so a red here surfaces immediately.
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: go build
run: go build ./...
- name: go vet
run: go vet ./...
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: bun install + build
working-directory: frontend
run: |
bun install --frozen-lockfile
bun run build
# Gate job 2 — Go test suite + migration smoke against snapshot-restored
# scratch DB.
#
# The Postgres service container uses the same supabase/postgres image
# as youpc-supabase prod. The CI scratch DB starts empty; a setup step
# installs pg_trgm + restores the snapshot. After restore, paliad
# schema is at HEAD-of-snapshot and applied_migrations covers every
# migration up to (and including) the snapshot's max version.
#
# ApplyMigrations called in TestBootSmoke / TestMigrations_EndToEndAsAppRole
# sees the snapshot's applied set, finds whatever NEW migrations this
# PR added on top, and applies only those. The role-split smoke runs as
# `postgres` (which is NOT a superuser on supabase/postgres, matching
# the prod role topology) — any new migration that needs supabase_admin
# privilege fails here as it would in prod.
test-go:
runs-on: ubuntu-latest
services:
# supabase/postgres baked-in auth schema + supabase role topology
# matches youpc-supabase prod. `postgres` here is NOT a superuser
# (verified live: \du postgres shows "Create role, Create DB,
# Replication, Bypass RLS" — no Superuser). This is the prod-shaped
# role the deploy uses.
postgres:
image: supabase/postgres:15.8.1.060
env:
POSTGRES_PASSWORD: ci
POSTGRES_DB: paliad_scratch
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 30
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Install postgresql-client
run: |
apt-get update -qq && apt-get install -y -qq postgresql-client
# Snapshot restore. Two prep steps as supabase_admin (the actual
# superuser): GRANT CREATE so the `postgres` role can later create
# schemas if a new mig needs it; install pg_trgm so the snapshot's
# trigram indexes restore. Snapshot itself loads as `postgres`.
- name: Provision + restore snapshot
env:
PGPASSWORD: ci
run: |
set -euo pipefail
psql -h localhost -U supabase_admin -d paliad_scratch -v ON_ERROR_STOP=1 \
-c "GRANT CREATE ON DATABASE paliad_scratch TO postgres;" \
-c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
psql -h localhost -U postgres -d paliad_scratch -v ON_ERROR_STOP=1 \
-f internal/db/testdata/prod-snapshot.sql
# Pre-flight: catches brunel slot collision in seconds, no DB
# contact (still useful even though the test-go job has Postgres
# running, because the failure mode is independent).
- name: Migration coordination check
run: go test -count=1 -run TestMigrations_NoDuplicateSlot ./internal/db/
# Role-split end-to-end apply. Connects as `postgres` (NOT a
# superuser on supabase/postgres) and runs ApplyMigrations against
# the snapshot-restored DB. Existing migs are skipped (already in
# applied_migrations); NEW migs in this PR apply here. If a new
# migration assumes supabase_admin privilege, fails with the same
# 42501 error class that took paliad.de offline on 2026-05-25.
- name: Migration end-to-end (deploy role)
env:
TEST_APP_DATABASE_URL: postgres://postgres:ci@localhost:5432/paliad_scratch?sslmode=disable
run: go test -count=1 -run TestMigrations_EndToEndAsAppRole ./internal/db/
# Boot smoke. Confirms ApplyMigrations succeeds + applied set
# matches on-disk set + /healthz returns 200 + /health/ready
# returns 200 (the live-pool variant via TestHealthReady_Live).
- name: Boot smoke + readiness
env:
TEST_DATABASE_URL: postgres://postgres:ci@localhost:5432/paliad_scratch?sslmode=disable
run: go test -count=1 -run 'TestBootSmoke|TestHealthReady_Live' ./cmd/server/
# Full Go test suite WITHOUT TEST_DATABASE_URL so live-DB service
# tests skip (same shape as a developer laptop without a scratch
# DB). Live-DB tests in internal/services/* will be activated by a
# follow-up shift once the snapshot is verified stable across
# multiple PRs — they need investigation against supabase/postgres
# 15.8 (parameter type inference differs subtly from youpc-supabase).
- name: go test ./... (pure + skip-on-no-DB)
run: go test -count=1 ./internal/... ./cmd/...
# Deploy step. Only runs on push to main and only after both gate jobs
# are green. Calls Dokploy's compose.deploy with the paliad compose ID
# (Zx147ycurfYagKRl_Zzyo) and polls /health/ready until it returns 200
# or times out.
#
# Skipped on PR / feature branch pushes — those run the gate tier as
# a status check but don't trigger a prod deploy. Dokploy's existing
# autoDeploy=true webhook continues to fire during the soft-launch
# window (per Q11.4); it can be disabled in the Dokploy UI once this
# workflow has gated ≥5 successful green deploys.
deploy:
runs-on: ubuntu-latest
needs: [build, test-go]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Trigger Dokploy compose deploy
env:
DOKPLOY_KEY: ${{ secrets.DOKPLOY_TOKEN }}
DOKPLOY_API: http://100.99.98.201:3000/api/trpc
COMPOSE_ID: Zx147ycurfYagKRl_Zzyo
run: |
set -euo pipefail
if [ -z "${DOKPLOY_KEY:-}" ]; then
echo "ERROR: DOKPLOY_TOKEN secret is not configured."
echo " Set the secret in Gitea repo settings before this step can deploy."
exit 2
fi
echo "==> POST compose.deploy"
curl -sS --connect-timeout 5 --max-time 30 \
-X POST \
-H "x-api-key: $DOKPLOY_KEY" \
-H "Content-Type: application/json" \
-d "{\"json\":{\"composeId\":\"$COMPOSE_ID\"}}" \
"$DOKPLOY_API/compose.deploy"
echo
- name: Wait for /health/ready
run: |
set -euo pipefail
echo "==> polling https://paliad.de/health/ready"
# Up to 5 minutes (60 × 5 s) — paliad's cold-start is normally
# ≤30 s; the longer budget covers slow image pulls + migration
# apply.
for i in $(seq 1 60); do
status=$(curl -sS --connect-timeout 3 --max-time 5 \
-o /dev/null -w '%{http_code}' \
https://paliad.de/health/ready || echo "000")
if [ "$status" = "200" ]; then
echo "ready after ${i} poll(s)"
exit 0
fi
echo " [$i/60] status=$status — sleeping 5s"
sleep 5
done
echo "ERROR: /health/ready did not return 200 within 5 minutes."
echo " The deploy fired but the new container is not serving."
echo " Investigate: ssh mlake 'docker logs --tail 50 compose-transmit-multi-byte-driver-v7jth9-web-1'"
exit 1

View File

@@ -1,6 +1,3 @@
# Project-specific mai configuration
# Auto-generated by 'mai init' — run 'mai setup' to customize
provider: claude
providers:
claude:
@@ -47,21 +44,13 @@ worker:
name_scheme: role
default_level: standard
auto_discard: false
max_workers: 5
max_workers: 7
persistent: true
head:
name: "paliadin"
max_loops: 50
infinity_mode: false
max_idle_duration: 2h0m0s
backoff_intervals:
- 5
- 10
- 15
- 30
name: paliadin
capacity:
global:
max_workers: 5
max_workers: 7
max_heads: 3
per_worker:
max_tasks_lifetime: 0

View File

@@ -21,18 +21,26 @@
# the test runner's working dirs. None of them touch internal/db/migrations/
# files.
.PHONY: help verify-migrations verify-mig test test-go
.PHONY: help verify-migrations verify-mig verify-mig-app test test-go test-frontend refresh-snapshot snapshot-upc
help:
@echo "Paliad — developer targets"
@echo ""
@echo " verify-migrations Dry-run pending migrations + boot smoke (needs TEST_DATABASE_URL)"
@echo " verify-mig Alias for verify-migrations"
@echo " verify-mig-app End-to-end migration smoke as non-superuser role"
@echo " (needs TEST_APP_DATABASE_URL — t-paliad-282 / m/paliad#114)"
@echo " test Short test pass — covers gate tier"
@echo " test-go Full Go suite with race detector"
@echo " test-frontend Frontend bun:test suite"
@echo " snapshot-upc Regenerate pkg/litigationplanner/embedded/upc/ from live DB"
@echo " (needs DATABASE_URL — see cmd/gen-upc-snapshot/README.md)"
@echo ""
@echo "Set TEST_DATABASE_URL to enable live-DB tests. Example:"
@echo " export TEST_DATABASE_URL=postgres://paliad:...@localhost:11833/paliad_test"
@echo ""
@echo "Set TEST_APP_DATABASE_URL to enable the role-split smoke. Example:"
@echo " export TEST_APP_DATABASE_URL=postgres://paliad_app:...@localhost:5432/paliad_scratch"
# Gate target — the test that would have caught mig 098 / mig 099 before
# deploy. Combines:
@@ -71,3 +79,86 @@ test:
# (full suite, not per-PR).
test-go:
go test -race ./...
# Frontend bun:test suite. Runs the 4 existing pure-TS tests today; will
# grow as mendel's Slice 3 (frontend test infill) lands.
test-frontend:
cd frontend && bun test
# Role-split end-to-end migration smoke — the catch for the mig 129 42501
# ownership class (m/paliad#114). Runs ApplyMigrations as a non-superuser
# role against TEST_APP_DATABASE_URL. Fails the build if any migration
# assumes more privilege than the deploy role has.
#
# Developer setup (local):
# psql -c "CREATE ROLE paliad_app LOGIN PASSWORD 'ci' NOSUPERUSER;"
# psql -c "CREATE DATABASE paliad_scratch OWNER paliad_app;"
# export TEST_APP_DATABASE_URL=postgres://paliad_app:ci@localhost:5432/paliad_scratch
verify-mig-app:
@if [ -z "$$TEST_APP_DATABASE_URL" ]; then \
echo "ERROR: TEST_APP_DATABASE_URL is not set."; \
echo " The role-split migration smoke cannot run without a non-superuser scratch DB."; \
echo " See Makefile comments above this target for setup."; \
exit 2; \
fi
go test -count=1 -run TestMigrations_EndToEndAsAppRole ./internal/db/
# Refresh the prod schema snapshot used by CI's migration smoke
# (t-paliad-282 / m/paliad#114). Connects to youpc-supabase prod, dumps
# the paliad schema + applied_migrations rows, strips rows beyond the
# current branch's max on-disk version, and writes
# internal/db/testdata/prod-snapshot.sql.
#
# When to refresh:
# - After merging a PR that added a new migration to main.
# - When CI's migration smoke starts spuriously failing because the
# snapshot's applied set diverges from on-disk by more than this
# branch's worth of new migs.
#
# Requires PALIAD_PROD_DATABASE_URL env var (a Postgres URL with
# pg_dump rights on youpc-supabase). Example:
# export PALIAD_PROD_DATABASE_URL='postgres://postgres:PW@100.99.98.201:11833/postgres'
refresh-snapshot:
@if [ -z "$$PALIAD_PROD_DATABASE_URL" ]; then \
echo "ERROR: PALIAD_PROD_DATABASE_URL is not set."; \
echo " Refresh requires read access to youpc-supabase prod."; \
exit 2; \
fi
@echo "==> dumping paliad schema (no owner, no privs)..."
@pg_dump --schema-only --schema=paliad --no-owner --no-privileges \
--no-publications --no-subscriptions \
"$$PALIAD_PROD_DATABASE_URL" > internal/db/testdata/prod-snapshot.sql.tmp
@echo "==> appending applied_migrations rows..."
@pg_dump --data-only --table=paliad.applied_migrations \
--no-owner --no-privileges \
"$$PALIAD_PROD_DATABASE_URL" >> internal/db/testdata/prod-snapshot.sql.tmp
@echo "==> stripping pg16 \\restrict / \\unrestrict commands for pg15 compat..."
@sed -i.bak '/^\\restrict /d; /^\\unrestrict /d' internal/db/testdata/prod-snapshot.sql.tmp
@rm -f internal/db/testdata/prod-snapshot.sql.tmp.bak
@echo "==> stripping applied_migrations rows beyond branch's max on-disk version..."
@MAX_VER=$$(ls internal/db/migrations/*.up.sql | xargs -I{} basename {} | sed 's/_.*//' | sort -n | tail -1); \
awk -v max=$$MAX_VER ' \
/^[0-9]+\t/ { split($$0, a, "\t"); if (a[1]+0 > max) next; } \
{ print } \
' internal/db/testdata/prod-snapshot.sql.tmp > internal/db/testdata/prod-snapshot.sql
@rm internal/db/testdata/prod-snapshot.sql.tmp
@wc -l internal/db/testdata/prod-snapshot.sql
# Regenerate the embedded UPC snapshot from a live paliad DB. The
# generator applies pending migrations first, then SELECTs the UPC
# subset and writes JSON files under pkg/litigationplanner/embedded/upc/.
#
# Requires DATABASE_URL — Slice C of the litigation-planner extraction
# (m/paliad#124 §19). See cmd/gen-upc-snapshot/README.md for the full
# operator runbook.
snapshot-upc:
@if [ -z "$$DATABASE_URL" ]; then \
echo "ERROR: DATABASE_URL is not set."; \
echo " Snapshot generation needs read access to a paliad DB."; \
echo " Set DATABASE_URL to the live paliad Postgres, then re-run."; \
exit 2; \
fi
@echo "==> regenerating UPC snapshot from $$DATABASE_URL"
go run ./cmd/gen-upc-snapshot
@echo "==> running snapshot tests against the regenerated data"
go test ./pkg/litigationplanner/embedded/upc/...

View File

@@ -0,0 +1,59 @@
# gen-upc-snapshot
Regenerates the embedded UPC snapshot consumed by
`pkg/litigationplanner/embedded/upc`. Slice C of the litigation-planner
extraction (m/paliad#124 §19). See
`docs/design-litigation-planner-2026-05-26.md` §19 for the full design.
## When to regenerate
After any change that affects the public UPC rule corpus:
- new rules merged via the admin rule-editor
- a deadline-rule migration that touches UPC rows
- a `paliad.holidays` update (new public holidays / vacation runs)
- a `paliad.courts` update (new UPC LD opens, etc.)
- a `paliad.proceeding_types` change for `jurisdiction = 'UPC'`
The snapshot is operator-controlled — there is no CI regeneration in v1.
## How to regenerate
```sh
make snapshot-upc
```
or directly:
```sh
DATABASE_URL=postgres://... go run ./cmd/gen-upc-snapshot
```
Flags:
| Flag | Default | Purpose |
|-----------------|----------------------------------------|---------|
| `-output` | `./pkg/litigationplanner/embedded/upc` | directory to write JSON files into |
| `-version` | auto-derived (`YYYY-MM-DD-N`) | override the snapshot version |
| `-source-label` | empty | text label written to `meta.json` (`paliad-prod`, `paliad-dev`, …) |
The generator:
1. Applies pending migrations against `DATABASE_URL` (snapshot always matches schema HEAD).
2. SELECTs UPC active proceeding_types + their published+active rules + referenced trigger_events + DE/UPC holidays + UPC courts.
3. Writes pretty-printed JSON to `<output>/{proceeding_types,rules,trigger_events,holidays,courts,meta}.json`.
## Idempotence
Running twice with the same DB state produces the same JSON (modulo `meta.generated_at`). Diff-friendly in git.
## Versioning
`meta.json.version` uses `YYYY-MM-DD-N` where N starts at 1 and increments on same-day regenerations. The generator reads the existing `meta.json` and bumps automatically.
## After regeneration
1. Review the diff: `git diff pkg/litigationplanner/embedded/upc/`.
2. Run tests: `go test ./pkg/litigationplanner/embedded/upc/...`.
3. Commit with a message like `chore(snapshot): regenerate UPC snapshot (<reason>)`.
4. Notify any downstream consumer (youpc.org) that a new paliad release is available.

View File

@@ -0,0 +1,307 @@
// Command gen-upc-snapshot reads paliad's live deadline corpus and
// writes the UPC subset as JSON files under
// pkg/litigationplanner/embedded/upc/. The package's embedded
// catalog/holiday/court implementations then serve this data without
// any DB roundtrip — letting youpc.org (or any future consumer) run
// the litigationplanner engine against the canonical UPC rule set.
//
// Slice C (m/paliad#124 §19). See docs/design-litigation-planner-2026-05-26.md
// §19 for the full design.
//
// Usage:
//
// DATABASE_URL=postgres://... go run ./cmd/gen-upc-snapshot \
// [-output ./pkg/litigationplanner/embedded/upc] \
// [-version 2026-05-26-1] \
// [-source-label paliad-dev-supabase]
//
// The generator applies migrations against DATABASE_URL before
// SELECTing (so the snapshot always matches schema HEAD). Idempotent —
// running twice with the same DB state produces the same JSON.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
const (
defaultOutput = "./pkg/litigationplanner/embedded/upc"
defaultSourceLabel = ""
)
// Meta is the version block written to meta.json. The embedded sub-
// package re-defines this type so consumers can decode it without
// importing the cmd; the cmd holds the canonical write shape.
type Meta struct {
Version string `json:"version"`
GeneratedAt time.Time `json:"generated_at"`
PaliadCommit string `json:"paliad_commit,omitempty"`
SourceDBLabel string `json:"source_db_label,omitempty"`
RuleCount int `json:"rule_count"`
ProceedingCount int `json:"proceeding_count"`
TriggerEventCount int `json:"trigger_event_count"`
HolidayCount int `json:"holiday_count"`
CourtCount int `json:"court_count"`
}
// EmbeddedHoliday is the holiday row shape the embedded snapshot
// stores. JSON tags mirror paliad.holidays so the generator's SELECT
// scans onto it directly + the embedded HolidayCalendar reads the
// same tag.
type EmbeddedHoliday struct {
Date string `db:"date_iso" json:"date"`
Name string `db:"name" json:"name"`
Country *string `db:"country" json:"country,omitempty"`
Regime *string `db:"regime" json:"regime,omitempty"`
State *string `db:"state" json:"state,omitempty"`
HolidayType string `db:"holiday_type" json:"holiday_type"`
}
// EmbeddedCourt is the court row shape the embedded snapshot stores.
type EmbeddedCourt struct {
ID string `db:"id" json:"id"`
Code string `db:"code" json:"code"`
NameDE string `db:"name_de" json:"name_de"`
NameEN string `db:"name_en" json:"name_en"`
Country string `db:"country" json:"country"`
Regime *string `db:"regime" json:"regime,omitempty"`
CourtType string `db:"court_type" json:"court_type"`
ParentID *string `db:"parent_id" json:"parent_id,omitempty"`
SortOrder int `db:"sort_order" json:"sort_order"`
}
func main() {
output := flag.String("output", defaultOutput, "directory to write JSON files into")
version := flag.String("version", "", "explicit snapshot version (auto-derived if empty)")
sourceLabel := flag.String("source-label", defaultSourceLabel, "label for source_db in meta.json")
flag.Parse()
url := os.Getenv("DATABASE_URL")
if url == "" {
log.Fatal("DATABASE_URL must be set")
}
if err := db.ApplyMigrations(url); err != nil {
log.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
log.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
if err := run(ctx, pool, *output, *version, *sourceLabel); err != nil {
log.Fatalf("snapshot: %v", err)
}
}
func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string) error {
if err := os.MkdirAll(output, 0o755); err != nil {
return fmt.Errorf("mkdir output: %w", err)
}
// 1. Proceeding types — UPC primaries only. The unified upc.apl row
// from B1 mig 134 is included; the 3 archived old appeal codes
// (is_active=false) are filtered out by the is_active predicate.
// The kind='proceeding' predicate (mig 153, t-paliad-325) belts the
// is_active filter so phase/side_action/meta rows can't slip into
// the embedded catalog even if some future deploy re-activates one
// for an admin task.
var procs []litigationplanner.ProceedingType
if err := pool.SelectContext(ctx, &procs, `
SELECT id, code, name, name_en, description, jurisdiction,
category, default_color, sort_order, is_active,
trigger_event_label_de, trigger_event_label_en,
appeal_target
FROM paliad.proceeding_types
WHERE jurisdiction = 'UPC'
AND is_active = true
AND kind = 'proceeding'
ORDER BY sort_order, id`); err != nil {
return fmt.Errorf("select proceeding_types: %w", err)
}
if len(procs) == 0 {
return fmt.Errorf("no active UPC proceeding_types — refusing to write empty snapshot")
}
procIDs := make([]int, 0, len(procs))
for _, p := range procs {
procIDs = append(procIDs, p.ID)
}
// 2. Deadline rules — published + active rules for those proceedings.
const ruleCols = `id, proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type, duration_value,
duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
alt_duration_value, alt_duration_unit, alt_rule_code,
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_active,
created_at, updated_at,
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
priority, is_court_set, lifecycle_state, draft_of, published_at,
choices_offered, applies_to_target`
q, args, err := sqlx.In(`
SELECT `+ruleCols+`
FROM paliad.deadline_rules
WHERE proceeding_type_id IN (?)
AND is_active = true
AND lifecycle_state = 'published'
ORDER BY proceeding_type_id, sequence_order`, procIDs)
if err != nil {
return fmt.Errorf("build rules IN: %w", err)
}
q = pool.Rebind(q)
var rules []litigationplanner.Rule
if err := pool.SelectContext(ctx, &rules, q, args...); err != nil {
return fmt.Errorf("select rules: %w", err)
}
// 3. Trigger events referenced by any UPC rule's trigger_event_id.
triggerIDSet := make(map[int64]struct{})
for _, r := range rules {
if r.TriggerEventID != nil {
triggerIDSet[*r.TriggerEventID] = struct{}{}
}
}
var triggers []litigationplanner.TriggerEvent
if len(triggerIDSet) > 0 {
triggerIDs := make([]int64, 0, len(triggerIDSet))
for id := range triggerIDSet {
triggerIDs = append(triggerIDs, id)
}
q, args, err := sqlx.In(`
SELECT id, code, name, name_de, description, is_active, created_at
FROM paliad.trigger_events
WHERE id IN (?)
ORDER BY id`, triggerIDs)
if err != nil {
return fmt.Errorf("build triggers IN: %w", err)
}
q = pool.Rebind(q)
if err := pool.SelectContext(ctx, &triggers, q, args...); err != nil {
return fmt.Errorf("select trigger_events: %w", err)
}
}
// 4. Holidays — DE national + UPC regime entries. The embedded
// calendar serves UPC computations so both axes matter.
var holidays []EmbeddedHoliday
if err := pool.SelectContext(ctx, &holidays, `
SELECT to_char(date, 'YYYY-MM-DD') AS date_iso,
name, country, regime, state, holiday_type
FROM paliad.holidays
WHERE country = 'DE' OR regime = 'UPC'
ORDER BY date, name`); err != nil {
return fmt.Errorf("select holidays: %w", err)
}
// 5. Courts — UPC subset.
var courts []EmbeddedCourt
if err := pool.SelectContext(ctx, &courts, `
SELECT id, code, name_de, name_en, country, regime, court_type, parent_id, sort_order
FROM paliad.courts
WHERE is_active = true
AND (regime = 'UPC' OR court_type LIKE 'upc%')
ORDER BY sort_order, id`); err != nil {
return fmt.Errorf("select courts: %w", err)
}
// 6. Compose meta.
meta := Meta{
Version: resolveVersion(version, output),
GeneratedAt: time.Now().UTC().Truncate(time.Second),
PaliadCommit: gitCommitShort(),
SourceDBLabel: sourceLabel,
RuleCount: len(rules),
ProceedingCount: len(procs),
TriggerEventCount: len(triggers),
HolidayCount: len(holidays),
CourtCount: len(courts),
}
// 7. Write each file.
files := []struct {
name string
data any
}{
{"proceeding_types.json", procs},
{"rules.json", rules},
{"trigger_events.json", triggers},
{"holidays.json", holidays},
{"courts.json", courts},
{"meta.json", meta},
}
for _, f := range files {
path := filepath.Join(output, f.name)
buf, err := json.MarshalIndent(f.data, "", " ")
if err != nil {
return fmt.Errorf("marshal %s: %w", f.name, err)
}
buf = append(buf, '\n')
if err := os.WriteFile(path, buf, 0o644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
}
log.Printf("snapshot written: version=%s rules=%d proceedings=%d triggers=%d holidays=%d courts=%d → %s",
meta.Version, meta.RuleCount, meta.ProceedingCount,
meta.TriggerEventCount, meta.HolidayCount, meta.CourtCount, output)
return nil
}
// resolveVersion picks a date-stamped version slug, bumping the suffix
// past any pre-existing same-day version found in the existing
// meta.json. If the caller passed -version, that wins.
func resolveVersion(explicit, output string) string {
if explicit != "" {
return explicit
}
today := time.Now().UTC().Format("2006-01-02")
// Read prior meta to detect same-day collisions.
prior, err := os.ReadFile(filepath.Join(output, "meta.json"))
if err != nil {
return today + "-1"
}
var pm Meta
if err := json.Unmarshal(prior, &pm); err != nil {
return today + "-1"
}
if !strings.HasPrefix(pm.Version, today+"-") {
return today + "-1"
}
// Same day: bump the suffix.
suffix := pm.Version[len(today)+1:]
var n int
if _, err := fmt.Sscanf(suffix, "%d", &n); err != nil {
return today + "-1"
}
return fmt.Sprintf("%s-%d", today, n+1)
}
// gitCommitShort returns the short SHA of the paliad checkout. Best-
// effort — empty string when we're not in a git checkout.
func gitCommitShort() string {
out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}

View File

@@ -0,0 +1,342 @@
// Command seed-orphan-concept-drafts stages draft sequencing_rules for
// deadline_concepts that have rule_count=0 ("orphans"). It calls the
// same services.RuleEditorService.Create that POST
// /admin/api/procedural-events runs internally, so the audit trigger
// + INSTEAD-OF view trigger fan-out into procedural_events +
// sequencing_rules + legal_sources fire identically. No HTTP/auth
// shell, no direct SQL writes by this command.
//
// All rules are created with lifecycle_state='draft' (forced by the
// service). The admin still reviews + publishes via
// /admin/procedural-events.
//
// t-paliad-320: editorial backlog from t-paliad-193, four remaining
// orphan concepts: counterclaim-for-revocation, versaeumnisurteil-
// einspruch, schriftsatznachreichung, weiterbehandlung. The
// weiterbehandlung concept gets two drafts (EPC Art. 121 + R. 135
// versus DPatG § 123a) since the two regimes have different durations
// and jurisdictions.
//
// Usage:
//
// DATABASE_URL=postgres://… go run ./cmd/seed-orphan-concept-drafts \
// [-dry-run] [-reason "free-text audit reason"]
//
// Idempotency: the command refuses to insert if any rule for a given
// (concept, proceeding_type, rule_code) already exists. Safe to re-run
// after a partial failure.
package main
import (
"context"
"database/sql"
"errors"
"flag"
"fmt"
"log"
"os"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/services"
)
// draftSpec captures one CreateRuleInput plus the metadata the command
// needs to resolve concept_id + proceeding_type_id from human-readable
// slugs/codes. ProceedingCode == "" means event-rooted
// (proceeding_type_id = NULL), used for cross-cutting rules whose
// jurisdiction has no matching proceeding_type yet.
type draftSpec struct {
Label string // human label for log output
ConceptSlug string
ProceedingCode string // "" → NULL proceeding_type_id (event-rooted)
SubmissionCode string
Name string
NameEN string
EventKind string
PrimaryParty string // "" → omit (NULL)
DurationValue int
DurationUnit string
Timing string
Priority string
IsCourtSet bool
RuleCode string
LegalSource string
DeadlineNotes string
DeadlineNotesEn string
}
func drafts() []draftSpec {
return []draftSpec{
// ─── 1. counterclaim-for-revocation (UPC R.25.1 ∧ R.23) ───────
{
Label: "counterclaim-for-revocation → upc.ccr.cfi",
ConceptSlug: "counterclaim-for-revocation",
ProceedingCode: "upc.ccr.cfi",
SubmissionCode: "upc.ccr.cfi.lodge",
Name: "Widerklage auf Nichtigkeit (CCR)",
NameEN: "Counterclaim for Revocation (CCR)",
EventKind: "filing",
PrimaryParty: "defendant",
DurationValue: 3,
DurationUnit: "months",
Timing: "after",
Priority: "mandatory",
IsCourtSet: false,
RuleCode: "RoP.025",
LegalSource: "UPC.RoP.25.1",
DeadlineNotes: "Die Widerklage auf Nichtigkeit (Counterclaim for Revocation, CCR) ist gemeinsam mit der Klageerwiderung (Statement of Defence) einzureichen — d. h. innerhalb von 3 Monaten ab Zustellung der Klageschrift " +
"(R. 23 i. V. m. R. 25.1 RoP). Inhaltliche Anforderungen folgen R. 25-30 RoP (insbes. R. 25.1(a)-(c) zu Antrag, Tatsachen und Beweismitteln; R. 27 zu Verfahren nach Einreichung; R. 30 zu einem Antrag auf Änderung des Patents).",
DeadlineNotesEn: "The Counterclaim for Revocation (CCR) must be lodged together with the Statement of Defence — i.e. within 3 months of service of the Statement of Claim " +
"(Rule 23 in conjunction with Rule 25.1 RoP). Substantive requirements follow Rules 25-30 RoP (in particular R. 25.1(a)-(c) on the application, facts and evidence; R. 27 on post-filing procedure; R. 30 on any application to amend the patent).",
},
// ─── 2. versaeumnisurteil-einspruch (ZPO § 339) ───────────────
{
Label: "versaeumnisurteil-einspruch → de.inf.lg",
ConceptSlug: "versaeumnisurteil-einspruch",
ProceedingCode: "de.inf.lg",
SubmissionCode: "de.inf.lg.einspruch_vu",
Name: "Einspruch gegen Versäumnisurteil",
NameEN: "Objection to default judgment",
EventKind: "filing",
PrimaryParty: "defendant",
DurationValue: 2,
DurationUnit: "weeks",
Timing: "after",
Priority: "mandatory",
IsCourtSet: false,
RuleCode: "§ 339 ZPO",
LegalSource: "DE.ZPO.339.1",
DeadlineNotes: "Notfrist von 2 Wochen ab Zustellung des Versäumnisurteils (§ 339(1) ZPO). " +
"Bei Auslandszustellung oder öffentlicher Bekanntmachung bestimmt das Gericht die Einspruchsfrist gesondert im Versäumnisurteil oder durch nachträglichen Beschluss (§ 339(2) ZPO) — in diesem Fall die gerichtlich festgesetzte Frist mit „Datum setzen“ überschreiben. " +
"Form: schriftlich oder zu Protokoll der Geschäftsstelle (§ 340(1) ZPO); die Einspruchsbegründung kann bis zum Verhandlungstermin nachgereicht werden (§ 340(3) ZPO).",
DeadlineNotesEn: "Statutory two-week emergency period (Notfrist) from service of the default judgment (§ 339(1) ZPO). " +
"If service is abroad or by public notice, the court sets the objection period separately in the default judgment or by a subsequent order (§ 339(2) ZPO) — in that case override with the court-set date. " +
"Form: in writing or before the registry clerk (§ 340(1) ZPO); substantive grounds may be filed up to the oral hearing (§ 340(3) ZPO).",
},
// ─── 3. schriftsatznachreichung (ZPO § 283) ───────────────────
{
Label: "schriftsatznachreichung → de.inf.lg",
ConceptSlug: "schriftsatznachreichung",
ProceedingCode: "de.inf.lg",
SubmissionCode: "de.inf.lg.nachreichung",
Name: "Schriftsatznachreichung",
NameEN: "Subsequent written submission",
EventKind: "filing",
PrimaryParty: "", // concept.party = "both" → no default
DurationValue: 3,
DurationUnit: "weeks",
Timing: "after",
Priority: "optional",
IsCourtSet: true,
RuleCode: "§ 283 ZPO",
LegalSource: "DE.ZPO.283",
DeadlineNotes: "Vom Gericht in der mündlichen Verhandlung gesetzte Schriftsatzfrist gem. § 283 ZPO. " +
"Sie wird nur auf Antrag einer Partei bestimmt, die sich auf neues Vorbringen des Gegners nicht erklären konnte. " +
"Die konkrete Frist (in der Praxis 2-3 Wochen) und der nachfolgende Verkündungstermin werden im Sitzungsprotokoll bzw. in der prozessleitenden Verfügung festgelegt — Default-Frist hier 3 Wochen, mit „Datum setzen“ überschreiben, sobald die Verfügung vorliegt. " +
"Nach Fristablauf darf das Gericht keine weiteren Erklärungen mehr berücksichtigen (§ 283 S. 2, § 296a ZPO).",
DeadlineNotesEn: "Court-set written-submission period under § 283 ZPO, granted on a party's application when it could not respond at the oral hearing to the opponent's new submissions. " +
"The actual period (in practice 2-3 weeks) and the announcement date are set in the hearing record / case-management order — default 3 weeks here, override via „set date“ once the order is on the file. " +
"After expiry, the court will disregard further submissions (§ 283 sent. 2, § 296a ZPO).",
},
// ─── 4. weiterbehandlung — EPC variant (Art. 121 + R. 135) ────
{
Label: "weiterbehandlung (EPC) → epa.grant.exa",
ConceptSlug: "weiterbehandlung",
ProceedingCode: "epa.grant.exa",
SubmissionCode: "epa.grant.exa.weiterbeh",
Name: "Antrag auf Weiterbehandlung",
NameEN: "Request for further processing",
EventKind: "filing",
PrimaryParty: "claimant",
DurationValue: 2,
DurationUnit: "months",
Timing: "after",
Priority: "mandatory",
IsCourtSet: false,
RuleCode: "Art. 121 EPÜ",
LegalSource: "EU.EPC-R.135.1",
DeadlineNotes: "Antrag auf Weiterbehandlung gem. Art. 121 EPÜ i. V. m. R. 135(1) EPÜ — 2 Monate ab Zustellung der Mitteilung über die Fristversäumung bzw. den eingetretenen Rechtsverlust. " +
"Der Antrag wird durch Zahlung der vorgeschriebenen Weiterbehandlungsgebühr gestellt; die versäumte Handlung muss innerhalb derselben 2-Monats-Frist nachgeholt werden (R. 135(1) EPÜ). " +
"Die Frist ist nicht verlängerbar. Ausgeschlossen sind insbesondere die Frist für die Weiterbehandlung selbst sowie die in R. 135(2) EPÜ ausdrücklich aufgeführten Fristen (u. a. die Beschwerdefrist nach Art. 108 EPÜ, die Prioritätsfrist nach Art. 87 EPÜ und die Frist zur Wiedereinsetzung).",
DeadlineNotesEn: "Request for further processing under Article 121 EPC in conjunction with Rule 135(1) EPC — two months from notification of the communication concerning the missed time limit or the loss of rights. " +
"The request is made by payment of the further-processing fee; the omitted act must be completed within the same two-month period (Rule 135(1) EPC). " +
"The period is non-extendable. Excluded: the further-processing period itself and the periods listed in Rule 135(2) EPC (notably the appeal period under Art. 108 EPC, the priority period under Art. 87 EPC, and the re-establishment period).",
},
// ─── 5. weiterbehandlung — DPatG § 123a variant ───────────────
// No `dpma.grant.*` proceeding_type exists yet, so this rule is
// event-rooted (proceeding_type_id NULL) — same pattern as 78
// other cross-cutting rules. Editorial follow-up: create a
// `dpma.grant.dpma` proceeding_type and reassign.
{
Label: "weiterbehandlung (DPatG § 123a) → event-rooted (NULL proceeding_type)",
ConceptSlug: "weiterbehandlung",
ProceedingCode: "", // event-rooted
SubmissionCode: "dpma.grant.weiterbeh",
Name: "Antrag auf Weiterbehandlung (DPMA)",
NameEN: "Request for further processing (DPMA, § 123a PatG)",
EventKind: "filing",
PrimaryParty: "claimant",
DurationValue: 1,
DurationUnit: "months",
Timing: "after",
Priority: "mandatory",
IsCourtSet: false,
RuleCode: "§ 123a PatG",
LegalSource: "DE.PatG.123a.1",
DeadlineNotes: "Antrag auf Weiterbehandlung einer DPMA-Patentanmeldung gem. § 123a PatG — 1 Monat ab Zustellung der Mitteilung über die Rechtsfolge der Fristversäumung. " +
"Innerhalb dieser Frist müssen (i) der Antrag schriftlich gestellt, (ii) die versäumte Handlung nachgeholt und (iii) die Weiterbehandlungsgebühr nach Patentkostengesetz (PatKostG) gezahlt werden. " +
"§ 123a PatG erfasst ausschließlich Anmeldungsfristen, deren Versäumung kraft Gesetzes die Zurückweisung der Anmeldung zur Folge hat. Für sonstige Fristversäumnisse kommt nur die Wiedereinsetzung nach § 123 PatG in Betracht (1 Monat ab Wegfall des Hindernisses, max. 1 Jahr ab Fristablauf). " +
"HINWEIS — Taxonomie: bisher kein dpma.grant.* proceeding_type vorhanden; Regel daher event-rooted (proceeding_type_id NULL). Editorial follow-up: dpma.grant.dpma proceeding_type anlegen und diese Regel umhängen.",
DeadlineNotesEn: "Request for further processing of a DPMA patent application under § 123a PatG — 1 month from notification of the consequence of the missed deadline. " +
"Within this period the applicant must (i) file the written request, (ii) complete the omitted act, and (iii) pay the further-processing fee under the German Patent Costs Act (PatKostG). " +
"§ 123a PatG covers only application-stage deadlines whose statutory consequence is rejection. For other missed deadlines, re-establishment under § 123 PatG is the only route (1 month from removal of the obstacle, max 1 year from the missed deadline). " +
"TAXONOMY NOTE: no dpma.grant.* proceeding_type exists yet; this rule is event-rooted (proceeding_type_id NULL). Editorial follow-up: create a dpma.grant.dpma proceeding_type and reassign this rule.",
},
}
}
func main() {
dryRun := flag.Bool("dry-run", false, "log the planned drafts but do not write")
reason := flag.String("reason", "t-paliad-320: editorial seed of orphan deadline-concept rules (researcher darwin + lex)", "audit reason recorded with each Create()")
flag.Parse()
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
log.Fatal("DATABASE_URL not set — export the paliad postgres URL before running")
}
ctx := context.Background()
conn, err := sqlx.Connect("postgres", dbURL)
if err != nil {
log.Fatalf("connect db: %v", err)
}
defer conn.Close()
rules := services.NewDeadlineRuleService(conn)
editor := services.NewRuleEditorService(conn, rules)
conceptIDs := map[string]uuid.UUID{}
proceedingIDs := map[string]int{}
specs := drafts()
for _, s := range specs {
if _, ok := conceptIDs[s.ConceptSlug]; ok {
continue
}
var id uuid.UUID
if err := conn.GetContext(ctx, &id,
`SELECT id FROM paliad.deadline_concepts WHERE slug = $1`, s.ConceptSlug); err != nil {
log.Fatalf("lookup concept %q: %v", s.ConceptSlug, err)
}
conceptIDs[s.ConceptSlug] = id
}
for _, s := range specs {
if s.ProceedingCode == "" {
continue
}
if _, ok := proceedingIDs[s.ProceedingCode]; ok {
continue
}
var id int
if err := conn.GetContext(ctx, &id,
`SELECT id FROM paliad.proceeding_types WHERE code = $1`, s.ProceedingCode); err != nil {
log.Fatalf("lookup proceeding_type %q: %v", s.ProceedingCode, err)
}
proceedingIDs[s.ProceedingCode] = id
}
fmt.Printf("Seeding %d drafts (dry-run=%v)\n", len(specs), *dryRun)
for i, s := range specs {
conceptID := conceptIDs[s.ConceptSlug]
var procID *int
if s.ProceedingCode != "" {
p := proceedingIDs[s.ProceedingCode]
procID = &p
}
// Idempotency: refuse if a rule with the same (concept, proceeding,
// rule_code) already exists in any lifecycle state.
if existing, err := findExisting(ctx, conn, conceptID, procID, s.RuleCode); err != nil {
log.Fatalf("[%d] idempotency check failed for %s: %v", i+1, s.Label, err)
} else if existing != uuid.Nil {
fmt.Printf(" [%d] SKIP %s — already exists as %s\n", i+1, s.Label, existing)
continue
}
input := services.CreateRuleInput{
Name: s.Name,
NameEN: s.NameEN,
ProceedingTypeID: procID,
DurationValue: s.DurationValue,
DurationUnit: s.DurationUnit,
Priority: s.Priority,
IsCourtSet: s.IsCourtSet,
}
input.ConceptID = &conceptID
code := s.SubmissionCode
input.SubmissionCode = &code
ek := s.EventKind
input.EventType = &ek
t := s.Timing
input.Timing = &t
rc := s.RuleCode
input.RuleCode = &rc
ls := s.LegalSource
input.LegalSource = &ls
dn := s.DeadlineNotes
input.DeadlineNotes = &dn
dne := s.DeadlineNotesEn
input.DeadlineNotesEn = &dne
if s.PrimaryParty != "" {
pp := s.PrimaryParty
input.PrimaryParty = &pp
}
if *dryRun {
fmt.Printf(" [%d] DRY %s (concept=%s, proc=%s, code=%s, %d %s, %s)\n",
i+1, s.Label, conceptID, codeOrNil(procID), code, s.DurationValue, s.DurationUnit, s.RuleCode)
continue
}
row, err := editor.Create(ctx, input, *reason)
if err != nil {
log.Fatalf(" [%d] CREATE failed for %s: %v", i+1, s.Label, err)
}
fmt.Printf(" [%d] OK %s → id=%s lifecycle=%s\n",
i+1, s.Label, row.ID, row.LifecycleState)
}
fmt.Println("Done.")
}
func findExisting(ctx context.Context, conn *sqlx.DB, conceptID uuid.UUID, procID *int, ruleCode string) (uuid.UUID, error) {
var id uuid.UUID
q := `
SELECT sr.id
FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
WHERE pe.concept_id = $1
AND sr.rule_code IS NOT DISTINCT FROM $2
AND sr.proceeding_type_id IS NOT DISTINCT FROM $3
LIMIT 1`
err := conn.GetContext(ctx, &id, q, conceptID, ruleCode, procID)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, nil
}
return id, err
}
func codeOrNil(p *int) string {
if p == nil {
return "<NULL>"
}
return fmt.Sprintf("%d", *p)
}

View File

@@ -128,6 +128,20 @@ func main() {
inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL)
reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL)
// t-paliad-223 Slice B (#49) — Supabase Admin API client for the
// new "Konto direkt anlegen" path on /admin/team. The key is
// optional: when unset the client still wires (so dependents
// don't panic) but every call short-circuits with
// ErrSupabaseAdminUnavailable so the rest of the server stays
// runnable.
supabaseAdminClient := services.LoadSupabaseAdminClient()
if supabaseAdminClient.Enabled() {
log.Println("supabase admin API configured — /admin/team Add-User path active")
} else {
log.Println("SUPABASE_SERVICE_ROLE_KEY not set — /admin/team Add-User path will return 503")
}
users.SetAddUserDeps(supabaseAdminClient, mailSvc, baseURL)
// Wire EmailTemplateService onto the MailService so DB-backed admin
// edits propagate without a process restart. The constructor is split
// from MailService creation because the DB pool isn't available yet
@@ -137,11 +151,49 @@ func main() {
eventTypeSvc := services.NewEventTypeService(pool, users)
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
partySvc := services.NewPartyService(pool, projectSvc)
// t-paliad-238 — dedicated submission draft editor. The variable
// bag service is shared between the renderer (export) and the
// preview HTML path. Resurrected from t-paliad-215 Slice 1 backend
// (commits 3677c81 + 1765d5e + 8ea3509).
submissionVarsSvc := services.NewSubmissionVarsService(pool, projectSvc, partySvc, users)
submissionRenderer := services.NewSubmissionRenderer()
submissionDraftSvc := services.NewSubmissionDraftService(pool, projectSvc, submissionVarsSvc, submissionRenderer)
// t-paliad-313 Composer Slice A — base catalog + section seeding.
// AttachComposer wires both into the draft service so Create
// seeds base_id + submission_sections rows on new drafts. v1
// fallback path stays active for pre-Composer drafts (base_id
// NULL, no section rows).
submissionBaseSvc := services.NewBaseService(pool)
submissionSectionSvc := services.NewSectionService(pool)
submissionDraftSvc.AttachComposer(submissionBaseSvc, submissionSectionSvc, branding.Name)
// t-paliad-313 Slice B — render-pipeline assembler. Reuses the
// existing SubmissionRenderer for the final placeholder pass so
// the {{rule.X}} alias contract stays preserved inside the
// composed body.
submissionComposerSvc := services.NewSubmissionComposer(submissionRenderer)
// t-paliad-315 Slice C — building-block library.
submissionBuildingBlockSvc := services.NewBuildingBlockService(pool, branding.Name)
// t-paliad-349 docforge slice 4/6 — uploaded-template store
// (Postgres bytea) backing the authoring surface.
templateStoreSvc := services.NewPgTemplateStore(pool)
// t-paliad-225 Slice A — user-authored checklist templates.
// Slice B adds checklist_shares grants + admin promotion.
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
sysAuditSvc := services.NewSystemAuditLogService(pool)
checklistTemplateSvc := services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users)
svcBundle = &handlers.Services{
Pool: pool,
Project: projectSvc,
Team: teamSvc,
PartnerUnit: partnerUnitSvc,
Party: services.NewPartyService(pool, projectSvc),
Party: partySvc,
SubmissionDraft: submissionDraftSvc,
SubmissionBase: submissionBaseSvc,
SubmissionSection: submissionSectionSvc,
SubmissionComposer: submissionComposerSvc,
SubmissionBuildingBlock: submissionBuildingBlockSvc,
TemplateStore: templateStoreSvc,
Deadline: deadlineSvc,
Appointment: appointmentSvc,
CalDAV: caldavSvc,
@@ -165,7 +217,11 @@ func main() {
EventType: eventTypeSvc,
Dashboard: services.NewDashboardService(pool, users),
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc),
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc, checklistCatalogSvc),
ChecklistCatalog: checklistCatalogSvc,
ChecklistTemplate: checklistTemplateSvc,
ChecklistShare: services.NewChecklistShareService(pool, checklistTemplateSvc, sysAuditSvc, users),
ChecklistPromotion: services.NewChecklistPromotionService(pool, checklistTemplateSvc, sysAuditSvc, users),
Mail: mailSvc,
Invite: inviteSvc,
Agenda: services.NewAgendaService(pool, users, eventTypeSvc),
@@ -178,13 +234,47 @@ func main() {
UserView: services.NewUserViewService(pool),
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
Pin: services.NewPinService(pool, projectSvc),
CardLayout: services.NewCardLayoutService(pool),
DashboardLayout: services.NewDashboardLayoutService(pool),
CardLayout: services.NewCardLayoutService(pool),
DashboardLayout: services.NewDashboardLayoutService(pool),
FirmDashboardDefault: services.NewFirmDashboardDefaultService(pool),
FirmNameComposition: services.NewFirmNameCompositionService(pool),
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
// t-paliad-214 Slice 1 — personal-scope data export. firm name
// is captured into __meta of every export and printed in the
// embedded README.
Export: services.NewExportService(pool, branding.Name),
// t-paliad-265 / m/paliad#96 — per-event-card optional choices.
EventChoice: services.NewEventChoiceService(pool, projectSvc, users),
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions.
Scenario: services.NewScenarioService(pool, projectSvc, rules),
// m/paliad#149 Phase 2 P0 (mig 154) — per-project scenario_flags
// SSoT. Drives Verfahrensablauf + Mode B result-view conditional
// rendering and per-rule selection state (`rule:<uuid>` keys).
ScenarioFlags: services.NewScenarioFlagsService(pool, projectSvc),
// t-paliad-340 / m/paliad#153 B0 (mig 157) — Litigation Builder.
// CRUD over the new normalised scenarios + scenario_proceedings
// + scenario_events + scenario_shares tables. B4 adds the
// Akte-mode dual-write: project-backed scenarios write through
// to paliad.projects.scenario_flags + paliad.deadlines via the
// injected project + scenarioFlags services.
ScenarioBuilder: services.NewScenarioBuilderService(pool, projectSvc, services.NewScenarioFlagsService(pool, projectSvc), services.NewFristenrechnerService(rules, holidays, courts)),
}
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
// PALIAD_EXPORT_DIR is set (LocalDiskStore needs a target
// directory). Without it the /admin/backups handlers return 503
// in the same shape as Paliadin's gate. The directory is created
// (0700) on first use; a malformed path fails fast at boot so
// misconfig surfaces before the server starts taking traffic.
if exportDir := strings.TrimSpace(os.Getenv("PALIAD_EXPORT_DIR")); exportDir != "" {
store, err := services.NewLocalDiskStore(exportDir)
if err != nil {
log.Fatalf("PALIAD_EXPORT_DIR: %v", err)
}
svcBundle.Backup = services.NewBackupRunner(pool, svcBundle.Export, store)
log.Printf("backup: LocalDiskStore at %s (/admin/backups active)", exportDir)
} else {
log.Println("PALIAD_EXPORT_DIR not set — /admin/backups will return 503")
}
// t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService
@@ -194,20 +284,21 @@ func main() {
// without approvals — so keeping this a setter keeps both
// constructors simple).
svcBundle.Dashboard.SetApprovalService(svcBundle.Approval)
// Slice C wires PinService into DashboardService for the
// pinned-projects widget. Pin pre-dates t-paliad-219; no new
// schema, no circular dependency (Pin doesn't know about the
// dashboard).
svcBundle.Dashboard.SetPinService(svcBundle.Pin)
// Slice C wires the firm-wide dashboard default into the
// per-user layout service so GetOrSeed/ResetToDefault prefer
// the admin-set firm default over the code-resident factory.
// Nil-safe: empty firm row falls back to the factory layout.
svcBundle.DashboardLayout.SetFirmDefaultService(svcBundle.FirmDashboardDefault)
// t-paliad-215 Slice 1 — submission generator. Three services
// stitched together by handlers/submissions.go: registry pulls
// templates from Gitea (reuses GITEA_TOKEN env), vars builds
// the placeholder map from project + parties + rule, renderer
// merges {{placeholder}} tokens into the .docx.
svcBundle.SubmissionRegistry = services.NewTemplateRegistry(giteaToken, branding.Name)
svcBundle.SubmissionVars = services.NewSubmissionVarsService(
pool,
svcBundle.Project,
svcBundle.Party,
svcBundle.Users,
)
svcBundle.SubmissionRenderer = services.NewSubmissionRenderer()
// t-paliad-230 — submission generator (format-only). No
// service wiring needed: handlers/submissions.go reuses the
// existing files.go HL Patents Style cache and calls
// services.ConvertDotmToDocx (stateless function).
// Paliadin backend selection.
//
@@ -283,6 +374,11 @@ func main() {
log.Printf("CalDAV start: %v", err)
}
reminderSvc.Start(bgCtx)
// Slice B.4 (mig 140, t-paliad-305): legacy paliad.deadline_rules
// dropped. The B.2 dual-write drift-check loop is retired — the
// procedural_events / sequencing_rules / legal_sources tables
// are now the source of truth and there is no parallel side to
// compare against. Pre-drop drift was verified clean in mig 140.
go func() {
<-bgCtx.Done()
log.Println("background services: shutdown signal received")

View File

@@ -98,6 +98,51 @@ func TestBootSmoke(t *testing.T) {
if body := strings.TrimSpace(rec.Body.String()); body != "ok" {
t.Errorf("GET /healthz: body=%q; want \"ok\"", body)
}
// (4) Readiness probe. With a nil Services bundle the endpoint MUST
// report 503 — that's the contract documented in handlers/handlers.go.
// A separate svc-with-Pool case is exercised in TestHealthReady (live).
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/health/ready", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("GET /health/ready (nil svc): status=%d; want 503", rec.Code)
}
}
// TestHealthReady_Live asserts the readiness probe answers 200 when the
// pool is reachable, 503 when it isn't. Requires TEST_DATABASE_URL.
//
// Why a separate test: TestBootSmoke runs Register with svc=nil to keep
// its setup minimal; the pool-reachable path needs the pool wired in
// through svc.Pool. Two tests, two assertions, no entanglement.
func TestHealthReady_Live(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live readiness probe")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("db.ApplyMigrations: %v", err)
}
pool, err := db.OpenPool(url)
if err != nil {
t.Fatalf("open pool: %v", err)
}
mux := http.NewServeMux()
authClient := auth.NewClient("https://test.invalid", "anon-key", []byte("test-secret"))
handlers.Register(mux, authClient, "", &handlers.Services{Pool: pool})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health/ready", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET /health/ready (live pool): status=%d, body=%q; want 200", rec.Code, rec.Body.String())
}
if body := strings.TrimSpace(rec.Body.String()); body != "ready" {
t.Errorf("GET /health/ready (live pool): body=%q; want \"ready\"", body)
}
}
// embeddedMigrationVersions returns every N where N_*.up.sql exists in

View File

@@ -8,6 +8,7 @@ services:
- SUPABASE_URL=${SUPABASE_URL}
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
- SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET}
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY:-}
- GITEA_TOKEN=${GITEA_TOKEN}
- DATABASE_URL=${DATABASE_URL}
- CALDAV_ENCRYPTION_KEY=${CALDAV_ENCRYPTION_KEY}
@@ -41,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

@@ -0,0 +1,738 @@
# Assessment — Deadline + Procedural-Events System
**Phase 1 of RFC m/paliad#149.** Read-only audit of every consumer of
`paliad.sequencing_rules` + `paliad.procedural_events` + the legacy
`paliad.trigger_events`, the corpus they project, and the surfaces that
read them.
- Author: athena (consultant role)
- Date: 2026-05-27
- Live data: youpc Supabase (`paliad` schema), counts captured during the
audit window (mig 153 applied).
- Scope: assessment only. No design proposals; no schema sketches; no
recommendations on column shape. Phase 2 (inventor) decides those.
---
## 0. Headline numbers
| Bucket | Total | Active + published | Notes |
|---|--:|--:|---|
| `procedural_events` | 236 | 222 | 5 drafts, 9 archived/inactive |
| `sequencing_rules` | 236 | 226 | 1:1 row-mirror with events (mig 136 + 140) |
| `trigger_events` (legacy) | 110 | — | bigint-keyed catalog; lives parallel to events |
| `proceeding_types` | 50 | 23 kind=`proceeding`; 0 active in kind=`phase`/`side_action`/`meta` (mig 153 flipped them off) |
Rules-corpus shape (active + published, 226 rows):
| Classification | Rows |
|---|--:|
| Parent only (chain-linked) | 105 |
| Both parent + legacy trigger | 2 |
| Legacy `trigger_event_id` only — `proceeding_type_id IS NULL` | **73** |
| Neither (root) — `proceeding_type_id` set | 46 |
Other corpus signals:
- `condition_expr` populated: 18 rules. Three distinct keys: `flag` (14),
`op` + `args` (4 each — always nested AND).
- `is_spawn = true`: 4 rules. All four point at the **inactive**
`upc.apl.merits` (id=11). The active appeal type is id=160
(`upc.apl.unified`). See risk R3.
- `is_court_set = true`: 46 rules.
- `is_bilateral = true`: 4 rules.
- `choices_offered` populated: 28 rules. Three shapes:
`{appellant:[…]}` (20), `{skip:[…]}` (6), `{include_ccr:[…]}` (2).
- `applies_to_target` populated: 16 rules.
- 67 distinct events act as chain-anchors (= parent of ≥1 active rule).
That is the *derived* trigger set today.
- `paliad.project_event_choices`: schema present, **0 rows** live.
- `paliad.scenarios` (mig 145): table created, **0 rows**.
`paliad.projects.active_scenario_id`: **0/18 projects** populated.
A more granular per-proceeding-type breakdown is in §4.
---
## 1. Audit — consumers of `sequencing_rules` + `procedural_events`
Every read site, by surface. File paths are repo-relative.
### 1.1 Direct services
| Service | File | What it reads | Surface(s) it backs |
|---|---|---|---|
| `DeadlineRuleService` | `internal/services/deadline_rule_service.go:14-365` | `paliad.deadline_rules_unified` view (sequencing_rules + procedural_events + legal_sources), + `paliad.trigger_events` for parent-chain labels (`:226-285`) | Admin rules list/editor, Fristenrechner result panel |
| `FristenrechnerService` | `internal/services/fristenrechner.go:115-172,1-700+` | `sequencing_rules` + `procedural_events` (proceeding-type catalog; `EXISTS` over rules); scenarios table (`:583-627`) | `/api/tools/fristenrechner` (Mode A + Mode B + Mode C) |
| `FristenrechnerService.LookupFollowUps` | `internal/services/fristenrechner_followups.go:87-403` | resolves anchor by `pe.id`/`pe.code`/`sr.id` (`:241-287`); one-hop children via `parent_id` (`:345-403`) | `/api/tools/fristenrechner/follow-ups` |
| `DeadlineSearchService` | `internal/services/fristenrechner_search_events.go:143-170,194,233,696` | sequencing_rules ⋈ procedural_events ⋈ proceeding_types + legal_sources; counts child rules via `parent_id` subquery | `/api/tools/fristenrechner/search` |
| `EventDeadlineService` | `internal/services/event_deadline_service.go:31-79,186-195,244` | `paliad.trigger_events` + `sequencing_rules WHERE trigger_event_id IS NOT NULL` | `/api/tools/event-deadlines` (legacy bigint surface) |
| `EventTriggerService` | `internal/services/event_trigger_service.go:24-230` | `event_types.trigger_event_id` bridge + sequencing_rules | `/api/tools/event-trigger` |
| `RuleEditorService` | `internal/services/rule_editor_service.go:104,136,232,371,381,459,625-843` | full CRUD on sequencing_rules + procedural_events; reads `trigger_event_id` as an optional filter on list | `/admin/api/procedural-events/*` (Slice B.5) |
| `RuleEditorOrphans` | `internal/services/rule_editor_orphans.go:218-224` | sub-select on sequencing_rules for orphaned deadlines | `/admin/api/orphans` |
| `DualWriteService` | `internal/services/dual_write.go` (+ `dual_write_test.go:50-300`) | parity assertion between legacy + unified projection | internal — write-side guard, no HTTP |
| `ProjectionService` (SmartTimeline) | `internal/services/projection_service.go:3+` | composes the timeline by reading via `DeadlineRuleService` + `FristenrechnerService`; does NOT touch `sequencing_rules` directly | `GET /api/projects/{id}/timeline`, milestone + counterclaim endpoints in `internal/handlers/projection.go:35-436+` |
| `ExportService` | `internal/services/export_service.go:1680` | bulk-exports `paliad.trigger_events` as the `ref__trigger_events` workbook sheet | `/api/admin/export/*` |
| `EventChoiceService` | `internal/services/event_choice_service.go:15-180` | reads + writes `paliad.project_event_choices` | per-project flag persistence (no rows live today) |
| `EventTypeService` | `internal/services/event_type_service.go:40-414` | user-defined `paliad.event_types` rows with optional `trigger_event_id` bridge | `/api/event-types` + Pipeline C compose |
| `ProjectService.validateProceedingTypeCategory` | `internal/services/project_service.go:1176-1267` | reads `paliad.proceeding_types.category` + `kind` + `is_active` | binding guard for `projects.proceeding_type_id` (sister to mig-153 trigger) |
The handlers behind each route are listed in §1.2.
### 1.2 HTTP routes
Every route that ultimately surfaces sequencing/event data. Path
literals + handler file:line cited.
**Knowledge-tool surface (public-ish, behind auth):**
| Route | Handler | Reads |
|---|---|---|
| `POST /api/tools/fristenrechner` | `internal/handlers/fristenrechner.go:39-95+` | `FristenrechnerService.CalculateForProceeding` → engine in `pkg/litigationplanner` |
| `GET /api/tools/fristenrechner/search` | `internal/handlers/fristenrechner_search.go` (filter params: `event_kind`, `primary_party`, `jurisdiction`) | `DeadlineSearchService.SearchEvents` |
| `GET /api/tools/fristenrechner/follow-ups` | `internal/handlers/fristenrechner_followups.go:27-65` | `FristenrechnerService.LookupFollowUps` |
| `GET /api/tools/proceeding-types` | `internal/handlers/event_types.go` | proceeding_types filter (event_kind, jurisdiction) |
| `GET /api/tools/trigger-events` | `internal/handlers/event_types.go` | trigger_events catalog (active only) |
| `POST /api/tools/event-trigger` | `internal/handlers/event_trigger.go:39-106` | unified Pipeline-A + Pipeline-C compose |
| `POST /api/tools/event-deadlines` | `internal/handlers/deadline_rules_db.go:67+` | **legacy** bigint trigger_event_id → rule list |
**SmartTimeline surface (project-bound):**
| Route | Handler | Reads |
|---|---|---|
| `GET /api/projects/{id}/timeline` | `internal/handlers/projection.go:35-109` | `ProjectionService.Render` (no direct rule reads — composes via services) |
| `POST /api/projects/{id}/timeline/milestone` | `internal/handlers/projection.go:445+` | milestone insert; reads `proceeding_type.kind` via service |
| `POST /api/projects/{id}/timeline/counterclaim` | `internal/handlers/projection.go:387-436` | spawns CCR project; reads `parent_id` on response composition |
**Admin editor surface (`/admin/procedural-events/*`):**
| Route | Handler | Reads |
|---|---|---|
| `GET /admin/procedural-events` | `internal/handlers/admin_rules.go:399-402` | page shell |
| `GET /admin/procedural-events/{id}/edit` | `:403-470` | editor form (full rule + event JSON) |
| `GET /admin/api/procedural-events` | `:101-160` | paginated list w/ canonical `code` + `event_kind` (Slice B.5 wrapper) |
| `GET /admin/api/procedural-events/{id}` | `:161-179` | single rule fetch |
| `POST /admin/api/procedural-events` | `:180-204` | create draft |
| `PATCH /admin/api/procedural-events/{id}` | `:205-233` | edit draft |
| `POST /admin/api/procedural-events/{id}/publish` | `:257-279` | publish flow |
| `GET /admin/api/procedural-events/{id}/audit` | `:326-361` | audit log |
| `GET /admin/api/orphans` | `:471-484` | orphaned deadlines (Slice 10 backfill UI) |
| `POST /admin/api/orphans/{id}/resolve` | `:485-520` | link orphan to rule |
| `/admin/rules/*``/admin/procedural-events/*` | `:761-772` | **301 redirects** (legacy bookmarks; one-slice deprecation window) |
| `?trigger_event_id=…` query param | `:119-122` | exposes legacy trigger filter on the admin list |
**Scenarios surface (mig 145):**
| Route | Handler |
|---|---|
| `GET /api/scenarios?project=<id>|abstract=true` | `internal/handlers/scenarios.go:51-90` |
| `GET /api/scenarios/{id}` | `:92-113` |
| `POST /api/scenarios` | `:115-136` |
| `PATCH /api/scenarios/{id}` | `:138-164` |
| `DELETE /api/scenarios/{id}` | `:166-200+` |
| `POST /api/paliadin/suggest/deadline` | `internal/handlers/paliadin_suggest.go:63+` (deadline drafts via Paliadin; does not read rules directly — calls into `DeadlineService`) |
Registration: `internal/handlers/handlers.go:497-501, 880`.
### 1.3 Frontend (TypeScript) consumers
These call the routes above; **no direct DB access**. References per the
i18n key search and `frontend/src/client/*` greps:
- `frontend/src/admin-rules-list.tsx:24-105+` — admin list page shell;
hits `/admin/api/procedural-events*`.
- `frontend/src/admin-rules-edit.tsx:29-187+` — admin editor form; reads
`procedural_events.edit.field.{code,event_kind,parent}` i18n keys.
- `frontend/src/verfahrensablauf.tsx` — proceeding-type ablauf page
(mode C); hits `/api/tools/fristenrechner` with proceeding shape.
- `frontend/src/client/fristenrechner-wizard.ts:80` — Mode A wizard;
`r4: string // procedural_events.code`.
- `frontend/src/client/fristenrechner-mode-a.ts` — Mode A search; hits
`/api/tools/fristenrechner/search?kind=events`.
- `frontend/src/client/fristenrechner-result.ts` — result panel; hits
`/api/tools/fristenrechner/follow-ups`.
- `frontend/src/client/projects-new.ts` — type-aware project wizard;
hits `/api/tools/fristenrechner?proceeding_type_code=…`.
- `frontend/src/client/deadlines-detail.ts` — deadline CRUD detail.
- i18n keys: `admin.procedural_events.list/edit/col.*` and translations
in `frontend/src/client/i18n.ts:3193-3204, 6338-6346+`.
### 1.4 Offline snapshot
- `cmd/gen-upc-snapshot/main.go:150-268` — reads `paliad.trigger_events`,
the legacy `paliad.deadline_rules` projection (now via the unified
view), and `paliad.proceeding_types`. Writes JSON to
`pkg/litigationplanner/embedded/upc/{trigger_events.json,
rules.json, proceeding_types.json, meta.json}`.
- `pkg/litigationplanner/catalog.go` + `engine.go` + `types.go:73-156`
Rule struct carries `TriggerEventID`, `SpawnProceedingTypeID`,
`ConditionExpr`, `Priority`, `IsCourtSet`, `PrimaryParty`, `IsSpawn`,
`SpawnLabel`, `CombineOp`. youpc.org consumes this snapshot.
### 1.5 Migrations touching the tables (chronological)
`internal/db/migrations/`:
`028_youpc_deadlines_import`, `030_event_types`, `033_trigger_events_de`,
`035_event_deadlines_title_de_backfill`, `038_concept_links_and_legal_source`,
`046_cross_cutting_triggers`, `047_deadline_search_view`,
`051_proceeding_display_order`, `063_frist_verpasst_upc`,
`078_unified_rule_columns`, `091_drop_legacy_rule_columns`,
`098_submission_codes_prefix_and_rename`, `125_cross_cutting_filter_legal_source`,
`132_wave1_tier1_rule_additions`, **`136_procedural_events_additive`**
(the schema-authoritative additive split), `139_deadline_rules_unified_view`,
**`140_drop_deadline_rules`** (legacy projection dropped),
`151_dedupe_null_procedural_events`, `152_dedupe_identical_sequencing_rule_clones`,
**`153_proceeding_types_kind`** (kind discriminator + projects FK trigger).
Mig 145 is scenario-side: creates `paliad.scenarios` (table, **not**
a `scenarios` jsonb column on `projects` — the RFC text was imprecise)
and `paliad.projects.active_scenario_id` FK.
---
## 2. Health-check per consumer
### 2.1 Works — green
- **`DualWriteService` parity.** Every CRUD on the editor surface
keeps sequencing_rules + procedural_events + legal_sources locked,
asserted by `dual_write_test.go:50-202`.
- **Admin editor (`/admin/procedural-events/*`).** Full create / edit /
publish / audit loop. Drafts state respected.
- **Mode A picker via search.** `DeadlineSearchService` filters by
`event_kind` / `primary_party` / `jurisdiction`; returns child-rule
counts (`fristenrechner_search_events.go:159`).
- **Mode B Verfahrensablauf calc.** `pkg/litigationplanner.CalculateRule`
+ the `proceeding_type` fan-out works for every type that has any
rule (17/23).
- **`gen-upc-snapshot`.** UPC snapshot for youpc.org keeps shipping;
no DB writes; reads only.
- **Counterclaim spawn project creation.**
`internal/handlers/projection.go:387-436` + mig 153 trigger guard
reject any non-`proceeding` `proceeding_type_id`.
- **EventChoiceService** SQL is wired and tested — but see §2.3.
### 2.2 Works with known caveats — yellow
- **Spawn rules.** Behaviour is correct in the abstract (rule fires,
user can spawn a child case), but every spawn target points at the
**inactive** `upc.apl.merits` (id=11). Surfaces that resolve the
spawn target via `paliad.proceeding_types` will return an inactive
row. See R3. Cited at `sequencing_rules` 4 rows; service code in
`fristenrechner_followups.go:388` SELECTs `spt.code` via
`LEFT JOIN paliad.proceeding_types spt ON spt.id = sr.spawn_proceeding_type_id`
— no `is_active` filter on the join. Frontend renders an "open
Berufungsverfahren" CTA that points at a UI flow expecting the
active id=160 (`upc.apl.unified`).
- **Legacy 73 globals.** 73 rules with `proceeding_type_id IS NULL`
and `trigger_event_id NOT NULL`. These all anchor on legacy
`null.<8hex>` event codes that don't match any `proceeding_types.code`
prefix. They are consumed via `/api/tools/event-deadlines` (the
bigint route) AND surface on the unified view. They have no place
in the Mode B "proceeding-type ablauf" view because they have no
proceeding. See R4.
- **Legacy `/api/tools/event-deadlines` route.** Live, used by
Pipeline-C `event_types` consumers (`EventTypeService`). The
`ExportService:1680` also still emits `ref__trigger_events` to the
workbook. Deprecation has been deferred — see R5.
### 2.3 Broken / leaky — red
- **B1 — Follow-up cross-party filter is over-broad.**
`fristenrechner_followups.go:358-367`:
```go
if party == "claimant" || party == "defendant" {
args = append(args, party)
where = append(where, fmt.Sprintf(
"(sr.primary_party = $%d OR sr.primary_party = 'both' OR sr.primary_party IS NULL)",
len(args)))
}
```
The filter keeps `both` + `NULL` rules but **drops cross-party
follow-ups**. From the corpus there are 39 active rules whose
`primary_party` differs from their parent's primary_party (excluding
`court`). Example: `upc.inf.cfi.def_to_ccr` is claimant-filed; its
child rule `RoP.029.d → reply_def_ccr` is defendant-filed. With
`party=claimant` selected on the result view, the defendant child
is hidden and the user reads "Keine Folge-Fristen" — a lie. This
is the exact bug the RFC §"What's actually broken" item 2 calls
out.
- **B2 — Picker doesn't distinguish triggers from leaves.**
`LookupFollowUps` (`fristenrechner_followups.go:241-287`) resolves
by `pe.id` / `pe.code` / `sr.id` with no
"is-this-event-actually-a-trigger" gate. The data already supports
derivation — 67 of 222 active events act as a chain anchor. The
picker just isn't wired to the derivation. Compounding: 4 events
are *spawn-only* consequences (`upc.{inf,rev,pi,dmgs}.cfi.appeal_spawn`)
— picking one returns the spawn rule itself with no follow-ups,
which surfaces as "Keine Folge-Fristen".
- **B3 — Scenario state is forked across three stores by design but
zero stores by data.**
- `paliad.project_event_choices` (mig 129) — schema present, 0 rows.
`EventChoiceService` reads + writes it via
`internal/services/event_choice_service.go:74,123,180`.
- `paliad.scenarios` (mig 145) — 0 rows, 0/18 projects bound via
`active_scenario_id`. `ScenarioService.LoadScenarios` in
`internal/services/fristenrechner.go:583-627` reads it.
- DOM state on the result view — Verfahrensablauf checkbox state
only lives client-side. Confirmed by absence of a write path
from `verfahrensablauf.tsx` to either DB-side store.
The RFC's "three independent stores" claim is *architecturally*
true today, but every store is empty. Risk is dormant — until
someone enables persistence on either path and the divergence
materialises. See R6.
- **B4 — 6 active `proceeding_types` have zero rules.**
`upc.bsv.cfi`, `upc.ccr.cfi`, `upc.costs.cfi`, `upc.dni.cfi`,
`upc.epo.review`, `upc.pl.cfi`. They appear in
`/api/tools/proceeding-types` (`is_active=true` + `kind='proceeding'`)
but produce empty timelines when chosen. The Mode A picker can
bind a project to them; the Mode B result view is blank.
### 2.4 Dead-or-decaying code
- **`paliad.trigger_events` table.** 110 rows; columns
`(id, code, name, name_de, description, is_active, created_at, concept_id)`.
Bigint PK. No `parent_id`, no `proceeding_type_id`. Consumed by:
`deadline_rule_service.go:226-285` (label fallback), `event_deadline_service.go`
(legacy route), `event_type_service.go` (Pipeline C bridge),
`export_service.go:1680` (workbook sheet), and 80 active
sequencing_rules' `trigger_event_id` (which is in turn primarily a
bridge for the 73 globals + 7 hybrid rules with a real proceeding).
- **Inactive proceeding_types still referenced by spawn rules.**
id 11 (`upc.apl.merits`), 19 (`upc.apl.cost`), 20 (`upc.apl.order`).
Mig 138 (`appeal_target_backfill_merits_order`) split them, mig
later unified them onto id 160. The 4 spawn rules' FK was not
updated.
- **3 non-`proceeding` kinds.** 23 rows total
(`phase` × 4 + `side_action` × 10 + `meta` × 9), all
`is_active=false` after mig 153. Live in the table for audit;
unused by any active surface. The Slice 10 orphan-resolution path
(`rule_editor_orphans.go`) could theoretically encounter them, but
active = false filters them out.
---
## 3. Rules-corpus quality audit (live data)
### 3.1 `parent_id` coverage
- 107/226 active+published rules have `parent_id` set (**47%**, matches
RFC).
- 119/226 do not. Decomposition (active+published):
| Subset | Rows | Meaning |
|---|--:|---|
| `parent_id NULL` AND `trigger_event_id IS NULL` AND `proceeding_type_id` set | 46 | Genuine proceeding-level roots (each PT has 16 such). |
| `parent_id NULL` AND `trigger_event_id` set AND `proceeding_type_id NULL` | 73 | The legacy globals — no place in the new chain model yet. |
Of the 46 proceeding-level roots:
| `proceeding_type.code` | roots | active rules |
|---|--:|--:|
| `de.inf.lg` | 5 | 9 |
| `de.null.bpatg` | 4 | 10 |
| `epa.grant.exa` | 4 | 7 |
| `upc.apl.unified` | 6 | 16 |
| `epa.opp.boa` | 3 | 8 |
| `upc.pi.cfi` | 3 | 7 |
| `epa.opp.opd` | 2 | 8 |
| `de.inf.bgh`, `de.inf.olg`, `de.null.bgh`, `dpma.appeal.bgh`, `dpma.appeal.bpatg`, `dpma.opp.dpma`, `upc.disc.cfi` | 1 each | various |
| `upc.dmgs.cfi`, `upc.inf.cfi`, `upc.rev.cfi` | 4 each | 8/25/17 |
Most "root" rules are legitimate (the chain start event has no logical
predecessor — `Klageerhebung`, `Zustellung`, `Veröffentlichung`,
`Anmeldung`, etc.). A small number are leaves whose parent chain just
hasn't been seeded (e.g. `de.inf.lg.berufung` / `de.inf.lg.beruf_begr`
list "Berufungsfrist" and "Berufungsbegründung" as parent-NULL despite
both having a logical predecessor in `de.inf.lg.urteil`).
### 3.2 `condition_expr` usage
18 rules use the column. Three keys total:
| Key | Uses | Sample shape |
|---|--:|---|
| `flag` | 14 | `{"flag":"with_ccr"}`, `{"flag":"with_amend"}`, `{"flag":"with_cci"}` |
| `op` | 4 | `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` |
| `args` | 4 | always nested under an `op:and` |
Distinct expressions (4 total, all UPC inf/rev):
`{"flag":"with_ccr"}` (×6), `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` (×4), `{"flag":"with_cci"}` (×4), `{"flag":"with_amend"}` (×4).
No formal validation at write time — `RuleEditorService` accepts the
column as freeform jsonb. The 3 flags are de-facto convention.
### 3.3 Spawn distribution
4 rules, all in the UPC CFI cluster, all `priority='optional'` +
`primary_party='both'` + spawn target id=11 (`upc.apl.merits`, inactive):
| Anchor event | Spawn label | Target |
|---|---|---|
| `upc.inf.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) |
| `upc.rev.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) |
| `upc.pi.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) |
| `upc.dmgs.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) |
### 3.4 `primary_party` distribution
Excluding the 73 globals (all NULL), the published+active rules split:
| `proceeding_type` cluster | `claimant` | `defendant` | `both` | `court` |
|---|--:|--:|--:|--:|
| `upc.inf.cfi` (25) | 6 | 7 | 8 | 4 |
| `upc.rev.cfi` (17) | 6 | 7 | 1 | 3 |
| `upc.apl.unified` (16) | 0 | 0 | 12 | 4 |
| `de.null.bpatg` (10) | 2 | 2 | 3 | 3 |
| `de.inf.lg` (9) | 2 | 3 | 2 | 2 |
| `epa.opp.opd` (8) | 0 | 1 | 6 | 1 |
| `epa.opp.boa` (8) | 0 | 0 | 6 | 2 |
| `de.inf.bgh` (8) | 0 | 0 | 6 | 2 |
| `upc.dmgs.cfi` (8) | 2 | 2 | 1 | 3 |
39 rules have a `primary_party` value that differs from their parent
rule's `primary_party` (excluding `court` ↔ anything, which is
trivial). All 39 are legitimate "ball-in-other-court" hand-offs
(claimant SoC → defendant SoD → claimant Reply → defendant Rejoinder
…). The /follow-ups filter (§2.3 B1) hides all of them when the user
picks a perspective.
### 3.5 `is_court_set` coverage
46 rules carry `is_court_set=true`. Distribution: every proceeding has
at least one (the decision / order / oral-hearing rows). Highest:
`de.inf.lg` (5), `epa.grant.exa` (4), `upc.apl.unified` (4),
`upc.inf.cfi` (3), `upc.rev.cfi` (3), `upc.pi.cfi` (3), `upc.dmgs.cfi`
(3). Calculator skips these in date math — they surface as
"wird vom Gericht bestimmt" markers.
### 3.6 Legacy `trigger_event_id` overlap with `parent_id`
| Combination | Rows |
|---|--:|
| `parent_id` set AND `trigger_event_id` set | **2** |
| `parent_id` set AND `trigger_event_id` NULL | 105 |
| `parent_id` NULL AND `trigger_event_id` set | 73 |
| `parent_id` NULL AND `trigger_event_id` NULL | 46 |
**Overlap is 2 rules out of 226 (0.9%).** The two models are
effectively **disjoint** in the corpus: the 73 legacy globals own the
`trigger_event_id` lane; the 105 chain-linked rules own `parent_id`.
The schema permits both columns to be set simultaneously, and 2 rules
exercise that — but they are outliers, not a documented pattern.
The legacy `paliad.trigger_events` table is still read for label
display by `deadline_rule_service.go:226-285` (the "abhängig von …"
chip rule fallback when `parent_id` isn't set) and for the legacy
`/api/tools/event-deadlines` route.
---
## 4. Editorial gap map
Per `proceeding_type` (active, kind=`proceeding`). Columns:
- **A** = active+published rules
- **P** = rules with `parent_id` set
- **R** = rules without `parent_id` (roots + leaves with missing parent)
- **E** = active+published events whose code matches this PT's
prefix
| PT code | A | P | R | E | Health |
|---|--:|--:|--:|--:|---|
| `upc.inf.cfi` | 25 | 21 | 4 | 25 | 84% chained — strongest |
| `upc.rev.cfi` | 17 | 13 | 4 | 17 | 76% |
| `upc.apl.unified` | 16 | 10 | 6 | 16 † | 63% — code-prefix issue, see below |
| `de.null.bpatg` | 10 | 6 | 4 | 10 | 60% |
| `de.inf.lg` | 9 | 4 | 5 | 9 | 44% — gappy |
| `epa.opp.opd` | 8 | 6 | 2 | 8 | 75% |
| `epa.opp.boa` | 8 | 5 | 3 | 8 | 63% |
| `de.inf.bgh` | 8 | 7 | 1 | 8 | 88% |
| `upc.dmgs.cfi` | 8 | 4 | 4 | 8 | 50% |
| `upc.pi.cfi` | 7 | 4 | 3 | 7 | 57% |
| `de.inf.olg` | 7 | 6 | 1 | 7 | 86% |
| `epa.grant.exa` | 7 | 3 | 4 | 7 | 43% |
| `de.null.bgh` | 6 | 5 | 1 | 6 | 83% |
| `dpma.appeal.bpatg` | 5 | 4 | 1 | 5 | 80% |
| `dpma.appeal.bgh` | 4 | 3 | 1 | 4 | 75% |
| `dpma.opp.dpma` | 4 | 3 | 1 | 4 | 75% |
| `upc.disc.cfi` | 4 | 3 | 1 | 4 | 75% |
| `upc.bsv.cfi` | 0 | 0 | 0 | 0 | **unruled** |
| `upc.ccr.cfi` | 0 | 0 | 0 | 0 | **unruled** |
| `upc.costs.cfi` | 0 | 0 | 0 | 0 | **unruled** |
| `upc.dni.cfi` | 0 | 0 | 0 | 0 | **unruled** |
| `upc.epo.review` | 0 | 0 | 0 | 0 | **unruled** |
| `upc.pl.cfi` | 0 | 0 | 0 | 0 | **unruled** |
† `upc.apl.unified` (id=160) is the active type, but its 16 events
retain the *legacy* code prefixes `upc.apl.{merits,cost,order}.*`
from the pre-unification taxonomy. The rules' `proceeding_type_id`
was rebound to 160; the event codes were not renamed. Functional but
inconsistent — see R3.
**Events with no rule:** 0. Every active+published event has at least
one rule (corpus is 1:1 since mig 136). Editorial gap is therefore
parent-chain-shaped, not rule-coverage-shaped.
**Unmatched-prefix events:** 69 events with `code LIKE 'null.%'`. They
have rules (the 73 legacy globals — note the disparity: 73 rules but
69 events, because dedupe in mig 151 collapsed some duplicates while
the rules still point at the canonical event). They do not belong to
any proceeding_type and never will under the current taxonomy.
---
## 5. Risk register
Eleven items. Each: what, where, severity. Severity scale:
**critical** (user-visible incorrect output / data loss possible) →
**high** (user-visible UX lie, no data corruption) → **medium**
(developer-trap; breaks at next refactor) → **low** (cosmetic / dead
code, deferred maintenance).
### R1 — Cross-party follow-up filter drops legitimate hand-offs — **high**
- Where: `internal/services/fristenrechner_followups.go:358-367`.
- Effect: with `party=claimant|defendant`, 39 active rules are hidden
because their `primary_party` is the *other* side. Result-view
reports "Keine Folge-Fristen" on chains that continue cross-party
(e.g. `def_to_ccr` claimant-filed → `reply_def_ccr` defendant-filed
in `upc.inf.cfi`).
- Impact: UX lies to users about chain completion; can lead to missed
deadlines on the opposing side's view.
### R2 — Picker accepts spawn-only and leaf events — **high**
- Where: `internal/services/fristenrechner_followups.go:241-287` (anchor
resolution does not check chain-anchor status); `internal/services/fristenrechner_search_events.go`
(search returns every event).
- Effect: Picking `upc.{inf,rev,pi,dmgs}.cfi.appeal_spawn` (spawn-only)
shows the spawn rule itself but no follow-ups → "Keine Folge-Fristen".
Picking a leaf event (e.g. `upc.inf.cfi.def_to_ccr`) only reaches
whatever hop-1 children exist on the leaf's own party, see R1.
- 67/222 active events are chain-anchors. Today's picker shows all
222 with equal weight.
### R3 — 4 spawn rules point at an inactive `proceeding_type` — **high**
- Where: 4 rows in `paliad.sequencing_rules` with `is_spawn=true`
and `spawn_proceeding_type_id=11` (`upc.apl.merits`, `is_active=false`).
The active appeal type is id=160 (`upc.apl.unified`).
- Effect: any consumer that joins on `spt.is_active=true` (none today,
but the moment any does) returns NULL for the spawn target. Today
the join is permissive (`fristenrechner_followups.go:394`) — it
returns `upc.apl.merits` to the frontend, which may surface as a
CTA pointing at a stale type slug.
- Plus consequence: `upc.apl.unified` events kept legacy code prefixes
`upc.apl.{merits,cost,order}.*` even though the type rebinds to 160.
Code/PT mismatch is harmless today; trap for any future code-prefix
routing.
### R4 — 73 "global" legacy rules orphan from the chain model — **medium**
- Where: `paliad.sequencing_rules WHERE proceeding_type_id IS NULL AND trigger_event_id IS NOT NULL` (73 rows). Anchored on `null.<8hex>`
procedural_events (69 distinct events, 73 rules — small overlap from
pre-dedupe history).
- Effect: invisible to Mode B (proceeding-type ablauf) because they
don't bind to any PT; visible to the legacy bigint route
`/api/tools/event-deadlines` and to /admin/procedural-events.
- Migration debt: any "deprecate `trigger_event_id`" plan must decide
whether to (a) reparent these onto a PT + parent chain, (b) keep them
as floating cross-cutting rules in a separate lane, or (c) drop them.
### R5 — Legacy `paliad.trigger_events` table is read by 5 surfaces — **medium**
- Where:
- `internal/services/deadline_rule_service.go:226-285` — bulk-load for
"abhängig von …" chip label fallback.
- `internal/services/event_deadline_service.go:79,244` — legacy
`/api/tools/event-deadlines` route.
- `internal/services/event_type_service.go:40-414` — Pipeline-C event
types bridge (`event_types.trigger_event_id`).
- `internal/services/export_service.go:1680` — `ref__trigger_events`
workbook sheet.
- `cmd/gen-upc-snapshot/main.go:185-202` — UPC offline snapshot for
youpc.org.
- Effect: 110-row catalog with bigint PK lives alongside the 222 active
procedural_events (UUID PK). Two ID spaces, two label sources,
partial overlap.
### R6 — Three scenario stores: 0 rows each, but 3 live read/write paths — **medium**
- Stores: `paliad.project_event_choices` (0 rows), `paliad.scenarios`
(0 rows), DOM state on Verfahrensablauf checkboxes.
- Paths:
- `EventChoiceService` (`internal/services/event_choice_service.go:15-180`)
reads + writes the table.
- `ScenarioService.LoadScenarios` + handlers
(`internal/services/fristenrechner.go:583-627`, `internal/handlers/scenarios.go:14-200+`)
read + write the table.
- Verfahrensablauf result view writes nothing back — DOM only.
- Effect today: nothing — empty tables. Effect tomorrow: the moment any
surface starts persisting, the three paths can diverge. The RFC
(§"What's actually broken" item 3) calls out the symptom: toggling
"Mit Widerklage" on Verfahrensablauf doesn't drive conditional
checkboxes in result-view submission cards.
### R7 — 6 active `proceeding_types` are entirely unruled — **medium**
- Where: `upc.bsv.cfi`, `upc.ccr.cfi`, `upc.costs.cfi`, `upc.dni.cfi`,
`upc.epo.review`, `upc.pl.cfi`. All `is_active=true`, `kind='proceeding'`,
0 active+published rules, 0 events with their code prefix.
- Effect: pickable on `/api/tools/proceeding-types`, bindable on
`paliad.projects.proceeding_type_id` (mig 153 only rejects non-
proceeding kind, not zero-rule). Binding succeeds → SmartTimeline +
Mode B render an empty result. UX lies.
### R8 — `condition_expr` is freeform jsonb — **medium**
- Where: column declaration in mig 136; consumer in
`deadline_rule_service.go` (selected + passed to engine in
`pkg/litigationplanner/engine.go`); writer in
`internal/services/rule_editor_service.go:625-843` (no validation).
- Effect: 4 distinct shapes used today, 3 keys (`flag`, `op`, `args`).
No write-time validation. New keys can be silently added; the
engine consumes by switching on string literals. Refactor trap.
### R9 — Inactive `proceeding_types` rows linger (23) — **low**
- Where: mig 153 flipped 4 phase + 10 side_action + 9 meta rows to
`is_active=false`. They still exist for audit.
- Effect: snapshots and snapshots-of-snapshots
(`proceeding_types_pre_153`, `procedural_events_pre_151`,
`sequencing_rules_pre_151/_pre_152`) accumulate without a decay
policy. Storage cost is trivial; query-shape cost is real if any
query forgets `WHERE kind='proceeding' AND is_active=true`.
### R10 — `event_kind` is nullable + not enumerated in DB — **low**
- Where: `paliad.procedural_events.event_kind text NULL`. Code at
`frontend/src/admin-rules-edit.tsx:187` lists `filing / hearing /
decision / order` in the UI but the DB accepts anything.
- Effect: drift between UI vocab and persisted values is possible.
Currently 5 buckets: `filing`, `hearing`, `decision`, `order`, NULL
(per RFC).
### R11 — `applies_to_target` + `choices_offered` lack a schema — **low**
- Where: `paliad.sequencing_rules.applies_to_target text[]`,
`choices_offered jsonb`.
- Effect: 16 rules use `applies_to_target`, 28 use `choices_offered`.
Three observed `choices_offered` shapes: `{appellant:[…]}` (20),
`{skip:[…]}` (6), `{include_ccr:[…]}` (2). Wire-level convention,
no documentation. New shapes silently land if a future editor
decides on one.
---
## 6. Recommendation — order of operations for the inventor
Phase 2 design starts with the highest-stakes, hardest-to-rewind
decisions and finishes with editorial/cleanup. Each step is a
question for m, not a design choice for the inventor.
### Tier 1 — model decisions (grill first)
1. **Trigger semantics.** Keep `parent_id` as the canonical link?
What is the role of `trigger_event_id` after this RFC ships? If
deprecated, what happens to the 73 legacy globals (R4) — reparent
onto PTs, keep as a separate "cross-cutting" lane, or drop?
2. **Trigger discoverability.** Derive from data (events that
parent ≥1 rule = 67 today), maintain a materialised view, or carry
an explicit `is_trigger` flag on `procedural_events`? Affects R2.
3. **Scenario state — single home.** Of the three stores in R6, which
wins? Migration shape for the others? The RFC mis-spoke about
`projects.scenarios jsonb` — the table is `paliad.scenarios` with
a `spec` jsonb column (mig 145). Confirm which storage the inventor
reasons from.
4. **Cross-party display semantics.** Backend stops filtering,
frontend groups by side? Or backend tags + frontend renders an
"andere Partei" group? Affects R1.
### Tier 2 — surface decisions
5. **Spawn → consequence-only events.** Stop surfacing spawn-only
events in the picker (R2), or keep them and tag visually?
6. **Re-target the 4 spawn rules** (R3) — point at id=160 vs reseed
legacy ids; align event code prefixes vs. accept the mismatch.
7. **Sequence-from-proceeding-type view** (Entry A). Where does it
live? How do its toggles persist to the chosen scenario store?
8. **Legacy `/api/tools/event-deadlines` deprecation** (R5). Drop,
redirect, or keep behind a flag during transition?
### Tier 3 — editorial + cleanup
9. **Editorial backfill plan.** Which of the 119 parent-NULL rules
are real roots vs. unseeded leaves (a per-PT walkthrough by m).
10. **Empty proceeding_types** (R7). Stub with placeholder rules, or
hide from the picker until rules land?
11. **`condition_expr` formalisation** (R8). Pick a grammar, document
it, add write-time validation. Same question for `choices_offered`
+ `applies_to_target` (R11).
12. **Legacy `trigger_events` table fate.** Drop, archive, or
repurpose? Depends on Q1 + Q2 above.
The inventor should grill m on Tier 1 before sketching anything.
Tier 2 follows from Tier 1's decisions. Tier 3 is mechanical once
Tier 1+2 land.
---
## Appendix — query receipts
All counts in this assessment came from the live `paliad` schema on
the youpc Supabase instance during the audit window (2026-05-27).
Representative queries:
```sql
-- §0 + §3.1 + §3.6
SELECT
CASE
WHEN parent_id IS NOT NULL AND trigger_event_id IS NOT NULL THEN 'both'
WHEN parent_id IS NOT NULL AND trigger_event_id IS NULL THEN 'parent only'
WHEN parent_id IS NULL AND trigger_event_id IS NOT NULL THEN 'legacy only'
ELSE 'neither (root)'
END AS classification,
proceeding_type_id IS NULL AS pt_null, count(*) AS rules
FROM paliad.sequencing_rules
WHERE is_active AND lifecycle_state = 'published'
GROUP BY classification, pt_null
ORDER BY classification, pt_null;
-- → both/false=2, legacy only/true=73, neither/false=46, parent only/false=105
-- §3.4
SELECT pt.code, sr.primary_party, count(*)
FROM paliad.sequencing_rules sr
LEFT JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
WHERE sr.is_active AND sr.lifecycle_state='published'
GROUP BY pt.code, sr.primary_party ORDER BY pt.code, count(*) DESC;
-- §4 (gap map)
SELECT pt.code, count(sr.id) AS active_rules,
count(*) FILTER (WHERE sr.parent_id IS NULL) AS roots
FROM paliad.proceeding_types pt
LEFT JOIN paliad.sequencing_rules sr ON sr.proceeding_type_id = pt.id
AND sr.is_active AND sr.lifecycle_state='published'
WHERE pt.is_active AND pt.kind='proceeding'
GROUP BY pt.code ORDER BY pt.code;
-- §3.2 (condition_expr keys)
WITH expanded AS (
SELECT jsonb_object_keys(condition_expr) AS k
FROM paliad.sequencing_rules
WHERE condition_expr IS NOT NULL AND condition_expr::text <> '{}'
) SELECT k, count(*) FROM expanded GROUP BY k ORDER BY count(*) DESC;
-- → flag=14, args=4, op=4
```
Full set of queries used during the audit is available in the agent
transcript; reproducible against any read-only Supabase role.
— end of assessment.

View File

@@ -0,0 +1,181 @@
# CI/CD runner setup — paliad
**Companion to:** `docs/design-cicd-pre-deploy-gate-2026-05-25.md` (Slice A, t-paliad-282 / m/paliad#114)
**Date:** 2026-05-25
**Audience:** mlake / mriver admin (m or head)
Slice A's `.gitea/workflows/test.yaml` requires (a) at least one online Gitea Actions runner and (b) a Dokploy API token wired as a repo secret. Both are one-time setup actions that paliad's source tree cannot perform itself — they live on infra-side. This doc lists them so the workflow can go green on its first run.
---
## 0. Pre-flight: what already exists
Verified live (2026-05-25 cronus inventor shift):
- Gitea 1.24.4 on `mgit.msbls.de`, `has_actions: true` on `m/paliad`.
- `/api/v1/admin/actions/runners` reports **2 runners** registered. They are likely the shared runners used by `m/mGreen` and `m/mGeo` (both have `.gitea/workflows/deploy.yml` with `runs-on: self-hosted`).
- `m/paliad/actions/tasks` reports `total_count=0` — paliad has never run a workflow yet.
The existing runners may already be capable of running paliad's workflow without further setup. The verification step (§3) below tells you whether they are.
---
## 1. Runner placement decision (m's Q11.1)
m's pick: **mriver**.
Rationale: mriver hosts the mai worker fleet but workers spend most of their time waiting on Anthropic. mlake's Dokploy + Swarm workload is more contended. A new runner on mriver adds the least pressure to either box.
If mriver is offline or saturated when CI first fires, fall back to the existing mlake-side runners (they're already registered; no provisioning needed).
---
## 2. One-time setup (admin steps)
### 2.1 Register a new Gitea Actions runner on mriver
```bash
# On mriver, as m:
# 1. Download the act_runner binary (matching Gitea 1.24.x)
curl -L -o /usr/local/bin/act_runner \
https://gitea.com/gitea/act_runner/releases/download/v0.2.13/act_runner-0.2.13-linux-amd64
chmod +x /usr/local/bin/act_runner
# 2. Get a runner registration token. In the Gitea UI:
# /admin → Actions → Runners → "Create new Runner"
# (or org-scope: /m/paliad/settings/actions/runners)
# Copy the token.
# 3. Register
mkdir -p ~/act_runner && cd ~/act_runner
act_runner register --no-interactive \
--instance https://mgit.msbls.de \
--token <REGISTRATION_TOKEN> \
--name mriver-paliad-1 \
--labels ubuntu-latest:docker://node:20-bookworm
# 4. Run as a systemd unit (preferred) or as a session daemon
# Systemd unit example: /etc/systemd/system/act_runner.service
# [Unit]
# Description=Gitea Actions runner
# After=network.target
# [Service]
# User=m
# WorkingDirectory=/home/m/act_runner
# ExecStart=/usr/local/bin/act_runner daemon
# Restart=on-failure
# [Install]
# WantedBy=multi-user.target
sudo systemctl enable --now act_runner
sudo systemctl status act_runner
```
**Why `ubuntu-latest:docker://node:20-bookworm` for the label?** Gitea Actions' `runs-on: ubuntu-latest` resolves via the runner's label map. Mapping it to a Docker image gives the workflow a sandbox with Docker available — required for our Postgres service container in `test.yaml`. mriver should have Docker (for `paliadin-shim`); if not, install it.
### 2.2 Register the Dokploy API token as a repo secret
The workflow's `deploy` job needs `secrets.DOKPLOY_TOKEN`. Use the existing project-wide Dokploy API key (the one stored in `~/.claude/skills/mai-dokploy/SKILL.md`).
In the Gitea UI:
- Navigate to `https://mgit.msbls.de/m/paliad/settings/actions/secrets`
- Click "Add secret"
- Name: `DOKPLOY_TOKEN`
- Value: `mai-ottosSyRHMhmLhhhXaCbKzbqKBuSqzqEtmKDOPelPCeimTaYsbmaVslVyEgJZGCIxVdz`
Or via API (mAi identity):
```bash
curl --netrc-file ~/.netrc-mai -sS -X POST \
-H "Content-Type: application/json" \
https://mgit.msbls.de/api/v1/repos/m/paliad/actions/secrets/DOKPLOY_TOKEN \
-d '{"data":"mai-ottosSyRHMhmLhhhXaCbKzbqKBuSqzqEtmKDOPelPCeimTaYsbmaVslVyEgJZGCIxVdz"}'
```
(Requires repo-owner permission. If mAi lacks it, m runs it.)
---
## 3. Verify the runner sees the workflow
After (2.1) + (2.2):
```bash
# Push the Slice A branch (the one this doc lives on)
git push origin mai/cronus/coder-cicd-slice-a
# Confirm the runner picked up the job
curl --netrc-file ~/.netrc-mai -sS \
"https://mgit.msbls.de/api/v1/repos/m/paliad/actions/tasks?limit=5" | jq '.'
```
A new task per job should appear (build, test-go). If `total_count` stays 0, the runner labels don't match the workflow's `runs-on`. Re-register with `--labels ubuntu-latest` (no docker:// suffix) and the existing runners on mlake will pick it up via shell mode.
---
## 4. Soft-launch (m's Q11.4)
m's pick: **keep both Dokploy auto-deploy and the workflow's deploy step alive for ~1 week. After ≥5 successful green deploys via the workflow, disable Dokploy's autoDeploy in the Dokploy UI for the paliad compose.**
While both are live, every push to main fires:
1. Dokploy webhook (existing path) → deploys immediately, no gate.
2. Gitea workflow → on green, ALSO calls `compose.deploy`.
The second call is idempotent — if Dokploy already deployed the same commit, this is a no-op. The workflow's value during soft-launch is the **gate signal**: a red workflow on a green main = the bad migration shipped via the unguarded webhook and broke prod, and the workflow is shouting about it.
After confidence builds:
1. In the Dokploy UI, navigate to the paliad compose → Settings.
2. Toggle "Auto Deploy" off.
3. Save.
From this point, the only path to deploy is the workflow's deploy job. Red workflow = no deploy.
---
## 5. What Slice A catches today — and what it doesn't
After this branch (`mai/cronus/coder-cicd-slice-a`) merges to main:
### Catches (active in CI)
- **Build breakage** — `go build`, `go vet`, `bun run build`. Red gate, no deploy.
- **Slot collisions** — `TestMigrations_NoDuplicateSlot` runs without a DB. A PR adding migration N when version N already exists fails at gate time. This is the brunel-class catch (m/paliad#114 ~13:20 outage).
- **New-migration shape errors (hermes class)** — `TestBootSmoke` runs `ApplyMigrations` against the snapshot-restored DB. New migs from this PR get applied for real; any column/relation/syntax error fails the gate before merge.
- **New-migration ownership errors (mig 129 42501 class)** — `TestMigrations_EndToEndAsAppRole` runs `ApplyMigrations` connected as `postgres` (NON-superuser on `supabase/postgres:15.8.1.060`, same role topology as youpc-supabase prod). Any migration that assumes supabase_admin privilege fails with the same `42501 must be owner` error class that took paliad.de offline on 2026-05-25.
- **Readiness probe regressions** — `TestHealthReady_Live` confirms `/health/ready` returns 200 against a live pool, 503 against a nil pool.
- **Pure-Go test regressions** — `go test ./internal/... ./cmd/...` runs without `TEST_DATABASE_URL` (live-DB service tests skip the same way they do on a developer laptop without a scratch DB).
### Mechanism — the snapshot approach
CI's scratch DB starts from a `pg_dump` of youpc-supabase paliad schema +
`paliad.applied_migrations` rows, committed to `internal/db/testdata/prod-snapshot.sql`. After restore, the scratch DB is at "paliad HEAD of snapshot" and `ApplyMigrations` sees only this PR's new migrations as pending.
This sidesteps the fresh-DB idempotence problem: several historical migrations (notably mig 037's missing `CREATE EXTENSION pg_trgm`, mig 051's inner `COMMIT;`) can't be replayed from scratch against `supabase/postgres:15.8.1.060`. The snapshot pins everything that's already applied in prod and lets CI focus on what's new — which is what we actually care about for outage prevention.
Snapshot refresh: `make refresh-snapshot` with `PALIAD_PROD_DATABASE_URL` set (see `internal/db/testdata/README.md`).
### Known gap — live-DB service tests don't run in CI
`internal/services/*_test.go` tests with `TEST_DATABASE_URL` set fail against `supabase/postgres:15.8.1.060` with `42P08 inconsistent types deduced for parameter` errors on some INSERT bind paths. The same tests pass against youpc-supabase prod. Cause is unconfirmed — likely subtle differences in type inference between the dockerized image and the prod cluster's configuration. CI today runs `go test ./...` without `TEST_DATABASE_URL` so these tests skip. Not blocking outage prevention; tracked as a follow-up for the post-Slice-A coder.
### Migration cleanup also bundled in this PR
Two surgical migration improvements that surfaced during snapshot debugging — kept here because they're small and harmless:
- **mig 024 + 027** — `ALTER INDEX` / `ALTER POLICY` exception handlers now catch `undefined_object` OR `undefined_table` OR `duplicate_object`. Old handler caught only `undefined_object`; Postgres raises `undefined_table` when the source object never existed and `duplicate_object` when the destination already exists. The expanded handler makes the migrations truly idempotent across the three plausible states: source-still-German (rename succeeds), already-renamed (catches duplicate_object), and fresh-DB-never-had-German (catches undefined_table).
Other migration history bugs (mig 037 missing pg_trgm, mig 051 inner COMMIT) are tracked as a separate cleanup task — not blocking, because the snapshot bypasses them.
### Verification checklist (after Slice A merges)
1. **Workflow green on its first PR run?** Check `/m/paliad/actions`. If not, fix before merging.
2. **Dokploy `compose.deploy` call succeeds?** The workflow's `deploy` job logs the POST response. A successful response is a Dokploy job ID; a 4xx is an auth or compose-id problem.
3. **`/health/ready` returns 200 within 5 minutes after a green deploy?** The workflow polls this. If it times out, the migration may have failed silently inside the new container — check `docker logs --tail 50 compose-transmit-multi-byte-driver-v7jth9-web-1` on mlake.
4. **Reproduce the slot-collision catch locally:** rename `131_…up.sql` to `129_…` (duplicate slot) → workflow MUST fail at `Migration coordination check`. Revert before pushing.
5. **Reproduce the role-split catch locally:** add a no-op migration `132_test_supersedes.up.sql` containing `REINDEX SYSTEM paliad_scratch;` (requires superuser). Workflow MUST fail at `Migration end-to-end (deploy role)`. Revert before pushing.
---
## 6. Future polish (Slice D, m's Q4 R-pick)
`mai-test` post-merge shift: once Slice A is stable, wire a Gitea webhook on push-to-main that fires `/mai-test` as a follow-up shift. It runs the broader smoke + integration suite and posts results as a Gitea commit status. Not blocking; the gate doesn't depend on it.
Implementation belongs in `m/mAi` (the mai webhook handler), not in paliad. Out of scope for Slice A.

View File

@@ -0,0 +1,448 @@
# Design: Align calendar-view rendering between Events/Termine and Custom Views
**Task:** t-paliad-224 — m/paliad#55
**Author:** bohr (inventor)
**Date:** 2026-05-20
**Status:** ACCEPTED — all 8 (R) defaults confirmed by head 2026-05-20 (msg #2087); coder shift authorised on same branch.
**Branch:** `mai/bohr/calendar-view-align`
---
## 0. Premise check (verified against live source 2026-05-20)
m's brief mentions two surfaces ("Events/Termine" and "Custom Views' calendar view type"). The live codebase has **three** distinct calendar implementations, not two:
| | A — Events tab | B — Standalone | C — Custom Views |
|---|---|---|---|
| URL | `/events?type=…&` calendar tab | `/deadlines/calendar`, `/appointments/calendar` | `/views/{slug}` with `render_spec.shape="calendar"` |
| Shell TSX | `frontend/src/events.tsx:239-269` (inline `events-calendar-wrap` block) | `frontend/src/deadlines-calendar.tsx`, `frontend/src/appointments-calendar.tsx` | `frontend/src/views.tsx:104` (`views-shape-calendar` host) |
| Renderer | `frontend/src/client/events.ts:589-656` (`renderCalendar()`) | `frontend/src/client/deadlines-calendar.ts`, `frontend/src/client/appointments-calendar.ts` | `frontend/src/client/views/shape-calendar.ts` (525 lines, mounted from `client/views.ts:227`) |
| Build entry | `events.html` (one bundle) | `deadlines-calendar.html` + `appointments-calendar.html` (two extra bundles) — `frontend/build.ts:258,261,387,390` | none (mounted into the views host at runtime) |
| Handler | `handleEventsPage` | `handleDeadlinesCalendarPage`, `handleAppointmentsCalendarPage``internal/handlers/handlers.go:470,476`; impls in `internal/handlers/deadlines_pages.go:26`, `internal/handlers/appointments_pages.go:27` | `handleViewsBySlug` |
**Reachability of B (standalone calendars).** `grep` for the URL strings inside `frontend/` finds only `paliadin-context.ts:96,100` (which decode the URL when the user is **already** on the page). The current Sidebar (`frontend/src/components/Sidebar.tsx:162-163`) routes to `/events?type=deadline` and `/events?type=appointment` — the calendar tab inside `/events` is the only UI-reachable calendar today. Routes B exist but are orphaned in navigation; they live for bookmarks / external links / paliadin context.
The brief's choice of canonical renderer ("likely the Custom Views renderer if it's the more recent / general one") is the right one — verified below in §3.
---
## 1. m's intent (as I read it)
> "the calendar views in Events / Termine are different than in the custom views calendar view type. That should be aligned!"
The literal statement is about visual + behavioural parity. Read alongside the brief's "drop the duplicate code path" and the explicit naming of `shape-calendar.ts` / `appointments-calendar.tsx` / `client/appointments-calendar.ts`, the intent is:
1. **One calendar component**, mounted from both the events-page surface and the custom-views surface.
2. **Identical visual output** when the same items land in either surface.
3. **No duplicate code path** — orphaned standalone calendar TSX + client + dist pages go.
4. **Alignment first, not new features** — drag-to-create / week-resize / etc. are explicitly out of scope per the issue body.
The smallest-diff path that delivers that intent is "canonicalise on shape-calendar.ts and fold A in" — see §3.
---
## 2. What actually diverges today
Side-by-side after reading all three implementations (cited line numbers above):
| Dimension | A (`/events` tab) | B (`/deadlines/calendar`, `/appointments/calendar`) | C (Custom Views) |
|---|---|---|---|
| Views offered | month only | month only | month + week + day |
| URL deep-link state | none (calendar month is in-memory, lost on refresh) | none | yes — `?cal_view=…&cal_date=YYYY-MM-DD` |
| Cell content | day-num + max 4 dots + "+N" | day-num + max 4 dots + "+N" | day-num + max 3 text **pills** + "+N" |
| Dot/pill colour key | urgency for deadlines (`frist-urgency-overdue/soon/later/done`) + single appointment colour (`events-cal-dot-appointment`) — mixed semantics | (deadlines page) urgency only; (appointments page) appointment-type colours via `termin-type-hearing/meeting/consultation/deadline_hearing` + legend strip | **kind-coded**`views-calendar-pill--{deadline|appointment|project_event|approval_request}` |
| Today indicator | accent circle on day-number (`frist-cal-today .frist-cal-day` → coloured pill) | identical to A | border + inset box-shadow ring on entire cell (`views-calendar-cell--today`) |
| Click cell | opens modal popup (`#events-cal-popup`) listing the day's items | opens modal popup (`#cal-popup`) | drills into **day view** (changes URL via `?cal_view=day&cal_date=…`), no modal |
| "+N" overflow | rendered as static `.frist-cal-more` span (not clickable) | identical | rendered as a button — opens the day view (same drill as the day-num button) |
| Empty state | per-month "Keine Einträge im ausgewählten Zeitraum." | per-month "Keine Fristen…" / "Keine Termine…" | per-day in week/day views ("Keine Einträge."), no per-month empty in month view |
| Toolbar | inline month-label + Heute button | identical | view-switcher chips (M/W/D) + range-label + (in day/week) "Zurück zum Monat" link |
| Weekday header | 7 static `.frist-cal-weekday` divs hard-coded in TSX | identical | rendered inline in the JS grid (single grid spans weekday row + day cells) |
| Mobile fallback | `@media (max-width: 700px)` shrinks cell min-height to 64px (CSS-only) | identical | `<600px` → adds a notice + uses cards-style stack; CSS-only no special media query (notice is data-driven) |
| Data source | `/api/events` (one fetch, all items unfiltered by date) | `/api/deadlines` or `/api/appointments` separately | `/api/views/{slug}/run` (filter-spec backed, ViewRow[] discriminated by `kind`) |
| Item shape | `EventListItem` (discriminator field `type`) | `Deadline` or `Appointment` (typed) | `ViewRow` (discriminator field `kind`) |
| Detail link | `/deadlines/{id}` or `/appointments/{id}` from popup row | identical | direct anchor on the pill/row, no popup |
| Lang / i18n | `cal.day.*`, `events.calendar.empty` | `cal.day.*`, `appointments.kalender.empty`, `deadlines.kalender.empty`, `appointments.type.*` (legend) | `cal.day.*`, `cal.view.*`, `cal.month.{prev,next}`, `cal.week.*`, `cal.day.no_entries`, `views.calendar.mobile_fallback` |
The two A/B implementations are near-clones of each other — Slice C alignment alone wouldn't fix the bigger "two of these are the same code with a coat of paint" problem.
CSS surface: `.frist-cal-*` is consumed **only** by A + B (verified by grep across `frontend/` + `internal/` — no third party). After the refactor, the entire `.frist-cal-calendar`, `.frist-cal-grid`, `.frist-cal-cell{,-empty,-has}`, `.frist-cal-day`, `.frist-cal-today`, `.frist-cal-dot{*}`, `.frist-cal-more`, `.frist-cal-popup-*`, `.frist-cal-weekday`, `.termin-cal-legend{,-item}`, `.termin-cal-dot`, `.events-cal-dot-appointment` block in `frontend/src/styles/global.css:7464-7620` and `:8019-8023` and `:8680-8700` and `:11519-11533` is deletable. About **180 lines of CSS** go away.
---
## 3. Recommended design (TL;DR)
| Area | Recommendation | Smallest-diff alternative considered & rejected |
|---|---|---|
| **Canonical renderer** | `shape-calendar.ts` is the canonical renderer. Extract its mount API behind a small `mountCalendar(host, items, opts)` boundary so both /events and /views call it. | Two-way merge (cherry-pick best of both into a third component) — strictly more code, no clean canon to point coders at later. |
| **/events calendar tab** | Replaces inline month grid + popup with a `mountCalendar(host, items, { urlState: true, defaultView: "month" })` call. Drops `renderCalendar()`, `openCalPopup()`, `wireCalNav()`, and the entire `events-cal-*` TSX subtree. Gains month/week/day views, drill-down, URL state — for free. | Keep A as-is, only converge B with C: leaves the headline divergence (the one m sees in the UI today) unresolved. |
| **/deadlines/calendar + /appointments/calendar** | Routes redirect 301 to `/events?type=deadline&view=calendar` and `/events?type=appointment&view=calendar`. TSX + client + dist artefacts deleted. `paliadin-context.ts` entries for the old paths kept (the redirect target carries through to the same context label). | Delete routes outright: breaks bookmarks. A 301 is one line per route. |
| **Data adapter** | `client/events.ts` already loads `EventListItem[]` from `/api/events`. Adapter is a one-liner field rename (`type``kind`) — the rest of the shape is identical to `ViewRow`. Existing API endpoints unchanged. | Migrate /events tab to `/api/views/{slug}/run` with an ad-hoc filter spec: pulls a lot of substrate (filter spec assembly, view caching) into the events flow for zero gain when the existing API already returns the right shape. |
| **Per-shape config** | Reuse `CalendarConfig` (`default_view`, `show_weekends`). `/events` calendar tab passes `default_view: "month"` so it stays month-first; future surfaces can pass `"week"` if needed. | Hard-code "month" inside mountCalendar — closes the door on /events week/day tabs we may want later. |
| **Subtype dot colouring** | Drop the per-appointment-type colour legend (deadline-only colouring was urgency-based and mixed semantics with subtype anyway). Pills are kind-coded only — same as `/views/{slug}` with `shape=calendar` does today. Subtype colouring can be added later as a `CalendarConfig.subtype_colors: bool` flag if a user asks. | Preserve the type-colour legend on the events page: only the orphaned /appointments/calendar page exposes it today, and bringing it into /events means designing the legend at the events-page level (events can be deadlines OR appointments OR both per current chip filter). Easier to defer until requested. |
| **CSS** | Delete the `.frist-cal-*` block entirely (~180 lines). The single source of truth becomes `.views-calendar-*`. Same lime-green accent (`var(--color-accent)`), same surface tokens — colour parity is automatic. | Keep both blocks: leaves a CSS minefield where future devs are unsure which class to use. |
| **i18n** | New keys land under the existing `cal.*` namespace (`cal.view.month/week/day`, `cal.day.back_to_month`, `cal.day.open_day`, `cal.day.no_entries`, `views.calendar.mobile_fallback`). These already exist for Custom Views — no new strings needed. Delete the `appointments.kalender.*`, `deadlines.kalender.*`, `appointments.type.*` (legend-only) keys, plus `events.calendar.empty` (replaced by `cal.day.no_entries` at the day-view level). | Keep DE/EN strings as-is for compatibility: just delete-and-go. The keys aren't part of any user-saved data. |
**Net code change (estimated by file):**
- **Delete:** `frontend/src/appointments-calendar.tsx`, `frontend/src/deadlines-calendar.tsx`, `frontend/src/client/appointments-calendar.ts`, `frontend/src/client/deadlines-calendar.ts` — together ~560 lines.
- **Trim:** ~80 lines from `events.tsx` (calendar subtree), ~140 lines from `client/events.ts` (`renderCalendar`/`openCalPopup`/nav handlers/calendar state).
- **Trim:** ~180 lines from `global.css` (`.frist-cal-*` block).
- **Add:** `frontend/src/client/calendar/mount-calendar.ts` — the extracted public API (~60 lines incl. types).
- **Refactor:** `frontend/src/client/views/shape-calendar.ts` becomes a 30-line wrapper that calls `mountCalendar` with `urlState: true` and the spec's calendar config. Most of the existing 525 lines move into `mount-calendar.ts` verbatim.
- **Backend:** 4 lines total — turn the two standalone-calendar handlers into 301 redirects (one line each, plus matching delete of the standalone HTML file write in `frontend/build.ts:387,390`).
Net: **~700 LOC removed, ~100 LOC added, zero new endpoints, zero schema changes, zero new dependencies.**
---
## 4. Architecture sketch
```
┌─────────────────────────────┐
│ frontend/src/client/ │
│ calendar/ │
│ mount-calendar.ts ★ │ ← new shared module
│ types.ts (CalendarItem)│
└──────────────┬──────────────┘
┌────────────────────────┼─────────────────────────┐
│ │ │
client/events.ts (Kalender tab) client/views/ │
│ shape-calendar.ts │
│ (thin wrapper) │
│ │ │
│ ▼ │
│ client/views.ts │
│ paintRows(…, "calendar") │
│ │
└──────────────────────────────────────────────────┘
Data flows:
A: /events → fetch /api/events?type=…&status=… → EventListItem[]
→ toCalendarItem(items) → CalendarItem[]
→ mountCalendar(host, items, opts)
C: /views/{slug} → fetch /api/views/{slug}/run → ViewRow[]
→ toCalendarItem(rows) (noop-ish: rename typekind already done)
→ renderCalendarShape() → mountCalendar(host, items, opts)
```
### 4.1 The shared module (`mount-calendar.ts`)
```ts
// frontend/src/client/calendar/mount-calendar.ts
import { t, tDyn, getLang, type I18nKey } from "../i18n";
export type CalendarKind =
| "deadline" | "appointment" | "project_event" | "approval_request";
export interface CalendarItem {
kind: CalendarKind;
id: string;
title: string;
event_date: string; // ISO-8601; first 10 chars are yyyy-mm-dd
project_id?: string;
project_title?: string;
project_reference?: string;
}
export interface CalendarOpts {
defaultView?: "month" | "week" | "day";
/** If true, calendar reads/writes ?cal_view + ?cal_date (or the prefixed
* equivalents); if false, state is in-memory only (use for embedded
* calendars where URL state belongs to the host page). */
urlState?: boolean;
/** Optional prefix for URL params (default: empty). Set if more than
* one calendar might live on the same URL. */
urlPrefix?: string;
/** Optional override: how to render a row's href. Default uses the
* kind→/deadlines|/appointments|/inbox|/projects routing the existing
* shape-calendar.ts ships with. */
hrefFor?: (item: CalendarItem) => string;
}
export interface CalendarHandle {
/** Re-render with a new item set (e.g. after a filter change in /events). */
update(items: CalendarItem[]): void;
/** Tear down listeners + clear host. */
destroy(): void;
}
export function mountCalendar(
host: HTMLElement,
items: CalendarItem[],
opts?: CalendarOpts,
): CalendarHandle;
```
Internals lifted verbatim from `shape-calendar.ts` (toolbar, renderMonth/Week/Day, renderPill, renderRowAnchor, bucketByDate, filterByDay, startOfWeek, shift, isToday, isoDate, formatRangeLabel, formatWeekHeader, readView/Anchor, writeURL). Two tweaks:
- `readView`/`readAnchor`/`writeURL` accept the `urlPrefix` so embedded calendars on `/events?…&` don't clobber other pages' `?cal_view`.
- `urlState: false` skips the URL read/write entirely — initial state comes from `opts.defaultView` and "today".
### 4.2 `shape-calendar.ts` (after refactor)
```ts
import type { RenderSpec, ViewRow } from "./types";
import { mountCalendar, type CalendarItem } from "../calendar/mount-calendar";
export function renderCalendarShape(
host: HTMLElement, rows: ViewRow[], render: RenderSpec,
): void {
const items: CalendarItem[] = rows.map(r => ({
kind: r.kind,
id: r.id, title: r.title,
event_date: r.event_date,
project_id: r.project_id,
project_title: r.project_title,
project_reference: r.project_reference,
}));
mountCalendar(host, items, {
defaultView: render.calendar?.default_view ?? "month",
urlState: true,
});
}
```
### 4.3 `client/events.ts` (calendar arm only)
```ts
// near the top
import { mountCalendar, type CalendarItem, type CalendarHandle } from "./calendar/mount-calendar";
// state
let calendar: CalendarHandle | null = null;
// inside applyView() when switching to calendar view:
function ensureCalendarMounted(host: HTMLElement, items: CalendarItem[]) {
if (calendar) { calendar.update(items); return; }
calendar = mountCalendar(host, items, { urlState: false, defaultView: "month" });
}
// inside applyView() when switching AWAY from calendar:
function teardownCalendar() {
if (calendar) { calendar.destroy(); calendar = null; }
}
function toCalendarItem(it: EventListItem): CalendarItem {
return {
kind: it.type as CalendarKind, // type "deadline" | "appointment"
id: it.id, title: it.title,
event_date: itemDateISO(it) + "T00:00:00",
project_id: it.project_id,
project_title: it.project_title,
project_reference: it.project_reference,
};
}
```
`urlState: false` for /events because the page already owns its own URL contract (`?type=`, `?status=`, etc.) and a second calendar deep-link param set would compete with future events-page state. (See §11 Q3 — this is a defaultable preference, not a hard constraint.)
### 4.4 Standalone calendar redirects
```go
// internal/handlers/deadlines_pages.go
func handleDeadlinesCalendarPage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/events?type=deadline&view=calendar", http.StatusMovedPermanently)
}
// internal/handlers/appointments_pages.go
func handleAppointmentsCalendarPage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/events?type=appointment&view=calendar", http.StatusMovedPermanently)
}
```
The `view=calendar` query string is a **new** events-page URL contract — needs a one-line addition to `client/events.ts:readURLState()` (which already reads `type`, `status`) to honour `view`. Today the view is in-memory only; pinning it to URL is a free side-benefit of this refactor (and lets the redirects land users on the calendar, not on the cards view).
Build pipeline: delete entries `frontend/build.ts:258`, `261`, `387`, `390` (the two standalone calendar bundles + HTML writes). `paliadin-context.ts:96,100` keep their URL matches — the 301 fires server-side, so the client only ever sees `/events?type=…&view=calendar` (which already maps to a paliadin context).
---
## 5. Visual + interaction parity audit
Walking m's brief checklist against the proposed end-state (assuming the user is on /events Kalender tab after this refactor):
| Brief item | Today (A) | After refactor | Matches /views? |
|---|---|---|---|
| Event tile shape | dot | **pill with text** | ✓ |
| Color | mixed (urgency + single appointment colour) | **kind-coded** (deadline / appointment / project_event / approval_request) | ✓ |
| Click behaviour (navigate to detail) | modal popup → anchor | **direct anchor on pill** (no modal) | ✓ |
| Today highlight | accent circle on day-num | **border ring on entire cell + box-shadow** | ✓ |
| Weekday header | static TSX divs | **rendered inline in the JS grid** | ✓ |
| Date-range / project / type filter shape | same `EventListItem[]` post-adapter | identical adapter feeds same `CalendarItem[]` shape | ✓ shared loader contract |
Two surfaces still differ after the refactor — and that's by design:
1. **/events** still has its three view chips above the calendar (Karten / Liste / Kalender) because the events page is multi-shape at the outer level. /views also has its outer shape chips (Liste / Karten / Kalender / Timeline). Both surfaces' shape chips look identical (`agenda-chip-row`).
2. **/events** keeps the events-page-level filters (type chip, status select, project select, event-type/appointment-type filters) above the calendar; /views shows its filter-bar (filter-spec-driven axes) instead. Both surfaces' filter chrome is governed by the page, not the calendar — the calendar component itself is the same DOM either way.
---
## 6. Mobile parity
`shape-calendar.ts` today does a mobile fallback at <600px (`mountCalendar` would carry this behaviour over). The fallback appends a single `<p>` notice "Auf schmalen Bildschirmen empfehlen wir die Listenansicht" or equivalent (i18n key `views.calendar.mobile_fallback`). Cells still render and are responsive (the existing CSS uses CSS-grid + 1fr columns).
After this refactor:
- /events Kalender tab: gets the **same** notice + a contextual hint suggesting "Wechsle zu Karten oder Liste" (the events-page shape chips). One new i18n key, OR reuse the existing `views.calendar.mobile_fallback` and accept that it mentions "Listenansicht" generically.
- /views Kalender shape: behaviour unchanged from today.
Mobile audit boxes ticked:
| | Today A | Today B | Today C | After |
|---|---|---|---|---|
| Cell shrinks on narrow viewport | (min-height 64px) | | partial (cells stay 80px) | (carry the C behaviour, plus the @media min-height shrink ported) |
| Touch target size on pills | n/a (dots, not tappable) | n/a | OK (8px+ at 1x) but verify on a real phone during coder smoke | OK |
| Modal vs drill-down | modal (small viewports lose layout) | modal | drill-down (changes URL natural back button) | drill-down across both surfaces |
| Sidebar collision | sidebar collapses to bottom nav under 768px (existing behaviour) | identical | identical | identical |
One coder-time TODO: verify the drill-down day-view is comfortable on mobile (it's a vertical list, should be fine, but worth one Playwright screenshot during smoke).
---
## 7. Tests + smoke
Existing test coverage relevant to this refactor:
- `frontend/src/client/views/shape-timeline-cv.test.ts` sibling of shape-calendar, no calendar-specific tests today. Add `frontend/src/client/calendar/mount-calendar.test.ts` for the extracted module.
- No Go tests touch handler dispatch for `/deadlines/calendar` or `/appointments/calendar` specifically (verified by grep).
- `internal/services/render_spec_test.go` covers `CalendarConfig.validate()` unchanged.
New test plan:
1. **`mount-calendar.test.ts` (new)** table-driven:
- Empty `items[]` month view renders 7-column grid + no pills + (for /views) per-day "no entries" only in day/week views.
- `items[]` with mixed kinds pills get the correct `views-calendar-pill--{kind}` class.
- `?cal_view=week` week column grid renders.
- Today bucket flagged with `--today` class on the correct cell.
- `+N` overflow renders when items per day > MAX_PILLS_PER_MONTH_CELL (3).
- `update(items)` after first mount swaps content without leaking listeners (assert no double-fire on month-nav click).
2. **`client/events.ts`** — light test (existing pattern): after refactor, switching to Kalender chip mounts the calendar, switching away calls `destroy()`. No test exists for events.ts today (it's mostly DOM glue), so this is a new test or skip with a comment.
3. **Smoke (manual, with `bun run build` + dev server)**:
- /events Kalender tab loads, shows pills, click pill navigates to detail.
- Day-num click → day view (URL changes if urlState is on for /events per Q3).
- /views/{slug} with `render_spec.shape=calendar` (need a saved view or temporary system view to exercise) still loads identical pills + drill-down.
- /deadlines/calendar → 301 → /events?type=deadline&view=calendar lands on Kalender tab.
- /appointments/calendar → 301 → /events?type=appointment&view=calendar lands on Kalender tab.
- DE + EN language toggle on both surfaces.
- Light + dark theme on both.
4. **Build gate**: `go build ./... && go test ./internal/... && cd frontend && bun run build` must all be clean (per task brief).
---
## 8. Risks + mitigations
| Risk | Likelihood | Mitigation |
|---|---|---|
| Custom Views users have saved views with `shape=calendar` and rely on the current week/day behaviour | low (shape-calendar is the canonical, only behaviour I'm changing about it is making `urlState` opt-in) | The refactor is structural — same toolbar, same drill-down, same URL params for /views. `urlState=true` stays the default for that surface. |
| `paliadin-context.ts` keys (`deadlines.calendar`, `appointments.calendar`) become unreachable after redirects | low | The 301 fires before the client sees the URL; new URL maps to existing `events` context. If we want to preserve the labels, add `events?type=…&view=calendar` matchers in paliadin-context (one if-branch each) — recommend doing this in the same coder PR for tidiness. |
| Subtype colouring loss is a feature regression for someone who used /appointments/calendar's legend | low | The page is unreachable from the UI; nobody reaches it without a bookmark. Q4 below confirms with m. |
| Events-page calendar `urlState: false` means refresh loses the Kalender chip selection | medium (today: same — calendar is in-memory either way) | Either accept (status quo) or extend events.ts URL state to include `view` (~3 lines). Q3 below. |
| /events fetch is unfiltered by date (loads everything); on a busy team Kalender may load slow | medium (existing behaviour) | Not addressed by this refactor. Filed as follow-up in §10. Filter spec / /api/views path solves it but is out of scope here. |
| The 301 redirect to `/events?type=…&view=calendar` requires events.ts to honour `view=calendar` from the URL | hard requirement | Must include this in the coder PR. ~3 lines in `readURLState()`. |
---
## 9. What stays "out of scope" (consistent with the issue body)
- New calendar UX: drag-to-create, week-resize, hover-preview, multi-day event spans.
- Performance: switching `/events` to a date-window-bounded fetch (today it loads everything and filters client-side).
- A unified events↔views landing (e.g. /events as a Saved View). Discussed in `design-events-unification-2026-05-04.md` and `design-data-display-model-2026-05-06.md`; deliberately not folded in here.
- /agenda surface. It's a timeline-grouped feed, not a calendar grid — separate conversation if m wants to converge it.
- Subtype dot colouring (deferred per §3 trade-off row).
---
## 10. Follow-ups (file as separate issues after this lands)
1. **Date-windowed loading for /events Kalender.** Pass `?from=…&to=…` to `/api/events` matched to the visible month so a 5-year-old project history doesn't ship to the client on every Kalender open. Backend already accepts `from`/`to` per `internal/handlers/events.go`. Small.
2. **Per-shape config: subtype colouring.** Add `CalendarConfig.subtype_colors` (bool, default false). Surface a `--subtype-{value}` modifier on the pill so the appointment-type colour key can come back per-view, if a user asks.
3. **Multi-day event spans.** Most events are single-day; deadlines are point-in-time. But appointments have `end_at`. Today neither A nor C surfaces span-rendering. Defer until requested.
4. **/agenda convergence.** /agenda is a different visual (day-grouped feed), but the data shape is the same `EventListItem`. If m wants /agenda to disappear (it's a sibling overview entry today per `design-events-unification-2026-05-04.md`), consider folding it into /events as a fourth shape ("feed" / "agenda"). Out of this design's scope.
---
## 11. Open questions for head (NO AskUserQuestion — answered via mai instruct)
> The role brief disables `AskUserQuestion` for this task. Each question below has a defaulted answer marked **(R)**; head/m can confirm or override via `mai instruct head`. After head replies, decisions land in §12.
**Q1 — Canonical renderer.** Adopt `shape-calendar.ts` as the canon, fold A into it (§3 sketch), and retire the two standalone routes B as 301-redirects to `/events?type=…&view=calendar`?
- **(R) Yes** — covers m's intent ("pick the canonical one — likely the Custom Views renderer"). Net code goes down, no schema changes.
- Alternative: keep the standalone routes as standalone pages but make them call `mountCalendar` internally — adds nothing for users (page is unreachable), wastes a build target each.
- *(answer: yes / keep-standalone / something-else)*
**Q2 — Events-page Kalender tab: drill-down vs modal-popup.** Today /events Kalender opens a modal listing the day's items. After the refactor, clicking a day-num drills into the day view (changes view chip, same URL component, but the page swaps to a day-list). Drop the modal entirely?
- **(R) Drop the modal** — matches /views behaviour, gives a real day-view (not just a list of links), and removes one popup-management code path.
- Alternative: keep the modal on /events only (parity break — defeats the point of the issue).
- *(answer: drop / keep)*
**Q3 — URL state for the /events calendar.** Should the /events Kalender persist its view (month/week/day) and date in the URL via `?cal_view=…&cal_date=…` (matching /views)?
- **(R) Yes, persist** — refresh-stable, shareable, ~3 lines in `readURLState()`. /views does it. Cost is owning the param contract on /events.
- Alternative: in-memory only — today's behaviour. Keeps /events URL surface minimal.
- *(answer: persist / in-memory)*
**Q4 — Subtype dot colouring on appointments.** The orphaned /appointments/calendar today colours dots by appointment_type (Verhandlung / Besprechung / Beratung / Fristverhandlung) with a legend strip. After the refactor pills are kind-coded only (deadline vs appointment vs …). Drop subtype colouring?
- **(R) Drop now, file as follow-up** (§10.2) — page is UI-unreachable today; nobody will notice; can come back as a `CalendarConfig.subtype_colors` flag if/when requested.
- Alternative: preserve subtype colouring on /events Kalender tab as well, with a fresh legend matching the new pill colours.
- *(answer: drop / preserve)*
**Q5 — Mobile fallback text.** /views Kalender shows a notice "Auf schmalen Bildschirmen empfehlen wir die Listenansicht" (key `views.calendar.mobile_fallback`). Reuse the same key on /events, or add an /events-specific key recommending the events-page "Karten" or "Liste" shape?
- **(R) Reuse the existing key** — generic phrasing covers both surfaces; both have Karten/Liste alternatives.
- Alternative: dedicated key per surface — clearer copy but more strings to maintain.
- *(answer: reuse / dedicated)*
**Q6 — Test approach for the extracted module.** Add `mount-calendar.test.ts` with the seven listed cases (§7.1), OR also add a Playwright smoke that drives the new flow end-to-end through both surfaces?
- **(R) Unit tests + manual smoke gauntlet** — matches the codebase's existing test layout (most client/* tests are unit-level; Playwright is reserved for fewer flows). Manual smoke per §7.3 is the brief's bar.
- Alternative: unit + Playwright.
- *(answer: unit-only / unit-plus-playwright)*
**Q7 — Sequencing across PRs.** One PR (extract + adopt + retire + CSS prune) or three (extract, then adopt+retire, then CSS prune)?
- **(R) One PR** — refactors that don't bisect well are worse split (each intermediate state has unused exports / dead code paths / orphaned CSS classes for a few hours). The diff is reviewable in one read because it's mostly moves + deletes.
- Alternative: three PRs — easier rollback at each step, but you'd have to land #2 before m sees any UI alignment, which loses the point.
- *(answer: one-pr / three-pr)*
**Q8 — When (if at all) to delete /events `events.calendar.empty` i18n key.** Replaced by `cal.day.no_entries` in the new flow. Drop now or leave as a dead key in `i18n-keys.ts` for one release?
- **(R) Drop now** — i18n-keys.ts is the source of truth; dead keys aren't enforced at compile time but they're a slow-rotting maintenance tax. /events' new calendar surface doesn't render an "empty month" message any more (per-day "no entries" is the only empty state, matching /views).
- Alternative: leave for one release as a soft-deprecate.
- *(answer: drop / leave)*
---
## 12. m's decisions (2026-05-20, via head msg #2087)
Head accepted all 8 (R) defaults in one round-trip ("Design accepted in
full — all 8 (R) defaults stand"). Recorded verbatim below; each entry
is the (R) pick from §11.
- **Q1 — Canonical renderer:** Yes. Canonicalise on `shape-calendar.ts`; fold A into it via extracted `mountCalendar()`; retire B as 301 redirects to `/events?type=…&view=calendar`.
- **Q2 — Drill-down vs modal:** Drop the modal on /events. Day-num/+N click drills into the day view, matching /views.
- **Q3 — URL state on /events:** Persist. /events Kalender reads/writes `?cal_view=…&cal_date=…` like /views does. Adds `view=calendar` to `client/events.ts:readURLState()` so refreshes/redirects land on the Kalender tab.
- **Q4 — Subtype dot colouring:** Drop now. Filed as follow-up §10.2. Pills are kind-coded only after the refactor (deadline / appointment / project_event / approval_request).
- **Q5 — Mobile fallback text:** Reuse the existing `views.calendar.mobile_fallback` key on /events as well — generic phrasing covers both surfaces.
- **Q6 — Test approach:** Unit tests (`mount-calendar.test.ts`) + manual smoke gauntlet (§7.3). No Playwright on this refactor.
- **Q7 — Sequencing:** One PR. Extract + adopt + retire + CSS prune land together on `mai/bohr/calendar-view-align`.
- **Q8 — Empty-state i18n key:** Drop dead keys now (`events.calendar.empty`, `appointments.kalender.*`, `deadlines.kalender.*`, appointment-type legend keys not used elsewhere).
---
## 13. Coder hand-off (after m's go on §11)
Once §12 is filled in, the coder shift can proceed in this order:
1. Create `frontend/src/client/calendar/mount-calendar.ts` + `frontend/src/client/calendar/mount-calendar.test.ts`. Lift the shape-calendar internals; add `update`/`destroy` to the returned handle; pipe `urlState` + `urlPrefix` through.
2. Update `frontend/src/client/views/shape-calendar.ts` to delegate to `mountCalendar` (≈30 lines after the lift).
3. Update `frontend/src/client/events.ts`: import `mountCalendar`, replace `renderCalendar`/`openCalPopup` and nav handlers with a `mountCalendar(host, items, { urlState: <per Q3>, defaultView: "month" })` call inside the existing `applyView()` branch. Add the `view=calendar` URL state handling per Q3.
4. Update `frontend/src/events.tsx`: strip the `events-calendar-wrap` inline DOM (toolbar + grid + modal). The empty container `<div id="events-shape-calendar" />` plus a wrapper class is enough — `mountCalendar` builds the DOM.
5. Delete `frontend/src/appointments-calendar.tsx`, `frontend/src/deadlines-calendar.tsx`, `frontend/src/client/appointments-calendar.ts`, `frontend/src/client/deadlines-calendar.ts`.
6. Update `frontend/build.ts`: remove the `*-calendar.ts` entry-point lines (≈250s) and the `*-calendar.html` writes (≈387s).
7. Update `internal/handlers/deadlines_pages.go` + `internal/handlers/appointments_pages.go`: turn the two calendar handlers into 301 redirects to `/events?type=…&view=calendar`.
8. Update `frontend/src/styles/global.css`: delete `.frist-cal-*`, `.termin-cal-*`, `.events-cal-dot-appointment`, the 700px-media tweak (lines ~7464-7620, ~8019-8023, ~8680-8700, ~11519-11533). Sanity-check no other consumer (already verified via grep — none).
9. Update i18n: drop `appointments.kalender.*`, `deadlines.kalender.*`, `appointments.type.*` (legend keys only — keep type values used elsewhere), `events.calendar.empty` per Q8. Make sure `cal.view.*`, `cal.day.no_entries`, `cal.day.back_to_month`, `cal.day.open_day`, `views.calendar.mobile_fallback` (or a new events-specific key per Q5) all exist DE + EN — most already do.
10. `paliadin-context.ts`: optional one-line addition to map `events?view=calendar` to the new context label.
11. Run `go build ./... && go test ./internal/... && cd frontend && bun run build`.
12. Manual smoke per §7.3.
13. Commit. `mai report completed` with SHA per task brief.
Estimated coder shift: one PR per Q7 (R).
---

View File

@@ -0,0 +1,856 @@
# Symmetric date-range picker — design
**Date:** 2026-05-25
**Task:** t-paliad-248 (Gitea m/paliad#79)
**Inventor:** atlas
**Branch:** `mai/atlas/inventor-symmetric-date`
**Status:** READ-ONLY design. Awaiting head's go/no-go before coder shift.
---
## §0 TL;DR
Today paliad has **three independent date-range schemes** scattered across surfaces:
1. **`/agenda`** — future-only chip row [7|14|30|90 Tage], state `rangeDays`.
2. **`/admin/audit-log`** — past-only `<select>` [24h|7d|30d|custom|all] + manual `<input type="date">` pair.
3. **`/projects/:id/chart`** — symmetric `RangePreset` [1y|2y|all|custom] + manual date pair.
…plus a **fourth, unified `TimeHorizon` contract** (`internal/services/filter_spec.go`, mirrored in `frontend/src/client/views/types.ts`) that's used by the filter-bar, Verlauf, Custom Views, and InboxFilterBar — but its "Anpassen" custom-range chip is still stubbed (`filter-bar/axes.ts:105-112`, marked Phase 2, disabled, "coming soon" tooltip).
The fix is **not** "build a fourth scheme." The fix is to **finish the TimeHorizon contract** (add `past_14d`, `next_14d`, `past_all`, `next_all`), build **one reusable `<DateRangePicker>`** that emits a `TimeSpec`, then migrate the three legacy affordances to it.
**Layout (m's brief, locked):**
```
┌──────────────────────────────────────────────────────────────┐
│ [Zeitraum: Nächste 30 Tage ▾] │
└──────────────────────────────────────────────────────────────┘
↓ click to open
┌──────────────────────────────────────────────────────────────┐
│ Vergangenheit (ALLE) Zukunft │
│ [Ganze Vergangenheit] [⌖ ALLE] [Ganze Zukunft] │
│ [90 T] [30 T] [14 T] [7 T] [7 T] [14 T] [30 T] [90 T] │
│ │
│ ── oder benutzerdefiniert ── │
│ Von [____.____.____] Bis [____.____.____] [Anwenden] │
└──────────────────────────────────────────────────────────────┘
```
**Slice plan:**
- **Slice A** — `<DateRangePicker>` component + 4 new horizon constants (`past_14d`, `next_14d`, `past_all`, `next_all`). Wired onto filter-bar `time` axis first (lights up Verlauf + InboxFilterBar + views simultaneously by replacing the stubbed Phase-2 chip).
- **Slice B** — `/agenda` migrates (highest-traffic standalone consumer).
- **Slice C** — `/admin/audit-log` + `/projects/:id/chart` migrate. Each surface picks the preset subset it cares about.
- **Slice D** *(optional, later)* — upckommentar-style two-handle slicer replaces the inline date-pair for the "custom" mode.
**Hard rules honoured:**
- No new top-level table or migration in Slice A — purely additive enum values + Go switch arms.
- No new dependency in Slice A — slicer is deferred (it's a non-trivial port from Svelte to paliad's plain TSX renderer).
- Backward-compatible URL shape — each surface keeps its current short-alias parser (e.g. `?range=30``horizon=next_30d`) and additionally accepts the canonical `?horizon=…&from=…&to=…`.
---
## §1 Current state — every date-range affordance
Cataloguing **every** place a paliad user picks a past/future window, with file:line refs.
### 1.1 `/agenda` — future-only chip row
`frontend/src/agenda.tsx:64-67`:
```tsx
<button className="agenda-chip" data-range="7" >7 Tage</button>
<button className="agenda-chip" data-range="14" >14 Tage</button>
<button className="agenda-chip" data-range="30" >30 Tage</button>
<button className="agenda-chip" data-range="90" >90 Tage</button>
```
State machine `frontend/src/client/agenda.ts:80-104`:
- `state.rangeDays ∈ {7,14,30,90}` (set `VALID_RANGES`). Default `30`.
- URL: `?range=30&types=…&event_type=…`.
- Fetch: `GET /api/agenda?from=<today>&to=<today+rangeDays-1>&types=…`.
- **Future-only by construction** — m's complaint applies precisely here. No "past 7 days" affordance, no "all" affordance.
### 1.2 `/admin/audit-log` — past-only `<select>` + manual date pair
`frontend/src/admin-audit-log.tsx:50-65`:
```tsx
<select id="audit-range">
<option value="24h">Letzte 24h</option>
<option value="7d" selected>Letzte 7 Tage</option>
<option value="30d">Letzte 30 Tage</option>
<option value="custom">Benutzerdefiniert</option>
<option value="all">Alles</option>
</select>
<!-- custom toggles a date-pair: -->
<input type="date" id="audit-from" />
<input type="date" id="audit-to" />
```
State machine `frontend/src/client/admin-audit-log.ts:135-174`:
- `rangePresetToFrom(preset)` converts `"24h" | "7d" | "30d"``Date`. `"custom"` reads `from`/`to` inputs. `"all"` clears both bounds.
- URL: `?source=…&range=7d&q=…&from=…&to=…&limit=…&before_ts=…&before_id=…` (cursor-paged).
- **Past-only by construction.** No future-projection — this is an audit log, looking forward makes no sense.
### 1.3 `/projects/:id/chart` — symmetric `RangePreset`
`frontend/src/client/views/types.ts:77-79`:
```ts
range_preset?: "1y" | "2y" | "all" | "custom";
range_from?: string;
range_to?: string;
```
UI `frontend/src/projects-chart.tsx:78-82`:
```tsx
<input type="date" id="projects-chart-range-from" />
<input type="date" id="projects-chart-range-to" />
```
State machine `frontend/src/client/projects-chart.ts:73-118`:
- `rangeFromURL()``{preset, from?, to?}` with default `"1y"`.
- "1y" = `today-1y..today+1y`, "2y" = `today-2y..today+2y`, "all" derived from loaded events, "custom" = read inputs.
- URL: `?range=1y&from=YYYY-MM-DD&to=YYYY-MM-DD`.
- **Symmetric around today** by construction — this is a chart, not a filter; the user is panning a viewport, not picking a fan.
### 1.4 `views-editor.tsx` (Custom Views config form)
`frontend/src/views-editor.tsx:102-109`:
```tsx
<select id="editor-time-horizon">
<option value="next_7d">Nächste 7 Tage</option>
<option value="next_30d">Nächste 30 Tage</option>
<option value="next_90d">Nächste 90 Tage</option>
<option value="past_30d">Letzte 30 Tage</option>
<option value="past_90d">Letzte 90 Tage</option>
<option value="any">Beliebig</option>
</select>
```
- Mixes past + future, but only 5 horizons exposed (no 14d, no past_7d, no all).
- Persists into `paliad.user_views.filter_spec` (JSON column) as a `TimeSpec`.
- **This is the closest existing affordance to m's symmetric fan**, but rendered as a plain `<select>` and incomplete.
### 1.5 Filter-bar `time` axis (riemann's t-paliad-163 Phase 1)
`frontend/src/client/filter-bar/axes.ts:65-115`:
- Renders a chip cluster: `[next_7d, next_30d, next_90d, past_30d, any]` (default presets, line 77-79).
- **"Anpassen" chip is disabled** with `coming_soon` tooltip (line 108-112). This is the documented Phase 2 substrate.
- Surfaces declaring axis `time` thread their own preset list via `RenderAxisOpts.timePresets` — e.g. Verlauf overrides to `["past_7d","past_30d","past_90d","any"]` (`frontend/src/client/projects-detail.ts:2310`).
Consumers:
- `/projects/:id` Verlauf (`projects-detail.ts:2296` initial state, 2310 preset override).
- `/views` and `/views/:id` (Custom Views runtime).
- `/inbox` (`InboxFilterBar` flow — t-paliad-138/139 derived inbox).
### 1.6 `horizonBounds()` — the materializer
`frontend/src/client/projects-detail.ts:393-406` mirrors the Go-side `computeViewSpecBounds()` (`internal/services/view_service.go:156-187`):
```ts
case "past_7d": return { from: offset(-7), to: offset(1) };
case "past_30d": return { from: offset(-30), to: offset(1) };
case "past_90d": return { from: offset(-90), to: offset(1) };
case "next_7d": return { from: day, to: offset(7) };
case "next_30d": return { from: day, to: offset(30) };
case "next_90d": return { from: day, to: offset(90) };
default: return {};
```
(Backend equivalent: `internal/services/view_service.go:160-186`.)
### 1.7 Single-date inputs (NOT date-range — listed for completeness)
These are out of scope but mentioned so the audit is exhaustive:
- `verfahrensablauf.tsx:174``#trigger-date` (calculator anchor).
- `fristenrechner.tsx:496,504,616``#trigger-date`, `#priority-date`, `#event-date` (calculator).
- `admin-rules-edit.tsx:265``#preview-trigger-date`.
- `deadlines-detail.tsx:82``#deadline-due-edit` (inline-edit).
- `deadlines-new.tsx:116``#deadline-due` (form).
- `appointments-new.tsx`, `appointments-detail.tsx``start_at`/`end_at`.
- `projects-detail.tsx:181``#smart-timeline-milestone-date` (add-milestone modal).
- `components/ProjectFormFields.tsx:134,138``#project-filing-date`, `#project-grant-date`.
### 1.8 Summary matrix
| Surface | Direction | Presets | Custom | URL contract | Default |
|---|---|---|---|---|---|
| `/agenda` | Future | 7\|14\|30\|90 | — | `?range=N` | 30d |
| `/admin/audit-log` | Past | 24h\|7d\|30d\|all + custom | date pair | `?range=…&from=…&to=…` | 7d |
| `/projects/:id/chart` | Symmetric ±N | 1y\|2y\|all + custom | date pair | `?range=…&from=…&to=…` | 1y |
| `/views/:id` editor | Past+Future mix | next_7d\|next_30d\|next_90d\|past_30d\|past_90d\|any | — | persisted JSON | next_30d |
| Filter-bar `time` axis | Past+Future mix | next_7d\|next_30d\|next_90d\|past_30d\|any | **stubbed** | persisted + `?…__time_from=` | per surface |
| Verlauf | Past + any | past_7d\|past_30d\|past_90d\|any | **stubbed** | URL | past_30d |
| InboxFilterBar | Mix | filter-bar default | **stubbed** | URL | per surface |
Three of seven surfaces have **incomplete** custom-range affordances. None of the seven exposes the full symmetric fan m wants.
---
## §2 upckommentar slicer pattern
Verified by reading source at `/home/m/dev/web/upc-kommentar/src/lib/`:
- **`DateRangeSlider.svelte`** (component, 448 lines).
- **`date-range-slider-pure.ts`** (pure-math helpers, 487 lines, fully unit-tested).
- **`InboxFilterBar.svelte`** (host).
### 2.1 What it is
A **two-handle range slider** that wraps `svelte-range-slider-pips` (npm: `svelte-range-slider-pips@4`). The slider's rail is the upckommentar floor (`2023-01-01`) to today, and the two handles define `dateFrom` and `dateTo`. Step is **1 day** regardless of zoom.
Public contract (DateRangeSlider.svelte:57-82):
```ts
interface Props {
minISO: string; // axis lower bound, default 2023-01-01
maxISO: string; // axis upper bound, today
fromISO: string | null; // current From (null = parked at min)
toISO: string | null; // current To (null = parked at max)
onChange: (from, to) => void; // emits on every slider change
testid?: string;
axisWidthPx?: number; // test override for jsdom
}
```
### 2.2 Anchor rail + granularity
Below the slider rail is a **custom-rendered anchor rail** (the lib's own pips are hidden via `pips={false}` because they're evenly-spaced approximations — issue #42 in upckommentar). Anchor day-numbers come from `pipAnchorsFor(granularity, minDay, maxDay)`:
- **year:** every Jan 1 in range.
- **month:** every 1st-of-month.
- **day:** every Monday.
Edges (`minDay`, `maxDay`) are always anchors so the user can park at the slider's extremes.
Granularity has **+/- zoom buttons** in the top-right of the slider (`year → month → day`), with each level showing more anchors.
### 2.3 Click-to-snap (left half / right half)
`DateRangeSlider.svelte:219-240` + pure helper `endOfPeriodDay()`:
- **Left half of an anchor label** → snap closest handle to **start** of period (the anchor day itself, e.g. Jan 1).
- **Right half of the same label** → snap to **end** of period (Dec 31 for year, last-of-month for month, Sunday for day).
- Keyboard activation falls back to left-half (start-of-period) deterministically.
### 2.4 Label thinning + two-row alternation
`pipLabelStrideFor()` + `pipLabelRow()` (pure helpers):
- Measures rail width via `ResizeObserver`.
- Computes a stride — only every Nth label is rendered.
- Adjacent rendered labels alternate row 0 / row 1 (~1.1em offset down) so they can sit closer horizontally without colliding.
### 2.5 Handle behaviour
- `range=true` draws a colored bar between handles.
- `draggy=true` lets the user drag the **bar itself** to shift the window without changing its width.
- `pushy=true` — handles push each other when crossed.
- `float=true` — tooltip floats above the dragged handle showing `DD.MM.YYYY`.
### 2.6 URL contract on host
`InboxFilterBar.svelte` debounces `onChange` at 250ms, then writes:
```
?date_from=2024-03-15&date_to=2024-09-30
```
When a handle is parked at min/max, that bound is **omitted** from the URL (`valuesToFromTo()` in the pure module). So `?date_from=2024-03-15` alone means "from March 15 onwards, no upper bound."
### 2.7 What's worth borrowing for paliad
| Element | Borrow? | Why |
|---|---|---|
| Two-handle drag | **Yes — but defer to Slice D** | Excellent fine-tune UX. Non-trivial to port without `svelte-range-slider-pips` (or a Svelte ↔ TSX adapter). |
| Anchor rail with click-to-snap | Yes (in Slice D) | Year/month/Monday anchors are the right granularities. |
| Label thinning + two-row alternation | Yes (in Slice D) | Makes the rail readable at any width. |
| Granularity + zoom +/- | Yes (in Slice D) | Single most useful interaction; users don't drag pixel-precise. |
| Epoch-day pure math | Yes — verbatim | The `date-range-slider-pure.ts` module is well-tested and dependency-free. Port to TS in paliad's pure-helper layer. |
| `null` = parked at edge | Yes — already aligned | TimeHorizon's `past_all` / `next_all` map cleanly to "one bound parked at infinity." |
| The library `svelte-range-slider-pips` itself | **No** | Adds a Svelte dependency to a non-Svelte project. Slice D would build a tiny equivalent on top of `<input type="range">` × 2 + CSS — or vendor the lib's pure parts. |
### 2.8 What does NOT apply to paliad
- **Floor at 2023-01-01.** upckommentar starts at the UPC's first day. paliad has decade-old patents and future-projecting deadlines; the axis must extend in both directions. We use `today ± 5 years` as the default visible range with `past_all` / `next_all` chips to escape it.
- **Single granularity locked per session.** upckommentar's UI shows one of year/month/day at a time. paliad's typical use ("next 30 days for the deadline list") doesn't benefit from a zoom; the chips ARE the granularity. Slicer in Slice D only opens when the user picks "Anpassen" — at which point the zoom UI makes sense.
---
## §3 Component design — `<DateRangePicker>`
### 3.1 Public API
```ts
type TimeHorizonExt =
| "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all"
| "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all"
| "any" | "custom";
interface DateRangePickerProps {
// Current state. The component is fully controlled.
value: TimeSpec;
onChange: (next: TimeSpec) => void;
// Per-surface preset filter — omit a chip by leaving it out of the array.
// Default: all symmetric chips + "any" + "custom".
presets?: TimeHorizonExt[];
// Closed-state button label override. Defaults to the i18n key for value.horizon
// (e.g. "Letzte 30 Tage"). Override for surfaces that want a heading prefix
// like "Zeitraum: Letzte 30 Tage".
labelPrefix?: string;
// i18n strings consumed via the i18n.ts dictionary. No props for individual labels.
// Localisation flows through existing data-i18n attributes.
// Surface tag — used to derive a stable testid and URL-param namespace if
// the host wires URL serialization through helpers we provide (see §4).
surface: string; // e.g. "agenda" | "audit-log" | "filter-bar"
// Mode — popover (default) or modal (rare).
mode?: "popover" | "modal";
// Anchor / placement for popover mode. Defaults to "below".
placement?: "below" | "above" | "right";
}
```
`TimeSpec` mirrors the existing shape (`internal/services/filter_spec.go:107-112`), extended with the 4 new horizon values:
```ts
interface TimeSpec {
horizon: TimeHorizonExt;
field?: "auto" | "created_at";
from?: string; // ISO YYYY-MM-DD; set only when horizon === "custom"
to?: string;
}
```
### 3.2 States
The component is a small state machine:
```
closed ────[click button]────► open
▲ │
└──[click outside / Esc]───────┘
open ───[click chip]──── closed (commit immediately)
open ───[click "Anpassen"]► custom-editor
custom-editor ─[Anwenden]► closed (commit)
custom-editor ─[Esc]─────► open
```
- **closed** — single button with current selection label and a chevron `▾`. No outline/highlight unless the value is not the default for this surface.
- **open** — popover anchored below the button (or below-then-flip-up on viewport-bottom). Contains the symmetric chip row + ALL center + "Anpassen" sub-section.
- **custom-editor** — replaces the "Anpassen" link with two `<input type="date">` + "Anwenden" / "Abbrechen" buttons. (In Slice D this becomes the slicer.)
### 3.3 Symmetric chip layout
The popover body — full ASCII sketch:
```
┌─────────────────────────────────────────────────────────────┐
│ ╭ Vergangenheit ────────╮ ╭ ALLES ╮ ╭ Zukunft ───────────╮ │
│ │ [Ganze Vergangenheit] │ │ [⌖] │ │ [Ganze Zukunft] │ │
│ │ [Letzte 90 Tage] │ │ │ │ [Nächste 7 Tage] │ │
│ │ [Letzte 30 Tage] │ │ │ │ [Nächste 14 Tage] │ │
│ │ [Letzte 14 Tage] │ │ │ │ [Nächste 30 Tage] │ │
│ │ [Letzte 7 Tage] │ │ │ │ [Nächste 90 Tage] │ │
│ ╰───────────────────────╯ ╰───────╯ ╰────────────────────╯ │
│ │
│ ── Anpassen ────────────────────────────────────────── │
│ Von [____.____.____] Bis [____.____.____] [Anwenden] │
└─────────────────────────────────────────────────────────────┘
```
Visual cues:
- The currently-selected chip gets the **lime accent** (`--color-bg-lime-tint` background, `--color-text` text, `--color-accent` border) — matches existing `.agenda-chip-active` so we don't introduce a new active state.
- The "ALLES" center button is **larger** than the fan chips (44px tall vs. 32px), drawn with a target-style glyph `⌖` (or `∞` — see Q3.B). Inventor pick: `⌖` plus the word "ALLES" beneath. Larger so it reads as "the no-filter affordance," not as one chip among many.
- The two fans are visually **mirrored** — past on the left, future on the right. Both have a "Ganze …" terminal chip at the outer edge (left-most for past_all, right-most for next_all) and decreasing-magnitude chips fanning toward the center. The ordering matches the human intuition: "left = back in time, right = forward in time."
- On viewports < 480px the popover stacks vertically (past fan above, ALL middle, future fan below). On viewports < 360px the popover becomes a modal-feeling slide-up sheet (existing inbox modal CSS pattern reusable).
### 3.4 Sketch of the closed button states
```
default: ┌─Zeitraum: Nächste 30 Tage ▾─┐
custom: ┌─Zeitraum: 15.03.2026 30.04.2026 ▾─┐
any: ┌─Zeitraum: Alles ▾─┐
past_all: ┌─Zeitraum: Ganze Vergangenheit ▾─┐
hover/open: same + outline + bg-accent-tint
```
When the value is **not** the surface default, an additional small `●` dot appears between "Zeitraum:" and the value the existing universal "filter is non-default" indicator used by the filter-bar.
### 3.5 Keyboard
- `Tab` lands on the button. `Enter`/`Space` opens the popover.
- `Esc` from open state closes it. `Esc` from custom-editor returns to chip view (one level back).
- Chips are focusable buttons in the natural left-to-right reading order: past_all past_90 past_30 past_14 past_7 any (center) next_7 next_14 next_30 next_90 next_all.
- The custom date inputs are `<input type="date" lang="de">` gets the OS-native picker on macOS / iOS / Android / Windows. No new custom calendar widget.
### 3.6 Accessibility
- The button has `aria-haspopup="dialog"` and `aria-expanded` toggled on open/close.
- The popover has `role="dialog"` with `aria-label` = `t("date_range.dialog.label")` ("Zeitraum wählen" / "Choose date range").
- Chips are `<button>` with `aria-pressed="true"` on the active one.
- The two fan groups have `role="group"` + `aria-label="Vergangenheit"` / `aria-label="Zukunft"`.
### 3.7 Module layout
```
frontend/src/
├── components/
│ └── DateRangePicker.tsx ← TSX shell (markup only)
├── client/
│ ├── date-range-picker.ts ← mount() + state machine + DOM event wiring
│ └── date-range-picker-pure.ts ← horizon-bounds math, label resolver, parse/serialize
└── styles/
└── global.css ← .date-range-* classes
```
`-pure.ts` is the headless module fully testable under `bun test`. The boot client in `-picker.ts` consumes it, mirroring the pattern used by `shape-timeline-chart.ts` + `shape-timeline-chart.test.ts` (see memory: t-paliad-173 / gauss).
Pure module exports (preliminary):
```ts
export function horizonBounds(h: TimeHorizonExt, now: Date): { from?: Date; to?: Date }
export function labelForHorizon(h: TimeHorizonExt, lang: "de"|"en"): string
export function labelForCustom(from: string, to: string, lang: "de"|"en"): string
export function parseURL(params: URLSearchParams): TimeSpec
export function serializeURL(spec: TimeSpec, defaults: Partial<TimeSpec>): URLSearchParams
export function isDefault(spec: TimeSpec, default_: TimeSpec): boolean
```
### 3.8 Go-side additions
`internal/services/filter_spec.go`:
```go
// Add four new constants alongside the existing TimeHorizon block.
HorizonNext14d TimeHorizon = "next_14d"
HorizonPast14d TimeHorizon = "past_14d"
HorizonNextAll TimeHorizon = "next_all"
HorizonPastAll TimeHorizon = "past_all"
```
`internal/services/view_service.go:computeViewSpecBounds()`:
```go
case HorizonNext14d:
bounds.from = &startOfDay; t := startOfDay.AddDate(0, 0, 14); bounds.to = &t
case HorizonPast14d:
f := startOfDay.AddDate(0, 0, -14); bounds.from = &f; bounds.to = &startOfTomorrow
case HorizonNextAll:
bounds.from = &startOfDay
// bounds.to left nil → "no upper bound"
case HorizonPastAll:
bounds.to = &startOfTomorrow
// bounds.from left nil
```
`HorizonNextAll` and `HorizonPastAll` are **one-sided unbounded** distinct from existing `HorizonAll` (bidirectional unbounded) and `HorizonAny` (no filter at all, same effect as `HorizonAll` for view-spec runtime but different in intent).
`filter_spec.go:validate()` (line 280-292) gains the two new past/next constants in the switch.
### 3.9 i18n keys
Two-language matrix (DE primary, EN secondary):
```
date_range.button.label "Zeitraum" / "Time range"
date_range.button.label.custom "Von … bis …" / "From … to …"
date_range.horizon.next_7d "Nächste 7 Tage" / "Next 7 days"
date_range.horizon.next_14d "Nächste 14 Tage" / "Next 14 days"
date_range.horizon.next_30d "Nächste 30 Tage" / "Next 30 days"
date_range.horizon.next_90d "Nächste 90 Tage" / "Next 90 days"
date_range.horizon.next_all "Ganze Zukunft" / "All future"
date_range.horizon.past_7d "Letzte 7 Tage" / "Last 7 days"
date_range.horizon.past_14d "Letzte 14 Tage" / "Last 14 days"
date_range.horizon.past_30d "Letzte 30 Tage" / "Last 30 days"
date_range.horizon.past_90d "Letzte 90 Tage" / "Last 90 days"
date_range.horizon.past_all "Ganze Vergangenheit" / "All past"
date_range.horizon.any "Alles" / "All"
date_range.horizon.custom "Benutzerdefiniert" / "Custom"
date_range.dialog.label "Zeitraum wählen" / "Choose date range"
date_range.fan.past.label "Vergangenheit" / "Past"
date_range.fan.future.label "Zukunft" / "Future"
date_range.center.label "Alles" / "All"
date_range.custom.from "Von" / "From"
date_range.custom.to "Bis" / "To"
date_range.custom.apply "Anwenden" / "Apply"
date_range.custom.cancel "Abbrechen" / "Cancel"
date_range.custom.invalid "Bis-Datum muss nach Von-Datum liegen." / "End date must be after start date."
```
Total: 21 keys × 2 langs = 42 new entries in `i18n.ts`. Existing per-surface keys (`agenda.range.7`, `admin.audit.range.24h`, `views.bar.time.next_30d` etc.) stay until each surface migrates, then get retired.
---
## §4 URL / form serialization contract
### 4.1 Canonical URL shape
The picker writes (and reads) **canonical** params on the host's URL:
```
?horizon=next_30d
?horizon=past_all
?horizon=any ← omitted if it matches the surface default
?horizon=custom&from=2026-03-15&to=2026-04-30
```
The host page's URL-init code (`bootDateRangePicker(surface, opts)`) calls `parseURL(searchParams)` to derive the initial `TimeSpec`, then calls `serializeURL(spec, defaults)` on every change. Params equal to the surface default are **omitted** so the canonical URL stays short and dedupable matches the existing `writeParamToURL` pattern in `projects-chart.ts:144-154`.
### 4.2 Backwards-compat aliases
Each migrating surface keeps its existing alias parser for the transition window:
| Surface | Legacy URL | Canonical URL | Adapter |
|---|---|---|---|
| `/agenda` | `?range=30` | `?horizon=next_30d` | `range=N → horizon=next_${N}d` if `N ∈ {7,14,30,90}`, else `next_all` for `N>90`. Read both, write canonical. |
| `/admin/audit-log` | `?range=7d` | `?horizon=past_7d` | `range=24h → horizon=past_1d` (new, see Q5) or kept as `past_7d` fallback. `range=all → horizon=any`. |
| `/projects/:id/chart` | `?range=1y` | `?range=1y` (kept) | **NOT migrated to TimeHorizon** projects-chart is symmetric-around-today. It uses DateRangePicker only for its **custom**-mode UI (the date-pair slicer in Slice D). The 1y/2y/all presets stay surface-specific. |
The Go side is unaffected by aliasing handlers receive whatever shape they always have, and the URL alias adapter lives entirely client-side per surface. **No backend route signature changes** in Slice A.
### 4.3 Custom Views (persisted JSON)
`paliad.user_views.filter_spec` is a JSON column. The TimeSpec extension is additive (new enum values, no shape change). Existing rows continue to validate. Migration not needed.
### 4.4 Form fields (Custom Views editor)
`views-editor.tsx:102-109` migrates from `<select>` to the picker. The form submits the same FormData shape (just one extra key for custom from/to already plumbed via TimeSpec.from / TimeSpec.to). The Go-side `parseViewForm()` (TBD by coder) gains 4 new acceptable horizon values; existing test cases continue to pass.
---
## §5 Migration plan
### Slice A — substrate + filter-bar `time` axis
**Backend** (single migration not needed additive constants only):
- `internal/services/filter_spec.go` 4 new `TimeHorizon` constants + validate switch arms.
- `internal/services/view_service.go` `computeViewSpecBounds()` 4 new switch cases.
- Pure unit tests for each new horizon (zero DB).
**Frontend**:
- New `frontend/src/components/DateRangePicker.tsx` + boot client + pure module.
- New i18n keys (42 entries).
- `frontend/src/client/filter-bar/axes.ts:renderTimeAxis()` replace the disabled "Anpassen" stub with the picker. The chip cluster either becomes the picker's open-state (preferred) OR the chips stay flat and the picker only opens on "Anpassen" click (fallback if popover-in-bar is visually noisy). **Inventor pick (R): chips stay flat in the bar; "Anpassen" chip becomes the picker trigger. Picker emits TimeSpec back into the bar's state, same patch path.**
**Surfaces lit up automatically**: Verlauf (`/projects/:id`), Custom Views (`/views`, `/views/:id`), InboxFilterBar (`/inbox`).
**LoC estimate**: ~600 LoC (pure: 180 / boot: 180 / TSX: 100 / CSS: 80 / Go: 30 / tests: 240). Tests-first per `docs/design-paliad-test-strategy-2026-05-19.md`.
### Slice B — `/agenda`
- `agenda.tsx:51-69` replace chip rows with `<DateRangePicker surface="agenda" presets={["next_7d","next_14d","next_30d","next_90d","next_all","custom"]} />`.
- `client/agenda.ts:85-104` replace `wireControls()` chip wiring with picker subscription.
- URL alias adapter accept `?range=N` for back-compat, emit `?horizon=…`.
**LoC**: ~80 LoC delta, mostly deletion.
### Slice C — `/admin/audit-log` + `/projects/:id/chart`
- `admin-audit-log.tsx:50-65` replace `<select>` + date-pair with `<DateRangePicker surface="audit-log" presets={["past_7d","past_14d","past_30d","past_90d","past_all","custom"]} />`.
- `projects-chart.tsx:75-83` **wrap** the existing 1y/2y/all presets in a custom-prop variant (a sibling component `<SymmetricRangePicker>` that shares the picker's popover scaffolding but emits the surface-specific `range_preset`). Or if the head/m prefers fold 1y/2y/all into TimeHorizon as `sym_1y` / `sym_2y` / `sym_all`. **Inventor pick (R): sibling component**, because symmetric-around-today is conceptually different from past/future fan. See §8 Q1.
**LoC**: ~120 LoC for audit-log, ~80 LoC for projects-chart wrap.
### Slice D *(optional, separate task)* — slicer
- Add `<DateRangeSlicer>` for the custom-editor sub-pane. Built on `<input type="range">` × 2 with a custom anchor rail above, ported from `date-range-slider-pure.ts`.
- Replaces inline date-pair when `horizon === "custom"` and `surface ∈ {agenda, audit-log, filter-bar}`. Projects-chart keeps inline date-pair OR also uses slicer its choice.
- No new dependency.
- ~400 LoC including pure helpers + DOM scaffolding + tests.
### Per-slice rollout
| Slice | Risk | Surfaces affected | Coder profile |
|---|---|---|---|
| A | Low additive only | 4 (filter-bar + 3 consumers) | Pattern-fluent Sonnet |
| B | Low | 1 | Same coder |
| C | Medium (projects-chart sibling) | 2 | Same coder |
| D | Medium (new slicer) | 0 (additive on top of A) | Separate task |
---
## §6 Visual decisions
### 6.1 Chip labels
Final labels bilingual (DE first):
| Chip | DE | EN |
|---|---|---|
| past_all | Ganze Vergangenheit | All past |
| past_90d | Letzte 90 Tage | Last 90 days |
| past_30d | Letzte 30 Tage | Last 30 days |
| past_14d | Letzte 14 Tage | Last 14 days |
| past_7d | Letzte 7 Tage | Last 7 days |
| any (center) | Alles | All |
| next_7d | Nächste 7 Tage | Next 7 days |
| next_14d | Nächste 14 Tage | Next 14 days |
| next_30d | Nächste 30 Tage | Next 30 days |
| next_90d | Nächste 90 Tage | Next 90 days |
| next_all | Ganze Zukunft | All future |
| custom | Anpassen | Customize |
Rationale on "Anpassen" vs "Benutzerdefiniert":
- "Anpassen" matches existing `views.bar.time.custom` key value in `i18n.ts`.
- "Benutzerdefiniert" is used in admin-audit-log's dropdown verbose, but more accurate.
- (R): **Anpassen** (consistent with filter-bar; six chars vs. eighteen).
### 6.2 Accent / active state
Reuse the existing **lime accent** chip-active state (`--color-bg-lime-tint` background, `--color-accent` border, `--color-text` text). This is the established affordance for the `agenda-chip-active` class same visual reused, no new accent token.
### 6.3 The "ALLES" center button
A larger, target-glyph button visually distinct from the fan chips so the user reads it as the "no time filter" exit, not as one chip among many:
```
╭──────╮
│ ⌖ │
│ ALLES│
╰──────╯
```
(R) glyph: `⌖` (Unicode U+2316 POSITION INDICATOR). Alternatives considered: `∞` (too math-y), `⊕` (too connect-y), `▣` (too checkbox-y), no glyph (chip then looks like every other chip). See §8 Q3.B.
### 6.4 Custom-range entry
In Slice A: **inline date-pair below the chip rows**, with an "Anwenden" button that commits + closes the picker. Plain `<input type="date" lang="de">` gets the OS-native picker.
In Slice D (later): same slot becomes the slicer. The chip rows remain; the slicer collapses under them so the user can switch back to a chip with one click.
### 6.5 Hover / focus
- Chip hover: existing `.agenda-chip:hover` (lighter background tint).
- Chip focus-visible: 2px outline using `--color-accent`.
- Button focus-visible: same.
- Popover entry: 120ms fade-in via `transform: translateY(-4px) → 0` + opacity. Reduced-motion users (prefers-reduced-motion: reduce) get instant show.
### 6.6 Indication that the filter is non-default
The closed button shows a small `●` dot to the left of the label when the value is **not** the surface default. This matches the existing filter-bar non-default-indicator pattern (`frontend/src/client/filter-bar/index.ts` has a similar dot but on the whole bar; we adopt it per-control).
---
## §7 Edge cases
### 7.1 Timezones
All horizon math runs against **UTC `startOfDay`** of `new Date()` same convention as `horizonBounds()` in `projects-detail.ts:393-406`. The user's browser may be in CEST in summer or CET in winter; the picker still treats "today" as a UTC date for filter purposes. The date-input localizes display (German locale DD.MM.YYYY) but the underlying ISO is `YYYY-MM-DD` parsed as UTC midnight.
Practical impact: a user in CEST clicking "Letzte 7 Tage" at 01:30 local on 2026-06-15 sees `from=2026-06-07T00:00Z, to=2026-06-15T00:00Z` even though their local clock shows the 15th. This matches every other date-filter in paliad and avoids "the same row vanishes at 01:00 vs. 23:00" surprises. Document the convention in the pure module's header comment.
### 7.2 Far past truncation
`past_all` materialises to `from: nil`. The Go side (view_service.go) treats nil as "no lower bound" the SQL `WHERE due_date >= ?` clause is omitted. No truncation needed.
For projects-chart's symmetric "all" mode, "all" still means **bounds derived from loaded events** (status quo) the picker for projects-chart's surface uses the sibling `<SymmetricRangePicker>` which doesn't have `past_all`/`next_all` chips, only `1y/2y/all`.
### 7.3 Overlapping selections — past_7 + next_7 simultaneously?
The picker is **single-select** one chip active at a time, OR custom mode. m's brief doesn't mention multi-select and the existing TimeSpec is single-valued. Multi-select would require a fundamental contract change. Don't.
If a user genuinely wants "last 7 days OR next 7 days," they use the custom-range with `from=today-7d`, `to=today+7d` which is what `±1w` would mean. The fact that this is two chip-clicks vs. one isn't a real ergonomic loss.
### 7.4 Custom dates with from > to
Validate client-side: when both inputs are filled and `from > to`, the "Anwenden" button is disabled and a hint appears: "Bis-Datum muss nach Von-Datum liegen" (i18n key `date_range.custom.invalid`). The picker does **not** auto-swap.
### 7.5 Empty inputs in custom mode
If the user clicks "Anpassen" then clicks elsewhere before filling inputs, the picker reverts to whatever horizon was active before (state cached on entry to custom-editor). No "half-custom" state persists.
### 7.6 Surface-specific preset overrides
Each surface declares its own presets via the `presets` prop. The picker hides chips not in the array. The default surface preset (read from `defaults` prop, or hardcoded if absent) is what `serializeURL()` omits from the URL.
Important invariant: `defaults` must be a member of `presets`, OR be a special value like `any` that's always rendered. The component asserts this at boot and falls back to `any` if violated.
### 7.7 Bilingual labels mid-session
`labelForHorizon()` consults the live `i18n.ts` dictionary on every render, so a language toggle updates the picker immediately including the closed-button label.
### 7.8 Embedded picker inside a filter bar
When the picker is mounted inside `filter-bar`, it should NOT use a full popover overlay the filter bar already wraps controls. Instead the open-state's chip rows render **inline below the time chip cluster**, expanding the bar's height. This is `mode="inline"` (a third mode beyond popover/modal). Slice A picks this for filter-bar consumers; standalone surfaces (`/agenda`, `/admin/audit-log`) use popover mode.
### 7.9 What happens if a saved Custom View references `past_14d` before Slice A ships?
The JSON validator rejects it (`filter_spec.go:validate()` enum check). Saved views are migration-safe in one direction only adding new enum values is fine; removing is not. Slice A adds, doesn't remove. No issue.
### 7.10 Race: URL change while picker is open
If the user has the picker open and a URL change happens via another control (e.g. they Cmd-Click a sidebar link), the picker is unmounted naturally with the page navigation. No state to preserve across navigations.
---
## §8 Open questions for m
Per task brief: **no AskUserQuestion**. Material picks escalated via `mai instruct head`; everything else defaults to (R) below. The head decides whether to forward to m or rule on the spot.
### Q1 [MATERIAL — escalate]: How to handle `/projects/:id/chart`?
The chart's range presets are **symmetric around today** (1y / 2y / all = ±1y / ±2y / all-data-bounds), conceptually different from past/future fans. Options:
- **(R) A sibling component.** Keep a separate `<SymmetricRangePicker>` for the chart surface. Same popover scaffolding, different chip set. Chart's URL stays `?range=1y`. Doesn't add to TimeHorizon.
- **B fold into TimeHorizon.** Add `sym_1y`, `sym_2y`, `sym_all` constants. Picker prop selects which fan vs. symmetric. Saved views could then express 1y" too.
- **C leave the chart as-is.** Don't migrate. Accept the visual inconsistency.
(R) **A.** Symmetric vs fan is a real semantic difference; one component trying to be both is muddier than two components sharing scaffolding. The chart isn't a "filter" it's a viewport, and viewports legitimately want symmetric panning.
### Q2 [MATERIAL — escalate]: Modal vs popover for the standalone case?
m's brief says "mini modal." Options:
- **(R) A popover always.** Anchored to the trigger button, click-outside dismiss. In-context, lightweight.
- **B modal for explicit "open date filter" intent.** Use a centered modal with scrim when the picker is the page's primary filter (e.g. `/admin/audit-log` where date is the most prominent control). Popover for embedded uses.
- **C modal everywhere.** Strong visual hierarchy, but interrupts the user.
(R) **A.** Modal feels heavy for what is conceptually a chip cluster. The "mini" qualifier in m's wording suggests popover, not full modal. If a surface specifically needs the modal weight, the `mode="modal"` prop is available but no default surface picks it.
### Q3 [MATERIAL — escalate]: Slice priority — what migrates first?
- **(R) A filter-bar `time` axis first** (Slice A). Lights up 4 surfaces simultaneously (Verlauf, InboxFilterBar, views runtime, Custom Views editor) by replacing the existing Phase-2 disabled stub.
- **B `/agenda` first** (per task brief default). Highest-traffic standalone surface, simplest migration.
- **C both A and B in parallel** (head splits between two coders).
(R) **A.** Filter-bar is the substrate everything else either uses or should use. Lighting it up first turns three downstream surfaces from "almost working" (the stubbed custom-range chip with "coming_soon" tooltip) to "fully working." Agenda then migrates as Slice B, on top of a proven component.
### Q3.B [DEFAULT — no escalation needed]: ALL center button glyph?
- **(R) `⌖`** (POSITION INDICATOR, U+2316). Implies "center / pin to here."
- B `∞` (infinity). Mathy.
- C `⊕` (circled plus). Looks like a button.
- D No glyph, just "ALLES" in bold.
(R) `⌖`. If the head/m doesn't like the unicode lookup, D is the safe fallback.
### Q4 [DEFAULT — no escalation]: Custom-range entry in Slice A?
- **(R)** Inline `<input type="date">` pair, OS-native picker. Slice D adds the slicer.
### Q5 [DEFAULT — no escalation]: Past `24h` in audit-log?
audit-log currently has a `24h` preset; the picker would express this as `past_1d`. Options:
- **(R)** Map legacy `?range=24h` `?horizon=past_1d`. Add a new `past_1d` constant.
- B Drop `24h` audit log defaults to `past_7d` like other surfaces. Users wanting "last 24h" use custom mode.
(R) Add `past_1d`. It's a one-line addition and audit-log users genuinely use "last 24h" for incident triage.
(Note: this means the picker actually has 5 past chips + 5 future chips + center + custom = 12 chips total, which fits comfortably in the popover.)
### Q6 [DEFAULT — no escalation]: Slice D (slicer) — separate task or fold in?
- **(R) Separate task.** Slice A-C are independently shippable. Slice D is meaningful design + ~400 LoC and shouldn't gate the main migration.
### Q7 [DEFAULT — no escalation]: Per-surface defaults?
Each migrating surface keeps its current default exactly:
- `/agenda` `next_30d` (was 30).
- `/admin/audit-log` `past_7d` (was 7d).
- `/projects/:id` Verlauf `past_30d` (was past_30d in `projects-detail.ts:2310`).
- `/views/:id` runtime whatever the saved view has (no change).
- `/inbox` (InboxFilterBar) whatever filter-bar's surface defines.
### Q8 [DEFAULT — no escalation]: Should `past_14d` and `next_14d` retroactively appear in `views-editor.tsx`'s `<select>`?
(R) **Yes** once Slice A ships, the `<select>` in `views-editor.tsx` is replaced by the picker (part of Slice A, as filter-bar consumers all flip in one commit). All 12 preset values become available for new Custom Views.
---
## §9 Implementer notes (for the coder shift, if approved)
### Lessons embedded
- **TimeSpec extension is additive only** Go enum + TS union + i18n keys + horizonBounds switch. No DB migration, no contract break.
- **Pure module is testable under `bun test`** no DOM needed for horizon math, label resolution, URL serialization. Aim for 95%+ coverage of the pure module before touching the boot client.
- **Reuse `.agenda-chip` styling** adds no new tokens, no new dark-mode contrast risk (cf. memory t-paliad-150 / fritz fritz lost 90 minutes to a `var(--token, #hex)` fallback bug because the token wasn't defined in dark mode).
- **`mode="inline"` for filter-bar consumers** the bar already wraps its own popover-like layout; nesting popovers gets visually noisy.
- **Surface defaults must be members of `presets`** assert at boot, fail loud in dev, fall back to `any` in prod.
### Recommended coder profile
Pattern-fluent Sonnet. Substrate is well-trodden (TimeSpec/TimeHorizon already lives, chip-cluster CSS exists, URL-codec pattern documented in `projects-chart.ts`). The novel piece is the popover scaffolding paliad doesn't have a generic Popover primitive today; the picker builds its own DOM-anchored overlay. ~80 LoC of plain JS, no dependency.
### Build hygiene checklist
- `go build ./...` clean
- `go vet ./...` clean
- `go test ./...` clean (existing tests must continue passing additive constants change zero behaviour)
- `bun run build` clean (i18n scan: 21 new keys added, all `data-i18n` attributes present)
- bun:test covers the pure module (horizon math, label resolver, URL parser/serializer)
- Playwright smoke (manual, not gated): on `/inbox` the time axis "Anpassen" chip is now functional; custom-from/to date pair commits a usable filter.
### Out of scope for the coder
- Slicer (Slice D) separate task.
- Per-language adjustments beyond DE/EN (per task brief, out of scope).
- Time-of-day picking separate concern.
- Recurring-event windows events feed handles separately.
- A generic Popover primitive extract only if a second consumer appears in the same slice.
### Acceptance criteria for Slice A
1. New `<DateRangePicker>` mounts on filter-bar's `time` axis, replacing the disabled "Anpassen" chip.
2. The 4 new horizon values (`past_14d`, `next_14d`, `past_all`, `next_all`) are accepted by Go's `TimeSpec.validate()` and produce correct `(from, to)` bounds in `computeViewSpecBounds()`.
3. The 4 new horizons round-trip through saved Custom Views (`paliad.user_views.filter_spec` JSON).
4. URL serialization is canonical (`?horizon=…&from=…&to=…`) and surface-default values are omitted.
5. Verlauf (`/projects/:id`), `/views`, `/views/:id`, and `/inbox` continue to function with their existing presets unchanged they pick up the new picker but don't switch their preset list yet.
6. Pure-module unit tests cover: 12 horizons × bound calculation; URL parse / serialize round-trip; default-omission rule; custom-mode date validation.
7. `bun run build` reports the new i18n keys (no missing-key warnings).
8. No regression in `go test ./internal/services/...` (existing TimeSpec tests stay green).
---
## §10 Material picks summary — escalation message
To be sent via `mai instruct head` after this doc is pushed:
> Three material picks for m on date-range-picker design:
>
> 1. **`/projects/:id/chart` migration** — keep symmetric (1y/2y/all) presets as a sibling component, NOT fold into TimeHorizon. Chart is a viewport, not a filter.
> 2. **Popover vs modal** — popover by default. Modal is a `mode` prop available per surface but no surface picks it in Slice A.
> 3. **Slice A first migrates filter-bar time axis** (lights up Verlauf + InboxFilterBar + Views + Custom-Views-editor simultaneously by un-stubbing the existing "Anpassen" chip), not `/agenda` as the task brief defaulted. `/agenda` is Slice B.
>
> Everything else (chip labels, accent, glyph, custom-mode entry, surface defaults, past_1d for audit, slicer-as-Slice-D, 42 i18n keys) defaults per (R) in §8. Doc at `docs/design-date-range-picker-2026-05-25.md`.
---
*Verified premises (live, before designing):*
- `internal/services/filter_spec.go:107-126` TimeHorizon enum at 9 values today.
- `internal/services/view_service.go:156-187` `computeViewSpecBounds()` switches on the same enum.
- `frontend/src/client/views/types.ts:21-33` TimeHorizon TS mirror; same 9 values.
- `frontend/src/client/filter-bar/axes.ts:65-115` chip cluster renderer; "Anpassen" stub at line 105-112 marked Phase 2, disabled, "coming_soon" tooltip.
- `frontend/src/agenda.tsx:64-67` chip row exact values `7|14|30|90`.
- `frontend/src/admin-audit-log.tsx:50-65` select exact values `24h|7d|30d|custom|all`.
- `frontend/src/projects-chart.tsx:78-82` + `frontend/src/client/projects-chart.ts:73-118` RangePreset `1y|2y|all|custom`, symmetric around today.
- `frontend/src/views-editor.tsx:102-109` select exact values `next_7d|next_30d|next_90d|past_30d|past_90d|any`.
- `/home/m/dev/web/upc-kommentar/src/lib/components/DateRangeSlider.svelte` 448 lines, wraps `svelte-range-slider-pips@4`, custom anchor rail above the lib's hidden pips, click-to-snap left/right halves, granularity year/month/day zoom.
- `/home/m/dev/web/upc-kommentar/src/lib/modules/date-range-slider/date-range-slider-pure.ts` 487 lines, fully testable pure helpers, dependency-free, portable to paliad's TS.
*Not verified live:* upckommentar.de in a browser (requires author auth; the source code IS the source of truth and was read end-to-end).

View File

@@ -0,0 +1,776 @@
# Design — Deadline + procedural-events system revision (Phase 2 of RFC m/paliad#149)
**Task:** t-paliad-329
**Gitea:** m/paliad#149 (Phase 2)
**Inventor:** atlas (shift-1)
**Date:** 2026-05-27
**Status:** Draft — coder gate held; awaiting m's go on the slice train
**Branch:** `mai/atlas/inventor-deadline-system`
**Builds on:**
- `docs/assessment-deadline-system-2026-05-27.md` (athena Phase 1, 738 lines — premises here are athena's)
- `docs/design-fristenrechner-followup-rules-2026-05-27.md` (atlas t-paliad-327, pre-ratified subset: cross-party display + scenario SSoT + spawn-only picker exclusion)
- `docs/design-proceeding-types-taxonomy-2026-05-26.md` (mig 153 shipped; `kind` discriminator)
- `docs/design-fristenrechner-overhaul-2026-05-26.md` (Entry B foundation S1-S6 shipped)
m authorised Phase 2 at 2026-05-27 11:33 ("Go on"). m's "big picture" direction at 13:53 ("yeah, b - big! We need an overall schema for all procedural events and how they are connected") makes the connection graph itself the spine of this design.
---
## §0 Premises — reconciliation with athena's audit
Athena established the live data; this design takes that as given. Three cross-checks ran 2026-05-27 against the live `paliad` schema; counts match athena's §0/§3 numbers (chain-linked 107 / PT-roots 46 / legacy globals 73 / overlap 2). The only material refinement is athena's R3 finding ("4 spawn rules point at INACTIVE id=11") — which m's Q5 answer now re-interprets as **correct** rather than broken (see §3.1).
### §0.1 The athena↔RFC conflicts surfaced
| Item | RFC said | Athena found | Picked side |
|---|---|---|---|
| Scenario state shape | "`projects.scenarios` jsonb (mig 145)" exists | `paliad.scenarios` table exists; `projects.scenarios` jsonb does **not** | Athena. Use new `projects.scenario_flags jsonb` column (Q4) — different from both. |
| Three stores diverge | "Three independent stores. No single source of truth." | All three stores empty (0 rows in `project_event_choices`, 0 in `scenarios`, DOM-only). Risk dormant. | Athena. Design picks one store going forward; nothing to migrate. |
| Spawn FK is "broken" | Implied | Athena R3: 4 spawn rules point at inactive `upc.apl.merits`. | m's Q5 inverts: the unification was the bug, not the FK. Re-split apl into merits/cost/order (§3.1). |
### §0.2 The pre-ratified subset from t-paliad-327
m ratified the following on 2026-05-27 (via `AskUserQuestion`, all on-recommendation in that task) — Phase 2 carries them forward unchanged:
- Cross-party display: backend stops filtering by party, `is_cross_party` derived field, "Gegenseitig" badge, muted/greyed visual, unchecked default, write-back excluded unconditionally. (Folded into §2.4.)
- Scenario flag SSoT: `paliad.projects.scenario_flags jsonb` column + GET/PATCH `/api/projects/{id}/scenario-flags`. (Folded into §2.3.)
- Spawn-only event picker exclusion: `SearchEvents` SQL adds `AND sr.is_spawn = false`. (Folded into §2.2.)
These are not re-asked. They are the foundation Phase 2 builds on.
---
## §1 The overall connection schema (m's "big picture")
Per m's direction: document the canonical connection graph across all procedural_events + sequencing_rules + proceeding_types as a unified model.
### §1.1 Conceptual model in one paragraph
A **rule** (`paliad.sequencing_rules` row) is the atomic node. It carries one deadline for one event, on one proceeding-type. Every rule has at most one **predecessor edge** via `parent_id` → another rule whose own deadline must elapse before this one starts. The chain root (rule with `parent_id IS NULL`) is anchored to its **proceeding-type root event** (typically a filing — Klageerhebung, Veröffentlichung, Anmeldung). A small number of rules are **spawn rules** (`is_spawn=true`) — they don't compute their own deadline; instead they open a fresh proceeding of a different type, edge labelled by `spawn_proceeding_type_id`. Conditional rules carry a `condition_expr` jsonb predicate over a small flag vocabulary (`with_ccr`, `with_amend`, `with_cci`); the active subset of the graph for a given project is the rules whose predicate is satisfied by `projects.scenario_flags`. **The only canonical predecessor link is `parent_id`. The `trigger_event_id` column is deprecated** (Q1). Trigger discoverability is **derived from data**: any event whose anchor rule has `EXISTS (non-spawn child WHERE child.parent_id = anchor.id)` is a valid trigger; everything else (spawn-only consequences, terminal leaves) is filtered out at the picker (Q3, §2.2).
### §1.2 The shape — ASCII tree per representative PT
Showing 3 representative PTs (the rest follow the same structural pattern; counts in §1.4).
#### upc.inf.cfi (25 rules, depth 5, the densest tree)
```
upc.inf.cfi (Verletzungsverfahren CFI)
├─ RoP.013.1 soc Klageerhebung [claimant · M] ← anchor
│ ├─ RoP.019.1 prelim Vorl. Einwendungen [defendant · O]
│ ├─ RoP.262.2 confidentiality_response Vertraulichkeit [both · O]
│ ├─ RoP.023 sod Klageerwiderung [defendant · M]
│ │ └─ RoP.029.b reply Replik [claimant · M · ?with_ccr]
│ │ └─ RoP.029.c rejoin Duplik [defendant · M · ?with_ccr]
│ ├─ RoP.025 ccr Widerklage auf Nichtigkeit [defendant · O · ?with_ccr]
│ │ └─ RoP.029.a def_to_ccr Erwiderung auf CCR [claimant · M · ?with_ccr]
│ │ └─ RoP.029.d reply_def_ccr Replik auf Erw. CCR [defendant · M · ?with_ccr] ← X-party from claimant
│ │ └─ RoP.029.e rejoin_reply_ccr Duplik auf Replik CCR [claimant · M · ?with_ccr]
│ │ └─ RoP.030.1 app_to_amend Antrag auf Patentänderung [claimant · M · ?with_amend]
│ │ └─ RoP.032.1 def_to_amend Erwiderung auf Änderung [defendant · M · ?with_amend]
│ │ └─ RoP.032.3 reply_def_amd Replik auf Erw. Änderung [claimant · M · ?with_amend]
│ │ └─ RoP.032.3 rejoin_amd Duplik auf Replik Änderung [defendant · M · ?with_amend]
│ ├─ RoP.333.2 cmo_review Antrag CMO-Überprüfung [both · O]
│ ├─ RoP.109.1 translation_request Übersetzungsantrag [both · O]
│ ├─ RoP.109.5 translations_lodge Übersetzungen einreichen [both · M]
│ ├─ RoP.118.4 cons_orders Antrag Folgenanordnungen [both · O]
│ ├─ RoP.151 cost_app Kostenantrag [both · O]
│ ├─ RoP.353 rectification Berichtigungsantrag [both · O]
│ └─ RoP.220.1.a appeal_spawn ⇲ Berufungsverfahren öffnen [both · O · SPAWN→ upc.apl.merits]
├─ RoP.104 interim Zwischenanhörung [court · M]
├─ (n/a) oral Mündliche Verhandlung [court · M]
├─ (n/a) decision Endentscheidung [court · M]
│ (Note: interim/oral/decision are court-set; they're chain-anchored but
│ have no scheduled rule of their own — phase markers carried via event_kind.)
└─ RoP.109.4 interpreter_cost Dolmetscherkosten [court · M]
```
**Legend.** `[party · M|O · ?flag · SPAWN→target]`. `M` = mandatory, `O` = optional. `?flag` = conditional on the scenario flag. ← X-party = cross-party row vs claimant perspective; see §2.4 for display. SPAWN → opens a new proceeding under that PT.
#### upc.rev.cfi (17 rules, depth 4, mirrors inf.cfi shape)
Same SoC → SoD → Reply → Rejoinder spine; CCR mirrored as Erwiderung auf Widerklage on revocation. `with_cci` (Widerklage auf Verletzung — the inverse of with_ccr) replaces `with_ccr`. Same `with_amend` branch for R.30. 13 chain-linked, 5 roots, 1 spawn (→ upc.apl.merits, post-Q5 split).
#### upc.apl (POST-Q5 SPLIT — 3 trees, 16 rules total)
After §3.1 mig: id=160 `upc.apl.unified` is retired; rules re-bound to the 3 reactivated PTs (id=11 `upc.apl.merits` 7 rules / id=19 `upc.apl.cost` 2 rules / id=20 `upc.apl.order` 7 rules). Trees:
```
upc.apl.merits (7 rules)
├─ RoP.224.1.a notice Berufungseinlegung
│ └─ RoP.224.2.a grounds Berufungsbegründung
│ └─ RoP.235.1 response Berufungserwiderung
│ └─ RoP.237 cross_a Anschlussberufung
│ └─ RoP.238.1 cross_a_reply Erwiderung Anschlussberufung
├─ (n/a) oral Mündliche Verhandlung [court · M]
└─ (n/a) decision Entscheidung [court · M]
upc.apl.cost (2 rules)
├─ RoP.221.1 leave_app Antrag auf Berufungszulassung
└─ (n/a) decision Kostenfestsetzungsbeschluss
upc.apl.order (7 rules)
├─ (n/a) order angegriffene Entscheidung
│ ├─ RoP.220.2 with_leave Berufung mit Zulassung
│ └─ RoP.220.3 discretion Ermessensüberprüfung
├─ RoP.224.2.b grounds_orders Berufungsbegründung (Orders Track)
│ └─ RoP.235.2 response_orders Berufungserwiderung (Orders Track)
└─ RoP.237 cross Anschlussberufung
└─ RoP.238.2 cross_reply Erwiderung Anschlussberufung
```
The 3 trees are independent. Determinator UX (proceeding_mapping.go) keeps a single user-facing "Berufung" entry that fans out to one of the 3 based on what's being appealed (judgment → merits, cost decision → cost, order → order). Routing layer unchanged from t-paliad-204 S1; only the data shape changes.
The remaining 14 ruled PTs (de.inf.lg / .olg / .bgh, de.null.bpatg / .bgh, dpma.opp / .appeal.bpatg / .bgh, epa.opp.opd / .opp.boa / .grant.exa, upc.dmgs.cfi, upc.disc.cfi, upc.pi.cfi) follow the same shape — root anchored on a filing/grant event, chain depth 1-3, optionals and conditionals branching off the root or first-hop. Athena's §4 gap map gives the per-PT P/R counts; see also §1.4 below.
### §1.3 Cross-PT edges — the spawn graph (post-Q5)
```mermaid
graph LR
upc_inf_cfi[upc.inf.cfi<br/>Verletzungsverfahren CFI] -.->|R.220.1.a<br/>appeal_spawn| upc_apl_merits[upc.apl.merits<br/>Berufung Hauptsache]
upc_rev_cfi[upc.rev.cfi<br/>Nichtigkeitsverfahren CFI] -.->|R.220.1.a<br/>appeal_spawn| upc_apl_merits
upc_dmgs_cfi[upc.dmgs.cfi<br/>Schadensbemessung] -.->|R.220.1.a<br/>appeal_spawn| upc_apl_merits
upc_pi_cfi[upc.pi.cfi<br/>Einstweilige Maßnahmen] -.->|R.220.1.a<br/>appeal_spawn| upc_apl_order[upc.apl.order<br/>Berufung Orders Track]
```
4 spawn edges, all in the UPC CFI cluster. PI appeals go to the orders track (not main proceedings); the rest go to merits. The cost-decision-appeal track (`upc.apl.cost`) is reached not via spawn but via direct filing (`leave_app` rule); cost decisions arrive within their parent proceeding and the cost-appeal opens as a standalone application.
DE-side, EPA-side, DPMA-side: no spawn edges today. Each tier-of-court is a separate `proceeding_type` (de.inf.lg / .olg / .bgh) with its own root + chain; chained-by-instance is not modelled as a spawn (the user explicitly creates a new project for the appeal stage). m may revisit this if DE-side workflow benefits from spawn edges; out of scope for this revision.
### §1.4 Per-PT health summary (post-Q5)
| PT code | rules | roots | chained | conditional | spawns | gap |
|---|--:|--:|--:|--:|--:|---|
| upc.inf.cfi | 25 | 4 | 21 | 10 | 1 | 84% chained — strongest |
| upc.rev.cfi | 17 | 4 | 13 | 8 | 1 | 76% |
| upc.apl.merits | 7 | 3 | 4 | 0 | 0 | post-Q5 split — to be re-rooted |
| upc.apl.order | 7 | 3 | 4 | 0 | 0 | post-Q5 split |
| upc.apl.cost | 2 | 1 | 1 | 0 | 0 | post-Q5 split |
| de.inf.lg | 9 | 5 | 4 | 0 | 0 | 44% — gappy |
| de.null.bpatg | 10 | 4 | 6 | 0 | 0 | 60% |
| de.inf.olg | 7 | 1 | 6 | 0 | 0 | 86% |
| de.inf.bgh | 8 | 1 | 7 | 0 | 0 | 88% |
| de.null.bgh | 6 | 1 | 5 | 0 | 0 | 83% |
| dpma.opp.dpma | 4 | 1 | 3 | 0 | 0 | 75% |
| dpma.appeal.bpatg | 5 | 1 | 4 | 0 | 0 | 80% |
| dpma.appeal.bgh | 4 | 1 | 3 | 0 | 0 | 75% |
| epa.opp.opd | 8 | 2 | 6 | 0 | 0 | 75% |
| epa.opp.boa | 8 | 3 | 5 | 0 | 0 | 63% |
| epa.grant.exa | 7 | 4 | 3 | 0 | 0 | 43% |
| upc.dmgs.cfi | 8 | 4 | 4 | 0 | 1 | 50% |
| upc.pi.cfi | 7 | 3 | 4 | 0 | 1 | 57% |
| upc.disc.cfi | 4 | 1 | 3 | 0 | 0 | 75% |
| **Empty (Q6)** | | | | | | |
| upc.bsv.cfi | 0 | — | — | — | — | unruled — badge "Keine Regeln" |
| upc.ccr.cfi | 0 | — | — | — | — | unruled — badge |
| upc.costs.cfi | 0 | — | — | — | — | unruled — badge |
| upc.dni.cfi | 0 | — | — | — | — | unruled — badge |
| upc.epo.review | 0 | — | — | — | — | unruled — badge |
| upc.pl.cfi | 0 | — | — | — | — | unruled — badge |
Plus **73 legacy globals** sitting in the corpus with `proceeding_type_id IS NULL` — these are the editorial backfill target (Q2 / §4.2). Each needs to be reparented onto one of the 23 PTs.
---
## §2 Tier 1 — model decisions (m ratified all 4 on-recommendation)
### §2.1 `parent_id` is the canonical predecessor link
`paliad.sequencing_rules.parent_id` (uuid FK to another rule) is the **only** predecessor pointer going forward. `paliad.sequencing_rules.trigger_event_id` (bigint FK to legacy `paliad.trigger_events`) gets dropped at the end of the migration train (§5).
**Implication for the 75 rules that currently use `trigger_event_id`:**
- The 73 legacy globals (proceeding_type_id IS NULL): editorial walk reparents each onto a real PT chain (Q2, §4.2). Slow but right — no data is lost, just structurally normalised.
- The 2 hybrid rules (both parent_id AND trigger_event_id set): keep `parent_id`, NULL out `trigger_event_id`. No data loss — `parent_id` already carries the live edge.
After backfill, `trigger_event_id` is unused — safe to drop the column (§5, Mig P4).
### §2.2 Trigger discoverability — derive from data
A `procedural_event` is a **picker-eligible trigger** when EXISTS a published+active non-spawn rule with `parent_id` pointing at this event's anchor rule. The picker SQL gains:
```sql
WHERE EXISTS (
SELECT 1 FROM paliad.sequencing_rules child
WHERE child.parent_id = anchor.id
AND child.is_active = true
AND child.lifecycle_state = 'published'
AND child.is_spawn = false -- spawn-only consequences not pickable (t-paliad-327 §3a)
)
```
No new column. No materialised view. The EXISTS subquery uses the existing `sequencing_rules.parent_id` index. At today's scale (226 rules) it's cheap; at 10× scale still fine (parent_id is indexed; child lookup is index-only scan).
Mode A's `SearchEvents` (`internal/services/fristenrechner_search_events.go`) and Mode B R4's chip-strip both apply this filter. Terminal leaves (Duplik etc.) stay pickable — they have a non-spawn anchor rule and result in an empty follow-up list, which is honest UX (t-paliad-327 §3a.4, m ratified).
### §2.3 Scenario state SSoT — `projects.scenario_flags jsonb`
Reconfirmed from t-paliad-327 §3.2:
```sql
ALTER TABLE paliad.projects
ADD COLUMN scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb;
```
Shape:
```json
{ "with_ccr": true, "with_amend": false, "with_cci": false }
```
Whitelist-validated against the set of flag names appearing in `sequencing_rules.condition_expr` (today: `with_ccr`, `with_amend`, `with_cci`).
API: `GET /api/projects/{id}/scenario-flags` returns the map; `PATCH /api/projects/{id}/scenario-flags` accepts partial deltas (null deletes a key).
**Kontextfrei (no project):** stays on localStorage. No DB writes when `project_id IS NULL`.
**Relationship with `paliad.scenarios`:** complementary, not duplicate. `scenarios.spec.flags[]` (the Litigation Planner Slice D shape) is a *named snapshot*; activating a scenario copies its flag array into `projects.scenario_flags`. Live edits write to `scenario_flags`. `paliad.project_event_choices` (the legacy empty table) is deprecated (§4.3).
### §2.4a Selection state + detail-level view-mode filter
m's reframe (14:40): the real ask isn't "rarity" — it's **detail-level control over the timeline**. Every event/rule is a card; the user picks which optional cards belong to *their* scenario; the Verfahrensablauf has a view-mode toggle that controls how much of the picture surfaces.
m's quote (14:40): *"It is more that I want a grade of detail in our swimlane display […] I want to show them but also be able to 'focus' by not displaying optional things. And we can select these options somehow, for example like we do with the appeal in the Decision dropdown. And if none is selected, none are displayed. We need an option 'Show unselected options' or 'show only selected' or 'mandatory' […] It would be great to basically filter events from the timeline based on whether they are selected in this scenario."*
The underlying mental model:
- **Mandatory rules** are always in the scenario. They render in every view-mode. The user cannot deselect them.
- **Recommended rules** are *selected by default* in the scenario. The user can deselect them.
- **Optional rules** are *not selected by default*. The user opts in via the same UI mechanism that already exists for `with_ccr` / `with_amend` (a chip / dropdown / "Aufnehmen" CTA per rule).
- **Conditional rules** (with `condition_expr`) are gated by scenario flags first, then by selection (a conditional rule whose flag is on still respects its priority's default selection rule).
The Verfahrensablauf gets a three-way **detail-level toggle** (§3.3a):
- **Nur Pflicht (Mandatory only)** — only `priority='mandatory'` cards.
- **Gewählt (Selected)** — mandatory + every rule the scenario has explicitly selected. Default.
- **Alle Optionen (All considered)** — every rule that *could* belong, including unselected optionals (rendered with a dotted border + "Aufnehmen" CTA) and conditional rules whose flag isn't set (rendered greyed with a "wenn-…" hint).
#### Schema — no new column on `sequencing_rules`
The original §2.4a strawman proposed `is_edge_case boolean` as a chain-head flag. m's reframe makes that wrong: **every** optional rule is potentially "rare" depending on the lawyer's scenario; the dimension isn't a property of the rule, it's a property of the scenario.
Instead, the selection state lives entirely in **`projects.scenario_flags jsonb`** (already on the table from P0, §2.3) with an extended shape:
```json
{
"with_ccr": true,
"with_amend": false,
"with_cci": false,
"rule:<uuid_of_recommended_X>": false,
"rule:<uuid_of_optional_Y>": true
}
```
The flat-map shape stays — entries are either named scenario flags (`with_*`) or per-rule selection deviations (`rule:<uuid>`). Storage only carries **deviations from the priority default**:
- `priority='recommended'` is selected-by-default; `rule:X = false` records an explicit deselection.
- `priority='optional'` is unselected-by-default; `rule:X = true` records an explicit selection.
- `priority='mandatory'` is always selected; trying to store `rule:X = false` is rejected (422 from the PATCH endpoint).
Whitelist (Q9 catalog) gains a wildcard pattern `rule:<uuid>` — any well-formed UUID matches; the handler validates that the UUID resolves to an active+published rule on the project's proceeding_type before persisting.
Kontextfrei (no project): localStorage stores the same shape under a per-PT key (`scenario:upc.inf.cfi`). Different PT → different stored selection set; this matches how kontextfrei users explore.
#### Visual — generalising the CCR dropdown to per-rule chips
The existing `with_ccr` / `with_amend` checkboxes are *coarse* scenario flags. The new per-rule selection is *fine-grained* but uses the same UI vocabulary:
- **Selected rule**: solid card, normal background. (Identical to today's mandatory render.)
- **Selected optional that's deselectable**: solid card with a small `[Entfernen]` chip; click removes from `selected_optionals` (writes `rule:X = false`).
- **Unselected optional (default state in "Alle Optionen" mode)**: dotted-border card, muted background, `[Aufnehmen]` CTA. Click writes `rule:X = true`.
- **Conditional rule whose flag isn't set**: greyed card with a "Aktivieren via 'Mit Widerklage' im Szenario" hint; clicking the hint scrolls to the scenario-flags strip.
- **Cross-party** (§2.4): orthogonal — applies its `Gegenseitig` badge and muted style on top of whichever state above.
Each card thus carries up to four orthogonal axes of display state — priority, selection, conditional-gate, cross-party. The 4 axes compose; no axis dominates.
#### Subtree semantics — implicit via parent chain
When a chain head is deselected (e.g. R.109.1 Übersetzungsantrag = `false`), its descendants in the parent_id tree (R.109.4 Mitteilung etc.) **inherit the deselected state for display** without needing their own entries in `selected_optionals`. The tree renderer walks the chain; if any ancestor is unselected, the descendant doesn't render in "Gewählt" mode. In "Alle Optionen" mode, the whole subtree renders greyed under the deselected head.
If a descendant has its own explicit `rule:X = true` entry, that overrides the ancestor — the user has explicitly pulled this leaf into their scenario despite not selecting the parent. Edge case; documented but no special UI affordance.
#### Default population on project creation
When a project is created with `proceeding_type_id = X`, the server seeds `scenario_flags = {}`. Nothing in the map. The tree renderer computes per-rule selection on-the-fly from priority + scenario_flags entries. No upfront write-storm of "rule:X = true" for every recommended rule — only deviations land in storage.
#### Why this beats the `is_edge_case` boolean
- **No new column.** All state lives in the existing `projects.scenario_flags jsonb` from P0.
- **Generalised.** Every optional rule is selectable, not just the few flagged as "rare". m's "sequence density is very high" complaint is solved by the user controlling which optionals belong to *their* scenario, rather than the editorial process having to decide globally which rules deserve dotted-border treatment.
- **Composable with condition_expr.** A conditional rule is selectable when its flag is on; the selection state is independent of the flag state.
- **Matches m's stated UX prior art.** The CCR dropdown pattern *is* the model; we're just generalising it from 3 named flags to N per-rule selections.
### §2.4 Cross-party display
From t-paliad-327 §2 (m ratified on-recommendation all 8 sub-Qs):
- Backend: drop the perspective WHERE clause in `queryFollowUpRows`; return all rows; add server-computed `is_cross_party` boolean.
- UI: render cross-party rows with a `Gegenseitig` badge, muted/greyed style, unchecked by default, date visible.
- Write-back: cross-party rows are **unconditionally excluded** from the project-deadline bulk insert, even if the user manually checks the box.
Composite `condition_expr` (and-of-flags) — checkbox is read-only in the result view; Verfahrensablauf is the canonical toggle surface for individual flags.
Sync: `document.dispatchEvent(new CustomEvent('scenario-flag-changed', { detail: { flag, value } }))`. Single-tab v1; cross-tab in Akte mode deferred.
---
## §3 Tier 2 — surface decisions
### §3.1 Appeal re-split: revert upc.apl.unified → merits/cost/order (m's Q5 divergent pick)
**m's call (2026-05-27):** *"Reverse the unification as suggested in 3. They are different proceedings, I only wanted the approach to be unified in the 'determinator' — but they are actually different proceedings!"*
The current state (mig 096 unified the appeal track):
- id=160 `upc.apl.unified` is `is_active=true`, holds 16 rules.
- id=11 `upc.apl.merits` is `is_active=false`.
- id=19 `upc.apl.cost` is `is_active=false`.
- id=20 `upc.apl.order` is `is_active=false`.
- 4 spawn rules point at id=11 (inactive) — looks like the R3 bug but is actually correctly aimed at merits since cost+order arrive differently (athena R3 partially mis-classified the situation).
- Event codes already carry the split prefix: `upc.apl.{merits,cost,order}.*`. 16 events split cleanly into 7 merits + 2 cost + 7 order.
The migration:
```sql
-- Mig P1: re-activate the three discrete appeal PTs and retire the unified row.
UPDATE paliad.proceeding_types SET is_active = true WHERE id IN (11, 19, 20);
UPDATE paliad.proceeding_types SET is_active = false WHERE id = 160;
-- Mig P1: re-target each rule whose proceeding_type_id is currently 160
-- to the right reactivated PT based on its event_code prefix.
UPDATE paliad.sequencing_rules sr
SET proceeding_type_id = 11
FROM paliad.procedural_events pe
WHERE pe.id = sr.procedural_event_id
AND sr.proceeding_type_id = 160
AND pe.code LIKE 'upc.apl.merits.%';
UPDATE paliad.sequencing_rules sr
SET proceeding_type_id = 19
FROM paliad.procedural_events pe
WHERE pe.id = sr.procedural_event_id
AND sr.proceeding_type_id = 160
AND pe.code LIKE 'upc.apl.cost.%';
UPDATE paliad.sequencing_rules sr
SET proceeding_type_id = 20
FROM paliad.procedural_events pe
WHERE pe.id = sr.procedural_event_id
AND sr.proceeding_type_id = 160
AND pe.code LIKE 'upc.apl.order.%';
-- 4 spawn FKs: stay at id=11 (merits) for inf/rev/dmgs; update upc.pi.cfi's
-- spawn to point at id=20 (order) — appeals against PI orders go to the
-- orders track, not merits.
UPDATE paliad.sequencing_rules
SET spawn_proceeding_type_id = 20
WHERE is_spawn AND procedural_event_id = (
SELECT id FROM paliad.procedural_events WHERE code = 'upc.pi.cfi.appeal_spawn'
);
-- The other 3 spawn rules (inf/rev/dmgs) keep spawn_proceeding_type_id = 11
-- (correct after re-activation).
```
**Determinator UX preserved.** `internal/services/proceeding_mapping.go` (t-paliad-204 S1) keeps its single "Berufung" front door. The mapping fans out to id=11/19/20 based on what's being appealed (judgment / cost decision / order). No user-facing routing change. The change is purely structural.
**Active scenarios / projects pointing at id=160:** none (`paliad.scenarios` and `paliad.projects.active_scenario_id` both empty per athena §0; only 6 projects have any `proceeding_type_id` set and none of them is 160). Zero data migration on the project side.
### §3.2 Empty PTs — show with "Keine Regeln gepflegt" badge
Per m's Q6 — option 2 with a follow-on editorial note ("We need to publish rules then... but yeah, show with the badge for now"):
Picker query for `/api/tools/proceeding-types` gains a flag-not-filter:
```sql
SELECT pt.*,
EXISTS (
SELECT 1 FROM paliad.sequencing_rules sr
WHERE sr.proceeding_type_id = pt.id
AND sr.is_active AND sr.lifecycle_state = 'published'
) AS has_rules
FROM paliad.proceeding_types pt
WHERE pt.is_active AND pt.kind = 'proceeding';
```
Frontend renders the chip with a muted/disabled treatment + badge "Keine Regeln gepflegt" when `has_rules = false`. Project creation can still bind to an empty PT (admin override), but Mode A/B/Verfahrensablauf surface a clear "this proceeding has no seeded rules yet" message.
Editorial follow-up: m publishes rules for the 6 empty PTs (`upc.bsv.cfi`, `upc.ccr.cfi`, `upc.costs.cfi`, `upc.dni.cfi`, `upc.epo.review`, `upc.pl.cfi`) over time; each new published rule auto-removes the badge for its PT. Not blocking this design.
### §3.3 Entry A — extend /tools/verfahrensablauf
Per m's Q7. The existing `/tools/verfahrensablauf` page (used by `frontend/src/client/verfahrensablauf.ts` + shared `views/verfahrensablauf-core.ts`) already serves the pick-a-PT shape. Extend it to:
- Render the parent_id chain as a **collapsible tree** (top-down chronological). Same data shape as §1.2's ASCII trees.
- Expose **optionals + conditionals as toggleable checkboxes** in the tree itself. Ticking writes via `PATCH /api/projects/{id}/scenario-flags` (Akte mode) or localStorage (kontextfrei).
- Reflect cross-party rows with the same muted style as §2.4 (Gegenseitig badge).
- Spawn rows render as **leaf with edge annotation** (⇲ Berufungsverfahren öffnen) and a "create child case" CTA in Akte mode.
- Optionally: a "Zur Frist-Ansicht" deeplink on each tree node → opens Mode B Fristenrechner with that event pre-locked as the trigger.
Backend: extend `/api/tools/fristenrechner` (the proceeding-type fan-out endpoint) to return a tree-shaped payload (`parent_id` resolved into nested children). New handler param or new endpoint `/api/tools/verfahrensablauf/tree?proceeding_type_code=X&project=Y`.
The legacy `/tools/fristenrechner?legacy=1` Procedure-mode page deprecates naturally — same scope, replaced by this Entry A view.
### §3.3a Verfahrensablauf view-mode toggle
A three-way segmented control above the tree at the Verfahrensablauf surface:
```
┌─ Anzeige ──────────────────────────────────────┐
│ ( ) Nur Pflicht (•) Gewählt ( ) Alle Optionen │
└────────────────────────────────────────────────┘
```
Behaviour:
- **Nur Pflicht**: only `priority='mandatory'` cards render. Tightest view.
- **Gewählt** (default): mandatory + every rule that resolves to "selected" given current scenario state (mandatory always; recommended unless explicitly deselected via `rule:X = false`; optional only if explicitly selected via `rule:X = true`; conditional only if its flag predicate holds AND the priority-default-or-deviation puts it in the selected set). Honest summary of what *this* lawyer has chosen for *this* project.
- **Alle Optionen**: everything that could belong, with unselected optionals rendered with the dotted-border + `[Aufnehmen]` CTA, and conditional rules whose flag isn't set rendered greyed with the activation hint.
**Persistence**: per-user, per-browser via `localStorage` under key `verfahrensablauf:view_mode`. Not project-scoped — the same user looking at two different projects probably wants the same verbosity. Not in `scenario_flags` either — view-mode is a UI preference, not a scenario fact. No new schema; no API; no migration.
Cross-surface sync: the **Mode B result view** does NOT carry its own view-mode toggle. It always renders in "Gewählt" semantics (mandatory + selected). Rationale: Mode B locks a single trigger event and lists its follow-ups; the lawyer isn't browsing the full ablauf, they're focused on one moment. The view-mode toggle is a Verfahrensablauf-only affordance.
The view-mode toggle composes with the scenario-flags strip (§2.3). Toggling "Mit Widerklage auf Nichtigkeit" off in "Gewählt" mode removes the CCR conditional branch from view; flipping to "Alle Optionen" re-renders the CCR branch greyed with the activation hint. The user can see what they're *not* currently considering without losing the simplified default view.
### §3.4 Legacy `/api/tools/event-deadlines` deprecation
Per m's Q8. Sequence:
1. **Mig P3 — 73-globals reparenting completes** (§4.2, editorial work). Once `paliad.sequencing_rules WHERE proceeding_type_id IS NULL` is empty, the legacy route has no live data shape it uniquely serves.
2. **Code drop:** remove `/api/tools/event-deadlines` route + `EventDeadlineService` + the `deadline_rule_service.go:226-285` label-fallback path + the `ExportService:1680` workbook sheet.
3. **Table drop:** `DROP TABLE paliad.trigger_events` (mig P4, §4.3).
4. **Snapshot generator:** `cmd/gen-upc-snapshot/main.go` stops reading `paliad.trigger_events`; UPC snapshot for youpc.org only carries the unified rule shape.
The cleanup is gated on §4.2 completion. If editorial backfill is slow, the route can live behind a `/api/legacy/` prefix until done — but the design assumption is that we close the loop within the slice train.
---
## §4 Tier 3 — editorial + cleanup framework
### §4.1 `condition_expr` grammar formalisation
Per m's Q9. The grammar:
```ts
type CondExpr =
| { flag: KnownFlag } // leaf
| { op: 'and' | 'or'; args: CondExpr[] } // composite (recursive)
type KnownFlag = 'with_ccr' | 'with_amend' | 'with_cci' // closed set; extensible via admin
```
Implementation:
- A JSON-schema validator in `RuleEditorService.create`/`update` rejects writes that don't match. Today's 18 rules all conform; no data migration.
- Known-flag whitelist sourced from a small Go constant + an admin-editable `paliad.scenario_flag_catalog(name, description, added_at)` table — keeps the vocabulary discoverable. (Lightweight ALTER, not a major migration.)
- Engine consumer (`pkg/litigationplanner/expr.go`, currently a switch over string literals) gains exhaustive-case enforcement against the same catalog. Linter catches drift between catalog and engine.
`choices_offered` and `applies_to_target` (athena R11) — same grammar treatment in a separate ticket (not blocking this revision). Document their 3 known shapes (`appellant`, `skip`, `include_ccr`) in code comments meanwhile.
### §4.2 Editorial backfill workflow — `/admin/procedural-events` parent-NULL filter
Per m's Q10:
- Add filter chip "parent: nicht gesetzt" to the admin list at `/admin/procedural-events`. The filter URL `?parent_filter=null` (or similar).
- Track completion per PT via the existing gap-map query (athena §3.1) — show as a progress bar in the admin shell ("upc.inf.cfi: 4/4 roots OK" / "de.inf.lg: 2/5 roots remain").
- For the 73 globals: a separate filter `?orphan=true` showing only `proceeding_type_id IS NULL` rules. m clicks each, assigns a PT + parent rule via the editor.
- Each save flips lifecycle_state to draft (unchanged from existing editor flow); m publishes a batch when satisfied with a PT.
No new code surface — the existing admin list + editor handle everything once the filter is added.
This is editorial work, not coder work. The design captures the framework; m drives the content at his own cadence. No mig is gated on completion (the parent-NULL filter is a feature add; rules stay valid in their current shape during the walk).
#### §4.2.1 Worked editorial example — R.109 translation chain
m flagged this case (14:35) as a concrete instance of malformed parent-chain shape. The current data for `upc.inf.cfi`:
| rule | event | current parent | current primary_party | correct shape |
|---|---|---|---|---|
| `RoP.109.1` | `upc.inf.cfi.translation_request` (Antrag auf Simultanübersetzung) | upc.inf.cfi root (Mündliche Verhandlung) | both | parent stays at MV; flagged optional (default-unselected) |
| `RoP.109.4` | `upc.inf.cfi.interpreter_cost` (Mitteilung Dolmetscherkosten) | upc.inf.cfi root (Mündliche Verhandlung) — **WRONG** | court — **WRONG** | parent = R.109.1; primary_party = both (parties give the Mitteilung, not the court); condition_expr = `{"flag": "with_interpreter_denied"}` |
| `RoP.109.5` | `upc.inf.cfi.translations_lodge` (Übersetzungen einreichen) | upc.inf.cfi root | both | parent = R.109.1 (lodging follows the request); priority stays mandatory but conditional via `{"flag": "with_translation_granted"}` |
Two new scenario flags introduced (`with_interpreter_denied`, `with_translation_granted`) get added to the `scenario_flag_catalog` (§4.1) when the editor saves these rules.
Editorial walk for m:
1. Open `/admin/procedural-events?orphan=false&parent_filter=null&proceeding_type=upc.inf.cfi`.
2. Find R.109.1, R.109.4, R.109.5 — they sit at depth 1 under the root.
3. Edit R.109.4: set `parent_id = <R.109.1's id>`; set `primary_party = both`; set `condition_expr = {"flag": "with_interpreter_denied"}`. Save (draft).
4. Edit R.109.5: set `parent_id = <R.109.1's id>`; set `condition_expr = {"flag": "with_translation_granted"}`. Save (draft).
5. Publish both.
6. The catalog accepts the two new flag names; the validator updates.
Result in the Verfahrensablauf tree (post-fix):
```
upc.inf.cfi root
├─ Mündliche Verhandlung (court · M)
├─ Antrag auf Simultanübersetzung (RoP.109.1) [both · O]
│ ├─ Mitteilung Dolmetscherkosten (RoP.109.4) [both · M · ?with_interpreter_denied]
│ └─ Übersetzungen einreichen (RoP.109.5) [both · M · ?with_translation_granted]
```
In **Gewählt** mode without scenario flags: only the root + Mündliche Verhandlung surface. R.109.1 is an unselected optional → hidden. R.109.4 + R.109.5 are conditional + below an unselected ancestor → hidden.
In **Gewählt** mode after the user clicks `[Aufnehmen]` on R.109.1: R.109.1 appears. R.109.4 still hidden (its flag `with_interpreter_denied` isn't set; the user would need to know the court denied the Antrag, then tick the flag in the Szenario-Flags strip). R.109.5 similarly hidden until `with_translation_granted` is on.
In **Alle Optionen** mode: every rule renders, conditionals greyed with their flag hint, R.109.1 dotted with `[Aufnehmen]`.
This is the model in miniature: the editorial fix is data-only (no schema change, just `parent_id` + `condition_expr` + `primary_party` UPDATEs via the editor); the display fix is policy that the existing scenario_flags + view-mode mechanism already supports.
### §4.3 `paliad.trigger_events` table fate — drop
Per m's Q11. Sequence (chained to §3.4):
1. After 73-globals reparented + route dropped + label-fallback ported to `procedural_events.name`:
2. `DROP TABLE paliad.trigger_events` (mig P5, last in the train).
3. Migrate `cmd/gen-upc-snapshot/main.go` to no longer SELECT from this table.
4. Remove the `ref__trigger_events` sheet from `ExportService` workbook output.
The bigint PK / parallel taxonomy disappears entirely. `procedural_events` (uuid PK) is the only event catalog.
---
## §5 Schema delta + migration plan (slice train)
Six slices, sequential where data-coupled, parallelisable where not. Each slice ships as one or two PRs.
| Slice | Mig | What ships | Reversible? |
|---|---|---|---|
| **P0 — Scenario SSoT** | mig 154 | `ALTER TABLE projects ADD COLUMN scenario_flags jsonb`; GET/PATCH endpoints w/ extended whitelist (named flags + `rule:<uuid>` per-rule entries, validated against project's PT rule set); Verfahrensablauf + result-view binding; `scenario_flag_catalog` table (§4.1) | Yes — DROP COLUMN |
| **P1 — Appeal re-split** | mig 155 | UPDATE proceeding_types (re-activate 11/19/20, deactivate 160); UPDATE sequencing_rules (rebind 16 rules to merits/cost/order by event_code prefix); UPDATE pi.cfi spawn FK → 20 | Reversible by inverse UPDATEs; documented in down mig |
| **S1+S1a from t-paliad-327** | — | Cross-party display backend + frontend; spawn-only picker filter (`sr.is_spawn = false` in SearchEvents) | Yes — code-only |
| **P2 — Empty-PT badge** | — | `has_rules` flag on /api/tools/proceeding-types; frontend muted-chip rendering | Yes — code-only |
| **P3 — Entry A (Verfahrensablauf tree)** | — | Tree endpoint + tree UI in /tools/verfahrensablauf; three-way view-mode toggle (localStorage); per-rule `[Aufnehmen]`/`[Entfernen]` chips wire to scenario_flags `rule:<uuid>` entries; subtree-hide-on-unselected-ancestor render logic | Yes — code-only |
| **P4 — Editorial walk (73 globals)** | — | parent-NULL filter on /admin/procedural-events; editorial work by m (no coder task per se) | Trivially reversible |
| **P5 — trigger_event_id deprecation** | mig 156 | DROP `/api/tools/event-deadlines`; DROP `EventDeadlineService`; port label-fallback in deadline_rule_service.go; remove ref__trigger_events sheet; `ALTER TABLE sequencing_rules DROP COLUMN trigger_event_id`; `DROP TABLE trigger_events`; condition_expr write-time validator | Last; downgrade requires re-adding column + re-populating — irreversible in practice |
Constraint: **P5 is gated on P4 completion** (no rules can have NULL proceeding_type_id when DROP runs). All other slices ship independently.
Ordering rationale:
- P0 unblocks the Fristenrechner-side bugs immediately (no waiting on appeal-split editorial).
- P1 is data-only, low risk, can land in parallel with P0.
- S1+S1a are code-only follow-ons to P0 (same scenario-flag plumbing).
- P2 ships once P1 lands (re-activated PTs need badge support too).
- P3 builds on P2 + the tree endpoint; depends on P0 for flag persistence.
- P4 is m's editorial work — duration depends on m's cadence, not coder velocity.
- P5 is the cleanup at the end. Only safe when P4 is done.
---
## §6 Entry A UI spec (sequence-from-proceeding-type)
Live URL: `/tools/verfahrensablauf?project=<id>&proceeding_type=upc.inf.cfi`.
### §6.1 Layout
```
┌─ Akte / kontextfrei ─────────┐ ┌─ Verfahren ──┐ ┌─ Anzeige ──────────────────────────┐
│ HL-2024-001 ▼ │ ohne Akte │ │ upc.inf.cfi ▼│ │ Nur Pflicht ⦿ Gewählt ○ Alle Optionen │
└──────────────────────────────┘ └──────────────┘ └────────────────────────────────────┘
┌─ Szenario-Flags ──────────────────────────────────┐
│ ☑ Mit Widerklage auf Nichtigkeit (with_ccr) │
│ ☐ Mit Antrag auf Patentänderung R.30 (with_amend) │
│ ☐ Mit Widerklage auf Verletzung (with_cci) │
└────────────────────────────────────────────────────┘
┌─ Ablauf ── (view-mode: Gewählt) ───────────────────────────────────┐
│ 📥 Klageerhebung [claimant · mandatory] │
│ ├─ Klageerwiderung [defendant · mandatory] │
│ │ └─ Replik [claimant · M · ?with_ccr]│
│ │ └─ Duplik [defendant · M · ?with_ccr]│
│ ├─ Widerklage auf Nichtigkeit [defendant · O · ?with_ccr][Entfernen]│ ← selected optional
│ │ └─ Erwiderung auf CCR [claimant · M · ?with_ccr]│
│ │ └─ Replik auf Erw. CCR [defendant · M · ?with_ccr][Gegenseitig]│
│ │ └─ Duplik auf Replik [claimant · M · ?with_ccr]│
│ └─ ⇲ Berufungsverfahren öffnen [SPAWN → upc.apl.merits] │
│ 🏛️ Zwischenanhörung [court · mandatory] │
│ 🏛️ Mündliche Verhandlung [court · mandatory] │
│ ⚖️ Endentscheidung [court · mandatory] │
└────────────────────────────────────────────────────────────────────┘
↓ (user flips view-mode to "Alle Optionen")
┌─ Ablauf ── (view-mode: Alle Optionen) ─────────────────────────────┐
│ 📥 Klageerhebung [claimant · mandatory] │
│ ├─ ┄ Vorl. Einwendungen [defendant · O] [Aufnehmen]┄ │ ← unselected, dotted
│ ├─ Klageerwiderung [defendant · mandatory] │
│ ├─ Widerklage auf Nichtigkeit [defendant · O · ?with_ccr][Entfernen]│
│ ├─ ┄ Antrag auf Patentänderung [O · ?with_amend] greyed │ ← flag not set
│ │ └─ wenn 'Mit Patentänderung' im Szenario aktiv │
│ ├─ ┄ Antrag auf Simultanübersetzung [O] [Aufnehmen]┄ │ ← post-§4.2.1
│ │ ├─ ┄ Mitteilung Dolmetscherkosten [M · ?with_interpreter_denied]│
│ │ └─ ┄ Übersetzungen einreichen [M · ?with_translation_granted]│
│ ├─ ┄ Antrag CMO-Überprüfung [both · O] [Aufnehmen]┄ │
│ ├─ ┄ Antrag Folgenanordnungen R.118(4) [both · O] [Aufnehmen]┄ │
│ └─ ⇲ Berufungsverfahren öffnen [SPAWN → upc.apl.merits] │
│ 🏛️ ... │
└────────────────────────────────────────────────────────────────────┘
```
### §6.2 Behaviour
- **Project picker (Step 0)** unchanged from Fristenrechner.
- **Proceeding-type picker** chips → switching re-fetches the tree.
- **View-mode toggle (§3.3a)** — three-way segmented control (Nur Pflicht / Gewählt / Alle Optionen). State in `localStorage["verfahrensablauf:view_mode"]`. Default = "Gewählt". Re-renders the tree on toggle; no network call.
- **Szenario-Flags strip** reads/writes `projects.scenario_flags` (Akte) or localStorage (kontextfrei). Same `scenario-flag-changed` CustomEvent as Mode B's result view — both surfaces stay in sync. Flag entries (`with_ccr` etc.) live alongside per-rule entries (`rule:<uuid>`) in the same jsonb.
- **Per-rule selection chips** — every non-mandatory rule's card carries `[Aufnehmen]` (unselected → tick selects) or `[Entfernen]` (selected → tick deselects). The handler PATCHes `projects.scenario_flags` with `{ "rule:<uuid>": true|false }` and fires the same `scenario-flag-changed` event.
- **Subtree hide-on-deselect** — when a chain head (any rule with children via `parent_id`) is unselected in "Gewählt" mode, its descendants don't render. The tree walker checks each rule's full ancestor chain; any unselected ancestor hides the descendant. In "Alle Optionen" mode, descendants render greyed under the unselected ancestor.
- **Cross-party rows** render with `Gegenseitig` badge, muted style (same as Mode B result view §2.4). Composes with selection state and view-mode independently.
- **Spawn rows** render as leaves with the ⇲ symbol + "Neues Verfahren öffnen" CTA (Akte mode only; kontextfrei shows the badge without the CTA). Spawn rows ignore selection state — they always render in "Gewählt" + "Alle Optionen" modes since they represent a possible next-procedure rather than an in-scenario deadline.
- **Empty PT** (the 6 unruled): tree area renders an inline "Für dieses Verfahren sind noch keine Regeln gepflegt" message + a link to /admin if the user is admin.
- **Deeplink to Mode B:** each tree node has a "Frist berechnen" link that opens `/tools/fristenrechner?event=<code>&trigger_date=…&project=…`.
### §6.3 Backend
New handler: `GET /api/tools/verfahrensablauf/tree?proceeding_type=upc.inf.cfi&project=<id>` returns:
```jsonc
{
"proceeding_type": { "code": "upc.inf.cfi", "name_de": "...", "name_en": "..." },
"scenario_flags": { "with_ccr": true, "with_amend": false },
"tree": [
{
"rule_id": "...", "event_code": "upc.inf.cfi.soc",
"name_de": "Klageerhebung", "primary_party": "claimant",
"priority": "mandatory", "has_condition": false, "is_spawn": false,
"is_cross_party": false,
"children": [
{ "rule_id": "...", "event_code": "upc.inf.cfi.sod", ... , "children": [...] },
...
]
},
... // chain-anchored roots
]
}
```
The tree is the result of walking `parent_id` recursively from the PT's root rules (those with `parent_id IS NULL` for this PT). Computed via one recursive CTE; cached per-PT (the tree shape changes only on rule edits).
`is_cross_party` is computed against `projects.our_side` (Akte mode) or the request's `?party=` query param (kontextfrei).
---
## §7 Entry B UI spec — reaffirms shipped Fristenrechner Mode A+B
Mode A (`/tools/fristenrechner?mode=search`) and Mode B (`?mode=wizard`) — both shipped via t-paliad-322 S1-S6. Surgical follow-ons from t-paliad-327 design (§0.2):
- Mode A search: add `AND sr.is_spawn = false` to `SearchEvents` WHERE block + add the derived-trigger filter `EXISTS (non-spawn child)` from §2.2. Compiled together as one PR (S1+S1a).
- Mode B R4 chip-strip: identical filter on the wizard's event-pool query.
- Result view: stop filtering follow-ups by party server-side (§2.4); render cross-party with badge.
- Scenario flag binding: result-view CONDITIONAL group reads/writes `projects.scenario_flags` via the new API (P0). Same CustomEvent sync as Entry A.
No layout changes. The mode tabs (⚡ Direkt suchen / 🧭 Geführt) stay as today. The 3rd entry path is Entry A on the verfahrensablauf page — not a Mode C.
---
## §8 Worked examples
### §8.1 Entry A — claimant on HL-2024-001 (upc.inf.cfi, with_ccr=true)
User opens `/tools/verfahrensablauf?project=HL-2024-001&proceeding_type=upc.inf.cfi`.
- Project context loads. `scenario_flags = {with_ccr: true}`.
- Tree GET returns the §1.2 shape, with conditional rules' `has_condition` flagged.
- UI renders: top-level SoC anchor → branches. The CCR branch is fully expanded because `with_ccr=true`. The R.30 amend branch renders but conditionals are greyed (with_amend=false).
- User clicks "Mit Antrag auf Patentänderung R.30" in the Szenario-Flags strip.
- Frontend fires `PATCH /api/projects/HL-2024-001/scenario-flags { with_amend: true }`. Server stores. CustomEvent dispatches.
- Tree re-renders: R.30 amend branch ungreys; conditional rules become live.
- User scrolls to "Erwiderung auf CCR" → clicks "Frist berechnen" → deeplinks to Mode B with `event=upc.inf.cfi.def_to_ccr&trigger_date=<today>&project=HL-2024-001`.
- Mode B result view loads. Cross-party RoP.029.d (defendant Replik) shows with `Gegenseitig` badge.
### §8.2 Entry B — Mode A search after picker filter
User types "Berufung" in Mode A.
- Backend SQL (post-§2.2 + post-spawn filter):
```sql
WHERE pe.name % 'Berufung' OR pe.code % 'Berufung'
AND sr.is_active AND sr.is_spawn = false
AND EXISTS (
SELECT 1 FROM paliad.sequencing_rules child
WHERE child.parent_id = sr.id AND child.is_active AND NOT child.is_spawn
)
```
- Returns: real triggers in the appeal track (`upc.apl.merits.notice`, `upc.apl.merits.grounds`, `upc.apl.order.with_leave`, etc. — post-Q5 split). Does NOT return: `upc.{inf,rev,pi,dmgs}.cfi.appeal_spawn` (spawn-only) or terminal leaves (no children).
User picks `upc.apl.merits.notice` → result view loads its follow-ups. Tree renders cleanly because the Q5 split gave merits its own chain root.
### §8.3 Editorial flow — m reparents a legacy global
m opens `/admin/procedural-events?orphan=true`. Sees the 73-row list.
- m clicks row "Antrag auf Verlängerung der Klagefrist" (one of the legacy globals with `proceeding_type_id NULL`).
- Editor opens. m assigns `proceeding_type_id = upc.inf.cfi` and `parent_id = <RoP.013.1 soc rule>`.
- Save. Rule lifecycle flips to draft. m clicks Publish.
- The rule now sits under upc.inf.cfi's tree as a hop-1 child of SoC. Mode A picker EXISTS check passes for SoC (was already passing); the tree gains one more chip.
- 72 globals to go. m walks at own cadence; no coder time blocked.
---
## §9 Out of scope
- **Calculator (`pkg/litigationplanner.CalculateRule`).** Working as designed.
- **Holiday / working-day logic.** Out of scope.
- **`choices_offered` + `applies_to_target` formalisation** (athena R11). Same shape as condition_expr would warrant — separate ticket once condition_expr formalisation ships.
- **Adding new proceeding_types.** The 23 are stable; editorial work fills the 6 unruled ones.
- **DE-side spawn edges** (LG → OLG → BGH as spawns instead of separate projects). Possible v2; not driven by current pain.
- **AI-extracted deadlines from documents.** Deferred per memory `b6a11b55…`.
- **Cross-tab scenario-flag sync in Akte mode.** Single-tab v1; SSE/WebSocket if it matters later.
- **`event_kind` ENUM-ing** (athena R10). Cosmetic; vocab is stable.
---
## §10 m's decisions (2026-05-27)
All 12 questions answered via `AskUserQuestion` on 2026-05-27 ~13:55 (3 batches of 4). 11 picks on-recommendation; Q5 diverged with verbatim reasoning. Plus 8 pre-ratified picks from t-paliad-327 carried forward (§0.2).
### Tier 1 — model decisions
- **Q1 (Trigger link canonical): `parent_id` wins, deprecate `trigger_event_id`.** [= recommendation] **Locks §2.1.** Drop the column after backfill completes.
- **Q2 (73 legacy globals fate): Reparent onto PT chains via editorial walk.** [= recommendation] **Locks §4.2.** m drives the walk at admin /admin/procedural-events; the orphan filter is the only new UI surface.
- **Q3 (Trigger discoverability): Derive from data.** [= recommendation] **Locks §2.2.** EXISTS subquery on parent_id; no new column, no view.
- **Q4 (Scenario SSoT shape): `projects.scenario_flags jsonb`.** [= recommendation; confirms t-paliad-327 design under wider scrutiny] **Locks §2.3.**
### Tier 2 — surface decisions
- **Q5 (Appeal taxonomy): Reverse the unification — split upc.apl.unified back into merits/cost/order.** [≠ recommendation; m picked option 3, "reverse the unification"] m's verbatim:
> yes, reverse the unification as suggested in 3. They are different proceedings, I only wanted the approach to be unified in the "determinator" - but they are actually different proceedings!
**Updates §1.4 + §3.1.** Mig P1 re-activates id=11/19/20, retires id=160, rebinds 16 rules by event_code prefix, retargets the pi.cfi spawn FK to id=20. Determinator routing layer (proceeding_mapping.go) keeps the single "Berufung" front door but fans out to the 3 PTs.
- **Q6 (Empty PTs): Show with "Keine Regeln gepflegt" badge for now.** [= recommendation; option 2] m's note: "We need to publish rules then... but yeah, show with the badge for now." **Locks §3.2.** Editorial follow-up is m's; not blocking the design.
- **Q7 (Entry A location): Fold into /tools/verfahrensablauf.** [= recommendation] **Locks §3.3 + §6.**
- **Q8 (Legacy /event-deadlines route): Drop after Tier 1 + 73-globals reparenting.** [= recommendation] **Locks §3.4. Gated on §4.2 completion.**
### Tier 3 — editorial + cleanup framework
- **Q9 (condition_expr grammar): Lock to `{flag: "X"} | {op: "and"|"or", args: [...]}`.** [= recommendation] **Locks §4.1.** Write-time JSON-schema validator + known-flag catalog table.
- **Q10 (Editorial backfill workflow): Admin /admin/procedural-events with parent-NULL filter.** [= recommendation] **Locks §4.2.** No new UI surface beyond the filter chip.
- **Q11 (`trigger_events` table fate): Drop after route is gone.** [= recommendation] **Locks §4.3.** Sequenced as Mig P5, last in the slice train.
- **Q12 (Visual format): ASCII trees per PT + Mermaid for spawn edges.** [= recommendation] **Locks §1.2 + §1.3.**
### 10.0a Post-ratification additions (m, 2026-05-27 14:3414:40)
After the §10 main grilling, m added three directions on top of the ratified design. None re-opened a Tier 1 decision; all extended the Verfahrensablauf surface.
- **Selection state + detail-level filter (m 14:40, supersedes earlier "rarity" framing).** Every optional rule becomes a per-scenario selectable card; selection state lives in the existing `projects.scenario_flags jsonb` with extended shape (`{flag: bool, "rule:<uuid>": bool}`). Recommended = default-selected; optional = default-unselected; mandatory = locked. Deviations only land in storage. No new column on `sequencing_rules`. **Locks §2.4a.** Replaces the pre-clarification strawman that proposed `is_edge_case boolean` — m's reframe makes that wrong (rarity is a scenario property, not a rule property).
- **View-mode toggle on Verfahrensablauf.** Three-way segmented control: Nur Pflicht / Gewählt / Alle Optionen. Per-user persistence via `localStorage["verfahrensablauf:view_mode"]`. Default "Gewählt". **Locks §3.3a.** Mode B result view does NOT carry the toggle — it's a Verfahrensablauf-only affordance.
- **R.109 chain editorial worked example.** m flagged R.109.1 / R.109.4 / R.109.5 as a concrete editorial-backfill case (wrong parent_id, wrong primary_party on R.109.4, missing condition_expr on R.109.4/.5). Folded as **§4.2.1** worked example demonstrating the parent-NULL filter workflow without code change. Two new scenario-flag names introduced (`with_interpreter_denied`, `with_translation_granted`); both land in the `scenario_flag_catalog` (§4.1) at edit time.
These additions don't change the slice train sequence (§5). They tighten P0 (the `scenario_flags` PATCH endpoint now validates `rule:<uuid>` keys against the project's active rule set) and P3 (Entry A tree now renders the view-mode toggle + per-rule selection chips), but no new mig is added.
### 10.1 What changed from the strawman as a result
Beyond §10.0a additions, the Q5 divergence is the only material change:
- **Mig P1 (appeal re-split)** is now part of the slice train. It was NOT in the strawman; the strawman assumed athena's R3 was a simple FK retarget. m's pick recasts the unification itself as the bug.
- §1.4 per-PT table shows 3 separate appeal PT rows (merits/cost/order) instead of one unified row. The 16 rules under id=160 redistribute to id=11/19/20.
- §1.3 spawn graph fan-out has merits (3 edges from inf/rev/dmgs) + order (1 edge from pi) as distinct targets instead of all 4 pointing at a single unified row.
All other §1-§8 sections hold as originally drafted.
---
## §11 Synthesis links
- mBrian: file as `[synthesis]` linked `triggered_by` t-paliad-329; `related_to` athena's assessment (`document-assessment-deadline-system`) + my proceeding_types taxonomy synthesis + Fristenrechner overhaul synthesis + t-paliad-327 follow-up rules synthesis.
- Cross-refs: `docs/assessment-deadline-system-2026-05-27.md` (athena), `docs/design-fristenrechner-followup-rules-2026-05-27.md` (atlas, pre-ratified subset), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus, S1-S6 shipped), `docs/design-proceeding-types-taxonomy-2026-05-26.md` (atlas, mig 153 shipped).
- Related migrations: 084 (condition_expr backfill), 136 (procedural_events additive), 140 (drop legacy deadline_rules), 145 (`scenarios` table), 153 (proceeding_types.kind).
- Coder phase (deferred per inventor SKILL): runs after m ratifies. Slice ordering per §5. NOT cronus (parked) / NOT atlas (inventor). A pattern-fluent Sonnet coder picks up P0 first; P1 + S1/S1a can parallelise; P3 follows; P4 + P5 are gated on each other.

View File

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

View File

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

View File

@@ -0,0 +1,553 @@
# Design — Fristenrechner complete UX overhaul (dual-mode + project write-back)
**Task:** t-paliad-322
**Gitea:** m/paliad#146
**Inventor:** cronus (shift-1)
**Date:** 2026-05-26
**Status:** Draft for m's ratification — coder gate held
## 0. Premises verified live (before designing)
Verified against the live youpc Postgres (port 11833, paliad schema) and the live source tree on `mai/cronus/inventor-fristenrechner` @ HEAD.
### 0.1 Rule-and-event corpus today
| Table | Active+published rows | Notes |
|---|---|---|
| `paliad.procedural_events` | 222 (236 total) | The events that anchor a deadline. 4 `event_kind` buckets: `filing`, `hearing`, `decision`, `order`, plus `NULL` for legacy/dpma stragglers. |
| `paliad.sequencing_rules` | 231 | The deadlines themselves, anchored on `procedural_event_id` and (sometimes) `trigger_event_id`. 80 carry a `trigger_event_id`, 4 are `is_spawn=true`, 45 are `is_court_set=true`, 18 carry a `condition_expr`. |
| `paliad.deadline_concepts` | 57 | Hub layer above events (Klageerhebung, Wiedereinsetzung, …). |
| `paliad.proceeding_types` | 46 fristenrechner | 4 jurisdictions: UPC (35), DE (5), EPA (3), DPMA (3). |
| `paliad.event_categories` | 125 (103 leaves) | The current cascade tree — 5 user-bucket roots (`cms-eingang`, `muendl-verhandlung`, `beschluss-entscheidung`, `frist-verpasst`, `ich-moechte-einreichen`) + `sonstiges` leaf. UI hides the forward-workflow root (`HIDDEN_CASCADE_ROOTS` in `client/fristenrechner.ts:2605`). |
| `paliad.deadlines` | 10 (8 with `sequencing_rule_id`) | Demand-side still tiny. The 2 without `sequencing_rule_id` are manual entries. |
Live `primary_party` vocabulary on `sequencing_rules`: `claimant`, `defendant`, `both`, `court`, `NULL`. Live `priority` vocabulary: `mandatory`, `recommended`, `optional` (no `informational` rows yet — Phase 2 reserved the slot but seeding is deferred).
### 0.2 The legacy `deadline_rules` reader is a view
`paliad.deadline_rules_unified` (mig 139, Slice B.3) is a **view** over `sequencing_rules ⋈ procedural_events ⋈ legal_sources`. All Go calculator paths read through it (see `deadline_rule_service.go:70`). The physical `paliad.deadline_rules` table was dropped in mig 140; the view is the canonical legacy-shape reader. Important for this design: there is no "trigger event" table parallel to events — the rule rows themselves are the things the wizard must land on. `trigger_events` (110 rows) is the youpc-parity legacy table used by `/api/tools/event-deadlines` only.
### 0.3 The frontend today (`/tools/fristenrechner`)
Two server-rendered surfaces share the same page (`frontend/src/fristenrechner.tsx`, 657 lines) — the legacy "Procedure mode" (R1 step-list, proceeding picker, trigger date, flag checkboxes) and the **Pathway-B row stack** (`buildRowStack` at `client/fristenrechner.ts:2848`, 4009 lines total). Row stack composes three row kinds via a single `.fristen-row` primitive:
| Row | Source | Filter or qualifier today |
|---|---|---|
| R1 Perspective (Beide / Klägerseite / Beklagtenseite) | `currentPerspective`, prefilled from `project.our_side` | hybrid — narrows party-tagged cascade chips AND is used as a column-bucket hint in the result view |
| R2 Inbox channel (CMS / beA / Postal / Alle) | `currentInboxChannel` | filter — narrows cascade by forum (CMS → upc, beA → de, …) |
| R3..Rn Cascade chain | `event_categories` tree | each step narrows children by `inboxFilterAllowsForums` + `perspectiveAllowsParty` + `cascadeChildAllowsProject` |
The cascade auto-walks single-child branches under a project context and stops at the first branching point. The user picks a leaf; the leaf's slug feeds `/api/tools/fristenrechner/search?event_category_slug=…` which returns concept cards. Each card expands inline to a calc panel (trigger-date input + flags + computed deadline + "in Akte" CTA).
### 0.4 What is broken in this UI (m's verdict, 2026-05-26 21:21)
m's brief in m/paliad#146 enumerates four visible bugs:
1. **"Beide" as default perspective** is incoherent for the headline use case ("file a deadline because something happened" — you ARE one side).
2. **R2 (inbox) does not constrain R3 (cascade)** the way a user expects — picking CMS still leaves "Mündliche Verhandlung" / "Frist verpasst" on the next step. (Cause: those roots have `forums=NULL` in the seed → neutral → visible from every inbox.)
3. **Mixed axes** — the form layers filters (forum, inbox channel) on top of qualifiers (event-kind, perspective, proceeding_type) without making the difference visible. The user can't tell which picks narrow and which define.
4. **Trigger vs follow-up confusion** — the wizard's purpose is to identify the *trigger event*, then surface the *follow-up deadlines*. Today that split is not reflected in the form. After landing on a leaf, the user gets a flat list of concept cards and has to figure out which one is "the thing that happened" vs "the thing I have to file next".
m's verdict: "complete overhaul. Should be easy to use."
### 0.5 Anchor files for the eventual coder
- `frontend/src/client/fristenrechner.ts` (4009 LoC) — page brain. `buildRowStack` @ L2848, `renderRowStack` @ L3112, `runB1Search` (concept-card render) downstream, `expandCardCalc` @ L1337 (inline calc panel), `openSaveModal` @ L290 + `submitSave` @ L374 (project write-back).
- `frontend/src/fristenrechner.tsx` (657 LoC) — server-rendered shell. Contains both the Procedure-mode form **and** the Pathway-B row-stack scaffold. The new design replaces the row-stack scaffold; the procedure-mode form survives.
- `internal/handlers/fristenrechner.go` + `_search.go` + `_event_categories.go` — three handler files. `POST /api/tools/fristenrechner` (procedure-mode calc), `GET /search` (concept cards), `GET /event-categories` (cascade tree).
- `internal/services/fristenrechner.go` (661 LoC) — calculator adapter to `pkg/litigationplanner`. The calculator is **not** touched by this design.
- `internal/handlers/deadlines.go:167` + `services/deadline_service.go:411` (`CreateBulk`) — the project write-back endpoint (`POST /api/projects/{id}/deadlines/bulk`). This survives; the design extends its caller.
### 0.6 Adjacent design docs to read alongside
- `docs/design-determinator-row-cascade-2026-05-13.md` — the row-cascade pillars (Project-driven narrowing / Visual hierarchy overhaul / Persistent row stack). This overhaul **keeps** Pillars 2 and 3 and reworks Pillar 1's contract.
- `docs/design-fristen-phase2-2026-05-15.md` — the unified `sequencing_rules` model the calculator already runs on.
- `docs/audit-fristen-logic-2026-05-13.md` — the trigger-event / Pipeline-A-vs-C distinction.
---
## 1. Vision
**One page, two complementary entry paths, one result surface, one write-back.**
```text
┌───────────────────────── /tools/fristenrechner ─────────────────────────┐
│ │
│ ╭──────── Akte / kontextfrei ────────╮ (Step 0 — unchanged today) │
│ │ HL-2024-001 ▼ | ohne Akte │ │
│ ╰─────────────────────────────────────╯ │
│ │
│ ╭────── Entry mode tabs ──────╮ │
│ │ [⚡ Direkt suchen] │ ◀── A: power user, search + chips │
│ │ [🧭 Geführt] │ ◀── B: 3-5 question wizard │
│ ╰─────────────────────────────╯ │
│ │
│ ┌── Mode A: Suche ──────────────┐ ┌── Mode B: Wizard ────────────────┐│
│ │ search-box ▢▢▢▢▢▢▢▢▢▢▢▢▢▢▢ │ │ R1 Was ist passiert? ✓ filing ││
│ │ filter chips: │ │ R2 Forum? ✓ UPC ││
│ │ Forum · Proceeding · Event- │ │ R3 Verfahren? ✓ INF ││
│ │ Kind · Partei │ │ R4 Welcher Schritt? [active] ││
│ │ ┌ Ergebnis-Liste ────────────┐│ │ R5 Welche Seite? ✓ Kläger ││
│ │ │ procedural_event hits ││ │ ││
│ │ │ [Trigger einrasten →] ││ │ (Direkt-suchen ←) ││
│ │ └────────────────────────────┘│ └───────────────────────────────────┘│
│ └────────────────────────────────┘ │
│ │
│ ════ shared from here ═══════════════════════════════════════════════ │
│ │
│ ┌── Trigger event (locked) ──────────────────────────────────────────┐ │
│ │ 📥 Klageschrift wurde eingereicht │ │
│ │ upc.inf.cfi · Verletzungsverfahren · Klägerseite │ │
│ │ Trigger-Datum: [📅 2026-05-20] (heute) │ │
│ │ ändern ↩ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌── Folge-Fristen ────────────────────────────────────────────────────┐ │
│ │ ◉ MANDATORY (auto-checked) │ │
│ │ ☑ Klageerwiderung (3 Monate) — 20.08.2026 — RoP 23 ✏ Datum │ │
│ │ ☑ ... │ │
│ │ ◇ OPTIONAL │ │
│ │ □ Wiedereinsetzungsantrag (R.320) — bei Versäumnis │ │
│ │ ◊ CONDITIONAL │ │
│ │ □ Erwiderung auf Nichtigkeitswiderklage nur wenn CCR │ │
│ │ ⇲ SPAWNED │ │
│ │ ☑ Berufung gegen Endurteil (kein Datum) │ │
│ │ ╭────────────────────────────╮ │ │
│ │ │ 4 ausgewählt → in Akte ▶ │ │ │
│ │ ╰────────────────────────────╯ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
The two modes never compete: they're two front doors into the **same** locked-trigger-event → follow-up-list → write-back flow.
---
## 2. Axis taxonomy — ratified (filters vs qualifiers)
The headline source of today's UX confusion is the unmarked mixing of *filters* (narrowing the question space without committing to an answer) and *qualifiers* (parts of the eventual deadline definition).
| Axis | Role | Source | Constrains | Visual in new UI |
|---|---|---|---|---|
| `forum` | **filter** | derived from `proceeding_types.jurisdiction` (UPC/DE/EPA/DPMA), or from `project.proceeding_type_id`, or user pick | which `proceeding_types` are reachable; which `event_categories` are visible | Mode A: filter chip strip. Mode B: explicit wizard row (R2). Pre-filled + collapsed when there's a project. |
| `proceeding_type` | **qualifier** | `project.proceeding_type_id` (binds via mig 096 codes) OR user pick during wizard | the set of `sequencing_rules` rows that can apply | Mode A: filter chip strip. Mode B: explicit wizard row (R3). Pre-filled + collapsed when there's a project. |
| `event_kind` | **filter** | `procedural_events.event_kind` (filing / hearing / decision / order) | which `procedural_events` are reachable as triggers | Mode A: filter chip strip. Mode B: explicit wizard row (R1 — the headline question). |
| `inbox channel` | **filter** (today) → **out of scope row** (new) | user pick | nothing the user can see (the rule corpus has no "inbox" column; it was only used to recolour the cascade) | Removed from the primary wizard. Pushed into Mode A's secondary chips (off by default). See §3.3. |
| `perspective (our_side)` | **qualifier in file-mode**, **filter in explore-mode** | `project.our_side` OR user pick OR implicit-via-event-kind | `sequencing_rules.primary_party`; result-view column bucketing | Wizard tail (R5) **only when** the trigger event's follow-ups actually differ by side. Pre-filled when project has `our_side`. |
| `instance_level` (first / appeal / cassation) | qualifier | `project.instance_level` (mig 084) — sparse | rare — used to disambiguate APP+DE | Surfaced only when the wizard hits APP+DE-style ambiguity. |
**Rule:** a **filter** narrows the visible options without locking in a deadline answer; it can be cleared and re-applied. A **qualifier** is part of the resulting deadline calculation and is locked the moment it's picked. Filters must propagate forward (Mode A's forum-chip narrows the proceeding-chip's options). Qualifiers are picked once and the answer view reads them.
The "Beide" perspective default (today's bug) is wrong because perspective is a *qualifier* in the headline use case ("file a deadline because something happened — you are one side"), not a *filter*. New default in Mode B: derive from the project's `our_side`, otherwise force a R5 pick (no "Beide"). See Q8 for the explore-mode exception.
---
## 3. Mode taxonomy
### 3.1 Mode A — "⚡ Direkt suchen" (power user)
Two visually distinct strips (per m §11.Q3):
```text
┌── Filter (eingrenzen) ─────────────────────────────────────────────────┐
│ Forum: [UPC] [DE] [EPA] [DPMA] [Alle] │
│ Verfahren: [upc.inf.cfi] [...] [Alle] │
│ Was passierte: [📥 Eingereicht] [🏛️ Termin] [⚖️ Entscheidung] [📜 Verfügung] [Alle] │
│ Partei: [Klägerseite] [Beklagtenseite] [Beide] │
├── Suchen ──────────────────────────────────────────────────────────────┤
│ 🔎 [_______________________________________________________________] │
└─────────────────────────────────────────────────────────────────────────┘
┌── Ergebnisse (klicken = als Trigger einrasten) ────────────────────────┐
│ 📥 Klageerhebung · upc.inf.cfi · UPC · 3 Folge-Fristen → │
│ ... │
└─────────────────────────────────────────────────────────────────────────┘
```
Single text input, four filter chip strips above it (Forum · Proceeding · Event-Kind · Partei), and a ranked result list of `procedural_events` underneath. The "Filter" strip is visibly grouped (e.g. light background + "Filter (eingrenzen)" header) so users see at a glance that those picks narrow but don't commit; clicking a result row IS the commit (the qualifier action).
- Search hits `/api/tools/fristenrechner/search` (extended to return events, not just concepts — see §6.1).
- Filter chips compose with the text query (`?forum=upc&pt=upc.inf.cfi&kind=filing&party=defendant&q=Klageerwiderung`).
- Result rows are individual `procedural_events` (not aggregated concept-cards). Each row shows: name (DE/EN), proceeding_type code, jurisdiction badge, event_kind icon, the rule-count it triggers ("3 Folge-Fristen").
- Click a row → "lock as trigger event" → page transitions to the §4 result view.
- Power affordance: a row with multiple linked rules can be locked in **per-rule** ("nur diese Frist") via a kebab menu on the row. (Sane default: lock the whole event; the kebab is for the lawyer who only wants one specific reactive deadline.)
### 3.2 Mode B — "🧭 Geführt" (the wizard)
A 3-5 question row stack that lands on one `procedural_events` row.
**Question order (strawman; m to ratify in Q5):**
1. **R1 — Was ist passiert?** Chips: 📥 Eingereicht (`filing`) · 🏛️ Termin (`hearing`) · ⚖️ Entscheidung (`decision`) · 📜 Verfügung (`order`) · 🕒 Frist versäumt (special bucket, routes to Wiedereinsetzung). One chip = one `event_kind` (or the special). Always asked. ~6 chips, fits one row.
2. **R2 — Vor welchem Gericht / bei welchem Amt?** Chips: UPC · LG/OLG/BGH · EPA · DPMA. Pre-filled from `project.proceeding_type → jurisdiction` (or `project.court` substring). **Skipped if R1 narrows to a single forum** (e.g. "Termin" + project has UPC → R2 is implied).
3. **R3 — In welchem Verfahren?** Chips: every active `proceeding_type` whose jurisdiction matches R2 AND whose event roster contains at least one event with R1's kind. Pre-filled from `project.proceeding_type_id`. **Auto-skipped** when the narrowed scope has only one candidate.
4. **R4 — Welches Schriftstück / Welcher Termin?** This is the wizard's landing question. Chips = `procedural_events` filtered by (R2 forum, R3 proceeding_type, R1 event_kind). Typical scope: 1-12 events. If the user types into this row, the chip layout flips to a search list (same widget as Mode A's result list, narrowed to the wizard's filters).
5. **R5 — Vertreten Sie Kläger- oder Beklagtenseite?** Asked **only when** the selected event's `sequencing_rules` have follow-ups that differ by `primary_party` (a quick "are there both claimant- and defendant-tagged rules among the follow-ups?" check on the catalog). Pre-filled from `project.our_side`. **Skipped otherwise.**
**Row badges** (per m §11.Q3): each wizard row carries a small "Filter" or "Qualifier" tag next to its row-number badge. R1 (event_kind), R2 (forum) → "Filter". R3 (proceeding_type), R4 (procedural_event), R5 (perspective) → "Qualifier". A user can tell at a glance which picks lock in vs which narrow.
Branching policy (locked):
- Pre-fill + collapse a row when the answer is implied by the project (Determinator §4 pattern, unchanged).
- Auto-skip a row when the narrowed scope has exactly one option (the user has effectively no choice). Show the skipped row as a compact `.fristen-row.is-prefilled` line with "(aus Akte)" or "(implizit aus R1)" annotation. *Don't hide the row* — m's "see your selections" pillar from the row-cascade design demands every decision stays visible.
- A user-edited upstream answer **preserves compatible downstream picks** (m §11.Q10): if a re-picked R2 (forum) keeps the existing R1 (event_kind) legal, R1 stays; if it makes R3 (proceeding_type) illegal, R3 resets to active. Rows whose pick was carried across an upstream change render with a one-render "erhalten" annotation so the user notices.
- "Welches Schriftstück?" (R4) is the landing question. Once R4 is answered, the wizard exits and the §4 result view takes over.
### 3.3 The dropped `inbox channel` row
R2-inbox in today's row stack is removed from the primary surface for both modes. Rationale:
- The rule corpus has no `inbox` column. The cascade's `forums=['cms']` etc. tags were a presentation-layer reflection of which forum naturally arrives on which channel — but the rule itself doesn't change based on whether a UPC document arrived via CMS or by post (it can't; only CMS is legal). So the only honest role for "inbox" is to nudge the forum filter on Mode A.
- Mode A keeps inbox as a *secondary* chip strip ("Erweitert" toggle, off by default). Picking CMS auto-sets the forum chip to UPC; picking beA auto-sets it to DE. The user can override.
- Mode B never asks. The wizard derives forum from project context or from R2.
This collapses one bug class entirely (R2-not-constraining-R3) by retiring R2 from the headline path.
---
## 4. Shared result view — "follow-up deadlines"
Once a trigger event is locked (via Mode A click or Mode B R4 pick), the same result view renders.
### 4.1 Trigger card (sticky header)
```text
┌─ Trigger-Ereignis ─────────────────────────────────────────────────────┐
│ 📥 Klageerhebung │
│ upc.inf.cfi · Verletzungsverfahren · UPC │
│ ⓘ "Einreichung der Klageschrift gemäß R.13 RoP" │
│ Trigger-Datum: 📅 2026-05-20 [ändern ↩] │
└─────────────────────────────────────────────────────────────────────────┘
```
`Trigger-Datum` defaults to today. The user can change it inline (date picker). Changing it re-renders the follow-ups with new computed dates.
The "ändern" link drops back to whichever mode the user came from with R1-R4 still answered. (Per Q4: the wizard preserves compatible upstream picks rather than rebooting.)
### 4.2 Follow-up groups
Group `sequencing_rules` rows that have the trigger event as **anchor** (i.e. `sr.procedural_event_id = trigger.id`) into 4 visible groups:
1. **MANDATORY** (`priority='mandatory'`) — pre-checked. The bread-and-butter follow-ups.
2. **RECOMMENDED** (`priority='recommended'`) — pre-checked. Best-practice fillings (R.19 EPÜ Einspruch, replication briefs).
3. **OPTIONAL** (`priority='optional'`) — unchecked. Discretionary actions (R.320 Wiedereinsetzung).
4. **CONDITIONAL** (`condition_expr IS NOT NULL`) — unchecked, with the condition rendered ("nur wenn CCR im Verfahren"). Lawyer ticks if applicable.
Plus a fifth implicit bucket:
5. **SPAWNED / CROSS-PROCEEDING** (`is_spawn=true`, `spawn_proceeding_type_id IS NOT NULL`) — surfaced as a separate sub-section with a clear "leitet ein neues Verfahren ein" annotation. Pre-checked when mandatory.
Recommendation (Q6): **4 visible groups, with SPAWNED inlined into whichever priority bucket it belongs to but tagged with a "⇲ neues Verfahren" badge.** Five groups is too many for a one-page result; folding SPAWNED into its priority keeps the math right (mandatory spawned = mandatory) while still flagging the cross-proceeding implication.
### 4.3 Per-rule row
```text
☑ Klageerwiderung ✏ Datum
3 Monate nach Klageerhebung 20.08.2026
RoP 23 · Beklagtenseite
ⓘ Schriftlich, mit Vollmacht. Erstmaliges Bestreiten der Patentverletzung.
```
Columns: checkbox · title (DE/EN) · duration phrase · computed due date · rule citation · party stance · expandable notes.
Inline date editor (✏ Datum) lets the lawyer override the computed date for this rule (same affordance as today's `wireDateEditClicks`). The override flows into the write-back payload.
`is_court_set=true` rules render with the "wird vom Gericht bestimmt" placeholder instead of a date and are unchecked-by-default (matches the current `openSaveModal` behaviour).
### 4.4 Result-view footer (write-back CTA)
```text
┌─ Auswahl ──────────────────────────────────────────────────────────────┐
│ 4 Fristen ausgewählt → In Akte HL-2024-001 eintragen ▶ │
│ (oder: 2 mit eigenem Datum, 2 mit Standardberechnung) │
└─────────────────────────────────────────────────────────────────────────┘
```
The CTA opens a **confirm-and-edit-dates modal** (per m §11.Q6) where the lawyer can revise each selected deadline's due date one last time, then commits via `POST /api/projects/{id}/deadlines/bulk` (today's endpoint).
**Kontextfrei mode (no Akte)** — per m §11.Q7, the entire write-back footer **does not render** when `project == null`. The result view stays informational. In its place, an inline nudge appears above the deadline groups:
```text
ⓘ Tipp: Wähle oben eine Akte, um diese Fristen einzutragen.
```
The "oben" link focuses the Akte picker. Once a project is picked, the nudge collapses and the footer materialises; no page reload, no result-view rebuild (the trigger event and date persist across the project pick).
Modal payload per deadline (extends today's `CreateDeadlineInput`):
```json
{
"title": "Klageerwiderung",
"rule_code": "RoP 23",
"due_date": "2026-08-20",
"original_due_date": "2026-08-20",
"source": "fristenrechner",
"rule_id": "<sequencing_rules.id>", /* maps to deadlines.sequencing_rule_id */
"notes": "..."
}
```
**audit_reason wording (per Q12):** every row inserted via this flow carries an audit-log breadcrumb on the project (matches the deadline `Verlauf` pattern). Default reason string:
> `Aus Fristenrechner — Trigger: {trigger_event_name} ({trigger_date_iso})`
e.g. `Aus Fristenrechner — Trigger: Klageerhebung (2026-05-20)`. Falls into `paliad.project_events` with `kind='deadline_created'` via the existing `DeadlineService.CreateBulk` audit hook; no schema change needed.
---
## 5. URL / state representation
The new flow keeps Pathway-B's URL-as-state contract, simplified:
| Param | Owner | Meaning |
|---|---|---|
| `project` | Step 0 | Active project UUID. Drives the prefills. |
| `mode` | mode tab | `wizard` (default) or `search`. |
| `q` | Mode A | Free text query. |
| `forum` | Mode A | Comma-separated forum codes (`upc,de`). Mode B writes this only when the wizard derives it. |
| `pt` | Mode A | Selected proceeding_type code. |
| `kind` | Mode A | event_kind chip pick. |
| `party` | both | Perspective. Mode A's chip; Mode B's R5. |
| `wizard` | Mode B | Dotted state cursor encoding which row is active and the picks made: `wizard=kind:filing,forum:upc,pt:upc.inf.cfi,active:event`. |
| `event` | both | The locked trigger `procedural_events.code`. Once set, the result view renders. |
| `trigger_date` | result | ISO date. Default = today; URL only carries it when overridden. |
| `selected` | result | Comma-separated `sequencing_rules.id` checkbox state. Only carried when it differs from the priority default. |
Deep links work end-to-end: `?project=…&event=upc.inf.cfi.soc&trigger_date=2026-05-20&selected=…` jumps a colleague straight to the result view with the same picks. (Per Q11 — query string, not pathname.)
`popstate` rebuilds the page from the params alone (same pattern as today). The wizard state cursor lets browser back/forward step the wizard rows instead of dropping back to the page root.
---
## 6. Backend contract changes
### 6.1 Extend `/api/tools/fristenrechner/search`
Today returns concept-cards. Add an alternate response shape (or a `?kind=events` flag) that returns `procedural_events` rows directly:
```json
{
"query": "Klageerhebung",
"filters": { "forum": "upc", "pt": null, "kind": "filing", "party": null },
"events": [
{
"id": "<uuid>",
"code": "upc.inf.cfi.soc",
"name_de": "Klageerhebung",
"name_en": "Statement of Claim",
"event_kind": "filing",
"proceeding_type": { "code": "upc.inf.cfi", "jurisdiction": "UPC", "name": "..." },
"follow_up_count": 3,
"concept_id": "<uuid>",
"score": 0.92
}
],
"total": 12
}
```
The concept-card shape stays available for the legacy Pathway-B-filter route (kept as a deep-link compat surface, not user-facing).
### 6.2 New `/api/tools/fristenrechner/follow-ups`
Given a trigger event id + trigger date + optional party qualifier, return the follow-up `sequencing_rules` rows, grouped + with computed dates. Wire shape:
```json
{
"trigger": { "id": "...", "code": "upc.inf.cfi.soc", "name_de": "Klageerhebung", "event_kind": "filing", "proceeding_type": { "code": "upc.inf.cfi", "name_de": "Verletzungsverfahren", "jurisdiction": "UPC" } },
"trigger_date": "2026-05-20",
"party": "claimant",
"follow_ups": [
{
"rule_id": "<uuid>",
"title_de": "Klageerwiderung",
"title_en": "Defence",
"priority": "mandatory",
"primary_party": "defendant",
"duration_phrase": "3 Monate",
"due_date": "2026-08-20",
"is_court_set": false,
"is_spawn": false,
"condition_expr": null,
"rule_code": "RoP 23",
"notes_de": "...",
"spawn_label": null,
"spawn_proceeding_type": null,
"appeal_target": null
}
]
}
```
Implementation: `FristenrechnerService.LookupFollowUps(ctx, eventID, triggerDate, party)` — wraps `catalog.LookupEvents(axes={EventID:…, Depth:Next})` (already implemented for the Litigation Planner per `services/fristenrechner.go:251`) and runs the result through `pkg/litigationplanner.Calculate` to fill the dates. The calculator is unchanged.
### 6.3 No schema changes
This design is pure UX + handler shape. The unified `sequencing_rules` model already has every column needed (priority, condition_expr, spawn_*, is_court_set, primary_party, applies_to_target). No migration accompanies this design.
---
## 7. Migration plan — from current row stack to the overhaul
Drop nothing on day one; co-exist for one release. The cutover is by URL flag.
| Phase | What changes | What survives | Branch |
|---|---|---|---|
| **S1 — Backend** | Add `GET /search?kind=events`. Add `GET /follow-ups`. Both feature-flagged behind a request header (off by default). | Existing endpoints. | one PR |
| **S2 — Result view** | New `frontend/src/client/fristenrechner-result.ts` module — given a trigger event + date, render the §4 result view. Mount under a `?overhaul=1` query flag on /tools/fristenrechner. The legacy `renderProcedureResults` stays. | All today's UI. | one PR |
| **S3 — Mode A** | New search-with-filter-chips UI. Mount alongside the row stack under `?overhaul=1`. | Row stack still primary. | one PR |
| **S4 — Mode B (wizard)** | New `frontend/src/client/fristenrechner-wizard.ts` — the 3-5 row stack. Replaces today's `buildRowStack` only when `?overhaul=1`. Project prefill logic from `buildRowStack` ports 1:1. | The legacy row stack stays in place under no flag. | one PR |
| **S5 — Flip the flag** | `?overhaul=1` becomes the default. Legacy row stack and `event_categories`-based cascade rendered with a hard-coded `?legacy=1` for two weeks. | Procedure mode (the upper half of `fristenrechner.tsx`) is unchanged throughout. | one PR |
| **S6 — Cleanup** | Drop the `buildRowStack` function tree and the `event_categories`-served cascade endpoint (the table can stay — it's still semantically a useful taxonomy for future tools, just not the Fristenrechner's UI). Drop the `HIDDEN_CASCADE_ROOTS` constant and the cascade-segment bridge. | None of today's row-stack code. | one PR |
Single project per slice; each PR rebases off main; no shared branches.
The `event_categories` table itself **stays**`audit-fristen-logic-2026-05-13.md` §2.4 already calls it "a config layer" useful for taxonomy work. The Fristenrechner just no longer reads it. Future tools (the "Ich möchte einreichen" forward-workflow surface m hid in `HIDDEN_CASCADE_ROOTS`) can resurrect it without DB migration.
---
## 8. Worked example — "PA at LG Düsseldorf bekommt einen Hinweisbeschluss via CMS in einer aktiven Akte"
Project: `HL-2024-001`, proceeding_type=`de.inf.lg` (Verletzungsverfahren LG), `our_side='defendant'`, `court='LG Düsseldorf'`.
### 8.1 Wizard path (Mode B, default)
User opens /tools/fristenrechner with that project in Step 0. Mode tab defaults to "🧭 Geführt".
Wizard rows render top-to-bottom, pre-filled where the project implies:
```text
[1] Was ist passiert? [ active — chips for filing/hearing/decision/order/missed ]
[2] Vor welchem Gericht? ✓ LG (aus Akte: HL-2024-001) ← prefilled+collapsed
[3] In welchem Verfahren? ✓ Verletzungsverfahren (de.inf.lg) ← prefilled+collapsed
```
User clicks ⚖️ Entscheidung in R1.
Row stack updates:
```text
[1] Was ist passiert? ✓ Entscheidung ← answered
[2] Vor welchem Gericht? ✓ LG (aus Akte) ← prefilled
[3] In welchem Verfahren? ✓ Verletzungsverfahren (de.inf.lg) ← prefilled
[4] Welche Entscheidung konkret? [ active — chips: Urteil, Beschluss, Hinweisbeschluss, ... ]
```
R4 chip set is the `procedural_events` whose `proceeding_type_id` = de.inf.lg AND `event_kind` = 'decision'. (Hinweisbeschluss is in this set — `de.inf.lg.hinweisbeschluss` or similar.)
User clicks Hinweisbeschluss. The wizard checks: do the follow-up rules differ by `primary_party`? In this case yes (the Hinweis triggers a reply window for the defendant only). So R5 fires:
```text
[5] Welche Seite vertreten Sie? ✓ Beklagtenseite (aus Akte) ← prefilled
```
R5 is pre-filled from `project.our_side='defendant'`. The user could click ändern to override, but doesn't.
Wizard transitions to the §4 result view. Trigger card: "📜 Hinweisbeschluss · de.inf.lg · LG · Beklagtenseite". Trigger date defaults to today.
### 8.2 Result view
Three follow-ups in scope (illustrative):
```text
MANDATORY
☑ Stellungnahme zum Hinweisbeschluss (Frist 4 Wochen) — 24.06.2026 — ZPO §139
RECOMMENDED
☑ Anpassung der Klageerwiderung — 24.06.2026 — best practice
OPTIONAL
□ Antrag auf Fristverlängerung (begründet) — auf Antrag
```
User unchecks "Anpassung", changes the Stellungnahme date inline to 2026-06-20 (one weekday earlier), clicks "In Akte HL-2024-001 eintragen ▶".
Modal opens with the 1 selected deadline + the user's date override. User confirms.
### 8.3 Write-back
Server-side: `POST /api/projects/HL-2024-001/deadlines/bulk` with one `CreateDeadlineInput`:
```json
{
"title": "Stellungnahme zum Hinweisbeschluss",
"rule_code": "ZPO §139",
"due_date": "2026-06-20",
"original_due_date": "2026-06-24",
"source": "fristenrechner",
"rule_id": "<sr-uuid>",
"notes": null
}
```
`DeadlineService.CreateBulk` inserts the row into `paliad.deadlines` (with `sequencing_rule_id` populated from `rule_id`), creates the audit event with the wording "Aus Fristenrechner — Trigger: Hinweisbeschluss (2026-05-26)", and the user is redirected to `/deadlines?project_id=…` with a green success toast.
### 8.4 Mode A path for the same user
User flips the mode tab to "⚡ Direkt suchen". Filter chips auto-load to Forum=DE + Proceeding=de.inf.lg (from project context). User types "Hinweis" — the result list shows `de.inf.lg.hinweisbeschluss` (and maybe `upc.inf.cfi.hinweis` filtered out because Forum=DE narrows it). User clicks. Same result view appears.
Total clicks Mode A: 2 (type + click). Mode B: 2 (R1 chip + R4 chip; R2/R3/R5 prefilled). The wizard wins for trainees who don't know vocabulary; search wins for power users who know "Hinweisbeschluss" and can type 4 chars.
---
## 9. What's NOT in scope
- **Replacing the `sequencing_rules` model.** Phase 3 schema is already what the calculator runs on.
- **Paliadin (LLM) integration into the wizard.** A "Frist-Extraktion aus Dokument" path is filed elsewhere (memory `b6a11b55…`) and stays out of this design. The wizard could later call out to Paliadin for "the user typed something we don't know" — Phase 2 of *this* overhaul, not Phase 1.
- **Calendar / Outlook sync** of created deadlines. Separate t-paliad ticket per project-status.md long-term goals.
- **Editing `sequencing_rules`** from the result view. Read-only here. The admin surface at `/admin/procedural-events` handles editing.
- **The Procedure-mode surface** (upper half of `fristenrechner.tsx`). The proceeding-picker + trigger-date + flag-checkbox UI stays exactly as it is today. That surface answers a different question ("show me the full procedural ablauf for upc.inf.cfi") and is the right tool for that question; the overhaul targets only the Pathway-B / row-stack half of the page.
---
## 10. Open questions for m (12 questions, batched for `AskUserQuestion`)
All 12 questions tracked in m/paliad#146 § "Open design questions". Each gets a recommended option (listed first in the AskUserQuestion call). Bundled into 3 batches of 4.
| # | Topic | Recommended pick |
|---|---|---|
| Q1 | Single page or stepper? | Single page with mode-tabs + collapsible rows. |
| Q2 | Mode switcher placement | Tab pair under Step-0 ("Akte / kontextfrei"). |
| Q3 | Filter-vs-qualifier UX | Qualifiers carry a small "(Pflichtangabe)" tag; filters render in a slimmer pill. |
| Q4 | Cascade tree (keep/replace) | Replace with the 5-question wizard. Drop `event_categories` from the Fristenrechner UI (table stays). |
| Q5 | Result grouping | 4 visible groups (Mandatory / Recommended / Optional / Conditional), SPAWNED folded with badge. |
| Q6 | Project write-back UX | Confirm-and-edit-dates modal (revise each date once before commit). |
| Q7 | No-project mode | CTA disabled with hint ("Wähle eine Akte oben"). Match today's pattern. |
| Q8 | Perspective semantics by mode | Mode B (file): qualifier — required pick. Mode A (search): filter — optional. |
| Q9 | Trigger-date input timing | In the result-view trigger card; default today; inline editable. |
| Q10 | Backward navigation | Preserve compatible downstream picks; reset only those invalidated. |
| Q11 | Deep-link encoding | Query string (`?event=…&trigger_date=…`). |
| Q12 | Audit reason wording | `Aus Fristenrechner — Trigger: {name} ({date})`. |
(Recommendations land as the "first option" in each AskUserQuestion call per the inventor SKILL contract.)
---
## 11. m's decisions (2026-05-26)
All 12 questions answered via `AskUserQuestion` on 2026-05-26 21:30. Recording each pick + the reasoning where it diverges from the inventor's recommendation. Sections of the design that are now load-bearing on these answers carry a "(m §11.Q{n})" cross-reference.
- **Q1 (Page layout): Single page, mode-tabs.** [= recommendation] Both modes share /tools/fristenrechner; the mode-tabs swap the question surface in place. Result view is shared. **Locks §3, §4, §5.**
- **Q2 (Mode switcher): Tab pair under Step-0.** [= recommendation] "⚡ Direkt suchen" / "🧭 Geführt" tabs render directly below the Akte picker. Project context survives the tab flip; compatible filter picks (forum, proceeding) carry across.
- **Q3 (Filter-vs-qualifier UX): Section split — Filter above, Qualifier below.** [≠ recommendation; m picked option 2.] Mode A's filter chips render in a "Filter (eingrenzen)" strip on top; below it, the result list is the qualifier surface (clicking a row locks). Mode B wizard rows carry a small "Filter" / "Qualifier" badge in the row badge area (e.g. R1/R2 = Filter, R3/R5 = Qualifier). The "(Pflichtangabe)" tag from the original recommendation is replaced by this section-level visual hierarchy. **Updates §3.1 (Mode A layout) and §3.2 (wizard row badges).**
- **Q4 (Cascade tree): Replace with wizard, keep table.** [= recommendation] The Fristenrechner UI stops reading `paliad.event_categories`. The table stays for future tools (the hidden "Ich möchte einreichen" forward-workflow). **Locks §3.2 and the cleanup in §7 S6.**
- **Q5 (Result grouping): 4 groups + SPAWNED badge.** [= recommendation] Mandatory / Recommended / Optional / Conditional are the four sub-sections; spawned rules fold into their priority bucket with a `⇲ neues Verfahren` badge. **Locks §4.2.**
- **Q6 (Write-back UX): Confirm-and-edit-dates modal.** [= recommendation] Inline checkbox selection in the result view → "In Akte eintragen ▶" → modal with editable due-date fields per row + Akte picker. **Locks §4.4.**
- **Q7 (No-project mode): Hide the CTA entirely.** [≠ recommendation; m picked option 3.] In kontextfrei mode the result view renders without the write-back footer at all — no disabled-with-hint button. Rationale (inferred from m's pick): the result view is informational by design in explore mode, and a permanently-disabled CTA is visual noise. **Updates §4.4** — the CTA is conditional on `project != null`, not on `disabled`. The hint message moves into the Step-0 picker: when a user is in kontextfrei mode and reaches a result view, a one-line nudge appears above the result groups ("Tipp: Wähle oben eine Akte, um diese Fristen einzutragen") with a link to focus the Akte picker. This preserves the affordance discovery without polluting the footer.
- **Q8 (Perspective semantics): Mode B qualifier, Mode A filter.** [= recommendation] Wizard Mode B's R5 is required and Klagerseite/Beklagtenseite only (no "Beide"); Mode A's perspective chip is a filter with a "Beide" option, off by default. **Locks §2 axis table and §3.2 R5 description.**
- **Q9 (Trigger-date input): In the result-view trigger card.** [= recommendation] The sticky header card on the result view shows the date; default today; inline editable. Changing it re-renders follow-up dates live. **Locks §4.1.**
- **Q10 (Backward navigation): Preserve compatible picks.** [= recommendation] Re-opening any wizard row keeps downstream picks that are still legal under the new upstream value; resets only the picks the new value invalidates. A small chip-strip annotation ("erhalten") appears for one render-cycle on rows whose pick was carried so the user notices. **Updates §3.2 branching policy.**
- **Q11 (Deep-link encoding): Query string.** [= recommendation] `?project=…&mode=…&event=…&trigger_date=…&selected=…&forum=…&pt=…&kind=…&party=…` — every state piece is a query param. `popstate` rebuilds the page from params. **Locks §5.**
- **Q12 (Audit reason wording): `Aus Fristenrechner — Trigger: {name} ({date})`.** [= recommendation] German-locale, includes the trigger event name and its ISO date. Stored as `paliad.project_events.metadata->>'audit_reason'` via the existing `DeadlineService.CreateBulk` audit hook. **Locks §4.4.**
### 11.1 What changed from the strawman as a result
Two follow-on edits flow from m's picks:
1. **§3.1 Mode A layout** — top strip is "Filter (eingrenzen)" with the four filter chip groups (Forum · Proceeding · Event-Kind · Partei); the result list directly below carries the implicit "click here to lock" qualifier action. No "(Pflichtangabe)" tag.
2. **§4.4 Write-back footer** — the footer is rendered conditionally on `project != null`. The kontextfrei-mode informational nudge moves into the result view body above the deadline groups.
These edits don't change the §7 migration plan or the §6 backend contracts.
---
## 12. Synthesis links
- mBrian topic: `topic-fristenrechner` (existing) — file this design as a `[synthesis]` node linked `triggered_by` t-paliad-322 and `related_to` the row-cascade + Phase 2 designs.
- Related memories: row-cascade design `0fbd2c1a-…`, Phase 2 design `a454dc86-…`, audit logic `f6c0c3a2-…`.

View File

@@ -0,0 +1,357 @@
# Generation-quality diagnosis — docforge loop (2026-06-01)
**Task:** t-paliad-363 · researcher (kepler) · READ-ONLY diagnosis + sliced fix-plan
**Trigger:** m exercised the docforge loop live 2026-06-01 ~15:33 (global `/submissions/new`
picker → start draft "Ohne Projekt" → generate) and surfaced 3 real problems.
**Scope:** investigate + plan only. No code/data/template edits this shift.
Three problems, increasing depth:
1. **P1** — the global picker lists court/trigger acts, not just party submissions.
2. **P2** — the grouped picker table looks layout-broken (proceeding name "in the row flow").
3. **P3** — a generated project-less draft is both **unstyled** (no HL Patents Style) and
**unfilled** (every caption/party value `[KEIN WERT: …]`). This is the docforge core.
---
## P1 — picker lists court acts, not just party submissions
### What m saw
"Service of OLG Judgment" (`de.inf.bgh.urteil_olg`, *Zustellung OLG-Urteil*) appears in the
draftable-submission picker. That is a **court/trigger act** (the service of the lower-court
judgment that *starts* the appeal clock), not a party submission anyone drafts.
### Root cause
The catalog query (`loadSubmissionCatalog`, `internal/handlers/submissions.go:177-211`) filters:
```sql
WHERE dr.is_active = true AND dr.lifecycle_state = 'published'
AND dr.event_type = 'filing'
AND dr.submission_code IS NOT NULL AND dr.submission_code <> ''
```
The model **already has a correct court-act tier**: 16 rows with `event_type='decision'` and
`primary_party='court'` (e.g. `de.inf.olg.urteil_olg` "OLG-Urteil", `upc.inf.cfi.decision`
"Entscheidung"). Those are correctly excluded by `event_type='filing'`.
But **8 anchor/trigger rows are mislabeled** `event_type='filing'` with `primary_party='both'`.
They are the "Zustellung …" (service of judgment) / "Veröffentlichung der Erteilung"
(publication-of-grant) events that **start** a proceeding's deadline chain. They are used
**structurally as `parent_id` anchors** — e.g. `de.inf.bgh.urteil_olg` is the parent of
Revisionsfrist, Revisionsbegründung, NZB, Mündliche Verhandlung BGH, BGH-Urteil. They are not
draftable by a party.
### Affected rows (the 8 to re-kind)
| submission_code | proceeding | name | event_type (is) | primary_party (is) | anchors (children) |
|---|---|---|---|---|---|
| `de.inf.bgh.urteil_olg` | de.inf.bgh | Zustellung OLG-Urteil | filing | both | Revisionsfrist, Revisionsbegr., NZB, NZB-Begr., Termin, BGH-Urteil |
| `de.inf.olg.urteil_lg` | de.inf.olg | Zustellung LG-Urteil | filing | both | Berufungsschrift, Berufungsbegr., Termin, OLG-Urteil |
| `de.null.bgh.urteil_bpatg` | de.null.bgh | Zustellung BPatG-Urteil | filing | both | Berufungsschrift, Berufungsbegr., Termin, BGH-Urteil |
| `dpma.appeal.bgh.entsch_bpatg` | dpma.appeal.bgh | Zustellung BPatG-Entscheidung | filing | both | Rechtsbeschwerde, BGH-Entscheidung |
| `dpma.appeal.bpatg.entscheidung` | dpma.appeal.bpatg | Zustellung DPMA-Entscheidung | filing | both | Beschwerde, Termin, BPatG-Entscheidung |
| `epa.opp.boa.entsch` | epa.opp.boa | Zustellung der Beschwerdeentscheidung | filing | both | Beschwerdeeinlegung, Beschwerdebegr. |
| `dpma.opp.dpma.publish` | dpma.opp.dpma | Veröffentlichung der Erteilung | filing | both | Einspruchsfrist, DPMA-Entscheidung |
| `epa.opp.opd.grant` | epa.opp.opd | Veröffentlichung der Erteilung | filing | both | Einspruchsfrist |
(Verified read-only via Supabase MCP against `paliad.deadline_rules_unified`, 2026-06-01.)
### The clean-filter question — and why a pure picker-filter does NOT work
The task hypothesised "exclude `primary_party='court'`". **That does not catch these 8** — they
carry `primary_party='both'`, not `'court'`. And `'both'` cannot be excluded wholesale: many real
party submissions are `'both'` (Berufungsbegründung, Anschlussberufung, every UPC appeal brief).
There is **no existing column value that isolates the 8** from genuine party submissions — the only
thing wrong with them is the mislabel itself. So a stronger picker WHERE-clause cannot cleanly fix
this without first fixing the data.
### → FORK (i): data-fix vs filter — RECOMMENDATION: **data correction**
**Recommended — data correction (re-kind the 8 anchor rows):**
Set on each of the 8 rows: `event_type = 'decision'` and `primary_party = 'court'`, matching the
16 sibling court-act rows the model already has. Then the *existing* picker filter
(`event_type='filing'`) excludes them automatically — **no handler change**.
- **Safe for anchoring:** the deadline chain links via `parent_id` (uuid FK), *not* via
`event_type`/`primary_party`. Re-kinding does not touch the FK, so Revisionsfrist &co. still
anchor correctly.
- **`submission_code` retained:** the 16 sibling `decision` rows keep their submission_codes too
(they're used as stable anchor identifiers), so retaining them here is consistent — no need to
strip.
- **Caveat to confirm before applying:** verify nothing computes a *deadline trigger* off
`event_type='filing'` for these specific rows (a quick grep of the deadline engine for
`event_type` filters). The `parent_id` graph is the structural link, so this is expected-safe,
but the coder should confirm.
**Defensive hardening (do in addition, not instead):** add `AND dr.primary_party IS DISTINCT FROM
'court'` to `loadSubmissionCatalog`'s WHERE clause. After the data fix the 8 rows are `'court'`, so
this is belt-and-braces: any future mis-kinded court act can't leak into the picker even if its
`event_type` is wrong.
**Why not "stronger filter only":** there is no filter on current columns that removes the 8
without also removing legitimate `'both'` party submissions. A filter alone cannot fix mislabeled
data — it would have to encode a brittle name-pattern blocklist ("Zustellung%/Veröffentlichung%"),
which is the kind of tech debt the project bars.
---
## P2 — grouped picker table layout
### What m saw
The full proceeding name ("Revision / Non-admission Appeal BGH (Infringement) de.inf.bgh") appears
"in the row flow" so the columns (Schriftsatz | Partei | Rechtsgrundlage | Entwurf starten) look
broken.
### Root cause — it is NOT structural; it is **visual weight**
The grouped rows are hydrated by `frontend/src/client/submissions-new.ts`. The group header is
**already a correct full-width colspan row** (committed `a911a2d`, 2026-05-23 — i.e. m saw this
exact code):
```ts
// client/submissions-new.ts:167-174
const colspan = 4;
html.push(`<tr class="entity-table-group-header"><th colspan="${colspan}" scope="colgroup">
<span class="entity-table-group-header__name">${esc(group.name)}</span>
<span class="entity-table-group-header__code">${esc(code)}</span></th></tr>`);
```
CSS for it exists at `frontend/src/styles/global.css:6066-6109`. So structurally the name spans all
4 columns — it is not crammed into column 1.
**The actual defect is contrast.** The group-header band paints
`background: var(--color-bg-subtle)` (`global.css:6074`) = **`#f7f3f0`** — and the table `thead`
column-label row uses the **same** `--color-bg-subtle` (`global.css:7979`), over a near-cream page
background, with faint `--color-border: #e1dcd6` top/bottom rules. Result: the proceeding-name band
is barely distinguishable from the column-header band directly above it and from the page. The eye
reads it as un-delimited floating text sitting between the column labels and the data — "the name is
in the row flow and the columns look broken." The long EN proceeding names amplify it.
(Could not confirm against the live render — `/submissions/new` is auth-gated and redirects to
`/login`. Diagnosis is from source + the CSS variable values, which are conclusive about contrast.)
### → Fix (quick — pure CSS, no fork)
Give `.entity-table-group-header th` a distinctly stronger treatment so each proceeding group reads
as its own band, clearly separated from the column header:
- a heavier/darker background than the `thead` (e.g. a deeper tint, or the midnight/lime accent the
`--own` modifier already uses at `6089-6093`), **and/or**
- a heavier top border / a small accent rule, plus a touch more vertical padding.
Anchor: `frontend/src/styles/global.css:6066-6109`. No TS change needed — the markup is already
correct. The sibling project-scoped tab (`client/submissions.ts:137-141`) shares the same classes,
so the CSS fix improves both at once.
(If the coder finds the live deploy *doesn't* show the colspan header at all, the secondary
hypothesis is a stale `/assets/submissions-new.js` bundle — verify the deployed bundle includes the
`a911a2d` grouping code. Source on `main` is correct as of this diagnosis.)
---
## P3 — generated Rubrum is UNSTYLED + UNFILLED (the docforge core)
m generated a draft from the global picker ("Ohne Projekt") and got: letterhead filled
(`Schriftsatz von HLC / Matthias Siebels, duesseldorf`) but **every** caption/party/project value =
`[KEIN WERT: …]` and the Rubrum is **plain text with no HL Patents Style formatting**. Two distinct
layers, diagnosed separately.
### P3(a) — UNSTYLED — confirmed: it's `BuildFallbackSkeleton` output
**The path m hit.** `resolveSubmissionTemplate` (`internal/handlers/submission_drafts.go:1359-1408`)
is a 7-tier fallback chain. For any code without a dedicated per-code template (only
`de.inf.lg.erwidg` has one), and with the firm/universal skeletons rejected by the merge-safe guard
(see below), generation lands on **tier 6 — `docx.BuildFallbackSkeleton(lang)`**
(`pkg/docforge/docx/fallback_skeleton.go`).
**What that emits.** A minimal in-process .docx whose `styles.xml`
(`fallback_skeleton.go:121-138`) defines only three generic styles:
`Heading1` (bold 14pt), `Heading2` (bold 12pt), `Normal` (nothing). The Rubrum is built from those
(`buildFallbackDocumentXML`, lines 215-269) — `fbHeading2` / `fbPlain` / `fbBold`. **No HL Patents
Style typography, no named Rubrum styles, no firm font/numbering.** m's "plain text" is exactly
this. Confirmed.
**Why the styled base isn't used (the merge-safe guard).** Tiers 3/4/5 fetch the firm/universal
skeletons from mWorkRepo but are **guarded by `docx.HasMergePlaceholders`** (lines 1378/1384/1388).
I fetched the firm-skeleton
(`HL/mWorkRepo:6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx`, 157 KB) and unzipped it:
- **`styles.xml` carries the FULL HL Patents Style catalogue** — including the exact named styles a
Rubrum needs:
- `HLpat-Table-Recitals-Party`, `HLpat-Table-Recitals-PartyDetails`,
`HLpat-Table-Recitals-PartyRoles`, `HLpat-Table-Recitals-Sequencers` — the party-block (Rubrum)
styles (e.g. `Recitals-Party` = `basedOn Body1`, bold, numbered list `numId 4`).
- `HLpat-Heading-H1..H5`, `HLpat-Body-B0/B1`, `HLpat-Header-Section/Subsection`,
`HLpat-Signature`, `SignatureBlock`, `HLpat-Requests-Intro`, `HLpat-Citation`, …
- full letterhead: `header1/header2` + `footer1/footer2` + `media/image1.jpeg` (logo) +
`media/image2.jpg`, theme1.xml, fontTable.xml.
- **But its `document.xml` body is ANCHORS-ONLY** — ten `{{#section:KEY}}…{{/section:KEY}}` pairs
(`letterhead`, `caption`, `introduction`, `requests`, `facts`, `legal_argument`, `evidence`,
`exhibits`, `closing`, `signature`) and **zero `{{key}}` merge placeholders**.
So `HasMergePlaceholders` correctly rejects it for the **merge** path (feeding it to `merge.go`
would print literal `{{#section:…}}` junk — the t-paliad-358 A-S1 regression). The styled base is a
**Composer base**, consumed by `pkg/docforge/docx/compose.go` (exists, 22 KB), which splices section
content into those anchors. **The merge path and the Composer path are different engines, and
one-click /generate + project-less export run the merge path.**
**The styled HL base therefore already carries every named paragraph style the Rubrum needs.** The
gap is purely that generation runs the merge path (→ bare in-process skeleton) rather than a styled
base.
#### → FORK (ii): styling approach — three framed options
- **Option A — drive generation off the Composer path (the "real docforge").**
Route one-click /generate and the project-less draft-export through `compose.go` with the
firm-skeleton base + the section seeds (`submission_bases.section_spec`, migs 146/150; the caption
seed already exists per t-paliad-358). Output is fully HL-styled (Rubrum in `HLpat-Table-Recitals-*`,
headings in `HLpat-Heading-*`, signature in `HLpat-Signature`, logo letterhead) **and** carries
seeded section bodies. *Cost:* the biggest change — generation currently never calls the Composer
for these entry points; needs routing + the project-less bag wired into the Composer.
*This is the long-term correct path.*
- **Option B — author a merge-safe styled firm-skeleton (tracer-bullet, RECOMMENDED first).**
Start from the firm-skeleton's full part-set (its `styles.xml`, theme, fonts, headers/footers,
logo media) and replace **only** its anchors-only `document.xml` body with a **merge-safe Rubrum
body** that uses the HLpat style IDs (`HLpat-Table-Recitals-Party` for party lines,
`HLpat-Heading-H2` for section heads, `HLpat-Signature` for the signature block) and the same
`{{key}}` / `{{caption.*}}` placeholders the current fallback already uses. Publish it as a
merge-safe `_firm-skeleton.docx` (or a new slug). The resolver's **tier 4 already prefers a
merge-safe firm-skeleton automatically** — its own comment says: *"should a merge-safe
firm-skeleton (with letterhead) be restored later it is preferred again automatically"*
(`submission_drafts.go:1352-1354`). So this needs **no handler change** — drop in the file and the
guard picks it. Output: HL-styled Rubrum + letterhead via the existing merge path.
*Cost:* author one .docx body (the styles/letterhead already exist in the base); smallest blast
radius; reuses the entire existing merge pipeline and the `caption.*` keys.
- **Option C — graft real styles into `BuildFallbackSkeleton`.**
Embed the firm `styles.xml` + theme + letterhead into the in-process builder and map its
paragraphs to HLpat styles. Keeps a code-only last-resort that is also styled. *Cost:* duplicates
the base's 89 KB styles.xml into Go and must track it on every firm-template change — more
maintenance than B for the same visual result. Only worth it if we want even the never-reached
last-ditch tier styled.
**Recommendation:** **B first** (tracer bullet — slots into the existing tier-4 hook, styled Rubrum
this iteration), **A as the strategic target** (full Composer-driven generation with seeded
sections). C is not recommended.
### P3(b) — UNFILLED (`[KEIN WERT]`) — confirmed cause + a pinned resolver-wiring gap
**Confirmed cause: no project context.** The draft was started "Ohne Projekt" → `project_id` NULL.
In `SubmissionVarsService.Build` (`internal/services/submission_vars.go:125-216`), the project-less
branch is **lines 171-178**:
```go
if in.ProjectID == nil {
// Project-less draft (t-paliad-243): no project / parties / deadline state to resolve.
out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag()
return out, nil
}
```
`resolvers` at that point is **only** `firmResolver, todayResolver, userResolver,
proceduralEventResolver` (lines 158-163). It does **not** include `projectResolver`,
**`captionResolver`**, `partiesResolver`, or `deadlineResolver` (those are appended only on the
project branch, lines 203-208). So `caption.*`, `project.*`, `parties.*` are never populated →
every one renders `[KEIN WERT: …]`. firm/user/today DID fill (letterhead correct) — consistent with
m's screenshot.
**The resolver-wiring gap (pinned).** This is the important finding the task asked for. Even with
no project, the draft has a `submission_code`, and:
- the `rule` is **already loaded** on this path (`loadPublishedRule`, line 138 — *before* the
`ProjectID == nil` check), and
- `DeadlineRule.ProceedingTypeID *int` is populated (`internal/models/…:114`; `proceeding_type_id`
is in `ruleColumns`, `deadline_rule_service.go:42`), and
- `resolveCaption(p, pt)` is a **pure function that tolerates a nil project**
(`submission_vars.go:453-533`): it guards `if pt != nil` (464) and `if p != nil` (506). With `pt`
set and `p == nil` it resolves **heading / subject / claimant_designation / defendant_designation
/ versus** from the proceeding alone (jurisdiction + dotted-code "nature" segment + the mig-137
role-label overrides). Only the *appeal/cassation* designation upgrade needs
`project.instance_level`, which simply falls back to the civil default when absent.
**So `caption.heading_de` is empty on the project-less path purely because the branch never loads
the ProceedingType from `rule.ProceedingTypeID` and never runs `captionResolver` — not because the
data is missing.** The submission_code *does* tie the draft to a proceeding; the wiring just doesn't
use it. This is a genuine gap, independently fixable from the product decision below.
Minimal fix shape (for the coder, if m chooses to fill-what-we-can): in the `ProjectID == nil`
branch, `pt, _ := s.loadProceedingType(ctx, rule.ProceedingTypeID)` (the helper already tolerates a
nil id, lines 275-291) and append `captionResolver{project: nil, pt: pt, lang}` (+ optionally a
proceeding-only `projectResolver` for `project.proceeding.name`). That fills the caption wording and
the proceeding line; party names / case number / court stay blank.
#### → FORK (iii): require-Akte vs fill-what-we-can
- **Option 1 — require an Akte/project to draft.** A draftable submission must be bound to a
project (drop or disable "Ohne Projekt", or block generation until linked). Rubrum is *fully*
filled (project + parties + caption all present). Simplest correctness, but removes the
quick project-less drafting affordance t-paliad-243 deliberately added.
- **Option 2 — fill-what-we-can on a project-less draft (RECOMMENDED, pairs with the wiring fix).**
Keep "Ohne Projekt" but resolve `caption.*` (heading / designations / versus / subject) and the
proceeding line from the submission_code's proceeding, leaving only party names / case number /
court blank for the lawyer. The Rubrum then renders with correct procedural wording instead of
five `[KEIN WERT]`s; the genuinely project-specific blanks remain honest gaps. This is the
resolver-wiring fix above; it does not require the require-Akte product change.
**Recommendation:** **Option 2** — it's the smaller change, preserves the t-paliad-243 affordance,
and turns a wall of `[KEIN WERT]` into a real (if partial) Rubrum. m may *additionally* prefer
"start from Akte" as the *primary* CTA for a fully-filled document, but that's a UX nudge, not a
hard gate.
---
## Sliced fix-plan (tracer-bullet first)
Ordered cheap → meaty. Each slice is independently shippable.
1. **P1 data-fix (quick, data-only).** Re-kind the 8 anchor rows
(`event_type='decision'`, `primary_party='court'`) in `paliad.deadline_rules_unified`. No code.
Confirm the deadline engine doesn't trigger off `event_type='filing'` for them first (expected
safe — anchoring is `parent_id`). *Optionally* add the `primary_party IS DISTINCT FROM 'court'`
guard to `loadSubmissionCatalog` as belt-and-braces.
2. **P2 layout (quick, CSS-only).** Strengthen `.entity-table-group-header th`
(`global.css:6066-6077`) so each proceeding band is visually distinct from the column header.
Verify the deployed `submissions-new.js` bundle is current.
3. **P3(b) fill-what-we-can (small, Go).** Wire `captionResolver` (and a proceeding-only
`projectResolver`) into the project-less branch of `SubmissionVarsService.Build` using
`rule.ProceedingTypeID`. Turns the Rubrum wording from `[KEIN WERT]` into resolved procedural
labels; party/case/court stay blank. (Decide FORK iii first — this *is* Option 2.)
4. **P3(a) styling — tracer bullet (Option B, medium).** Author a merge-safe styled firm-skeleton
(firm-skeleton part-set + a `{{key}}`/`{{caption.*}}` Rubrum body in `HLpat-Table-Recitals-*` /
`HLpat-Heading-*` / `HLpat-Signature`). Drops into the existing tier-4 hook — no handler change.
Combined with slice 3, the project-less draft now renders an HL-styled, partially-filled Rubrum.
5. **P3(a) styling — strategic (Option A, meaty, later).** Route generation through the Composer
(`compose.go`) off the firm-skeleton base with section seeds, for fully-styled documents with
seeded section bodies. Larger change; do after slices 1-4 validate.
---
## The three forks for m
- **(i) P1 — data-fix vs filter:** → **data correction** (re-kind the 8 anchor rows to
`decision`/`court`; existing filter then excludes them). A picker-filter alone *cannot* isolate
them (`primary_party='both'`, shared with real submissions). Optional `primary_party<>'court'`
guard as hardening.
- **(ii) P3 styling — Composer vs merge-safe styled skeleton vs grafted fallback:** → **Option B**
(merge-safe styled firm-skeleton) as the tracer bullet; **Option A** (Composer-driven) as the
strategic target. The styled base already carries all HL Patents Style + Rubrum styles — the work
is a styled body, not new styles.
- **(iii) P3 fill — require-Akte vs fill-what-we-can:** → **Option 2** (fill caption.* from the
submission_code's proceeding on project-less drafts; leave party/case/court blank). Pairs with the
pinned resolver-wiring fix.
---
## Appendix — evidence index
- Picker filter: `internal/handlers/submissions.go:177-211`.
- P1 affected rows + the 16-row correct `decision`/`court` tier + `parent_id` anchoring:
Supabase MCP read-only on `paliad.deadline_rules_unified` (2026-06-01).
- P2 group header: `frontend/src/client/submissions-new.ts:167-174` (commit `a911a2d`, 2026-05-23);
CSS `frontend/src/styles/global.css:6066-6109`; `--color-bg-subtle:#f7f3f0` (`:28`), same as
`thead` (`:7979`); auth-gated render not directly observed.
- P3(a) unstyled fallback: `pkg/docforge/docx/fallback_skeleton.go:53-89,121-138,215-269`;
resolver chain + merge-safe guard `internal/handlers/submission_drafts.go:1359-1408` (esp.
1352-1354, 1378/1384/1388/1395).
- P3(a) styled base: `HL/mWorkRepo:6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx`
(fetched + unzipped: full HLpat styles incl. `HLpat-Table-Recitals-*`; anchors-only body);
Composer `pkg/docforge/docx/compose.go`; registry `internal/handlers/files.go:94-101`.
- P3(b) project-less gap: `internal/services/submission_vars.go:125-216` (branch 171-178),
`resolveCaption` 453-533, `loadProceedingType` 275-291, `rule` load 138; `DeadlineRule.
ProceedingTypeID` (`internal/models/…:114`, `ruleColumns` `deadline_rule_service.go:42`).

View File

@@ -0,0 +1,848 @@
# Design: /inbox overhaul — project-events feed + filtering + list/cards/calendar toggles
**Task:** t-paliad-249
**Gitea:** m/paliad#80
**Author:** icarus (inventor)
**Date:** 2026-05-25
**Status:** LOCKED — head confirmed Q1=A with two refinements (2026-05-25), see §12.
**Branch:** `mai/icarus/inventor-inbox-overhaul`
---
## 0. TL;DR
`/inbox` today is approval-requests only. m wants it to become the actual
"what's new on my projects" surface — approval requests **plus** recent
project_events on visible projects — with the same view-toggle paradigm
as `/events` (list / cards / calendar) and a meaningful filter row.
The good news: the substrate already exists.
- `view_service.RunSpec` unions four sources (deadline, appointment,
**project_event**, **approval_request**) into one ranked `[]ViewRow`.
- `FilterSpec` has predicates for every axis we need
(`ProjectEventPredicates.EventTypes`, `ApprovalRequestPredicates`).
- `filter-bar` knows the axes we need: `time`, `project`,
`approval_viewer_role`, `approval_status`, `approval_entity_type`,
`project_event_kind`, plus `shape` / `sort` / `density`.
- Shape renderers exist: `shape-list` (table + compact + approval), `shape-cards`
(day-grouped), `shape-calendar` (thin adapter on `mountCalendar`).
So the work is **mostly re-mix**:
1. Extend `InboxSystemView` from `Sources=[ApprovalRequest]` to
`Sources=[ApprovalRequest, ProjectEvent]`, default
`Time.Horizon=Past30d`, and add a curated `project_event.event_types`
default that filters out noise (approvals duplicate-suppression,
checklist mutations, status churn).
2. Extend `shape-list.ts` so `row_action="approve"` no longer assumes
every row is an approval — rename it `"inbox"`, dispatch per
`row.kind` (approval → existing approve-card layout; project_event →
navigate-style stream row).
3. Wire the existing view-axis selector (the chip cluster on `/events`)
onto `/inbox`'s host, persisting selection via the filter-bar URL
codec (axis `shape` already in `AxisKey`).
4. Add a high-watermark read cursor (`paliad.users.inbox_seen_at`) +
`POST /api/inbox/mark-all-seen` + extend `/api/inbox/count` to count
unseen project_events too. Adds one new axis `unread_only` to the bar.
That's Slice A. Slice B layers cards + calendar toggles cleanly. Slice C
is per-item dismissal — keep out of v1 unless the cursor proves not
enough (m's pick Q3 is the cursor).
No new aggregation service, no new endpoint family — the inbox runs on
`/api/views/inbox/run` like every other system view does today.
---
## 1. Current `/inbox` state
**Routes (`internal/handlers/approvals.go`):**
| Path | Behaviour |
|---------------------------------------|--------------------------------------------------------------|
| `GET /inbox` | Serves `dist/inbox.html`, a thin shell. No SSR data. |
| `GET /api/inbox/pending-mine` | Approval requests I can approve. |
| `GET /api/inbox/mine` | Approval requests I submitted (all statuses by default). |
| `GET /api/inbox/count` | `{count: N}` for the sidebar bell badge — `PendingCountForUser`. |
| `GET /api/approval-requests/{id}` | Hydrate one request (used by suggest-changes modal). |
| `POST /api/approval-requests/{id}/{action}` | `approve` / `reject` / `revoke` / `suggest-changes`. |
**Data path:** `frontend/src/client/inbox.ts` mounts the universal
`FilterBar` over the inbox `SystemView` (slug `"inbox"`, sources
`[approval_request]`, viewer_role `any_visible`, status `[pending]`).
The bar fetches `/api/views/system`, hands the spec to itself, calls
`/api/views/inbox/run?…`, and stamps rows via `shape-list.ts`'s
`renderApprovalList(rows)` path (gated by `row_action="approve"`).
**Action wiring:** `wireApprovalActions(host)` listens on
`.views-approval-action` clicks; on success it triggers
`bar.refresh()` and `refreshInboxBadge()` (which pokes
`/api/inbox/count`).
**Empty state + admin nudge:** when the result list is empty AND the
caller is `global_admin` AND no `approval_policies` row exists firm-wide,
the page shows a "configure policies" CTA. Otherwise the localized
"no items" empty-state text.
**Sidebar bell:** `Sidebar.tsx:143` `navItem("/inbox", BELL_ICON, …)`
plus `client/sidebar.ts:320345`'s `initInboxBadge` which polls
`/api/inbox/count` every 60s. Badge clamps to `"9+"`.
### What aggregates cleanly
The whole approval flow already plugs into `RunSpec`'s union pipeline.
That's the win — extending sources from `[ApprovalRequest]` to
`[ApprovalRequest, ProjectEvent]` is a `[]DataSource` literal edit in
`InboxSystemView()` and the engine fans out per source, sorts, returns
one `[]ViewRow`. The hard work (`runProjectEvents` + the
visibility predicate + project metadata join) is already in
`view_service.go:344430`.
### What doesn't aggregate (yet)
- **Read state.** There is no `inbox_seen_at` on `paliad.users` (verified
via information_schema). The bell badge counts pending **approval
requests for the caller** only — it has no notion of "new project
events since last visit". We have to add it.
- **Mixed `row_action`.** `shape-list.ts`'s `renderApprovalList` assumes
every row is an approval and unconditionally parses
`row.detail` as an `ApprovalDetail`. Project_event rows in the same
list would crash the parse. We need to branch per `row.kind` inside
the inbox row stamper.
- **`/inbox` shape toggle.** `client/inbox.ts` hardcodes `shape-list`;
the `shape` axis is wired into `filter-bar/axes.ts` but `/inbox`'s
`INBOX_AXES` deliberately omits it (because today the only meaningful
shape was list). Adding it onto INBOX_AXES + a small dispatcher in
`onResult` gives us cards + calendar for free.
Everything else (sidebar entry, /api/views machinery, FilterBar URL
codec, RowAction validation) carries through unchanged.
---
## 2. Event-type catalogue for inbox v1 (Q1)
This is the only design pick that requires a head/m signal. **Open
question Q1 in §9 — defaulting to (A) until head answers.**
### (R) Recommendation (A): curated subset
Sources: `[approval_request, project_event]`.
**Approval requests:** all rows whose `viewer_role=any_visible` AND
status ∈ {pending} by default; the existing chip cluster
(approver_eligible / self_requested / any_visible) stays. Decided
requests are filtered by the chip, not hidden by source-removal — so a
user who wants to see "what got approved this week" toggles the status
chip rather than the source.
**Project events:** filter by `event_type ∈ InboxProjectEventKinds`
where InboxProjectEventKinds is a new sub-list of KnownProjectEventKinds:
| event_type | In inbox v1? | Reason |
|-------------------------|--------------|---------------------------------------------------------------------|
| `project_created` | no | The author already saw the page; not news to the team yet (the team grows post-creation). |
| `project_archived` | **yes** | High-signal lifecycle event ("Akte XY wurde archiviert"). |
| `project_reparented` | **yes** | Hierarchy moves matter to everyone with access. |
| `project_type_changed` | **yes** | Same reason. |
| `status_changed` | no | Currently too granular; surface in Verlauf, revisit if m disagrees. |
| `deadline_created` | **yes** | New deadline on a project I can see — exactly the kind of event m named ("we should also display new events"). |
| `deadline_completed` | **yes** | Likewise. |
| `deadline_reopened` | **yes** | Likewise. |
| `deadline_updated` | **yes** | Currently in DB (11 rows live) but not in KnownProjectEventKinds — add it. |
| `deadline_deleted` | **yes** | Likewise — add to KnownProjectEventKinds. |
| `deadlines_imported` | **yes** | Bulk-import event surfaces what got added. |
| `appointment_created` | **yes** | |
| `appointment_updated` | **yes** | |
| `appointment_deleted` | **yes** | |
| `note_created` | **yes** | A note is "someone said something about this project". High-signal; add to KnownProjectEventKinds. |
| `our_side_changed` | **yes** | Party-side flip; high-signal, add to KnownProjectEventKinds. |
| `member_role_changed` | no | Admin churn; would dominate active users' inbox. Revisit slice B. |
| `*_approval_requested` | **no — de-duped** | The approval_request row itself carries the signal; the audit event is the same fact in a different table. Filtering it out avoids duplicate inbox entries. |
| `*_approval_approved/rejected/revoked` | **no — de-duped** | Same reason. The approval_request row's status flip is what the user sees. |
| `*_approval_changes_suggested` | **no — de-duped** | Same. |
| `approval_decided` | no | This is the umbrella audit-only kind; superseded by the approval_request row. |
| `checklist_*` | no | Low signal; checklists are surfaced on the project's checklist page. |
The de-dup pattern means: if a row exists in `approval_requests` for an
entity, the corresponding `*_approval_*` project_event is **not** shown
in the inbox — we trust the approval_request row.
### Alternative (B): everything in KnownProjectEventKinds + approvals
Simpler — no curated sub-list, no de-dup. Two drawbacks:
1. `*_approval_*` duplicates would render twice per request.
2. `status_changed` and `member_role_changed` are admin churn; in firm
tests both would dominate.
If head picks B, we need at minimum the `*_approval_*` de-dup; otherwise
the inbox renders the same fact twice.
### Alternative (C): minimal — approvals + appointment_* + deadline_*
Tightest set. Drops notes + our_side_changed + project_*. Risk: m's
brief literally says "new events that relate to one's projects" — notes
and side changes ARE such events. C feels too narrow.
---
## 3. Read/unread model (Q3 → R: high-watermark cursor)
### (R) Decision: per-user high-watermark `inbox_seen_at`
**Schema:**
```sql
ALTER TABLE paliad.users
ADD COLUMN inbox_seen_at timestamptz NULL;
```
NULL means "never visited" → everything counts as unread. The high-water
cursor advances exactly when the user POSTs to
`/api/inbox/mark-all-seen` (UI affordance: a button in the inbox header
+ implicit advance on page-mount, see Slice A wiring below).
### Why cursor, not per-item
m's recommendation: cursor. Mine matches: single column, no fan-out
table, covers the common case ("I checked my inbox, mark everything
read"). Per-item dismiss is Slice C — opt-in only if the cursor proves
inadequate. The risk we're guarding against: a single high-value pending
approval that's a week old gets buried by 80 fresh deadline_updated
events; the user clears the badge and may now never look at the
approval. Mitigation: **approval_requests with status=pending never
fall behind the cursor** — they count toward the badge regardless of
seen_at. This is a tiny conditional in the count query (Slice A).
### Cursor advance behaviour
- **Explicit:** "Alles als gelesen markieren" button in the inbox
header. POSTs `/api/inbox/mark-all-seen`; server sets
`inbox_seen_at = now()`.
- **Implicit:** when the page mounts AND the bar surfaces at least one
row that's newer than the current cursor, the *new* cursor is
remembered locally as the timestamp of the **newest visible row**.
We do **not** auto-advance the server cursor on mount — too easy to
lose items behind a stray pageview. The "neu" highlight on rows
newer than the saved cursor is the silent UX. Explicit click is the
one and only path to clearing the badge.
### `unread_only` axis
New filter-bar axis (Slice A):
```ts
// types.ts
unread_only?: boolean;
```
When `true`, the bar overlays a FilterSpec predicate:
`row.event_date > inbox_seen_at` (substrate-side filter; for project_events
that's `pe.created_at > $cursor`, for approval_requests that's
`requested_at > $cursor` OR `status='pending'` per the carve-out above).
Default: **unread_only=true** for first paint (per Slice A — landing on
the inbox shows you what's new). The "Alle" chip flips it off so the
user can see history.
---
## 4. Filter contract
The bar surfaces these axes on `/inbox` (`INBOX_AXES` constant in
`client/inbox.ts`):
| Axis | Why on /inbox | New? |
|--------------------------|----------------------------------------------------------------------|------|
| `time` | "Last 30 days" (default) with chip cluster + "Älter anzeigen" . | already |
| `project` | Single-select autocomplete from visible projects. | already |
| `approval_viewer_role` | "Zur Genehmigung" / "Eigene Anfragen" / "Alle sichtbaren". | already |
| `approval_status` | pending / approved / rejected / revoked / changes_requested. | already |
| `approval_entity_type` | Frist / Termin (chip pair). | already |
| `project_event_kind` | Chip cluster over InboxProjectEventKinds. | already |
| **`unread_only`** | Boolean toggle ("Nur ungelesen" / "Alle"); defaults to ungelesen. | **Slice A new axis** |
| `shape` | list / cards / calendar. | already in `AxisKey`, not yet on `/inbox` |
| `sort` | Newest first (default) / oldest first. | already |
| `density` | comfortable / compact. | already |
**Default landing state** for a brand-new pageview:
`?time=past_30d&unread_only=true&a_status=pending&shape=list&sort=date_desc`.
Bookmarks from older clients (e.g. the legacy `?tab=pending-mine`)
still work because `client/inbox.ts:4658` already applies the legacy
tab → `a_role` redirect at hydration.
### Source-removal not exposed as an axis
Users do **not** see a "show approvals only / show events only" chip.
The signal we want is "what's new across my projects"; splitting the
two via the filter row is busywork. If they want approvals-only they
chip-pick `project_event_kind` empty + status=any (or future axis pick
`source=approval_request`). If feedback shows otherwise after Slice A
ships, we add the axis in Slice B trivially (`Sources` is a
spec.Sources literal flip).
---
## 5. View toggle implementation plan (Q5 → R: list / cards / calendar)
The pattern `/events` uses today (see `frontend/src/events.tsx:107141`
for the `<div className="events-view-selector">` block and
`client/events.ts:617650` for the `applyView` function):
- One chip cluster `data-event-view="cards|list|calendar"`.
- Active class toggle.
- Per-shape `display: none` on the table-wrap / cards-wrap / cal-wrap
hosts.
- For calendar, `mountCalendar()` constructs a month/week/day grid
into a dedicated `events-calendar-wrap` host; the handle is destroyed
on shape-leave so its URL state doesn't leak into the other shapes.
### Mapping onto /inbox
The cleanest path: **use `filter-bar`'s built-in `shape` axis instead of
a per-page selector.** The axis already round-trips into the URL via
`url-codec.ts` and serialises into `RenderSpec.Shape`. `client/inbox.ts`
just needs:
1. Add `"shape"` to `INBOX_AXES`.
2. Dispatch in the `onResult` callback by `effective.render.shape`:
```ts
onResult: (result, effective) => {
switch (effective.render.shape) {
case "cards": return paintCards(result.rows, effective.render, ...);
case "calendar": return paintCalendar(result.rows, ...);
case "list":
default: return paintList(result.rows, effective.render, ...);
}
}
```
3. The renderers exist already: `renderCardsShape` in
`views/shape-cards.ts`, `renderCalendarShape` in
`views/shape-calendar.ts`, `renderListShape` in `views/shape-list.ts`.
The only piece of new code is the per-shape host-clearing on switch
(so we don't leak a stale shape's DOM into the new host).
### Calendar shape — items without dates
Calendar can only render rows with a calendar-mappable date. Today:
- **approval_request:** `requested_at` (timestamp). Maps fine, but
shows up as a single point — rendering an approval-request on a month
grid is semantically "you got asked on this day". OK for v1.
- **project_event:** `created_at`. Same shape.
- **deadline:** `due_date`. Already supported.
- **appointment:** `start_at`. Already supported.
So every row in the inbox v1 has a calendar position. No
need to filter rows on calendar-mount. **One caveat:** the calendar
shape currently doesn't render action affordances (approve/reject) — it
opens a detail dialog on click. Slice B accepts that: clicking an
approval row on the calendar opens the inbox-list-style detail in a
modal (re-using the existing per-row /api/approval-requests/{id}
fetch). Out of scope for Slice A.
### Cards shape — day-grouped chronological cards
`shape-cards.ts` groups by day and renders one card per row, with
title + meta + actor. The approval-card layout there is the standard
card (no approve buttons — same caveat as calendar). For Slice B, we
extend `shape-cards.ts` to detect `row.kind === "approval_request"
&& row.detail.status === "pending"` and stamp the approve/reject button
strip inline. The DOM template is the same as
`shape-list.ts:renderApprovalRow`, so most of the work is hoisting that
template into a shared util.
---
## 6. Backend aggregation service (Q6 → R: reuse RunSpec)
**Decision: do not build a new aggregation service.** The
substrate-level work is exactly two edits:
### 6.1 InboxSystemView (system_views.go:103144)
```go
func InboxSystemView() SystemView {
return SystemView{
Slug: "inbox",
Name: "Inbox",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{
SourceApprovalRequest,
SourceProjectEvent,
},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "any_visible",
Status: []string{"pending"}, // default; bar can override
}},
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
EventTypes: InboxProjectEventKinds, // curated subset
}},
},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateDesc, // newest first — different from today's date_asc
RowAction: RowActionInbox, // new — see §6.3
},
},
}
}
```
Curated sub-list lives in `filter_spec.go` next to KnownProjectEventKinds:
```go
var InboxProjectEventKinds = []string{
"project_archived", "project_reparented", "project_type_changed",
"deadline_created", "deadline_completed", "deadline_reopened",
"deadline_updated", "deadline_deleted", "deadlines_imported",
"appointment_created", "appointment_updated", "appointment_deleted",
"note_created", "our_side_changed",
}
```
(With Q1 pick A locked. If head picks B, drop the InboxProjectEventKinds
list and remove the `EventTypes` predicate. If head picks C, narrow the
list to deadline_* + appointment_* only.)
KnownProjectEventKinds in `filter_spec.go:186` needs **additions** so
`note_created`, `our_side_changed`, `deadline_updated`, `deadline_deleted`,
`deadlines_imported` are valid filter values — without this the
validator rejects the InboxSystemView spec. Migrate this list at the
same time. (`event_categories` and similar grouping infra are already
covered by `event_category_service.go` and won't move.)
### 6.2 Approval-duplicate suppression
In `view_service.runProjectEvents` (or in a tiny new predicate helper),
skip `event_type LIKE '%_approval_%'` when source-set includes
ApprovalRequest. This avoids the double-count described in Q1 §2.
Implementation: extend `allowedProjectEventKinds` (view_service.go:649) to
auto-drop the `*_approval_*` strings when the same RunSpec already
fans out the approval_request source. One conditional, six lines.
### 6.3 Mixed-row row_action
`shape-list.ts` today: `row_action="approve"` → calls
`renderApprovalList(rows)` which assumes every row is an approval.
Need a new value:
```go
// render_spec.go
const RowActionInbox ListRowAction = "inbox"
```
And register it in `KnownRowActions`.
Frontend (`shape-list.ts`):
```ts
if (rowAction === "inbox") {
host.appendChild(renderInboxList(sorted));
return;
}
```
Where `renderInboxList(rows)`:
- approval_request rows → existing `renderApprovalRow(row)` template (the
per-row factor-out from `renderApprovalList`).
- project_event rows → a new `renderProjectEventRow(row)` template:
timestamp + actor + title + project chip + optional "Öffnen" link
to the underlying entity (deadline / appointment / note / project
detail). Modelled on the Verlauf row in
`client/projects-detail.ts:651700` (`.entity-event` markup).
This makes the inbox stamping kind-aware. The
existing `wireApprovalActions` continues to find buttons via class
`.views-approval-action` and works unchanged.
### 6.4 Endpoints — what's new vs reused
| Path | Behaviour | Slice |
|-------------------------------------|----------------------------------------------------------|-------|
| `GET /api/views/inbox/run` | **Already exists** — fans the InboxSystemView spec. | A reuse |
| `GET /api/inbox/count` | **Behaviour change:** count includes unread project_events on visible projects + pending approval_requests (the latter regardless of cursor). | A |
| `POST /api/inbox/mark-all-seen` | New. Sets `users.inbox_seen_at = now()` for the caller. | A |
| `GET /api/inbox/pending-mine` | **Keep** — backwards-compat for clients (sidebar bell may still use it). | unchanged |
| `GET /api/inbox/mine` | **Keep** — used by the saved view `inbox-mine`. | unchanged |
The two `/api/inbox/{pending-mine,mine}` endpoints stay because they're
narrower-than-RunSpec optimisations and used by the dashboard's
`loadInboxSummary`. No reason to remove them.
### 6.5 InboxSummary on the dashboard (out of scope, but flag)
`DashboardData.InboxSummary` (dashboard_service.go:89) currently counts
only pending approvals. If Slice C extends the badge count to include
unread project_events, the dashboard widget also needs to swap
`PendingCountForUser` for the new unified count — keep this as a small
follow-up after Slice A ships and the cursor semantics are proven.
---
## 7. Slice plan
### Slice A — Project-event aggregation + read cursor + list view
**Goal:** /inbox shows pending approvals + curated project_events for
visible projects in the last 30 days, with the new "Nur ungelesen"
toggle. List view only.
Tasks:
1. **Migration `NNN_inbox_seen_at.up.sql`:**
`ALTER TABLE paliad.users ADD COLUMN inbox_seen_at timestamptz NULL;`
2. **`filter_spec.go`:** extend `KnownProjectEventKinds` (add
`note_created`, `our_side_changed`, `deadline_updated`,
`deadline_deleted`, `deadlines_imported`). Add
`InboxProjectEventKinds` (curated subset, Q1=A).
3. **`system_views.go`:** rewrite `InboxSystemView` per §6.1 with
both sources, `HorizonPast30d`, `SortDateDesc`,
`RowAction=RowActionInbox`.
4. **`render_spec.go`:** add `RowActionInbox`, register in
`KnownRowActions`.
5. **`view_service.go`:** in `runProjectEvents`, auto-drop
`*_approval_*` event_types when ApprovalRequest is in
`spec.Sources` (§6.2).
6. **`approvals.go`:**
- New handler `handleInboxMarkAllSeen` →
`UPDATE paliad.users SET inbox_seen_at = now() WHERE id = $1`.
- Modify `handleInboxCount` to return
`pending_approvals_count + unread_project_events_count`. SQL
in approval_service.go: one new method
`UnseenInboxCountForUser(userID)` returning that union. Keep
`PendingCountForUser` (dashboard still uses it).
7. **`shape-list.ts`:** factor `renderApprovalRow(row)` out of
`renderApprovalList`. Add `renderInboxList(rows)` that dispatches
per `row.kind`. Wire `row_action="inbox"` to it.
8. **`client/inbox.ts`:**
- Add the `unread_only` axis to `INBOX_AXES` and wire to a FilterSpec
overlay (sub-spec `Time.Horizon=Past30d` AND
filter predicate "newer than cursor OR pending-approval").
- Render "Alles als gelesen markieren" button in the page header
(in `inbox.tsx`); on click POST `/api/inbox/mark-all-seen`,
refresh bar + badge.
- Listen for cursor update (server response) and refresh.
9. **Sidebar badge (`client/sidebar.ts:initInboxBadge`):** unchanged code
path, but the new server count includes project_events. Add no client
changes for v1 — server returns the wider count.
10. **i18n:** new keys —
- `inbox.title.feed` ("Inbox") replaces "Genehmigungen" in the page
header (since the page is now more than approvals).
- `inbox.subtitle.feed` ("Neuigkeiten zu Ihren Projekten und offene
Genehmigungen.").
- `inbox.action.mark_all_seen` ("Alles als gelesen markieren").
- `inbox.axis.unread_only.on/off`.
- `inbox.empty.feed` ("Keine Neuigkeiten in den letzten 30 Tagen.").
- `views.col.event_kind` (for the kind column in
table-density list).
- DE primary, EN secondary, both in `i18n.ts`.
11. **Tests:** `system_views_test.go` covers the
InboxSystemView spec shape; new test for the de-dup helper in
view_service. `approval_service_test.go` adds tests for the new
`UnseenInboxCountForUser` method. New
`inbox_seen_at_test.go` covers the cursor migration + the POST
handler.
12. **Verify** the page renders for a sample user with both event types
visible, "Nur ungelesen" toggles correctly, mark-all-seen clears the
badge, the project-events deduplicate against approval requests.
### Slice B — Cards + calendar shape toggles
**Goal:** `?shape=cards` and `?shape=calendar` work on /inbox; users can
switch via the bar's shape chip. Approval rows on cards/calendar are
*read-only* (open detail modal on click; no inline approve/reject).
Tasks:
1. **`client/inbox.ts`:** add `"shape"` to `INBOX_AXES`. Add the
per-shape host divs to `inbox.tsx` (one for cards, one for calendar)
matching the `/events` pattern. Implement `onResult` dispatch.
2. **`shape-cards.ts`:** when `row.kind==="approval_request"` AND
`row.detail.status==="pending"`, stamp the approval row template
inline. Hoist the template out of `shape-list.ts` if reuse pays.
3. **`shape-calendar.ts`:** approval_request rows render as date-point
chips; click opens a detail modal. The modal reuses the existing
`approval-edit-modal` for suggest-changes when the user is the
approver; otherwise a read-only summary.
4. **CSS:** ensure `.entity-event` and `.views-approval-row` markup
coexist on the cards view without z-index clashes; lightweight
targeting via `.views-cards-list[data-surface="inbox"]`.
5. **Tests:** shape toggle persistence via URL codec (already covered
in `url-codec.test.ts`; add one inbox-surface case).
### Slice C — Badge upgrade + per-item dismiss (deferred)
**Goal:** sidebar badge reflects unified count; per-item dismiss for
power-users.
Tasks:
1. **`paliad.inbox_dismissals` table** —
`(user_id, source, row_id, dismissed_at)` PK `(user_id, source, row_id)`.
"source" is `approval_request` / `project_event`; "row_id" is the
row's UUID. New endpoint `POST /api/inbox/dismiss` body
`{source, row_id}`. RunSpec for inbox subtracts dismissed rows.
2. **`/api/inbox/count`:** subtract dismissed rows from the count.
3. **Dashboard widget:** `DashboardData.InboxSummary` swaps to a new
`UnifiedInboxSummary` that mirrors the page count. Backwards-compat
JSON: keep old fields, add `total_count` and `top_unified`.
4. **Empty-state:** "Alle Einträge gelesen — gut gemacht."
5. **Optional `member_role_changed` etc.:** if Slice A surfaces that
one of the excluded event_types is actually wanted, this slice opens
up `InboxProjectEventKinds` accordingly.
### Why Slice A alone is shippable
Slice A delivers m's full ask except the cards/calendar views — which
are aesthetic shape toggles, not data changes. Slice A gives:
- Inbox feed across approvals + project_events for visible projects
- Project / type / time / read-state filters
- Newest-first list with mark-all-seen
- Sidebar badge reflects unified unread count (server-side)
Slice B + C are layer cake on top with no schema or substrate changes.
---
## 8. Out of scope
- **Push notifications.** Telegram / WhatsApp / email — different
channel concerns, separate design.
- **Cross-user inbox views.** No "admin sees others' inboxes" in v1.
- **Pinning / starring items.** Not in m's ask. If feedback after Slice
A wants it, opens its own design.
- **Paliadin chat unread.** Not part of project_events; paliadin lives
in its own pane. Slice C could surface a banner if asked.
- **Replacement of the existing /api/inbox/{pending-mine,mine} endpoints.**
They stay because the dashboard's `loadInboxSummary` uses them and
no benefit to consolidating.
- **Detail-page changes.** Clicking a project_event row in the inbox
navigates to the existing entity detail page (deadline, appointment,
note); we don't build a new "event detail" view.
- **InboxSummary on the dashboard.** Out of Slice A. Slice C upgrades
it; for now the widget keeps showing approval-only.
---
## 9. Open questions for m
Defaulted to (R) per the inventor protocol — only **Q1** is escalated
to head for explicit confirmation because it changes the
inbox's surface area. Everything else falls to the recommended pick
unless head/m flag otherwise.
**Q1 — Event-type catalogue (material pick, head answered):**
**LOCKED = A** (curated subset with `*_approval_*` de-dup). Head added
`member_role_changed` to the curated list with a Slice B narrowing
follow-up + a coarser `inbox_focus` chip cluster on the bar. Full
decision recorded in §12.
**Q2 — Time window:** (R) Past30d default + chip cluster
(today / past_7d / past_30d / past_90d / any) + custom range via the
existing time picker. Locked unless head overrides.
**Q3 — Read/unread model:** (R) High-watermark cursor
(`users.inbox_seen_at`). Pending approval_requests carry forward even
when older than the cursor — guards against burying a high-value
approval. Per-item dismiss is Slice C, opt-in. Locked.
**Q4 — Filters surfaced on the bar:** (R) time / project /
approval_viewer_role / approval_status / approval_entity_type /
project_event_kind / unread_only / shape / sort / density. Locked
unless head wants `source` (approvals-only vs events-only chip)
added — defaulting to "not in v1".
**Q5 — View toggle parity with /events:** (R) list (default — newest
first) / cards (day-grouped) / calendar (date-point). Wired via the
filter-bar's existing `shape` axis, not a per-page selector. Locked.
**Q6 — Architecture:** (R) Reuse `view_service.RunSpec` with both
sources in the InboxSystemView spec; no new aggregation service.
Approval-event de-dup applied in `runProjectEvents`. Locked.
**Q7 — Notification badge:** (R) Yes — Slice A makes the existing
`/api/inbox/count` return the unified unread count; sidebar badge
client unchanged. Locked.
**Q8 — Acknowledgement flow:** (R) Approval rows keep
approve/reject/revoke buttons inline (list shape only). project_event
rows have no inline action — click row → navigate to the underlying
entity. Cursor advance is via "Alles als gelesen markieren" only —
no per-row mark-read in v1. Locked.
**Q9 — Empty-state copy:** (R) "Keine Neuigkeiten in den letzten 30
Tagen." (DE primary) / "No updates in the last 30 days." (EN). The
existing admin nudge for unseeded approval_policies stays untouched.
Locked.
---
## 10. Risks + mitigations
- **Performance.** `runProjectEvents` reads up to LIMIT 500 rows per
user-call; with two sources unioned + 30-day window + visibility
predicate this should stay under 50ms on the live shape (project
count ~100, events/day low double digits). If
it doesn't, partial index hint: `paliad.project_events (created_at DESC)
WHERE event_type IN (curated list)` — Slice A optional, add if
EXPLAIN shows a seq scan in dev.
- **De-dup correctness.** Suppressing `*_approval_*` events in the
project_event source relies on the approval_request row being the
authoritative signal. **Edge case:** a request gets revoked, then
re-requested — both audit events exist. Both correspond to a single
approval_request row at any moment (the latter via the partial-index
upsert). De-dup stays valid.
- **Cursor advance race.** If two browser tabs both POST mark-all-seen,
the second wins (now() wins). Acceptable. If a user reads in tab A
then clicks an item in tab B that was created between the two reads,
tab A's "Alles als gelesen" advances past that newer item without
the user seeing it. Mitigation: server-side, `mark-all-seen` accepts
an optional `?up_to=<iso>` so the client can pin to the timestamp of
the newest visible row. Slice A wires this.
- **shape-list factor-out.** Pulling `renderApprovalRow` out of
`renderApprovalList` risks regressions on the *current* /inbox. Cover
with a snapshot/golden test on the approval row markup in Slice A
before the dispatch change.
- **Sidebar bell badge cap.** Current code clamps at "9+". Once we add
project_events, the count can easily exceed 100. Keep the "9+" clamp
for visual reasons — but make the page header show the *exact* count
("123 neu") so the user knows what's behind it.
- **Q1 fallback.** If head doesn't reply before Slice A coder shift
starts, the (R) pick A locks. If head later picks B or C, the only
change is the `InboxProjectEventKinds` list literal in
`filter_spec.go` — no schema impact, no migration change. Cheap to
flip.
---
## 11. Build/test verify list (Slice A done-when)
1. `make build` clean.
2. `go test ./...` passes; new tests cover:
- InboxSystemView spec shape includes both sources + curated kinds.
- `runProjectEvents` drops `*_approval_*` when ApprovalRequest is in spec.
- `UnseenInboxCountForUser` returns expected count for cursor and pending-approval combinations.
- POST `/api/inbox/mark-all-seen` updates the column.
- URL codec round-trip for `unread_only` axis.
3. Inbox loads at `/inbox` with project-event rows interleaved with
approval rows in date-desc order.
4. "Nur ungelesen" chip toggles between unread (with pending-approval
carve-out) and full feed.
5. "Alles als gelesen markieren" advances cursor; bar refreshes;
badge clears (except for any still-pending approvals).
6. Sidebar bell badge count is the unified number (approval + unread events).
7. Existing approve/reject/revoke + suggest-changes flows on inbox
rows still work unchanged.
8. `?tab=mine` legacy redirect still hits the right state.
9. Bilingual labels render (DE/EN toggle).
That's the doneness bar for Slice A.
---
## §12 — m's decisions (head 2026-05-25 11:30)
Head replied to the `mai instruct head` escalation; folded in below.
**Q1 (Event-type catalogue): A — locked.** Curated subset with
`*_approval_*` de-dup. Tracks Verlauf, matches m's framing ("new events
that relate to one's projects"), avoids double-counting approval audit
events against the approval_request row.
Locked InboxProjectEventKinds:
- IN: `project_archived`, `project_reparented`, `project_type_changed`,
`deadline_created`, `deadline_completed`, `deadline_reopened`,
`deadline_updated`, `deadline_deleted`, `deadlines_imported`,
`appointment_created`, `appointment_updated`, `appointment_deleted`,
`note_created`, `our_side_changed`, **`member_role_changed`**
(added by head — see refinement #1).
- OUT (audit duplicates of approval_requests): every `*_approval_*` event.
- OUT (too granular / authoring noise): `status_changed`,
`project_created`, `checklist_*`.
**Refinement 1 — `member_role_changed` visibility predicate.**
Head wants this kind included but narrowed: surface the row only when
the role change applies to the **viewer themselves** or someone above
them in the project tree (i.e. impacts the viewer's permissions / chain
of command), not when it's a peer's role changing on a project the
viewer happens to see.
- Slice A: include `member_role_changed` in
`InboxProjectEventKinds` without the narrowing predicate. The row
will appear for everyone who can see the project — over-surfacing but
not wrong. This keeps Slice A's MVP scope tight.
- Slice B: add a per-row narrowing filter on top of the inbox source
(likely a small extension to `runProjectEvents` that, when
`event_type='member_role_changed'`, inspects `metadata.affects_user_id`
+ walks the project-membership predicate before emitting). The
metadata shape is already written by the responsible handler; verify
+ lock the filter in B.
Q2-Q9 all default to (R) per the inventor protocol.
**Refinement 2 — Filter chip copy.**
For the visible chip cluster in the bar, head wants user-readable groupings,
not raw event-kind names. The bar today exposes `project_event_kind`
as one chip per kind (rendered via the
`event.title.<kind>` i18n key). For the inbox surface, surface a
**coarser grouping chip cluster** ahead of that:
- "Genehmigungen" — narrows to `Sources=[approval_request]` only.
- "Genehmigungen + Termine" — adds appointment_* event_kinds + the
approval_entity_type=appointment slice of approvals.
- "Genehmigungen + Fristen" — adds deadline_* event_kinds + the
approval_entity_type=deadline slice of approvals.
- "Alles" — default; both sources, full curated kinds list.
Implementation: a new axis `inbox_focus` (Slice A, additive — replaces
the lower-level `project_event_kind` chip's *default visibility* in the
inbox UI; advanced users still see `project_event_kind` if they expand
the bar). The four values map to FilterSpec overlays that tweak
`Sources` + per-source `EventTypes`. Coder owns the exact chip-text
final copy and the placement (probably first axis in `INBOX_AXES`).
The lower-level `project_event_kind` chip stays in `INBOX_AXES` as an
advanced override for power users — when active, it overrides the
`inbox_focus` chip's per-kind defaults.
---
### What changes for Slice A as a result
Doc deltas vs the draft text above:
1. **§2 / §6.1:** add `member_role_changed` to InboxProjectEventKinds.
Note Slice B narrowing follow-up.
2. **§4 / §5:** front of the bar gets a new `inbox_focus` axis
(4 chips: Alles / Genehmigungen / +Termine / +Fristen). Default
"Alles". `project_event_kind` stays available as an advanced chip,
visible after the user expands the bar's overflow section.
3. **§7 Slice A task list:** add task —
"**12a.** New `inbox_focus` axis (`filter-bar/types.ts`,
`axes.ts`). FilterSpec overlay translates the chip value to a
`(Sources, ProjectEventPredicates.EventTypes, ApprovalRequestPredicates.EntityTypes)`
triple. URL codec round-trips."
4. **§11 Slice B done-when:** add — "`member_role_changed` narrowing
predicate is in place; rows surface only when the change affects
the viewer's permissions chain."
No schema changes from the head's adjustments. The `inbox_focus` axis
is a pure UI/overlay primitive; nothing about the InboxSystemView spec
schema moves.

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,277 @@
# Slice B.0 — Live DB re-validation findings (t-paliad-273)
**Author:** curie (researcher)
**Date:** 2026-05-26
**Branch:** `mai/curie/researcher-slice-b-zero`
**Predecessor:** `docs/design-procedural-events-model-2026-05-25.md` (cronus, t-paliad-262)
**Scope:** READ-ONLY re-validation of the design doc's §1 premises against the live youpc Supabase `paliad` schema. No migration SQL written, no writes to `deadline_rules` or any table. B.1 (additive migration) remains blocked pending m's greenlight.
This document does **not** redesign the schema. It does **not** propose new structural changes. It records what the live DB looks like ~24 hours after the design was authored, flags every claim that drifted, and gives the eventual B.1 coder a current-as-of-2026-05-26 baseline to plan against.
---
## §0 TL;DR
The design doc's §1 premises were sound on 2026-05-25. **All numeric premises drifted in the 24 hours since.** The qualitative model (`deadline_rules` conflates three concepts; live `deadlines.rule_id` FK; snapshot precedent established; no `proceeding_event*` tables) still holds.
The Q5 default ("10 archived multi-row submission_codes collapse safely") is now **moot**: those rows were removed from the live DB between 2026-05-25 15:30 and 2026-05-26 13:30. There are now **zero** multi-row submission codes; every active submission_code maps 1:1 to one rule row. B.1 backfill no longer needs the multi-row collapse logic that §5 of the design doc anticipated.
The Q6 default ("concept_id attaches to procedural event, not sequencing rule") is **directionally correct but needs refinement**. The empirical attachment is **above** the procedural-event level — `deadline_concepts` rows cluster legal meaning *across* jurisdictional procedural-event variants. One concept_id can span 15 distinct submission_codes (e.g. "Berufungsfrist" across BGH / BPatG / LG / OLG for both PatG and ZPO paths). The FK in §4.1's draft schema (`procedural_events.concept_id REFERENCES deadline_concepts(id)`, N:1) is **already correctly shaped** for this — no schema change needed. The verbal claim in the design doc should be tightened to "one `deadline_concept` row may be referenced by many procedural events; the FK lives on `procedural_events`."
Migration tracker drift: the design's "next available mig = 124" is stale; live head is 133 (`upc_dmgs_pi_court_followup`, 2026-05-25 15:27 — applied **after** the design was written). **Next available is 134.** Ten migrations landed since the doc was authored — 124..133. None of them touched `deadline_rules` schema, but they did mutate row content (the missing 23 rows and the new event_type/legal_source distribution come from migs 127/128/132/133).
The design's claimed migration tracker `paliad.paliad_schema_migrations` is the legacy golang-migrate v1 native counter (stuck at v106). The **canonical** tracker is `paliad.applied_migrations` (one row per applied migration, with checksum + applied_at). `internal/db/migrate.go:9-21` is the source of truth. Project CLAUDE.md still says `paliad.paliad_schema_migrations`; that's a stale doc, not a B.0-scope fix.
One doc-side bug fixed by this slice: design doc §1 + m/paliad#93 issue body referenced `paliad.deadlines.deadline_rule_id`. Live column is `paliad.deadlines.rule_id`. Both files patched on this branch.
---
## §1 Headline-count drift table
All numbers taken 2026-05-26 ~13:30 UTC against the live `paliad` schema.
| Metric | Design (2026-05-25) | Live (2026-05-26) | Δ | Notes |
|---|--:|--:|--:|---|
| `deadline_rules` row count | 254 | **231** | -23 | All rows `is_active = true`. No soft-deletes in flight. |
| Rows with `submission_code` | 177 | **153** | -24 | |
| Distinct `submission_code` values | 158 | **153** | -5 | **All 5 lost are the multi-row `_archived_litigation.*` codes** — see §2. |
| Rows with `legal_source` | 102 | **112** | +10 | |
| Distinct `legal_source` values | 70 | **87** | +17 | New jurisdictional variants seeded by recent migs (127/132/133). |
| Rows with `concept_id` (linked to `deadline_concepts`) | 125 | **129** | +4 | 56% of the corpus is concept-linked, vs 49% in the design. |
| `paliad.deadlines` rows | 1 | **5** | +4 | Still tiny — destructive cutover stays cheap. |
| `paliad.submission_drafts` rows | 4 | **7** | +3 | |
| Rules in `lifecycle_state = 'draft'` | 4 | **0** | -4 | All 4 design-era drafts were published or discarded. |
### event_type distribution
| `event_type` | Design | Live | Δ |
|---|--:|--:|--:|
| `filing` | 130 | 105 | -25 |
| NULL | 77 | 89 | +12 |
| `decision` | 25 | 21 | -4 |
| `hearing` | 21 | 15 | -6 |
| `order` | 1 | 1 | 0 |
| **Total** | **254** | **231** | -23 |
The -23 row delta lands almost entirely in `filing` (-25) and `hearing` (-6), offset by +12 NULL — consistent with the disappearance of the `_archived_litigation.*` filings and a few archived `hearing` rows, plus seeding of new structural / parent-only rows by recent migrations.
### What did NOT drift (qualitative claims, still valid)
- `paliad.deadline_rules` carries 39 columns (design said 38 — drift +1; likely from mig 128 `deadline_rules_unit_check` which adds a CHECK without adding a column — or one of migs 124-133 added a column. Not investigated further; out of B.0 scope).
- `paliad.deadlines.rule_id` (uuid, nullable) is the FK column to `paliad.deadline_rules.id`. **Confirmed via `information_schema.referential_constraints`**`rule_id → paliad.deadline_rules(id)`. The doc-side mention of `deadline_rule_id` was always a typo.
- `paliad.deadlines.rule_code` + `paliad.deadlines.custom_rule_text` both still present (the denormalized-display columns from mig 122).
- `paliad.submission_drafts` uses `(project_id uuid nullable, submission_code text NOT NULL)` as its key — **no FK to deadline_rules**. Confirms the design's claim that the Schriftsätze surface filters on a text key, not on `deadline_rules.id`.
- No `paliad.proceeding_event*` tables exist (einstein's 2026-05-08 graph design was never built — still the case).
---
## §2 Archived submission_code audit (Q5 re-confirm)
**Premise re-checked:** "10 archived multi-row submission_codes (`_archived_litigation.*`) collapse safely into single procedural events with multiple sequencing variants."
**Finding:** the premise is **moot in the live DB**.
```sql
SELECT submission_code, COUNT(*)
FROM paliad.deadline_rules
WHERE submission_code LIKE '_archived_litigation.%'
GROUP BY submission_code;
-- 0 rows
```
```sql
SELECT submission_code, COUNT(*)
FROM paliad.deadline_rules
WHERE submission_code IS NOT NULL
GROUP BY submission_code
HAVING COUNT(*) > 1;
-- 0 rows
```
Every active submission_code in the live corpus is 1:1 with its `deadline_rules` row. The 10 multi-row codes the design anticipated no longer exist.
**Consequence for B.1 backfill:**
- The §5.1 / §5.2 backfill SQL the design sketched (collapsing N rows-with-same-submission_code into 1 procedural_event + N sequencing_rules) is **simpler than expected**: a straight 1:1 backfill, no GROUP-BY-and-collapse step needed.
- B.1's `INSERT INTO paliad.procedural_events ... SELECT DISTINCT submission_code ...` becomes equivalent to `INSERT ... SELECT submission_code, ... FROM deadline_rules WHERE submission_code IS NOT NULL`. No deduplication needed.
- The 78 rows where `submission_code IS NULL` (231 - 153) still need a B.1 decision: do they become `procedural_events` rows (with synthetic codes), do they become free-standing `sequencing_rules` with `procedural_event_id` NULL, or do they get parked? This was implicit in the design (the 77 NULLs were framed as "structural / parent-only rows in the proceeding tree"); B.1 should make the decision explicit and document it in the migration's `.up.sql` comments.
---
## §3 concept_id attachment shape (Q6 re-confirm)
**Premise re-checked:** "concept_id attaches to procedural event, not sequencing rule."
**Finding:** **partly true.** The FK direction the design proposes (`procedural_events.concept_id → deadline_concepts.id`, N:1) is correct. The verbal phrasing in Q6's default needs refinement — the empirical attachment is **above** the procedural-event level, not "at" it.
### Empirical pattern
129 of 231 rows carry a `concept_id`. Those 129 rows reference **53 distinct `deadline_concepts`** rows. Averages: 2.43 rows-per-concept, 2.42 submission-codes-per-concept (the two are nearly identical because today's corpus has no multi-row submission codes — see §2). Span distribution:
- 33 of 53 concepts (62%) attach to exactly 1 submission_code → procedural-event-scoped.
- 20 of 53 concepts (38%) attach to >1 submission_code → cross-procedural-event scoped.
- Maximum: 1 concept attaches to **15 distinct submission_codes**.
### Example: one concept, four procedural events
The concept `b85b2e5a-4064-40b2-b862-24b7abaa5b94` ("Berufungsfrist / Berufungsschrift") is referenced by 4 `deadline_rules` rows that today carry these 4 distinct submission_codes:
| rule_code | submission_code | court | name |
|---|---|---|---|
| § 110 PatG | `de.null.bgh.berufung` | BGH | Berufungsschrift |
| § 110 PatG | `de.null.bpatg.berufung` | BPatG | Berufungsfrist |
| § 517 ZPO | `de.inf.lg.berufung` | LG | Berufungsfrist |
| § 517 ZPO | `de.inf.olg.berufung` | OLG | Berufungsfrist |
Under Slice B's target schema (§4.1), each of these four rows becomes a separate `procedural_events` row (different `code`s, different jurisdiction-specific names, different `legal_source_id`s), but **all four reference the same `deadline_concepts.id`**.
### Implication for B.1
- `procedural_events.concept_id` should be **nullable** (62% of rows today have no concept link — the §4.1 sketch already allows this).
- The constraint must be **N:1, not 1:1** (one `deadline_concept` may be referenced by many `procedural_events`). The §4.1 sketch (`concept_id uuid REFERENCES paliad.deadline_concepts(id)`) is already correctly N:1; a hypothetical "UNIQUE INDEX on `procedural_events.concept_id`" would break the existing data. **Do not add UNIQUE.**
- The design doc's Q6 phrasing can be tightened to: "concept_id attaches to procedural event (N procedural events → 1 concept). Sequencing rules do not carry concept_id." — but this is a wording nit, not a structural change. It does **not** block B.1.
---
## §4 Snapshot precedent audit
**Premise re-checked:** the `paliad.deadline_rules_pre_<N>` snapshot pattern is established and ready for B.4's destructive drop.
**Finding:** confirmed and consistent.
Snapshot tables in `paliad`:
| Snapshot table | Origin migration |
|---|---|
| `deadlines_pre_089` | mig 089 |
| `deadline_rules_pre_091` | mig 091 (destructive drop of legacy columns) |
| `event_deadlines_pre_092` | mig 092 |
| `event_deadline_rule_codes_pre_092` | mig 092 |
| `deadline_rules_pre_093` | mig 093 |
| `proceeding_types_pre_093` | mig 093 |
| `projects_pre_094` | mig 094 |
| `deadline_rules_pre_095` | mig 095 |
| `proceeding_types_pre_096` | mig 096 |
| `deadline_rules_pre_098` | mig 098 |
Pattern: `<original_table>_pre_<migration_number>`. Always created in the `.up.sql` of the destructive migration as `CREATE TABLE paliad.<t>_pre_<N> AS TABLE paliad.<t>;` (followed by the destructive DROP / ALTER).
**B.4's template:** before `DROP TABLE paliad.deadline_rules;` (and `ALTER TABLE paliad.deadlines DROP COLUMN rule_id;`), `mig <N>.up.sql` must include:
```sql
CREATE TABLE paliad.deadline_rules_pre_<N> AS TABLE paliad.deadline_rules;
-- (optional) CREATE TABLE paliad.deadlines_pre_<N> AS TABLE paliad.deadlines;
```
This is non-negotiable per m's snapshot policy and the precedent of migs 089-098. B.4 should not enter the deploy queue without it.
---
## §5 deadlines.rule_id doc bug — verified + patched
**Premise re-checked:** the live column on `paliad.deadlines` referencing `deadline_rules` is named `rule_id`, not `deadline_rule_id`.
**Verification:**
```sql
SELECT column_name FROM information_schema.columns
WHERE table_schema='paliad' AND table_name='deadlines' AND column_name LIKE '%rule%';
-- rule_id (uuid, nullable)
-- rule_code (text, nullable)
-- custom_rule_text (text, nullable)
```
```sql
SELECT kcu.column_name, ccu.table_name, ccu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu ON ...
JOIN information_schema.constraint_column_usage ccu ON ...
WHERE tc.constraint_type='FOREIGN KEY' AND tc.table_schema='paliad' AND tc.table_name='deadlines';
-- rule_id → paliad.deadline_rules.id
```
**Fix applied on this branch:**
- `docs/design-procedural-events-model-2026-05-25.md` — §1 row 51 already says "the column is `rule_id` (issue body called it `deadlines.deadline_rule_id` — that's a doc-side typo)". §1 row 63 (the "Doc-side bug flagged" line) already names the fix target. **No change needed to the design doc — the inventor already flagged and described the bug; B.0 just re-confirms it.**
- `m/paliad#93` issue body — line 56 says `paliad.deadlines.deadline_rule_id` in the Q3 migration shape. Patched via Gitea API on this slice. See §6 of this report.
---
## §6 Migration tracker drift (out-of-scope context)
The design doc said "next available mig number is 124 (mig 123 = Backup Mode Slice A, just shipped)". Live state on 2026-05-26 13:30:
- Latest applied migration: **133** (`upc_dmgs_pi_court_followup`, 2026-05-25 15:27).
- Next available: **134**.
- Migrations 124-133 (all applied after the design was authored):
```
124 de_inf_lg_replik_duplik_sequencing (2026-05-25 13:49)
125 cross_cutting_filter_legal_source (2026-05-25 14:13)
126 users_inbox_seen_at (2026-05-25 13:51)
127 wave0_tier0_deadline_fixes (2026-05-25 14:13)
128 deadline_rules_unit_check (2026-05-25 14:13)
129 project_event_choices (2026-05-25 15:02)
130 submission_drafts_language (2026-05-25 15:05)
131 submission_drafts_party_selection (2026-05-25 15:02)
132 wave1_tier1_rule_additions (2026-05-25 15:40)
133 upc_dmgs_pi_court_followup (2026-05-25 15:27)
```
These touched `deadline_rules` content (wave0/wave1 rule additions, sequencing fixes, unit checks) and adjacent tables, but did not change the conflated-three-concepts shape that motivates Slice B. The structural premise of the design holds; the row-level numbers shifted.
**Side observation (not a B.0 fix scope):** the project's `CLAUDE.md` says "Migration tracker is `paliad.paliad_schema_migrations` (avoids collision with other apps on the shared `public.schema_migrations`)." That sentence is stale. The **canonical tracker is `paliad.applied_migrations`** (per `internal/db/migrate.go:9-21,53,105`). `paliad.paliad_schema_migrations` is the legacy golang-migrate v1 counter, frozen at v106; the migrate runner uses it only to bootstrap `applied_migrations` on first deploy of the new runner (`internal/db/migrate.go:219-240`). Recommend a separate doc-fix slice (out of B.0 scope) to update `.claude/CLAUDE.md`.
---
## §7 Updated B.1 brief (no-op / minor adjustments only)
What the live data means for the design's §5 migration plan:
1. **Backfill is simpler.** No multi-row collapse logic needed (§2). One-to-one `INSERT INTO paliad.procedural_events SELECT submission_code, name, name_en, description, event_type AS event_kind, primary_party, ... FROM paliad.deadline_rules WHERE submission_code IS NOT NULL` against 153 rows.
2. **The 78 NULL-submission_code rows need an explicit decision in B.1.** Either:
- (a) Skip them — they remain `deadline_rules`-only and become orphan-once-deadline_rules-is-dropped. Not acceptable; B.4 would lose them.
- (b) Mint synthetic codes (`null.<uuid8>` or similar) for the structural rows and create `procedural_events` for them.
- (c) Treat them as "sequencing-rule-only" (a `sequencing_rules` row with NULL `procedural_event_id`) — would require `sequencing_rules.procedural_event_id` to be nullable, which contradicts §4.1's NOT NULL FK.
- Default recommendation: **(b)** — mint codes, preserve every row. B.1 must document the mint rule in the `.up.sql`. Surface this to head before scheduling B.1.
3. **concept_id stays N:1 on procedural_events.** No UNIQUE constraint. §4.1's sketch already does this; just don't accidentally tighten it.
4. **Use migration number 134** (or whatever's the live `MAX(version)+1` at B.1-write-time; re-check at the moment of writing the file).
5. **Snapshot before drop in B.4:** `CREATE TABLE paliad.deadline_rules_pre_<N> AS TABLE paliad.deadline_rules;` per §4 precedent. **This is the hard-stop pre-condition for B.4 entering the deploy queue.**
6. **Submission_drafts.submission_code → procedural_events.code text join** continues to work unchanged through B.1-B.3 because both names match. No B.5 dual-write needed for `submission_drafts`. (The design's §6.3 already noted this.)
None of these change the **shape** of the design — they tighten the backfill SQL and surface one explicit decision (point 2) for head.
---
## §8 Outputs of this slice (B.0)
| Artifact | Status |
|---|---|
| `docs/design-procedural-events-b0-findings-2026-05-26.md` (this file) | created on `mai/curie/researcher-slice-b-zero` |
| `docs/design-procedural-events-model-2026-05-25.md` | cherry-picked from `mai/cronus/inventor-procedural` onto this branch (design doc was never merged to main; B.0 brings it onto a branch off main so the doc bug fix has somewhere to land) |
| m/paliad#93 issue body — `deadline_rule_id``rule_id` correction | patched via Gitea API |
| Gitea comment on m/paliad#93 summarizing this report | posted (see §6 trailing summary on the issue) |
**Nothing migrated, nothing written to `paliad.deadline_rules` or any other live data table.** Only `mai.reports` (progress) and the GitHub issue body / repo files were touched.
---
## §9 Hard-stop status
**B.0 COMPLETE. AWAITING B.1 GREENLIGHT.**
Per the original instruction:
- B.1 (additive migration creating `paliad.procedural_events`, `paliad.sequencing_rules`, `paliad.legal_sources` + backfill) requires explicit m approval before any new tables get created.
- B.4 (destructive drop of `paliad.deadline_rules` + `paliad.deadlines.rule_id`) requires m's downtime-window approval AND a `paliad.deadline_rules_pre_<N>` snapshot table in the same migration.
- This researcher (curie) stays parked until head re-hires.
---
## §10 Decisions worth surfacing to m before B.1 starts
1. **NULL-submission_code rows (78 of them) — what to do during backfill?** Recommendation (b): mint synthetic codes. m should confirm or pick (a)/(c).
2. **B.5 deprecation header window length** — the design (§8.2) says "one slice". For 7 active submission_drafts that's safe; the question is whether external integrations (Word templates with `{{rule.X}}`) need a longer window. The variable-bag alias contract (`submission_vars.go`) covers Word templates without a wire-format change, so "one slice" is defensible. m should confirm.
3. **Migration number reservation** — by the time B.1 ships, the live head may be 135+. The B.1 coder must re-check `MAX(version)` at write-time. (Not a decision; just a process note.)
These are the only open questions the B.0 audit surfaced. Everything else in the design holds.

View File

@@ -0,0 +1,571 @@
# Design — Procedural-Events Data Model (t-paliad-262)
**Author:** cronus (inventor)
**Date:** 2026-05-25
**Issue:** m/paliad#93 (mai task t-paliad-262)
**Branch:** `mai/cronus/inventor-procedural`
**Status:** DESIGN — read-only, no schema or code changes in this branch.
**B.0 re-validation:** see `docs/design-procedural-events-b0-findings-2026-05-26.md` (curie, 2026-05-26) for the live-DB premise re-check. Numeric §1 claims drifted; Q5 multi-row collapse premise is moot (no `_archived_litigation.*` rows remain); Q6 N:1 attachment confirmed; mig number target updated 124 → 134.
**Prior art read:**
- `docs/design-deadline-data-model-2026-05-08.md` (einstein, t-paliad-158) — proposed `proceeding_event_types` + `proceeding_event_edges`; the **graph-shape recommendation has not been built** (no `proceeding_event*` tables exist in the live DB as of 2026-05-25, verified via `information_schema.tables`).
- `docs/design-fristen-phase2-2026-05-15.md` (Phase 2/3 unified-rule columns — migs 078/079/091, **shipped**).
- `docs/design-submission-generator-2026-05-19.md` and `docs/design-submission-page-2026-05-22.md` (Slice 1 → Slice A of the Schriftsätze stack — shipped on top of today's `deadline_rules`).
This doc names a single conflation in the schema and proposes a two-slice fix (cosmetic immediate, structural follow-up). It is intentionally narrower than einstein's 2026-05-08 graph proposal — it does **not** re-litigate the proceeding-as-DAG question.
---
## §0 TL;DR
`paliad.deadline_rules` today is **one row that wears three hats**:
1. **The procedural-event template**`submission_code`, `name`, `name_en`, `description`, `event_type`, `primary_party`. This is "what kind of step is this in the proceeding": Rechtsbeschwerdebegründung, mündliche Verhandlung, Entscheidung, etc.
2. **The legal-norm citation**`legal_source`, `rule_code`, `alt_rule_code`, `rule_codes[]`. This is "the source-of-law anchor": § 102 PatG, UPC RoP R.220(1).
3. **The sequencing rule**`parent_id`, `trigger_event_id`, `duration_value`, `duration_unit`, `timing`, `alt_duration_*`, `combine_op`, `condition_expr`, `is_spawn`, `spawn_*`, `sequence_order`, `is_court_set`, `priority`, `anchor_alt`, `proceeding_type_id`. This is "how and when does it fire relative to other events".
The conflation surfaces most painfully in the submission-draft editor's variable sidebar (m's report 2026-05-25 15:02), where the lawyer sees field labels like `{{rule.submission_code}}` for what is plainly a *procedural-event code*, `{{rule.event_type}}` for what is plainly the *procedural-event kind*, and `{{rule.legal_source_pretty}}` for what is plainly the *legal norm* — all under a `rule.*` namespace that reads as if the lawyer were filling in arithmetic.
**Recommendation = Q1 option (C):**
- **Slice A (immediate, this design's coder shift):** cosmetic rename — placeholders, i18n labels, Go struct-comment naming, admin-UI page titles all shift to `procedural_event.*` as the canonical name. **Database schema, table name, column names, FK directions, JSON envelope keys on the wire all stay exactly as they are.** Old `{{rule.*}}` placeholders remain emitted in the variable bag as legacy aliases so existing Word templates and saved drafts keep working.
- **Slice B (planned follow-up, separate mai task, separate slice plan):** structural rework — extract `paliad.procedural_events`, `paliad.sequencing_rules`, `paliad.legal_sources`, with a phased dual-write migration. **Not shipped here.** This doc defines the target shape (§4) and the migration shape (§5) so the eventual coder has a brief, not so the eventual coder is hired today.
**Umbrella term lock = Q2 option (R):** **"procedural event"** (DE: **"Verfahrensschritt"**) as the umbrella covering filings, hearings, decisions, orders. Justification in §2.
Both Slice A and the eventual Slice B preserve the Schriftsätze surface (t-paliad-238/242/243): the submissions list query changes its predicate from `dr.event_type = 'filing'` to `pe.event_kind IN ('filing', 'reply')` (Slice B only) — same rows, cleaner predicate.
---
## §1 Premises verified live (2026-05-25)
Every load-bearing claim was checked against the running paliad codebase + youpc Supabase. Numbers and schema facts are point-in-time as of 2026-05-25 15:30.
| Claim | Verification |
|---|---|
| `paliad.deadline_rules` carries the 38 columns listed in §0's three-hats decomposition. | `information_schema.columns WHERE table_schema='paliad' AND table_name='deadline_rules'` — 38 rows; columns confirmed verbatim. |
| Live row count = 254. | `SELECT COUNT(*) FROM paliad.deadline_rules` → 254. |
| 177 rows carry a `submission_code` (procedural-event identity); 158 distinct values. | `COUNT(*) FILTER (WHERE submission_code IS NOT NULL)` → 177; `COUNT(DISTINCT submission_code)` → 158. |
| 102 rows carry a `legal_source`; 70 distinct citations. | Same query, `legal_source` column. |
| 125 rows are linked to a `deadline_concepts` row via `concept_id`. | `COUNT(*) FILTER (WHERE concept_id IS NOT NULL)` → 125 (49 % of the corpus). |
| `event_type` distribution: 130 `filing` · 77 NULL · 25 `decision` · 21 `hearing` · 1 `order`. | `SELECT event_type, count(*) GROUP BY event_type` — confirmed; the 77 NULL rows are structural / parent-only rows in the proceeding tree. |
| 10 `submission_code` values appear on more than one row (jurisdictional / bilateral variants). | All 10 today are `_archived_litigation.*` codes (claimant/defendant splits + multi-stage hearing rows). Live non-archived codes are 1:1 with rows in the current corpus. |
| `paliad.deadlines` joins to `deadline_rules` via column `rule_id` (uuid, FK). The text `rule_code` and free-text `custom_rule_text` (mig 122, t-paliad-258) are denormalized for display when the rule row is deleted. | `internal/services/deadline_service.go:69-127`; live column list confirms `rule_id`, `rule_code`, `custom_rule_text` — there is **no** `deadline_rule_id` column on deadlines (issue body called it `deadlines.deadline_rule_id` — that's a doc-side typo; the column is `rule_id`). |
| `paliad.submission_drafts` keys to a procedural event via `submission_code` text — **no FK** to `deadline_rules`. | `information_schema.columns` for `submission_drafts`: `submission_code text` plus `(project_id, submission_code)` as the joint identifier. Confirms the Schriftsätze surface filters on the *text key*, not on `deadline_rules.id`. |
| The Schriftsätze list (t-paliad-238) filters `deadline_rules` by `event_type='filing'` and `submission_code IS NOT NULL`. | `internal/handlers/submissions.go:193-211` — verbatim. |
| The variable bag emits exactly 8 `rule.*` placeholders. | `internal/services/submission_vars.go:349-364``rule.submission_code`, `rule.name`, `rule.name_de`, `rule.name_en`, `rule.legal_source`, `rule.legal_source_pretty`, `rule.primary_party`, `rule.event_type`. Frontend i18n labels at `frontend/src/client/submission-draft.ts:158-185`. |
| Admin rule-edit form binds the same `rule.X` fields. | `frontend/src/admin-rules-edit.tsx:74-110` + `frontend/src/client/admin-rules-edit.ts:253-278` — same eight columns surfaced as form inputs. |
| The Fristenrechner client surface refers to `calc.rule.nameDE` / `calc.rule.nameEN`. | `frontend/src/client/fristenrechner.ts:1592,1655`. |
| einstein's 2026-05-08 `proceeding_event_types` + `proceeding_event_edges` are **not** in the DB. | `SELECT table_name FROM information_schema.tables WHERE table_schema='paliad' AND table_name LIKE '%proceeding_event%'` → 0 rows. The graph-shape proposal was never built. |
| `paliad.deadline_concepts` (57 rows in the original einstein audit; live count not directly queried this shift) still exists and is referenced via `deadline_rules.concept_id`. | `information_schema.tables` confirms `deadline_concepts`, `deadline_concept_event_types`, `deadline_event_types`, `event_types`, `trigger_events`, `event_categories` all still present — the deadline-knowledge graph from the einstein design lives on alongside the unified rule columns. |
| Phase 2/3 columns (`priority`, `condition_expr`, `is_court_set`, `lifecycle_state`, `draft_of`, `published_at`, `rule_codes[]`) are live and load-bearing. | `internal/models/models.go:622-684` + mig 091. Slice B's structural rework must preserve every one of these on the new `sequencing_rules` table — they are not legacy. |
| Live `paliad.deadlines` references to rules are sparse (1 row in prod). | `SELECT COUNT(*) FROM paliad.deadlines` → 1. The 4 `submission_drafts` rows reference a procedural event by `submission_code` text only. Tiny live FK surface → migrations can be aggressive without losing user data. |
| Migration tracker is `paliad.paliad_schema_migrations`; next available number is 124 (mig 123 = Backup Mode Slice A, just shipped). | `internal/db/migrations/` directory listing; latest applied = 123. |
**Doc-side bug flagged for this issue's body:** the deliverable spec writes `paliad.deadlines.deadline_rule_id` in §3 (Q3 migration shape). The live column is `paliad.deadlines.rule_id`. Slice B's rename target is therefore `paliad.deadlines.procedural_event_id`, renamed directly from `paliad.deadlines.rule_id` — there is no intermediate `deadline_rule_id` step (no such column exists). Updating the issue body is m's call — flagged here so it doesn't propagate into a coder brief. *(B.0 update 2026-05-26: issue body patched. See `docs/design-procedural-events-b0-findings-2026-05-26.md` §5.)*
---
## §2 m's vocabulary call (Q2 — lock the umbrella term)
m proposed "procedural event" in the report. Options weighed:
| Option | Reads as | Collisions | Verdict |
|---|---|---|---|
| **"procedural event"** (DE: "Verfahrensschritt") | Umbrella that naturally covers filings, hearings, decisions, orders. Matches lawyer mental model: "the next thing that happens in the proceeding". | None — no `paliad.procedural_event*` table or column today (verified). | **(R) — adopt as canonical.** |
| "submission" | Today the Schriftsätze surface uses this for *filings only* (`event_type='filing'`). Expanding the meaning would silently change Slice A's semantics for an existing UI. | Surface-level collision with the Schriftsätze nomenclature already in production. | Reject — would lose precision for an existing concept. |
| "event" / "event_type" | Existing `deadline_rules.event_type` column. | **Hard collision** with `paliad.events` (audit feed, distinct table, distinct meaning). Renaming around it would be worse than the conflation we're trying to fix. | Reject. |
| "Verfahrensschritt" only (no English) | Cleanest German but no English fallback. | Bilingual UI (DE primary, EN secondary per project CLAUDE.md) requires both. | Reject in isolation — but **adopt as the canonical German rendering** of "procedural event". |
| "Verfahrensereignis" | Closer literal translation of "procedural event". | None. | Reject in favor of "Verfahrensschritt" — m's broader vocabulary uses "Schritt" (e.g. "Antragsschritt") more naturally than "Ereignis", which already maps to `paliad.events` in the audit-feed sense. |
**Lock:**
| Surface | Canonical |
|---|---|
| English | **procedural event** (lowercase except sentence-initial) |
| German | **Verfahrensschritt** (m. — der Verfahrensschritt) |
| Plural EN | procedural events |
| Plural DE | Verfahrensschritte |
| Code identifier (Go struct names, TS types) | `ProceduralEvent`, `ProceduralEventKind`, `ProceduralEventTemplate` |
| Snake-case (DB columns, JSON keys, i18n keys, placeholders) | `procedural_event`, `procedural_event_kind`, `procedural_events` (table) |
| Slice A: variable-bag placeholder namespace | `procedural_event.*` (with `rule.*` kept as legacy alias) |
| Slice B: table name (if shipped) | `paliad.procedural_events` |
`event_type` (the column) becomes `event_kind` in Slice B — using "kind" rather than "type" to free up the word "type" for the proceeding-level taxonomy (`paliad.proceeding_types`, untouched) and to mirror the "event_type vs event_kind" disambiguation einstein hit in the 2026-05-08 doc. In Slice A the column stays `event_type` (no DB change).
**Q2 is locked by inventor recommendation.** It costs nothing structurally and clears noise across every downstream conversation. If m disagrees in the head round-trip, the only thing that flips is the term — Slice A's scope shape stays.
---
## §3 Scope decision (Q1 — A vs B vs C)
**Recommendation = (C) — cosmetic rename now, structural rework as a planned follow-up.**
### Why not (A) — cosmetic only and stop
(A) leaves the model wrong forever. The conflation isn't just a labelling annoyance — it makes future questions harder to answer cleanly:
- "How many distinct procedural events does paliad model?" Today: ambiguous (rows vs distinct `submission_code`s vs distinct `(submission_code, proceeding_type_id)` tuples).
- "Where can we attach a per-procedural-event Word template that's independent of which proceeding it appears in?" Today: nowhere — the FK chain forces a per-row template registry, see `internal/handlers/files.go` template fallback.
- "Show me every sequencing rule that triggers a given procedural event across all proceedings." Today: requires joining `deadline_rules` to itself on `submission_code` + `parent_id`, brittle.
If m signals (A) anyway — fine; the cosmetic-only slice is a strict subset of (C)'s Slice A and ships the same value (label clarity in the editor). But the recommendation is to write down the structural target now while the analysis is fresh.
### Why not (B) — restructure immediately
(B) means: one slice plan, one cutover. With:
- 254 live rule rows,
- 1 live `paliad.deadlines` row,
- 4 live `submission_drafts` rows,
- 12 Go services + 6 handlers touching `deadline_rules` + 8 placeholder strings on the wire + the admin rule-editor UI bound to the column shape,
…doing this in one cutover means a big-bang migration during a downtime window. m has granted exactly one such window in recent memory (2026-05-15 for mig 091's destructive drops), and that one was constrained to a 4-column drop. A four-table restructure has a meaningfully larger blast radius; it warrants its own task with its own slice plan and its own risk review.
### Why (C) — cosmetic-rename Slice A this design, structural Slice B as a separate task
Three properties of (C) make it the safe call:
1. **Slice A is reversible at any time** — every change is in i18n strings, Go struct comments, admin-UI page titles, and the variable-bag aliases. No DB migration. No drop. A revert is a `git revert` of the Slice A commit.
2. **Slice B is fully designed but uncommitted** — §4 and §5 below define the target shape and migration plan, but the design doc itself ships in Slice A. m can read it, redirect it, or park it without pressure to ship it now.
3. **The Schriftsätze surface doesn't care which slice we ship** — Slice A leaves it on `event_type='filing'`; Slice B flips it to `event_kind IN ('filing', 'reply')` over a dual-write window. Either way, the lawyer-facing behavior is unchanged.
### Slice A's deliverable boundary (what gets renamed, what stays)
**Renamed in Slice A:**
- **i18n keys** for the admin rule-editor field labels: `admin.rules.edit.field.submission_code``admin.rules.edit.field.procedural_event_code`, etc. (16 keys total — `name`, `name_en`, `description`, `submission_code`, `rule_code`, `legal_source`, `primary_party`, `event_type` × DE/EN — full list in §7.1.)
- **Variable-bag placeholder labels** in `submission-draft.ts:158-185`: the *visible label* (`{ de: "Schriftsatz-Code", en: "Submission code" }`) is unchanged for filings (filings are still Schriftsätze on that surface), but the **namespace shown next to the placeholder string** changes: lawyer sees `{{procedural_event.code}}` in the placeholder column with the same Schriftsatz-Code label and same value. The old `{{rule.submission_code}}` stays in the catalog as an "(alt)" entry pointing at the same field.
- **Variable-bag emission** (`internal/services/submission_vars.go:351-364`): the bag emits **both** key-names for every value, so any Word template / saved draft holding `{{rule.X}}` keeps working without a touch. New templates and the in-app catalog show the canonical `{{procedural_event.X}}` name.
- **Admin page titles + section headings**: "Regel bearbeiten" → "Verfahrensschritt bearbeiten" (DE), "Edit rule" → "Edit procedural event" (EN). "Regeln verwalten" → "Verfahrensschritte verwalten" / "Procedural events". The URL path `/admin/rules` stays — URL renames have downstream cost (bookmarks, audit log entries) and would need their own redirect slice (out of scope here).
- **Go struct comments + service docstrings + worker-facing log lines** that refer to "the rule" → "the procedural event" where the referent is the procedural-event aspect (not the sequencing-rule aspect). Function names, type names, table name stay (Slice B handles those).
- **The "Submission Code / Einreichung-Kennung" label** itself stays (it's the lawyer's anchor — they recognize it). The framing around it changes: it now reads as "the code that identifies this *procedural event*", not "the code attached to this *rule*".
**Untouched in Slice A:**
- Database schema. Table name (`paliad.deadline_rules`). Column names. FK directions. Indexes. RLS policies. Triggers. Audit log column `rule_id`.
- Go struct names: `DeadlineRule` stays. The renames here are *prose*, not *code*. Renaming `DeadlineRule` to `ProceduralEvent` couples Slice A to Slice B's table rename — keep them decoupled.
- JSON envelope keys on the wire (`POST /api/admin/rules/:id` still accepts `submission_code` in the body — Slice B's API rename is a breaking change with its own deprecation window).
- URL paths (`/admin/rules`, `/api/admin/rules/:id`, `/api/projects/:id/submissions` etc.).
- `paliad.deadlines.rule_id` FK column name.
- The variable-bag's legacy `{{rule.X}}` keys — kept forever as aliases (cheap, zero rot).
- The `submission_drafts` table's `submission_code` text key.
This boundary makes Slice A a one-day coder shift: scoped, reversible, label-only.
### What Slice B inherits
Slice B inherits a codebase + a UI where every prose surface already speaks "procedural event". It also inherits a *legacy alias contract* (the dual emission in the variable bag) that gives it freedom to rename the JSON keys on the wire and the Go struct in two separate sub-slices without rushing.
---
## §4 Restructure schema (Q3 — if/when we ship Slice B)
This is the target the eventual Slice B coder would land. **Nothing here ships in this task.**
### §4.1 Three new tables (plus the rename of `deadline_rules`)
```sql
-- 1. Procedural event templates — one row per (procedural-event identity)
-- For now the live corpus is 1:1 with non-archived submission_codes
-- (148 of the 158 distinct codes), so we get ~177 rows minus the 10
-- multi-row codes' duplicates. Bilateral / jurisdictional variants
-- are modeled at the sequencing_rules layer.
CREATE TABLE paliad.procedural_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code text NOT NULL UNIQUE, -- former submission_code
name text NOT NULL, -- DE
name_en text NOT NULL,
description text,
event_kind text NOT NULL, -- filing|reply|hearing|decision|order|other
primary_party_default text, -- claimant|defendant|both|court
legal_source_id uuid REFERENCES paliad.legal_sources(id),
concept_id uuid REFERENCES paliad.deadline_concepts(id),
lifecycle_state text NOT NULL DEFAULT 'published', -- draft|published|archived
draft_of uuid REFERENCES paliad.procedural_events(id),
published_at timestamptz,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
```
```sql
-- 2. Legal sources — the source-of-law citations the procedural event
-- anchors against. ~70 distinct values today (live corpus).
CREATE TABLE paliad.legal_sources (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
citation text NOT NULL UNIQUE, -- "DE.PatG.102", "UPC.RoP.220.1", …
jurisdiction text NOT NULL, -- DE|UPC|EPA|DPMA|other
pretty_de text NOT NULL, -- "§ 102 PatG"
pretty_en text NOT NULL, -- "Section 102 PatG"
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
```
```sql
-- 3. Sequencing rules — the timing / trigger / condition mechanics that
-- today live alongside the procedural-event identity on deadline_rules.
-- One row per (procedural_event × proceeding × variant). The 10
-- "_archived_litigation.*" codes that today have 2-5 rows become
-- 2-5 sequencing_rules rows for the same procedural_events row.
CREATE TABLE paliad.sequencing_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
procedural_event_id uuid NOT NULL REFERENCES paliad.procedural_events(id),
proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
parent_id uuid REFERENCES paliad.sequencing_rules(id), -- structural tree, today's parent_id
trigger_event_id bigint REFERENCES paliad.trigger_events(id), -- event-rooted variant
duration_value integer NOT NULL DEFAULT 0,
duration_unit text NOT NULL DEFAULT 'months',
timing text DEFAULT 'after',
alt_duration_value integer,
alt_duration_unit text,
alt_rule_code text, -- legacy free-text alt citation, retained
anchor_alt text,
combine_op text, -- max|min
condition_expr jsonb,
primary_party text, -- per-rule override of the procedural_event default
sequence_order integer NOT NULL DEFAULT 0,
is_spawn boolean NOT NULL DEFAULT false,
spawn_label text,
spawn_proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
is_bilateral boolean NOT NULL DEFAULT false,
is_court_set boolean NOT NULL DEFAULT false,
priority text NOT NULL DEFAULT 'mandatory',
rule_code text, -- legacy short-form citation, retained on the rule
rule_codes text[], -- multi-citation array (mig pre-091)
deadline_notes text,
deadline_notes_en text,
lifecycle_state text NOT NULL DEFAULT 'published',
draft_of uuid REFERENCES paliad.sequencing_rules(id),
published_at timestamptz,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
```
```sql
-- 4. Rename downstream FK + add the link to procedural_events.
ALTER TABLE paliad.deadlines
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id),
ADD COLUMN sequencing_rule_id uuid REFERENCES paliad.sequencing_rules(id);
-- (rule_id stays as a transitional alias during the dual-write window;
-- dropped at end of Slice B)
```
```sql
-- 5. Submission drafts: add procedural_event_id FK alongside submission_code.
ALTER TABLE paliad.submission_drafts
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id);
-- (submission_code stays — it's the cosmetic anchor lawyers recognize
-- in URLs and chat, and it doubles as the procedural_events.code value)
```
### §4.2 What goes where (column-by-column map)
Every column on today's `paliad.deadline_rules` lands on exactly one of the three new tables:
| Today's `deadline_rules` column | Lands on | Notes |
|---|---|---|
| `id`, `created_at`, `updated_at` | `sequencing_rules` | The current row's identity becomes a sequencing-rule row. `procedural_events.id` is **new** — backfilled from `submission_code`. |
| `submission_code` | `procedural_events.code` | Promoted up. Multi-row codes (10 in corpus, all `_archived_litigation.*`) collapse to one row on the new table; the 2-5 sequencing rows hang off it. |
| `name`, `name_en`, `description` | `procedural_events` | Procedural-event identity. |
| `primary_party` | `procedural_events.primary_party_default` AND `sequencing_rules.primary_party` | Both. The procedural event has a default party (claimant for Klage etc.); the sequencing rule can override per-jurisdiction (bilateral variants — e.g. `litigation.reply` claimant vs defendant become two sequencing rows with overridden party). |
| `event_type` | `procedural_events.event_kind` | Hat 1, with rename to `event_kind` (term lock §2). |
| `legal_source` | `legal_sources.citation` + FK from `procedural_events.legal_source_id` | The citation moves to its own row; the procedural event points at it. `pretty_de` / `pretty_en` materialize the existing `legalSourcePretty()` function output as columns (with the function retained as the migration source). |
| `rule_code`, `alt_rule_code`, `rule_codes[]` | `sequencing_rules` | Short-form citation arrays stay on the sequencing rule — they're rule-specific. |
| `proceeding_type_id`, `parent_id`, `trigger_event_id`, `spawn_proceeding_type_id`, `is_spawn`, `spawn_label`, `is_bilateral`, `is_court_set`, `combine_op` | `sequencing_rules` | Hat 3 (mechanics) — exact copies. |
| `duration_value`, `duration_unit`, `timing`, `alt_duration_value`, `alt_duration_unit`, `anchor_alt` | `sequencing_rules` | Hat 3 (mechanics). |
| `condition_expr` (jsonb) | `sequencing_rules` | Hat 3. The grammar from mig 091 stays. |
| `priority`, `sequence_order` | `sequencing_rules` | Hat 3. |
| `is_active`, `lifecycle_state`, `draft_of`, `published_at` | **BOTH** `procedural_events` AND `sequencing_rules` | A procedural event can be retired independently of any one of its sequencing variants. Backfill: copy onto both during dual-write; new rows go through the rule-editor service which writes both sides together. |
| `concept_id` (FK to `deadline_concepts`) | `procedural_events.concept_id` | The concept layer (einstein 2026-05-08) attaches to the procedural event, not the sequencing rule. |
| `deadline_notes`, `deadline_notes_en` | `sequencing_rules` | They're rule-specific notes ("filing the appeal in DE costs €X if you also did Y") — not procedural-event-wide. |
Three columns disappear:
- The semantically-overloaded part of `event_type` (renamed to `event_kind` and moved).
- The "what is this thing" vs "how does it fire" name conflict — gone by construction.
- Any column that exists only because of the conflation (none of today's columns are pure overhead — they all carry data — so the count stays at 38 across the three new tables).
### §4.3 Indexes + RLS
`paliad.can_see_project()` is the canonical RLS predicate (mig 055). None of the three new tables hold project-scoped data — they're firm-wide reference tables. RLS = none, same posture as today's `deadline_rules` (which is firm-wide and unrestricted at the row level; access control is via the `lifecycle_state='published'` filter in the read paths).
Indexes inherited from today:
- `paliad.legal_sources(citation)` — UNIQUE.
- `paliad.procedural_events(code)` — UNIQUE.
- `paliad.procedural_events(concept_id)` — for the deadline-concept join.
- `paliad.sequencing_rules(procedural_event_id, proceeding_type_id, lifecycle_state)` — primary read path for the calculator.
- `paliad.sequencing_rules(parent_id)` — tree walk.
- `paliad.sequencing_rules(trigger_event_id)` — event-rooted variant.
---
## §5 Migration plan (Slice B — when it ships, not in this task)
Phased dual-write, so the cutover is **never** a single instant where the wire format flips. m gets to roll back any one phase with a `git revert` + an `ALTER TABLE` if a phase misbehaves in prod.
### §5.1 Phase 1 — Additive (no down-time)
1. Create `procedural_events`, `sequencing_rules`, `legal_sources`.
2. Backfill `legal_sources` from `DISTINCT legal_source` on `deadline_rules` (70 rows). Populate `pretty_de`/`pretty_en` by calling the existing `legalSourcePretty()` function in a one-shot SQL/Go shim during the migration. Verify `COUNT(DISTINCT legal_source FROM deadline_rules) = COUNT(*) FROM legal_sources`.
3. Backfill `procedural_events` from `DISTINCT submission_code` on `deadline_rules WHERE submission_code IS NOT NULL`. Take `name`, `name_en`, `event_type → event_kind`, `primary_party`, `concept_id`, `description` from the lowest-`id` rule row for each code (tie-breaker: lowest `sequence_order`). Verify `COUNT(*) FROM procedural_events = COUNT(DISTINCT submission_code FROM deadline_rules WHERE submission_code IS NOT NULL)` (= 158).
4. Backfill `sequencing_rules` 1:1 from `deadline_rules` (254 rows). FK `procedural_event_id` resolved by code lookup; sequencing-rule row inherits the `deadline_rules.id` (so existing `deadlines.rule_id` FKs continue to resolve via the new column for the dual-write window — see Phase 3).
5. Add `paliad.deadlines.procedural_event_id` + `sequencing_rule_id` columns, backfill from `deadlines.rule_id` join.
6. Add `paliad.submission_drafts.procedural_event_id`, backfill from `submission_code` join.
This phase ships behind a feature flag (or just behind unused code) — readers + writers stay on `deadline_rules`. No behavior change.
### §5.2 Phase 2 — Dual-write (no down-time)
7. Update `RuleEditorService` to write to both `deadline_rules` (legacy) and (`procedural_events`, `sequencing_rules`, `legal_sources`) on every Create/Update/Publish/Archive. Audit log writes one row per side.
8. Update read paths to **read from the new tables**, falling back to `deadline_rules` if the new row is missing (defense-in-depth during backfill catch-up).
9. Run for ≥ 1 week (m's call on length). Compare row counts and a hash digest of the union daily — if drift, surface.
### §5.3 Phase 3 — Cutover (no down-time, but reversible only via re-application of the dual-write)
10. Flip read paths to **only** the new tables (`SubmissionVarsService.loadPublishedRule`, `DeadlineRuleService.*`, `SubmissionService.list`, `ProjectionService`, `FristenrechnerCalc`, etc.).
11. Stop writing to `deadline_rules`.
12. `paliad.deadlines.rule_id` is kept as a no-op alias for one more week; new writes go to `procedural_event_id` + `sequencing_rule_id`.
13. `submission_drafts.submission_code` is kept as the URL anchor; the FK `procedural_event_id` is the primary join key going forward.
### §5.4 Phase 4 — Drop legacy (downtime window, destructive)
14. `paliad.deadline_rules_pre_<slice-B-mig>` snapshot of the entire table.
15. DROP TABLE paliad.deadline_rules (after CASCADE-safe FK rewires).
16. DROP COLUMN paliad.deadlines.rule_id (keep `rule_code` + `custom_rule_text` as the human-readable denormalized columns — they're the safety net for orphaned deadlines per t-paliad-258).
m grants this destructive phase its own window (precedent: mig 091 on 2026-05-15). Until then, the legacy table sits dormant.
### §5.5 Migration tracker
- Slice B uses migration numbers 124 (Phase 1 — create tables + backfill) and onward — a 4-5 migration sequence, one per phase boundary, mirroring the Phase 2/3 slicing that shipped under t-paliad-195.
- Each migration includes a `paliad.audit_reason = 'mig <n>: <slice-B-phase>'` set_config like mig 091 did, so the audit log captures the schema journey.
---
## §6 Service-layer impact
### §6.1 Slice A — prose-only changes
| File | Change |
|---|---|
| `internal/services/submission_vars.go` | `addRuleVars` → also emit `procedural_event.code`, `procedural_event.name`, `procedural_event.name_de`, `procedural_event.name_en`, `procedural_event.legal_source`, `procedural_event.legal_source_pretty`, `procedural_event.primary_party`, `procedural_event.event_kind` (8 new keys, 1:1 with the 8 existing `rule.*` keys, same values). Rename docstrings + the package-level placeholder map comment ("`rule.*`" → "`procedural_event.*` (with legacy alias `rule.*`)"). |
| `internal/services/deadline_rule_service.go` | Top-of-file comment + struct comment renames only. Method names stay (`DeadlineRuleService`, `GetByID`, etc.). |
| `internal/services/rule_editor_service.go` | Same. |
| `internal/services/projection_service.go`, `deadline_service.go`, `fristenrechner.go`, `submission_draft_service.go`, `event_trigger_service.go`, `event_deadline_service.go`, `proceeding_mapping.go`, `export_service.go` | No code changes. Comments mentioning "the rule"/"rules" stay accurate as long as the file is about sequencing — only services that surface the **identity** aspect of the rule (`submission_vars.go`) need a prose pass. |
| `internal/handlers/submissions.go` | No SQL change. Type+comment renames: the catalog response type stays `submissionListEntry` (it's still a Schriftsatz-level list); doc comments speak of "procedural events whose kind is filing" instead of "rules of type filing". |
| `internal/handlers/admin_rules.go` | URL path stays. JSON envelope stays. Page-render comments + log-line text shift to "procedural event". |
| `internal/handlers/submission_drafts.go`, `deadlines.go`, `fristenrechner.go` | No service-layer change. |
### §6.2 Slice B — structural
Mostly load-bearing; not enumerated here in detail (out of scope per (R)=C). The shape:
- `RuleEditorService` splits into `ProceduralEventService` + `SequencingRuleService` + `LegalSourceService`. The Save / Publish / Archive flow on the editor coordinates all three.
- `DeadlineRuleService.GetByID` becomes `SequencingRuleService.GetByID`; the `submission_code` lookup moves to `ProceduralEventService.GetByCode`.
- `SubmissionVarsService.loadPublishedRule` becomes `loadPublishedProceduralEvent` and returns a triple (`event`, `defaultSequencingRule`, `legalSource`); the variable-bag emission consumes all three.
- `ProjectionService` and the Fristenrechner calculator read from `sequencing_rules` (same column set, same logic — only the table name changes).
- `SubmissionService.list` (handlers/submissions.go) filters `procedural_events.event_kind IN ('filing', 'reply')`.
- Backfill orphans + audit triggers (mig 079 / 089) are re-pointed at `sequencing_rules` + a new `procedural_events_audit`.
---
## §7 UI / i18n impact
### §7.1 i18n keys (Slice A)
Existing keys (DE + EN) at `frontend/src/client/i18n.ts` lines ~2834-2920 and ~5800-5890 — surface area is *labels*, not *placeholders-in-Word*:
| Old key | New key (Slice A) | DE label | EN label |
|---|---|---|---|
| `admin.rules.list.title` | `admin.procedural_events.list.title` | "Verfahrensschritte verwalten — Paliad" | "Manage procedural events — Paliad" |
| `admin.rules.list.heading` | `admin.procedural_events.list.heading` | "Verfahrensschritte verwalten" | "Manage procedural events" |
| `admin.rules.list.subtitle` | `admin.procedural_events.list.subtitle` | "Verfahrensschritte anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived." | "Create, edit and publish procedural events. Lifecycle: draft → published → archived." |
| `admin.rules.list.new` | `admin.procedural_events.list.new` | "+ Neuer Verfahrensschritt" | "+ New procedural event" |
| `admin.rules.col.submission_code` | `admin.procedural_events.col.code` | "Code" (drop "/ Einreichung-Kennung" — the new heading already disambiguates) | "Code" |
| `admin.rules.col.legal_citation` | `admin.procedural_events.col.legal_source` | "Rechtsgrundlage" | "Legal source" |
| `admin.rules.col.name` | `admin.procedural_events.col.name` | "Bezeichnung" | "Name" |
| `admin.rules.col.proceeding` | `admin.procedural_events.col.proceeding` | "Verfahrenstyp" | "Proceeding" |
| `admin.rules.col.priority` | `admin.procedural_events.col.priority` | "Priorität" | "Priority" |
| `admin.rules.col.lifecycle` | `admin.procedural_events.col.lifecycle` | "Lifecycle" | "Lifecycle" |
| `admin.rules.col.modified` | `admin.procedural_events.col.modified` | "Zuletzt geändert" | "Last modified" |
| `admin.rules.edit.title` | `admin.procedural_events.edit.title` | "Verfahrensschritt bearbeiten — Paliad" | "Edit procedural event — Paliad" |
| `admin.rules.edit.heading.loading` | `admin.procedural_events.edit.heading.loading` | "Verfahrensschritt laden…" | "Loading procedural event…" |
| `admin.rules.edit.breadcrumb` | `admin.procedural_events.edit.breadcrumb` | "← Verfahrensschritte verwalten" | "← Manage procedural events" |
| `admin.rules.edit.field.submission_code` | `admin.procedural_events.edit.field.code` | "Code (Schriftsatz-Code / Einreichung-Kennung)" — keep the parenthetical so lawyers familiar with the old label know what they're looking at. | "Code (submission / procedural-event identifier)" |
| `admin.rules.edit.field.rule_code` | `admin.procedural_events.edit.field.short_citation` | "Rechtsgrundlage (Kurzform)" | "Legal source (short form)" |
| `admin.rules.edit.field.legal_source` | `admin.procedural_events.edit.field.legal_source` | "Rechtsgrundlage (Langform)" | "Legal source (long form)" |
| `admin.rules.edit.field.name` | `admin.procedural_events.edit.field.name` | "Bezeichnung (DE)" | "Name (DE)" |
| `admin.rules.edit.field.name_en` | `admin.procedural_events.edit.field.name_en` | "Bezeichnung (EN)" | "Name (EN)" |
| `admin.rules.edit.field.proceeding` | `admin.procedural_events.edit.field.proceeding` | "Verfahrenstyp" | "Proceeding type" |
| `admin.rules.edit.field.trigger` | `admin.procedural_events.edit.field.trigger` | "Trigger-Ereignis" | "Trigger event" |
| `admin.rules.edit.field.parent` | `admin.procedural_events.edit.field.parent` | "Übergeordneter Verfahrensschritt (UUID)" | "Parent procedural event (UUID)" |
| `admin.rules.edit.field.concept` | `admin.procedural_events.edit.field.concept` | "Konzept (UUID)" | "Concept (UUID)" |
| `admin.rules.edit.field.sequence_order` | `admin.procedural_events.edit.field.sequence_order` | "Reihenfolge" | "Order" |
| `admin.rules.edit.field.duration_value` | `admin.procedural_events.edit.field.duration_value` | "Dauer" | "Duration" |
| `admin.rules.edit.field.primary_party` | `admin.procedural_events.edit.field.primary_party` | "Partei (typisch)" | "Primary party" |
| `admin.rules.edit.field.event_type` | `admin.procedural_events.edit.field.event_kind` | "Art des Verfahrensschritts" | "Procedural-event kind" |
| `admin.rules.edit.field.description` | `admin.procedural_events.edit.field.description` | "Beschreibung" | "Description" |
**Legacy keys retained as aliases** so any existing translation imports or external integrations keep working — old keys point at the same DE/EN values during a deprecation window of one full Slice B cycle.
### §7.2 Variable-bag placeholders (Slice A)
`frontend/src/client/submission-draft.ts:155-185` — the catalog of placeholders the lawyer sees in the sidebar:
| Old placeholder (kept as legacy alias) | New canonical placeholder | DE label | EN label |
|---|---|---|---|
| `{{rule.submission_code}}` | `{{procedural_event.code}}` | "Code (Verfahrensschritt)" | "Code (procedural event)" |
| `{{rule.name}}` | `{{procedural_event.name}}` | "Bezeichnung" | "Name" |
| `{{rule.name_de}}` | `{{procedural_event.name_de}}` | "Bezeichnung (DE)" | "Name (DE)" |
| `{{rule.name_en}}` | `{{procedural_event.name_en}}` | "Bezeichnung (EN)" | "Name (EN)" |
| `{{rule.legal_source}}` | `{{procedural_event.legal_source}}` | "Rechtsgrundlage (Code)" | "Legal source (code)" |
| `{{rule.legal_source_pretty}}` | `{{procedural_event.legal_source_pretty}}` | "Rechtsgrundlage" | "Legal source" |
| `{{rule.primary_party}}` | `{{procedural_event.primary_party}}` | "Partei (typisch)" | "Primary party" |
| `{{rule.event_type}}` | `{{procedural_event.event_kind}}` | "Art des Verfahrensschritts" | "Procedural-event kind" |
The catalog renders the canonical name in the "copy-this-placeholder" button. The variable bag (`submission_vars.go`) emits both names with identical values, so any Word template the lawyer already has continues to work; new templates are encouraged to use the canonical name.
### §7.3 Admin rule-editor form (Slice A)
`frontend/src/admin-rules-edit.tsx:74-110` — i18n key rebinds + heading text update. The DOM `id` attributes (`f-submission-code`, `f-rule-code`, `f-legal-source`, …) stay — they're internal, the rename here is cosmetic, the form still POSTs the same JSON envelope (Slice A doesn't touch the API). The fieldset `legend` for the "Identität" section changes to "Verfahrensschritt-Identität" (DE) / "Procedural-event identity" (EN). The "Verfahren & Trigger" section heading stays — that section is about sequencing, and Slice A doesn't rename sequencing-level labels (those are Slice B).
### §7.4 Project-detail Schriftsätze tab + dashboard
`frontend/src/client/submissions.ts`, `submissions-index.ts`: no surface-level label change in Slice A. The Schriftsätze tab continues to show Schriftsätze (the lawyer's preferred term for *filings specifically*). The tab is a filtered view onto procedural events of kind `filing`/`reply` — that distinction surfaces only in admin contexts.
### §7.5 Help text + docs
A short addition to the in-app help: "What is a procedural event?" — one-paragraph definition explaining the umbrella term, with examples (Klage, Klageerwiderung, mündliche Verhandlung, Endurteil). Stored in `frontend/src/client/i18n.ts` under `help.procedural_events.intro`. Out of scope for the URL/router changes — added as static copy where it fits naturally.
---
## §8 Slice plan
### §8.1 Slice A (this design's downstream task)
**Scope:** prose-only rename per §3 ("renamed in Slice A" list).
**Mechanics:**
1. Add 8 new placeholder keys to the variable bag in `submission_vars.go` (1:1 with the existing 8 `rule.*` keys). Keep the legacy keys.
2. Update `frontend/src/client/submission-draft.ts` placeholder catalog labels.
3. Rebind admin i18n keys per §7.1 (with legacy keys retained).
4. Update admin page titles + section headings.
5. Update Go struct comments + service docstrings in `submission_vars.go`, `deadline_rule_service.go`, `rule_editor_service.go`, `submission_draft_service.go`, `submissions.go` handler. No code-flow change.
6. Update `internal/handlers/submissions.go` doc comments.
7. Add a short `docs/glossary.md` entry (or extend an existing one) for "procedural event" / "Verfahrensschritt" — single source of truth for the term.
8. Tests: rename strings in existing test fixtures + add a regression test that the variable bag emits **both** the legacy `rule.X` and the canonical `procedural_event.X` keys with the same value. (Critical — without this test, a future commit could drop the legacy alias and silently break user templates.)
9. Manual smoke: open the admin rule editor, confirm the new title appears. Open the submission-draft editor, confirm both `{{rule.X}}` and `{{procedural_event.X}}` placeholders are listed (with canonical first). Generate a `.docx` from a project using each placeholder name — both render identically.
**Risk:** very low. No DB change, no API change, fully reversible.
**No hours estimate per project CLAUDE.md.**
### §8.2 Slice B (separate mai task — designed here, hired later)
**Scope:** structural rework per §4 + §5.
**Mechanics:** Phase 1 → Phase 4 per §5.
**Prerequisite:** m greenlights via a new mai task with this doc + §11's open items addressed. **Not part of Slice A.**
**Sub-slices (suggested for Slice B's own task):**
- **B.0** — Re-validate this doc's premises against live DB (numbers shift over weeks).
- **B.1** — Phase 1 additive migration + backfill (mig 124).
- **B.2** — Phase 2 dual-write + read-fallback.
- **B.3** — Phase 3 read cutover (no schema change).
- **B.4** — Phase 4 destructive drop (downtime window).
- **B.5** — Rename Go types `DeadlineRule``SequencingRule` + `ProceduralEvent`; rename JSON API envelope keys with a deprecation header. Independent of B.4.
- **B.6** — Rename admin URL paths `/admin/rules``/admin/procedural-events` with redirects. Optional / low-priority.
### §8.3 Why splitting is the right call
The conflation is real, but the *fix* for the most-painful surface (the editor sidebar) is independent of the table restructure. Splitting lets m ship the fix this week, see whether the prose change alone resolves enough of the cognitive friction, and then decide whether the structural rework is still worth the migration cost. If after Slice A m says "this reads fine now, B isn't worth it", that's a legitimate outcome — Slice B is a *good* refactor, not an *urgent* one.
---
## §9 Risk assessment
### §9.1 Slice A risks
| Risk | Likelihood | Severity | Mitigation |
|---|---|---|---|
| Lawyer's existing Word template has `{{rule.submission_code}}` baked in; a future commit drops the legacy alias and breaks templates. | Low (Slice A keeps the alias) | High if it happens | Regression test (§8.1 step 8) asserts both keys emit. Add an audit-log line on every variable-bag call recording which keys were consumed by the merge engine — gives a 30-day window of evidence before we'd consider deprecating the legacy keys. |
| i18n key rename misses a binding, leaving an English string visible to a DE user. | Medium | Low | The build pipeline (`bun test` / `bun build`) fails on missing i18n keys in `i18n-keys.ts`. Add the new keys to the type union; leave the old keys in the union with `@deprecated` JSDoc. |
| Renamed admin page heading confuses returning admin users ("Where did 'Regeln verwalten' go?"). | Medium | Low | One-time changelog entry; the URL `/admin/rules` is unchanged so muscle memory still lands them on the page. Internal users only (whitelist-gated). |
| Slice A reads as "we're done" and Slice B never ships. | Medium | Medium (the model stays wrong) | This doc files the Slice B design as a separate task entry **before** Slice A merges, so the to-do is visible. m's call whether to schedule it. |
### §9.2 Slice B risks (deferred; recorded for the future task)
| Risk | Mitigation |
|---|---|
| Backfill collapses too eagerly: 10 multi-row submission_codes today are `_archived_litigation.*` — confirm they should collapse into one procedural event with 2-5 sequencing variants, vs. each row becoming its own procedural event. | The `_archived_litigation.*` codes are archived per their prefix — collapse is safe. **Decision-flag for Slice B's own design pass.** |
| `deadline_concepts` linkage (125 of 254 rules link to a concept) — does the concept attach to the procedural event or the sequencing rule? §4.2 says procedural event; verify this is right when re-validating premises in B.0. | Read-path audit: every consumer that joins `deadline_rules.concept_id` (rule_editor, projection, fristenrechner) operates on the rule-level today. Reconfirm none of them depend on per-jurisdiction concept-attachment. |
| The dual-write window introduces drift if a write hits one side and fails on the other. | Atomicity via single transaction per write in `RuleEditorService`. Daily drift-check job (one SELECT pair, alert if mismatched). |
| `paliad.deadlines.rule_id` (1 live row, but more in future) — backfilling `procedural_event_id` + `sequencing_rule_id` must not orphan the live row. | The 1 live row joins cleanly. Backfill in the same migration that adds the new columns. |
| The submission-draft `submission_code` text key — what if two `procedural_events.code` values collide post-rename (e.g. a draft was saved against a code that we then archive)? | Slice B Phase 1 enforces `procedural_events.code UNIQUE`; the backfill verifies no collision on the existing 158 distinct values. Drafts with codes that no longer exist as published procedural events are handled by the existing `submission_drafts.submission_code` text fallback (no FK enforcement). |
| Slice B's API-key rename (`submission_code``code` in JSON) breaks external integrations. | None exist today (paliad is internal-only); add a one-Slice deprecation header (`X-Deprecated-Field: submission_code`) before flipping. |
| **Coordination risk with future fristen/calculator work.** The Fristenrechner calculator reads `deadline_rules` directly today. Slice B Phase 2's read-fallback handles this, but a parallel calculator feature in flight could land changes that need re-merging. | B.0's job: confirm no in-flight task touches `deadline_rules` table shape before scheduling. |
### §9.3 What rolls Slice A back
`git revert <slice-a-commit>` + reload. Zero data side-effects (no DB writes). 30 seconds.
### §9.4 What rolls Slice B back
Per phase — Phases 1-3 reversible via reverting code + `DROP TABLE`. Phase 4 reversible only by restoring `deadline_rules` from the `_pre_<n>` snapshot taken at the start of Phase 4. Same posture as mig 091 — m's call when to commit to this point.
---
## §10 Out of scope
- **Renaming `paliad.events`** (the audit feed). Distinct table, distinct concept. The umbrella-term lock (§2) deliberately uses "procedural event" not "event" to avoid colliding with it.
- **Renaming `paliad.deadline_concepts`** to align with the procedural-event taxonomy. The concept layer is the cross-proceeding semantic bridge (einstein 2026-05-08 Q5); the relationship "procedural event has-a concept" already reads cleanly under the new term.
- **Per-jurisdiction variations of the same procedural event** (issue body's explicit out-of-scope). The 10 multi-row codes in the corpus today stay multi-row.
- **Multi-tenant / cross-firm sharing of procedural events** — paliad is single-tenant per deploy via `FIRM_NAME`; cross-firm is a separate design.
- **einstein's `proceeding_event_edges` graph proposal.** That design proposed a graph of typed event-types connected by typed edges. This design's procedural-events / sequencing-rules split is **compatible** with that graph shape (the edges would attach to procedural-event-IDs rather than sequencing-rule-IDs), but the graph layer is a Slice C, not Slice B. Flagged for future continuity, not part of either slice here.
- **Renaming Go type `DeadlineRule` to `SequencingRule` or `ProceduralEvent` in Slice A.** Slice A is prose; Slice B's B.5 sub-slice handles the type rename. Coupling them costs the reversibility property.
- **API-envelope key renames** (`submission_code``code`, `event_type``event_kind` on the wire). Slice B only.
- **URL path renames** (`/admin/rules``/admin/procedural-events`). Slice B.6, optional.
- **Touching `paliad.trigger_events`** beyond keeping the FK path open (today `deadline_rules.trigger_event_id`; Slice B maps to `sequencing_rules.trigger_event_id`).
- **Touching `paliad.event_categories` / Pathway-B navigation.** Independent layer.
---
## §11 Open questions for m (escalated via `mai instruct head` per project CLAUDE.md)
Per project CLAUDE.md "Head answers questions — NO AskUserQuestion" rule, these are surfaced to head, not picked-as-chip with the user.
| ID | Question | Inventor recommendation | Material to head? |
|---|---|---|---|
| **Q1** | Scope: cosmetic-only (A) · full restructure (B) · cosmetic now + B as planned follow-up (C). | **(R) = C** | Yes — material. Defines whether Slice B is hired today or filed as a future task. |
| **Q2** | Umbrella term: "procedural event" (DE: Verfahrensschritt) · "submission" (filings only) · "Verfahrensereignis" · other. | **(R) = procedural event / Verfahrensschritt** | Yes — material. The term ripples through every label in §7. Inventor's pick is the canonical choice; head can override with a single message. |
| **Q3** | Slice B migration shape: confirmed (§4 + §5) or rescope. | **(R) = §4 + §5 as written, decision deferred until Slice B is hired** | No — informational. Locked when Slice B's own design pass runs. |
| **Q4** | Effect on Schriftsätze surface: filter `procedural_events.event_kind IN ('filing', 'reply')` is acceptable replacement for today's `event_type='filing'`. | **(R) = yes, semantically equivalent under Slice B; no behavior change to lawyer.** | No — informational. |
| **Q5** | Are the 10 archived multi-row submission_codes (`_archived_litigation.*`) safe to collapse into single procedural events with multiple sequencing variants in Slice B? | **(R) = yes, prefix indicates archival; collapse-safe.** | No — informational, defers to Slice B. |
| **Q6** | `concept_id` attaches to procedural event, not sequencing rule. Confirmable? | **(R) = yes, per §4.2 (one concept per identity, not per jurisdiction).** | No — informational, defers to Slice B. |
| **Q7** | Keep the legacy `{{rule.X}}` placeholder aliases **forever**, or set a deprecation horizon (e.g. 1 year)? | **(R) = forever, with `@deprecated` annotation in the catalog. Removing them risks breaking lawyer-authored templates that paliad doesn't see.** | Yes — material to Slice A's contract (test in §8.1 step 8 asserts both keys emit). |
| **Q8** | Document side: update m/paliad#93 issue body to fix the `deadlines.deadline_rule_id``deadlines.rule_id` typo (§1 last paragraph). | **(R) = yes, head's call when to edit.** | No — informational, doc hygiene. |
| **Q9** | After Slice A ships, do we file Slice B as a new mai task **now** (so it's visible), or wait for m to ask? | **(R) = file now, status:planning, no owner. Visibility >> deferred surprise.** | Yes — material to "does the model stay wrong forever". |
Q1, Q2, Q7, Q9 are the four head needs to answer before the coder shift. Q3-Q6, Q8 defer cleanly.
---
## §12 Appendix — verbatim m quote
From m's report 2026-05-25 15:02 (paliad#93 body):
> This shows how our 'rule' table system may need a revision?! It feels like we are rule based not submission based. But here we have a specific submission that is connected to a rule (as in: legal norm). And of course also connected to other 'procedural events' (which is a good term for it all) by rules how they are sequenced. But it makes it sound weird in the fields...
The design above takes m's three-way split — *the procedural event* / *the legal norm* / *the rule by which they are sequenced* — at face value and turns it into a column-level map (§4.2), a slice plan (§8), and a deprecation contract (§9.1).
---
*End of design.*

View File

@@ -0,0 +1,510 @@
# Design — `/tools/procedures` workflow tracker (m/paliad#152)
**Task:** t-paliad-337
**Gitea:** m/paliad#152
**Inventor:** atlas (shift-1, fresh — name-recycle, not the atlas from earlier today)
**Date:** 2026-05-27
**Branch:** `mai/atlas/inventor-extend-tools`
**Status:** Draft — coder gate held; m to ratify the remaining open questions via `AskUserQuestion` before any coder shift.
**Builds on:**
- `docs/design-unified-procedural-events-tool-2026-05-27.md` (cronus's U0-U4 design, shipped today as `/tools/procedures`)
- `docs/design-deadline-system-revision-2026-05-27.md` §3.3 + §3.3a (atlas Phase 2 model layer + view-mode toggle)
- `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26 Mode A+B+result, shipped via t-paliad-322)
**Reframe note (2026-05-27 21:01):** the first draft of this doc overengineered the surface — three-view toggle, separate compound drawer, separate Konstellationen drawer. m re-anchored: "clean display of timelines that have potential forks the user can select. UX should be key. It should be easy to find your thing." This rewrite collapses to a single canonical shape and folds the zoom / constellation / cross-cut concepts into it. The pre-grilling §13 + the 11-Q batch in §14 of the first draft are gone — superseded by m's 4 answers in §0.2 and the smaller open-question set in §10.
---
## §0 Premises
### §0.1 What shipped today and what m hit
`/tools/procedures` (U0-U4, knuth, m/paliad#151) is a **catalog browser**:
- 4 always-visible tabs (Verfahren wählen / Direkt suchen / Geführt / Aus Akte).
- Shared filter strip + search box at the top (markup-only in U0).
- Two output shapes — TREE (Verfahrensablauf) and LINEAR (Mode A/B result view) — bound to specific entry tabs.
m's bugs (2026-05-27 20:43 / 20:46):
1. 4 tabs visible → pre-form leaks across them, page feels like 4 disjoint workflows.
2. Result view fires too many rules incl. conditional-flag-off + curie's 7 compound rules.
3. Proaktiv/Gericht/Reaktiv columns are a stance grouping, not a sequence anchor.
4. No "you are here" marker.
5. Sequence isn't visualised as a sequence — flat priority groups, not chained.
m's reframe (verbatim, 20:43): "view proceedings with all possible constellations and the sequences and determine **where we are** in that sequence and **what steps are coming next** for any given procedural event."
Tightened by m on 21:01:
> "clean display of timelines that have potential forks the user can select. procedural_events that act as triggers for mandatory or optional events. And there is a limited type of proceedings — a sequence of the events builds the proceeding. Some aux proceedings, some main… but a lot is connected. UX should be key. It should be easy to find your thing."
### §0.2 The four m-answers that lock the architecture
Asked back during the grilling round at 20:57, answered 21:01:
| | inventor's grilling question | m's answer | what it locks |
|---|---|---|---|
| 1 | One canonical shape or still 3 views? | "I still want zoomability for one event and all events it triggers. But that can be from within the full timeline/tree as well." | **One canonical view** (full timeline/tree); zoom is an *interaction* on it, not a separate view. The Anchor / Verfahren / Konstellationen toggle is dropped. |
| 2 | What's a "fork" — scenario flags only / +optionals / everything? | "c" (everything: flags + optionals + appeal-target + court-set picks) | **Every choice point in the data is a fork.** Optionals (priority='optional') + conditional flags + appeal-target + perspective + court-set scheduling. Inline pickers. |
| 3 | "Easy to find" — timeline-as-index / search box / proceeding picker first? | "all of these — text search, filter pills, a display of the resulting proceedings timelines" | **Find = combined affordance.** Text search + filter pills + the displayed result *is* the matched proceeding timelines. The page never has chrome that isn't either the find affordance or the timelines themselves. |
| 4 | Aux proceedings inline or drillable? | "inline" | **Aux proceedings draw inline as expandable child timelines** hanging off the spawn point in the parent timeline. The full connected graph is one visible thing. |
### §0.3 Live data the tracker has to work against
Verified 2026-05-27 against `paliad.sequencing_rules` (231 published / 242 total):
- 110 chained (parent_id not null) — most rules in a chain.
- 78 trigger-rooted, 4 spawns (cross-PT), 47 court-set, 18 conditional (6 `with_ccr` / 4 `with_amend` / 4 `with_cci` / 4 compound `with_ccr AND with_amend`).
- Biggest single proceeding: `upc.inf.cfi` (50 rules).
- ~46 proceeding types total (UPC 35 / DE 5 / EPA 3 / DPMA 3).
- `paliad.deadlines` carries both `procedural_event_id` and `sequencing_rule_id` → Akte actuals overlay is a direct join.
### §0.4 Scope
**In:** redesign the `/tools/procedures` surface as a single timeline-tree view with inline forks + a combined find affordance.
**Out:**
- Calculator changes.
- Editorial backfill (curie's t-paliad-333 owns the 7 compound rules + R.109 chain). This design is *independent* of curie's column-shape work; compound rules surface inline via parent_id like any other rule, with whatever annotation curie ships.
- `/admin/procedural-events` write surface.
- `/projects/{id}` Verlauf / SmartTimeline — cross-link only.
- youpc.org cross-repo / Outlook sync / PDF export.
---
## §1 The single canonical shape
One page. One view. Top section = find affordance. Below = matched proceeding timelines, each as an inline-forked tree, vertically stacked.
```
┌────────────────────────────────────────────────────────────────────┐
│ [🔍 Suche: Klageerwiderung_____________________] │
│ Forum: [● UPC] [DE] [EPA] [DPMA] │
│ Verfahren: [● Verletzung] [● Widerklage] [Berufung] [Nichtigkeit] … │
│ Partei: [Klägerseite] [● Beklagtenseite] │
│ Akte: HL-2024-001 ▼ Stichtag: 2026-04-01 │
│ │
│ 2 Verfahren passen — Anker: Klageerwiderung (HL-2024-001) │
└────────────────────────────────────────────────────────────────────┘
┌─ upc.inf.cfi · Verletzungsverfahren UPC ─────────────────────────┐
│ │
│ ● Klageerhebung (R.13) 2026-01-15 · Klg · M │
│ │ │
│ ▼ ● Klageerwiderung (R.23.1) 2026-04-01 · Bekl · M │
│ │ ━━━━ DU BIST HIER ━━━━ │
│ │ Optionen für dieses Ereignis: │
│ │ ☑ Widerklage auf Nichtigkeit │
│ │ ☐ Antrag Patentänderung (R.30) │
│ │ ☐ Vorläufige Einwendungen │
│ │ │
│ ├─● Replik (R.29.a/b) 2026-06-01 · Klg · M │
│ │ ├─● Duplik (R.29.c) 2026-07-01 · Bekl · M │
│ │ └─● Replik auf Defence to CCR (R.29.d) 2026-08-01 · Klg · M │
│ │ └─● Rejoinder (R.29.e) 2026-09-01 · Bekl · M │
│ │ │
│ ├─● Widerklage auf Nichtigkeit ✓ │
│ │ └─▼ Tochterverfahren upc.rev.cfi ▾ │
│ │ │ │
│ │ ├─● Antrag Patentänderung (R.50) optional ☐ │
│ │ ├─● Hauptverhandlung [Gericht] │
│ │ └─● Entscheidung [Gericht] │
│ │ │
│ └─● Vorläufige Einwendungen ☐ (optional, ausgewählt: nein) │
│ │
│ ● Mündliche Verhandlung [Gericht bestimmt] │
│ │ │
│ └─● Urteil [Gericht] │
│ └─▼ Berufungsverfahren upc.apl ▸ (auf Endentscheidung) │
│ │
└────────────────────────────────────────────────────────────────────┘
┌─ upc.ccr.cfi · Widerklage auf Nichtigkeit (Tochter, oben verlinkt) ┐
│ … │
└────────────────────────────────────────────────────────────────────┘
```
No tabs. No view toggle. The output reacts to the find affordance, the anchor pin, and per-node fork selections.
### §1.1 The shape's components
1. **Find header** (sticky at top): search input + filter pills + Akte/date row + a one-line result summary. §2.
2. **Timeline-trees** (the page body): one block per matched proceeding, full chain + inline forks + inline aux branches. §3-§5.
3. **Anchor pin** (when set): the "DU BIST HIER" band on a specific node, optionally with zoom mode collapsing everything else. §6.
That's the entire UI surface. No drawers, no separate drillable panes, no constellation viewer. Forks are inline checkboxes; aux proceedings are inline expandable subtrees; zoom is an interaction on the existing rendering.
---
## §2 The find affordance
m's #3 answer makes this load-bearing: text + pills + result-timelines are all the same affordance. As the user narrows, the timelines below filter; as the timelines change, the result-count summary updates; clicking a node in a timeline auto-narrows the filter pills to that proceeding (optional sugar).
### §2.1 Composition
| Control | Source | Composes via | Persists in |
|---|---|---|---|
| Free-text search | input box, debounced 200ms | OR-against (procedural_event.name DE/EN, rule_code, aliases) | `?q=<text>` |
| Forum pill row | static enum (UPC/DE/EPA/DPMA), single-select | AND | `?forum=<id>` |
| Verfahren pill row | proceeding_type chips, multi-select (deduped from active forum) | AND (any-of) | `?procs=<csv>` |
| Partei pill row | claimant / defendant / both / — (or auto from Akte) | AND | `?party=<x>` |
| Akte picker | dropdown of user's projects | seeds Verfahren + Partei + scenario_flags + anchor | `?project=<uuid>` |
| Stichtag (date) | date input, defaults today | feeds computed dates throughout the timelines | `?trigger_date=<iso>` |
All controls live in one sticky header. The header keeps its height stable so the timelines below don't reflow on every keystroke.
### §2.2 Cold open behaviour
No URL params, no Akte:
- Search box empty, all forums neutral, all proceeding pills neutral. Show a curated default of the most-common proceedings: `upc.inf.cfi`, `upc.rev.cfi`, `upc.apl.merits`, `de.inf.lg`, `epa.opp.opd`, `dpma.opp.dpma`. (See Q4 below.)
- A hint above the timelines: "Suche oder filtere, um andere Verfahren einzublenden."
With a `?project=` param: filters pre-fill from the Akte, anchor pins to the latest completed deadline.
With a `?q=` or `?event=` param: filters pre-fill to match, single matched proceeding renders pinned.
### §2.3 What the search matches
Free-text search hits the same corpus the existing `/api/tools/fristenrechner/search?kind=events` endpoint covers — procedural_events by name + code + aliases. Spawn-only events stay filtered out (per atlas P0 §2.2). Hits surface in two ways simultaneously:
- The matched proceeding(s) render expanded with the hit event(s) anchor-pinned.
- A small "Treffer: 3 Ereignisse in 2 Verfahren" summary above the timelines.
If the user types something narrow enough to match a single event, the page auto-pins that event (auto-anchor). If multiple events match, the user picks via a small dropdown under the search input — picking sets the anchor.
### §2.4 Why pills, not chips-with-sub-modes
The shipped 4-tab UI tried to express "what kind of question are you asking" via tabs. m's answer #3 collapses that — the find affordance doesn't care which "kind" of question; it cares about the active filter set. A user with a search + a forum + an Akte set gets the right timelines regardless of which tab they "came from". The mental model is: narrow the set; the timelines arrive.
---
## §3 Timelines and forks
Each matched proceeding renders as one card. Inside the card: the proceeding's name + jurisdiction badge in a thin header strip, then the chain.
### §3.1 The chain
Vertical, top-to-bottom = chronological. Each node = one procedural_event (the rule that fires it lives inside). Edges = parent_id. Per node:
- **Bullet style** by priority: solid filled (mandatory), solid outline (recommended), dotted (optional), dashed (conditional-flag-off and hidden).
- **Bullet colour**: priority band — black/grey/blue/light depending on the scale we end up picking. Lime accent (`#c6f41c`) reserved for the anchor pin.
- **Inline metadata**: name, rule code, computed date, party badge, priority badge. Stripped to one line.
- **Court-set events**: render with `[Gericht bestimmt]` in date column.
- **Spawn nodes**: terminate the bullet with `▼ Tochterverfahren <code> ▾` — expandable inline; collapsed by default unless the spawn flag is on. §5.
### §3.2 Forks — every choice point is one
A "fork" is anywhere the user can flip the proceeding's shape:
1. **Scenario flags** (`with_ccr`, `with_amend`, `with_cci`) — currently 3, extensible via curie's `scenario_flag_catalog`.
2. **Optional rules** (`priority='optional'`) — each is a "do I do this?" pick.
3. **Appeal-target picks**`applies_to_target` array on appeal proceedings (endentscheidung / kostenentscheidung / anordnung / schadensbemessung / bucheinsicht). Per-card chip group at the appeal root.
4. **Perspective** — claimant / defendant per proceeding (mostly comes from Akte's `our_side`, picker overrides).
5. **Court-set timing choices**`choices_offered` JSON on `sequencing_rules` (`appellant` / `include_ccr` / `skip` shapes from einstein). Per-card chip set.
**Where forks render.** Inline, *on the node where the fork's effect begins.* Not in a top-of-page flag strip (m's bug #5 — sequences should be visualised as sequences; flags above the tree decouple cause from effect).
Concretely: the `with_ccr` fork renders as a checkbox **on the Klageerwiderung node**, because that's where the user decides "we are filing a Widerklage with our KEW". Toggling it lights up the CCR child branches below. Similarly:
- `with_amend` renders on the KEW node *and* on the Antrag-Patentänderung node it gates.
- `with_cci` renders on the Defence-to-Revocation node.
- Optional rules render as a checkbox on their own card.
- Appeal-target picks render on the appeal root.
If multiple forks share a node, they cluster as a small "Optionen für dieses Ereignis" mini-strip *below* the node header:
```
▼ ● Klageerwiderung (R.23.1) 2026-04-01 · Bekl · M
│ Optionen:
│ ☑ Widerklage auf Nichtigkeit
│ ☐ Antrag Patentänderung (R.30)
│ ☐ Vorläufige Einwendungen einlegen
```
### §3.3 Default rendering ("Gewählt" semantics)
Each node renders iff:
- It's mandatory (priority='mandatory'), OR
- It's selected per current scenario state (priority='recommended' unless explicitly deselected; priority='optional' iff explicitly selected; conditional iff flag is on).
- Same as atlas P3's "Gewählt" view-mode.
Conditional rules whose flag is off **do not render at all** by default. The fork checkbox to *turn the flag on* still appears on the gating node — turning it on causes the dependent branch to render.
This is m's bug #2 fix: no more dump of every-rule including flag-off conditional. The forks themselves are the affordance that brings hidden branches into view.
### §3.4 Optional reveal — "Alle Optionen"
A single toggle at the top of each proceeding card (NOT page-wide):
```
[· Gewählt ·] [ Alle Optionen ]
```
"Alle Optionen" renders every rule including flag-off conditionals (greyed with their flag hint) and unselected optionals (dotted with `[Aufnehmen]` chip). Useful when the user wants to see the whole shape. Per-proceeding so a page with 3 proceedings can have one in "Alle Optionen" mode without affecting the others. State persists in `localStorage` per proceeding code.
### §3.5 Why dropping "Nur Pflicht"
Atlas P3's three-way toggle had Nur Pflicht / Gewählt / Alle Optionen. With forks made inline + per-node, "Nur Pflicht" loses load-bearing — it was useful when the page had no fork interactivity (you wanted to dial down clutter). Now the user can just leave all forks off and see the mandatory-only chain in Gewählt mode. The two-way Gewählt ↔ Alle Optionen is enough.
### §3.6 Cross-party rows
Per atlas §2.4 / m's lock: every rule for the proceeding renders, with rows where the user is *not* the primary_party muted + carrying a "Gegenseitig" badge. Same treatment in this tracker. Not hidden by perspective; just visually deemphasised.
---
## §4 Court-set events & date rendering
`is_court_set=true` rules don't compute a date — the court picks it on the day. Two display options that interact:
- Render with `[Gericht bestimmt]` in the date column, no date. Standard.
- When the user has scheduled the actual date (an `appointments` row on the project or a manual override), the actual date replaces the badge. Akte-only.
If the date is "vom Gericht" and matters as a trigger for downstream events, downstream events render `[abhängig von Verhandlung]` instead of a date, and recompute live once the court date is known.
`choices_offered` per-rule (the 3 known shapes today: `appellant`, `include_ccr`, `skip`) — also inline per-node, treated as forks (§3.2 #5).
---
## §5 Aux proceedings inline
Per m's #4 answer: spawned proceedings draw inline as expandable subtrees, not as drillable separate pages.
### §5.1 Render
A spawn node renders as a leaf chip terminating the parent's chain segment:
```
●─● Widerklage auf Nichtigkeit ✓
└─▼ Tochterverfahren upc.rev.cfi ▾
├─● Antrag Patentänderung (R.50) optional ☐
├─● Hauptverhandlung [Gericht]
└─● Entscheidung [Gericht]
└─▼ Berufungsverfahren upc.apl ▸
```
- Collapsed by default unless the gating fork is on (e.g. `with_ccr` ticked → CCR's spawn into upc.rev.cfi auto-expands).
- Expanding writes nothing — pure UI state in `sessionStorage["procedures:expanded_spawns"]`.
- The aux subtree renders with the same node vocabulary as the parent; forks inside the aux are independently editable.
- Aux subtrees can themselves have aux subtrees (e.g. CCR → Berufung). Depth is bounded by the data — today 2 levels deep at most.
### §5.2 Cross-references
When two paths converge on the same aux proceeding (e.g. CCR triggers from a couple of places), the aux renders inline at the first path's spawn point and renders as a back-reference at subsequent spawn points: `▸ (siehe oben: Tochterverfahren upc.rev.cfi)`. Single source of truth in the rendered tree, even when the graph has multiple edges.
### §5.3 Akte mode
In Akte mode, if the spawn was actualised (a child project exists linked via `parent_project_id`), the aux subtree shows the child project's badge: `📁 HL-2024-001-CCR · Tochterakte`. Clicking the badge navigates to that child project. The subtree itself still renders inline.
---
## §6 Anchor pin & zoom
m's #1 answer: "zoomability for one event and all events it triggers, from within the full timeline".
### §6.1 The anchor pin
Any node can be pinned as the anchor. Pinning sources:
- URL `?event=<sequencing_rule_id>` (deep link).
- Search box auto-pin when the search narrows to a single hit.
- Click-to-pin on any node (small pin icon in the node's metadata row).
- Akte landing: auto-pin to latest completed deadline.
The pinned node renders with a 4px lime-coloured left band + a `━━ DU BIST HIER ━━` divider above its successors. The pin is also reflected in the find-header's result summary: "Anker: Klageerwiderung (HL-2024-001)".
### §6.2 Zoom mode
A small `[ 🔍 Fokus ]` chip on the anchored node toggles zoom mode for that anchor. When zoom is on:
- The anchored node's ancestors collapse to a single breadcrumb at the top of the proceeding card:
```
upc.inf.cfi ▸ Klageerhebung ▸ ━ Klageerwiderung ━
```
- The anchored node renders full.
- Successors render fully (the forward subtree under the anchor).
- Sibling branches at every ancestor depth collapse to a single-line summary card: `… 4 weitere Schritte verborgen — [zeigen]`.
This is what m means by "zoom into one event from within the timeline" — the *same view*, just with non-relevant siblings collapsed. Toggle off → full timeline restored, anchor still pinned.
Zoom is page-scoped (one anchor per page, one zoom state). State in URL: `?event=<id>&zoom=1`.
### §6.5 Multi-proceeding anchor scope (m's Q3 divergence)
When the page shows >1 matched proceeding *and* an anchor is pinned, the non-anchored proceedings auto-collapse to a header-only one-line card:
```
┌─ upc.inf.cfi · Verletzungsverfahren UPC ─────┐
│ … full timeline … │
│ ━━ DU BIST HIER: Klageerwiderung ━━ │
└──────────────────────────────────────────────┘
┌─ upc.rev.cfi ▸ ausblenden — [zeigen] ────────┐
└──────────────────────────────────────────────┘
┌─ upc.apl.merits ▸ — [zeigen] ────────────────┐
└──────────────────────────────────────────────┘
```
Clicking a header card's `[zeigen]` link restores that proceeding's full timeline (the header stays as a per-card affordance for re-collapse). The collapsed state persists in `sessionStorage["procedures:collapsed_proceedings"]`. Un-pinning the anchor restores all visible proceedings to full-render automatically.
The rule applies regardless of how the anchor was pinned (URL, search-auto, click-to-pin, Akte landing). The find-header result count still shows N proceedings matched — header cards are present, just collapsed.
### §6.3 The "where I came from" question
m's brief asked for backward-walk visualisation. Without zoom: the chain above the anchor is the backward walk — it's the same tree. With zoom: the breadcrumb at the top of the proceeding card is the backward walk in compact form. No separate concept; backward walk = upward in the tree.
### §6.4 Akte mode: actuals overlay
When `?project=<uuid>` is set, each node in the chain queries `paliad.deadlines WHERE project_id = $p AND sequencing_rule_id = $r` and overlays:
- `status='done'` → ✓ in the node bullet area + actual completed date in the date column. Greyed slightly to read as "past".
- `status='open'` and `due_date < today` → ⚠ overdue.
- `status='open'` and `due_date >= today` → 📅 actual due date if differs from computed; ◇ marker.
- No deadline row → render as template (current behaviour).
The anchor auto-pins to the latest `status='done'` deadline by default — the natural reading is "we just finished this".
---
## §7 What lives where: the find header vs the timelines
A short table to make the responsibility boundary explicit:
| Concern | Find header | Timeline body |
|---|---|---|
| Pick proceeding(s) | Filter pill row | (auto-rendered after) |
| Pick anchor | Search-narrow → auto-pin / URL `?event=` | Click pin icon on any node |
| Pick perspective | Pill (or auto from Akte) | (read-only — feeds rendering) |
| Pick scenario flags | (no) | Inline fork checkboxes on gating nodes |
| Pick optional rules | (no) | Inline fork checkboxes on each optional node |
| Pick appeal target | (no) | Inline chip group on appeal root |
| Pick date | Stichtag input | (read-only — feeds computed dates) |
| Toggle Alle Optionen / Gewählt | (no) | Per-proceeding 2-way toggle |
| Zoom on anchor | (no) | `[Fokus]` chip on anchored node |
| Akte select | Akte picker | (read-only — feeds actuals overlay) |
Find header = "narrow the set + global context". Timelines = "everything per-event". No drawers, no overlays.
---
## §8 Cold open + empty state
Cold open with no Akte, no URL params (Q4 below): show a curated default of 6 most-common proceedings (`upc.inf.cfi`, `upc.rev.cfi`, `upc.apl.merits`, `de.inf.lg`, `epa.opp.opd`, `dpma.opp.dpma`), each rendered with default Gewählt + no forks selected + no anchor. Hint text above: "Suche oder filtere, um andere Verfahren zu sehen."
Empty filter result (e.g. user types nonsense): zero timelines render, with a helper card: "Keine Treffer. Filter zurücksetzen ▸"
---
## §9 Migration (direct replace per m's Q7)
4 slices + 1 cleanup, all surface, no DB mig, no `?tracker=1` flag. Each slice ships visibly to users at `/tools/procedures`. T1 must be at least as functional as today's catalog browser — so the find header + multi-proceeding render + inline forks + aux inline all front-load there. T2-T4 layer the remaining behaviour.
All independent of curie's editorial work — compound rules render inline via parent_id like any other rule; if curie ships a `compound_predecessors uuid[]` column later, those rules can render at multiple positions (one inline per predecessor) without tracker code changes beyond the join.
| Slice | What ships | Notes |
|---|---|---|
| **T1 — Tracker shell replaces the catalog page** | `/tools/procedures` now renders: sticky find header (search + Forum/Verfahren/Partei pills + Akte picker + global Stichtag), N-proceeding render (one card per matched proceeding), inline forks (scenario flags + optionals visible as checkboxes on the gating node), aux proceedings inline-expandable at spawn points, cold-open with 6 curated defaults (Q4), default = Gewählt. The 4 entry-mode tabs are deleted in the same PR; URL params `?mode=proceeding\|search\|wizard\|akte` 301-redirect or drop. URL anchor `?event=<rule_id>` scroll-highlights the matching node (no zoom yet). | Replaces catalog UI; users see the new tracker immediately. |
| **T2 — Anchor pin + zoom + multi-proceeding scope** | Anchor pin (lime band + DU BIST HIER divider), `[Fokus]` chip on anchored node toggles zoom (§6.2), URL state `?event=…&zoom=1`. Multi-proceeding auto-collapse rule (§6.5) kicks in when an anchor is set. Click-to-pin on any node. | Layered on T1's existing render. |
| **T3 — Akte landing + actuals overlay** | `?project=<uuid>` derives anchor from latest `status='done'` deadline (Q5), backward walk overlays `paliad.deadlines` actuals as status badges (§6.4), scenario_flags load from project, fork write-back via existing `PATCH /api/projects/{id}/scenario-flags` + `POST /api/projects/{id}/deadlines/bulk`. | The first slice that exercises the project hookup end-to-end. |
| **T4 — Appeal-target + court-set choices + polish** | Wire `applies_to_target` array forks on appeal proceedings, `choices_offered` shapes (`appellant`, `include_ccr`, `skip`), court-set date override from `appointments` table, cross-party muted treatment per §3.6. Per-proceeding "Alle Optionen" toggle (§3.4). | Polish + the edge-case fork shapes. |
| **T5 — Cleanup** | Dead-code removal: legacy `procedures.ts` tab toggling, `fristenrechner-mode-a.ts` / `fristenrechner-wizard.ts` / `fristenrechner-result.ts` / `verfahrensablauf.ts` if no longer referenced (verify with grep before deletion). Sidebar/cmd-K unchanged (URL same). | No user-visible change. |
### §9.1 Constraint: T1 is the new floor
Because there's no flag, **T1 must not regress** from today's catalog UI in any non-trivial way. The catalog today serves four user workflows:
1. **Pick a proceeding, see its full Verfahrensablauf** → T1 covers via "Verfahren" pill click → that proceeding renders alone.
2. **Search for an event** → T1 covers via search input + auto-pin.
3. **Wizard from R1-R5** → T1 covers via Forum/Verfahren/Partei pills + search (the wizard's narrowing is just a sequence of filter applications).
4. **Enter via Akte** → T1 covers via the Akte picker; full actuals overlay arrives in T3 (open/done badges may render partial in T1, but the Akte's scenario_flags + proceeding pre-load works).
If T1 reviewing exposes a regression, T1 holds (the issue blocks merge) — m's PR review gates the slice landing.
### §9.2 What stays unchanged
- URL: `/tools/procedures` keeps it.
- Sidebar entry "Verfahren & Fristen" keeps it.
- cmd-K palette keeps it.
- All other tools, calendar, projects, deadlines surfaces — untouched.
- Calculator (`pkg/litigationplanner.CalculateRule`) — untouched.
### §9.3 Out-of-band dependencies
- The compound-predecessors editorial column is owned by curie's t-paliad-333. Tracker reads whatever lands. If it slips past T4, compound rules render via their primary parent_id only (today's shape) — degraded but still correct on that path. No tracker re-render needed when curie ships.
- The Akte actuals overlay (T3) reads `paliad.deadlines.sequencing_rule_id` — column exists, nothing new.
### §9.4 Test surface per slice
- **T1**: cold-open 6 curated defaults render; search narrows to single proceeding; pill toggles change render; `?project=` loads Akte filters (no actuals yet); URL deep-link `?event=` highlights matching node; legacy `?mode=` redirects.
- **T2**: click-to-pin sets anchor with lime band; `[Fokus]` zoom collapses siblings; un-zoom restores; multi-proceeding auto-collapse when anchor active; URL state survives reload.
- **T3**: Akte landing auto-pins latest done deadline; status badges render on each node from `paliad.deadlines`; fork tick writes to `scenario_flags`; "In Akte speichern" persists.
- **T4**: appeal-target chips switch the rule set rendered on appeal proceedings; `choices_offered` per-node chip groups visible + functional; "Alle Optionen" reveals hidden conditional rules with greyed state.
- **T5**: production deploy unchanged surface; no live regression; deleted files don't break build.
---
## §10 Open questions for m
Seven questions in 2 batches (4 + 3) for `AskUserQuestion`. Tier 1 = how the per-node fork UI feels + how zoom interacts with multi-proceeding pages. Tier 2 = cold-open content + Akte default + Stichtag scope + migration cadence.
m's picks fold back into §11 below before the "TRACKER DESIGN READY FOR REVIEW" signal.
### Batch 1 — fork UI + zoom + cross-party
- **Q1 (Fork-cluster shape on a node)** — when a node has 2-4 forks (e.g. Klageerwiderung: `with_ccr` + `with_amend` + Vorl. Einwend.) — (a) inline checkbox list below the node header (current sketch), (b) collapsed "Optionen (3) ▾" affordance that expands on hover/click, (c) chip strip on the same line as the node header.
- **Q2 (Zoom interaction)** — `[Fokus]` chip on the anchored node — (a) collapses siblings to one-line summaries (current sketch), (b) outright hides siblings, breadcrumb-only, (c) split-view (zoomed pane below original full tree).
- **Q3 (Anchor scope on a multi-proceeding page)** — when 3 timelines are visible and the user pins an anchor in one — (a) the other 2 timelines stay expanded normally (no zoom effect on them), (b) the other 2 timelines auto-collapse to header-only ("upc.rev.cfi ▸ ausblenden — [zeigen]"), (c) the other 2 timelines reorder to bottom of page (anchored proceeding floats to top).
- **Q4 (Cold-open default content)** — opening `/tools/procedures` with no URL params and no Akte — (a) the 6-curated-default-proceedings sketch (Verletzung UPC + DE LG, Nichtigkeit UPC, Berufung UPC, EPA-Einspruch, DPMA-Einspruch), (b) all ~46 proceedings rendered with all forks off (lots of scrolling), (c) empty state with a "Filter wählen, um Verfahren einzublenden" prompt.
### Batch 2 — Akte semantics + Stichtag + migration
- **Q5 (Akte landing — default anchor)** — `?project=<uuid>` — (a) auto-pin to latest `status='done'` deadline (current sketch), (b) auto-pin to next-open deadline (forward-looking), (c) no auto-pin, just pre-fill filters + actuals overlay, user picks anchor.
- **Q6 (Stichtag scope)** — date input in the find header — (a) global, feeds all visible proceedings' computed dates (current sketch — useful for browsing "if today were the trigger"), (b) per-proceeding (each timeline carries its own date input), (c) only valid in single-proceeding mode (hidden when the page shows >1 proceeding).
- **Q7 (Migration cadence)** — (a) flag-gated dev under `?tracker=1`, T1-T4 ship, T5 hard-cut (current sketch, cronus precedent), (b) direct replace at T1 (no flag — every slice ships visibly to users), (c) parallel URL `/tools/procedures-v2` until hard-cut.
---
## §11 m's decisions (2026-05-27)
All 7 questions answered via `AskUserQuestion` in 2 batches (4 + 3) at 21:0?. 5 picks on-recommendation, 2 diverged. Decisions below; the underlying question list lives in §10 above as the historical record.
### Tier 1 — fork UI + zoom + cross-party
- **Q1 (Fork cluster on a node): Inline checkbox list below node header.** [= REC] **Locks §3.2.** Every fork on a given node renders as a checkbox in an "Optionen:" cluster line below the node header. Always visible, no hover, no extra click. Vertical real estate per node is acceptable because the default `Gewählt` mode keeps the tree compact (most events have zero forks).
- **Q2 (Zoom interaction): Collapse siblings to one-line summaries.** [= REC] **Locks §6.2.** `[Fokus]` chip on the anchored node folds sibling branches at each ancestor depth to a `… 4 weitere Schritte verborgen — [zeigen]` line. The anchored node's subtree renders full. Breadcrumb at the top of the proceeding card. Toggle off restores everything.
- **Q3 (Multi-proceeding anchor scope): Other timelines auto-collapse to header-only.** [≠ REC; m diverged from "stay expanded"] **Locks new §6.5.** When an anchor is pinned on a multi-proceeding page, the non-anchored proceedings fold to a one-line header card (`upc.rev.cfi ▸ ausblenden — [zeigen]`). Clicking the header line restores that proceeding's full timeline. Rationale (interpreted): with an anchor pinned, the page is *about* that anchor — having other proceedings full-render in parallel competes for attention without earning it. The header card preserves the find-header result count and offers a one-click escape if the user wants to compare.
- **Q4 (Cold open content): 6 curated default proceedings.** [= REC] **Locks §8.** No URL params + no Akte → render `upc.inf.cfi`, `upc.rev.cfi`, `upc.apl.merits`, `de.inf.lg`, `epa.opp.opd`, `dpma.opp.dpma` stacked vertically, all forks off, no anchor. Hint: "Suche oder filtere, um andere Verfahren zu sehen."
### Tier 2 — Akte + Stichtag + migration
- **Q5 (Akte default anchor): Latest `status='done'` deadline.** [= REC] **Locks §6.4 + §9.** `?project=<uuid>` → derive anchor by `SELECT … FROM paliad.deadlines WHERE project_id=$p AND sequencing_rule_id IS NOT NULL ORDER BY completed_at DESC NULLS LAST LIMIT 1`. Fallback: next open deadline → proceeding root. The backward chain reads as Akte history; the anchor itself is the most recently completed work; forward is upcoming.
- **Q6 (Stichtag scope): Global, feeds all visible proceedings.** [= REC] **Locks §2.1 + §7.** One date input in the find header. All visible proceedings compute dates against it. When the user has an Akte loaded, the Stichtag pre-fills from the project's latest trigger date but is overrideable. When the anchor is pinned to a `status='done'` deadline, the date input shows that deadline's `completed_at` but can still be overridden for "what-if" exploration.
- **Q7 (Migration cadence): Direct replace at T1 — no flag.** [≠ REC; m diverged from flag-gated dev] **Rewrites §9.** Every slice ships visibly to users at /tools/procedures. T1 must be at minimum equivalent to today's catalog browser (so the slicing has to front-load find header + multi-proceeding render + forks inline + aux inline). The flag-gated dev plan is dropped. cronus's Q11 hard-cut precedent extends here: m would rather ship per-slice visibly than carry a parallel surface during dev. Rationale (interpreted): partial-tracker > no-tracker, and ~50 internal lawyers absorb the per-slice deltas through release comms.
### §11.1 Changes triggered by m's divergences
**Q3 divergence — multi-proceeding anchor scope.** New §6.5 added below. The header-card-only render for non-anchored proceedings preserves filter compose (you can still see "upc.rev.cfi matched the filter") while clearing the page's vertical real estate for the anchor's full context.
**Q7 divergence — direct replace.** §9 rewritten end-to-end. T1 now ships the minimum-viable tracker (find header + multi-proceeding render + forks inline + aux subtrees inline + URL-anchor highlight), replacing the catalog UI at /tools/procedures from the moment it merges. T2-T4 layer zoom, Akte semantics, polish. T5 ("cleanup only") is now just dead-code removal.
### §11.2 What stays unchanged
The other 5 picks (Q1, Q2, Q4, Q5, Q6) ratified the inventor proposal. Inline checkbox forks per node, breadcrumb-collapse zoom, 6-curated cold open, latest-done-deadline Akte anchor, global Stichtag — all locked as drafted in §1-§8.
---
## §12 Out of scope
- Calculator changes.
- Editorial backfill (curie's t-paliad-333). Compound rules render inline as parent_id-chained rules with curie's annotation; no special tracker treatment.
- `/admin/procedural-events`, `/projects/{id}` Verlauf / SmartTimeline.
- youpc.org / Outlook / PDF export.
- Multi-project anchor comparison.
- Free-text scenario flag i18n.
---
## §13 Synthesis links
- **mBrian** (after m's ratification): file as `[synthesis]` linked `triggered_by` t-paliad-337; `related_to` cronus's unified-procedural-events-tool design + atlas's deadline-system-revision + cronus's earlier Fristenrechner overhaul.
- **Cross-refs in this repo**: `docs/design-unified-procedural-events-tool-2026-05-27.md` (cronus, U0-U4 shipped today), `docs/design-deadline-system-revision-2026-05-27.md` (atlas Phase 2), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26).
- **Gitea**: m/paliad#152 (this design), m/paliad#151 (cronus U0-U4), m/paliad#149 (atlas Phase 2).
- **Coder phase** (deferred per inventor SKILL): runs after m ratifies §10 + §11. Slice ordering per §9. NOT atlas (parked at "TRACKER DESIGN READY FOR REVIEW"). Pattern-fluent Sonnet coder picks up T1 first.

View File

@@ -0,0 +1,580 @@
# Design — `paliad.proceeding_types` taxonomy cleanup: primary proceedings vs phases vs side-actions vs meta
**Task:** t-paliad-324
**Gitea:** m/paliad#147
**Inventor:** atlas (shift-1)
**Date:** 2026-05-26
**Status:** Draft — coder gate held until m ratifies the 10 design questions in §9
**Branch:** `mai/atlas/inventor-proceeding`
---
## 0. Premises verified live (before designing)
Verified against live youpc Postgres (port 11833, `paliad` schema) on 2026-05-26 22:05. Findings supersede the audit grouping in m/paliad#147 wherever they diverge — the issue body was correct on shape but conservative on counts.
### 0.1 The 46-row table, fully classified by usage
`paliad.proceeding_types` has 49 rows total; 46 active, 3 inactive (`upc.apl.merits/cost/order` — superseded by `upc.apl.unified`, id 160) plus 1 archive bucket (`_archived_litigation`, id 32). Cross-references against the four downstream consumers:
| Consumer | Column | Active rows that point at the 46 active types |
|---|---|---|
| `paliad.sequencing_rules.proceeding_type_id` | rule's anchor proceeding | **18 distinct rows used** — the primaries with corpus. 28 rows have 0 rules. |
| `paliad.sequencing_rules.spawn_proceeding_type_id` | cross-proceeding spawn target | **1 distinct row used**`upc.apl.merits` (id=11, **inactive!**). 0 active types are spawn targets. |
| `paliad.projects.proceeding_type_id` | project's primary type | **6 distinct rows used** (across 18 projects). All 6 are in the 18 primaries. |
| `paliad.event_category_concepts.proceeding_type_code` | concept's owning proceeding | **18 distinct codes used.** 3 of those codes (`upc.apl.merits`, `upc.apl.order`, `upc.apl.cost`) point at **inactive** rows — pre-existing data drift from the `upc.apl.unified` merger (flagged §8, out of scope here). |
The audit answer in one sentence: **of the 46 active rows, only 18 have any downstream consumer pointing at them today** (the 18 primaries with corpus). The remaining 28 rows are decorative — they exist in the table but nothing references them.
This makes reparenting **trivially safe**: no FK invariant breaks, no SQL update touches existing data, no migration risk.
### 0.2 The 18 primaries with corpus (rules + concepts)
Ordered by `paliad.sequencing_rules` count (descending), with `event_category_concepts` count alongside:
| id | code | jurisdiction | rules | concepts | projects |
|---:|---|---|---:|---:|---:|
| 8 | `upc.inf.cfi` | UPC | 25 | 14 | 1 |
| 9 | `upc.rev.cfi` | UPC | 17 | 10 | 0 |
| 160 | `upc.apl.unified` | UPC | 16 | 0 *(see drift note)* | 0 |
| 12 | `de.inf.lg` | DE | 11 | 4 | 1 |
| 13 | `de.null.bpatg` | DE | 10 | 4 | 1 |
| 14 | `epa.opp.opd` | EPA | 8 | 7 | 1 |
| 15 | `epa.opp.boa` | EPA | 8 | 12 | 0 |
| 16 | `epa.grant.exa` | EPA | 8 | 0 | 0 |
| 17 | `upc.dmgs.cfi` | UPC | 8 | 1 | 0 |
| 26 | `de.inf.bgh` | DE | 8 | 17 | 0 |
| 25 | `de.inf.olg` | DE | 7 | 8 | 0 |
| 10 | `upc.pi.cfi` | UPC | 7 | 3 | 0 |
| 27 | `de.null.bgh` | DE | 6 | 10 | 0 |
| 29 | `dpma.appeal.bpatg` | DPMA | 5 | 6 | 0 |
| 30 | `dpma.appeal.bgh` | DPMA | 4 | 8 | 0 |
| 28 | `dpma.opp.dpma` | DPMA | 4 | 3 | 1 |
| 18 | `upc.disc.cfi` | UPC | 4 | 1 | 0 |
| 35 | `upc.ccr.cfi` | UPC | 1 | 0 | 1 |
These 18 are unambiguously **primary proceedings** in the m/paliad#147 sense — self-contained matters, own filing, own deadline cascade, own ablauf. They survive every model.
### 0.3 The 4 unloaded primaries (Group A continued)
Four more active rows are conceptually primaries but carry **zero rules and zero concepts today** — seeded for catalog completeness, waiting for corpus:
| id | code | jurisdiction | what it is |
|---:|---|---|---|
| 171 | `upc.dni.cfi` | UPC | Negative Feststellungsklage — standalone declaratory action |
| 172 | `upc.epo.review` | UPC | Überprüfung von EPA-Entscheidungen — standalone review action |
| 179 | `upc.bsv.cfi` | UPC | Beweissicherung / saisie — standalone evidence-preservation order |
| 188 | `upc.pl.cfi` | UPC | Schutzschrift — pre-litigation defensive filing |
These are **primary** by character (each has its own RoP-defined filing pathway and its own deadline tree once rules get seeded) but **unloaded** today. Decision: keep them as `kind='proceeding'` so Mode B R3 surfaces them for future rule attachment and `pkg/litigationplanner` accepts them as valid catalog codes.
§9 Q3.b discusses `upc.pl.cfi` (it's the only borderline — Schutzschrift is technically a pre-action filing, not a proceeding at the time of filing). m's call.
### 0.4 The 28 non-primary rows
The 28 active rows that have **zero rules + zero concepts + zero projects pointing at them** group cleanly into three categories:
#### Group B — Phases of a primary CFI proceeding (5 rows)
These describe stages *within* an existing CFI proceeding, not standalone matters. A `upc.inf.cfi` action passes through interim → oral → decision phases; the phase isn't a separately-elected proceeding type.
| id | code | name |
|---:|---|---|
| 173 | `upc.cfi.interim` | CFI - Zwischenverfahren |
| 174 | `upc.cfi.oral` | CFI - Mündliche Verhandlung |
| 175 | `upc.cfi.decision` | CFI - Endentscheidung |
| 176 | `upc.costs.cfi` | Separate Kostenentscheidung *(post-decision sub-phase)* |
| 185 | `upc.default.cfi` | Versäumnisentscheidung *(alt. decision outcome)* |
The "phase" concept already has a natural home in the data model: `paliad.procedural_events.event_kind` (filing/hearing/decision/order). What `upc.cfi.interim` actually represents is "all events with kind=filing under upc.inf.cfi/upc.rev.cfi/upc.pi.cfi/etc."; `upc.cfi.oral` is "all events with kind=hearing"; `upc.cfi.decision` is "all events with kind=decision". The proceeding-type row buys nothing the event_kind already carries.
#### Group C — Side-actions inside a proceeding (10 rows)
Applications and court orders that arise *inside* a primary proceeding. They could each become a `condition_expr`-gated rule on the parent proceeding when corpus arrives; they don't need their own proceeding row.
| id | code | name |
|---:|---|---|
| 178 | `upc.evidence.cfi` | Beweisanordnungen (allgemein) |
| 182 | `upc.experiments.cfi` | Gerichtlich angeordnete Versuche |
| 177 | `upc.security.cfi` | Sicherheitsleistung |
| 184 | `upc.intervention.rop` | Streitbeitritt |
| 165 | `upc.parties.change` | Parteiwechsel / Patentübergang |
| 170 | `upc.optout.cfi` | Antrag auf Opt-out |
| 180 | `upc.inspection.cfi` | Besichtigungsantrag |
| 181 | `upc.freezing.cfi` | Anordnung zur Vermögenssperre |
| 187 | `upc.withdrawal.rop` | Klagerücknahme |
| 183 | `upc.rehearing.coa` | Wiederaufnahmeantrag |
A subtle distinction: `upc.bsv.cfi` (Beweissicherung) IS a standalone primary (its own RoP filing) whereas `upc.evidence.cfi` (Beweisanordnungen allgemein) is a side-action class (orders the court makes inside any proceeding). The two are not duplicates; the categorisation is structural, not nominal.
#### Group D — Cross-cutting administrative / meta (8 rows)
These describe rules-of-procedure mechanics, not matters a lawyer takes on. None of them is a "Verfahren" in any user-facing sense.
| id | code | name |
|---:|---|---|
| 162 | `upc.case.mgmt` | Verfahrensverwaltung |
| 161 | `upc.general.rop` | Allgemeine Bestimmungen |
| 163 | `upc.service.rop` | Zustellung von Schriftsätzen |
| 168 | `upc.language.rop` | Verfahrenssprache |
| 164 | `upc.representation.rop` | Vertretung / Anwaltsprivileg |
| 166 | `upc.fees.court` | Gerichtsgebühren |
| 167 | `upc.legalaid.cfi` | Prozesskostenhilfe |
| 186 | `upc.special.cfi` | Besondere Verfahrenslagen |
| 169 | `upc.reestablishment.rop` | Wiedereinsetzung in den vorigen Stand *(cross-cutting; applies to every proceeding)* |
`upc.reestablishment.rop` lands in Group D because **every** proceeding has a Wiedereinsetzung path — it isn't a kind-of-proceeding, it's a cross-cutting remedy. Today's rules already model it correctly (it's a `condition_expr`-gated rule on each primary, not a separately-elected proceeding type).
### 0.5 Counts reconciled
| Group | Count | Total of 46 |
|---|---:|---:|
| A.1 Primary with corpus (18 rows) | 18 | |
| A.2 Primary, unloaded (4 rows) | 4 | |
| B Phases (5 rows) | 5 | |
| C Side-actions (10 rows) | 10 | |
| D Meta / cross-cutting (9 rows) | 9 | |
| **Total** | | **46 ✓** |
m/paliad#147's audit listed 8 Group-D rows; live data shows 9 once `upc.reestablishment.rop` is moved into the meta bucket (it appeared as ambiguous "cross-cutting admin / meta" — confirming this design's read).
---
## 1. Categorization — ratified
The taxonomy proposal: a row in `paliad.proceeding_types` has exactly one of four **structural kinds**.
| `kind` | What it is | Visible in Mode B R3 wizard? | In `pkg/litigationplanner` catalog? | Eligible for `projects.proceeding_type_id`? |
|---|---|---|---|---|
| `proceeding` | A self-contained matter with its own filing pathway and its own deadline tree | **Yes** | **Yes** (filtered by `kind='proceeding' AND is_active=true`) | **Yes** |
| `phase` | A stage *within* a primary proceeding | No | No | No |
| `side_action` | An application/order that arises inside a primary proceeding | No | No | No |
| `meta` | RoP mechanics, cross-cutting rules, court administration | No | No | No |
This is **Model 1 from m/paliad#147** (kind discriminator on `proceeding_types`). §2 explains why it beats Models 2-4 for the actual data.
The 46 active rows map to the 4 kinds as follows:
- **`proceeding` (22 rows):** all 18 primaries-with-corpus + the 4 unloaded primaries from §0.3. Specifically the union of §0.2 + §0.3.
- **`phase` (5 rows):** the §0.4 Group B list.
- **`side_action` (10 rows):** the §0.4 Group C list.
- **`meta` (9 rows):** the §0.4 Group D list (incl. `upc.reestablishment.rop`).
### 1.1 Edge calls
- **`upc.ccr.cfi` (id 35)** — stays `kind='proceeding'` with the existing routing-to-`upc.inf.cfi` from t-paliad-204 §0.3 S1 (the determinator surfaces it, the mapping returns inf.cfi's id with `with_ccr=true`). Rationale: the routing layer is already built and m ratified it 2026-05-18. This design does not re-open that decision. §9 Q7 lets m revisit.
- **`upc.pl.cfi` (Schutzschrift, id 188)** — borderline. Schutzschrift is filed *before* a proceeding exists; it's a defensive pre-litigation filing. Recommendation: keep as `kind='proceeding'` (it has its own RoP path + its own deadlines once seeded). The alternative — calling it `side_action` of a not-yet-existing inf.cfi — is semantically backwards. §9 Q3.b lets m revisit.
- **`upc.bsv.cfi` (saisie, id 179)** vs **`upc.evidence.cfi` (id 178)** — bsv stays `kind='proceeding'` (own RoP filing under R.192-198), evidence stays `kind='side_action'` (the orders a court makes inside any proceeding under R.190). The codes are not duplicates.
### 1.2 What the categorisation buys
- **Mode B R3 (Fristenrechner overhaul, t-paliad-322)** queries `proceeding_types WHERE is_active AND kind='proceeding'` and gets a clean 22-row pick list — no phase/side-action/meta noise.
- **`projects.proceeding_type_id` integrity** is enforceable: an FK + CHECK (or a triggered constraint, see §3.3) blocks setting a project's type to anything except `kind='proceeding'`.
- **`pkg/litigationplanner` snapshot generator** filters identically; youpc.org's catalog stays UPC-primary-only with no leakage of phase/admin rows.
- **Determinator + dropdowns** get a forward-compatible filter; future feature work (e.g. "show me all side-actions available in this proceeding") becomes a different query against the same table.
- **Forward-compatibility for new rows** — when corpus for a side-action arrives (e.g. `upc.evidence.cfi` gains 4 sequencing_rules with `condition_expr='evidence_order_issued'`), the rules anchor on the *parent* primary, not on the side-action row. The kind classification stays correct; the side-action row remains a taxonomic label.
---
## 2. Model choice — Model 1 (kind discriminator)
### 2.1 The four candidate models, scored
| Model | Schema churn | Models phase parentage? | Mode B R3 filter | Migration risk | Verdict |
|---|---|---|---|---|---|
| **1. `kind` discriminator on `proceeding_types`** | One column + CHECK constraint | No, but doesn't need to | `WHERE kind='proceeding'` | Trivial — UPDATE only | **Recommended** |
| 2. Self-referencing `parent_id` | One column + FK + CHECK | Yes, but parentage is wrong shape (phases are phase-of-EVERY-CFI, not of one) | `WHERE parent_id IS NULL` | Trivial | Over-modelled |
| 3. Separate tables | Three new tables + view/JOINs | Yes, fully | Just query `proceeding_types` | Migration churn + every consumer query learns a new shape | Overkill for 28 unused rows |
| 4. Move phases into `procedural_events` | One mass row-move + DELETE | n/a (phases vanish from `proceeding_types`) | Trivial | Highest — would touch event_kind taxonomy and Fristenrechner result-view structure | Wrong shape (phases ≠ events) |
### 2.2 Why Model 1 wins
The fundamental observation: **the 28 non-primary rows have zero downstream pressure**. No rule, no project, no concept, no spawn FK references them. They exist in the table as taxonomic placeholders — names someone wrote down so future corpus could attach. We don't need to physically restructure the table; we just need to label what's what so consumers can filter correctly.
Model 1 gives us exactly that with one column. The other models pay schema/migration cost to model a parent-child relationship that **no consumer queries**. Mode B R3 doesn't ask "what are the phases of upc.inf.cfi?" — it asks "what are the proceedings I can pick?". The Fristenrechner result view doesn't ask the proceeding-types table about phases — phases live inside `procedural_events.event_kind` and the priority-bucket sub-sections in the §4.2 of the Fristenrechner overhaul doc.
Model 2's `parent_id` is wrong in shape: `upc.cfi.interim` doesn't have ONE parent (`upc.inf.cfi`), it has SEVEN parents (every CFI proceeding). Modelling that as a self-reference would force either (a) duplicating the phase rows per primary, or (b) using NULL parent_id for "applies to all". Both options are uglier than just dropping parent_id and trusting `kind='phase'`.
Model 3's separate tables would create rich relations that no consumer reads. Premature relational normalisation.
Model 4 would force phases into `procedural_events`, but phases aren't events. A phase is a *bucket of events*. The bucket is already implicit in the `event_kind` column (filing → interim, hearing → oral, decision → decision). If anything, Model 4 is *backwards* — phases should disappear into `event_kind`, not become event rows. The way to "delete" the phase rows from proceeding_types is just to deactivate them (or mark them `kind='phase'`); we don't need to re-locate them into another table to claim that conceptual move.
### 2.3 What we don't do — physical deletion
The 28 non-primary rows are NOT dropped from the table. They:
- Get tagged with the right `kind` value.
- Optionally get `is_active=false` flipped (m's call, §9 Q9).
- Stay in the table so consumers that historically referenced them by id (admin tools, audit logs, future schema-rescue scripts) keep working.
`DROP` is a one-way door we don't need to walk through. The CHECK constraint + kind tagging gives us the same logical cleanliness with none of the irreversibility risk.
---
## 3. Schema sketch + migration plan
### 3.1 DDL — the new column
```sql
-- Migration NNN_proceeding_types_kind.up.sql
-- (NNN = whatever MAX(version) + 1 is at write time; see project-status.md
-- for the live numbering. As of 2026-05-26 the head is mig 152 per the
-- recent dedupe of identical sequencing_rule clones.)
ALTER TABLE paliad.proceeding_types
ADD COLUMN kind text NOT NULL DEFAULT 'proceeding'
CHECK (kind IN ('proceeding', 'phase', 'side_action', 'meta'));
COMMENT ON COLUMN paliad.proceeding_types.kind IS
'Structural classification — see docs/design-proceeding-types-taxonomy-2026-05-26.md §1. '
'proceeding = self-contained matter (own filing + deadline tree); '
'phase = stage inside a primary CFI proceeding; '
'side_action = application/order inside a proceeding; '
'meta = RoP mechanics, court admin, cross-cutting remedies.';
CREATE INDEX proceeding_types_kind_active_idx
ON paliad.proceeding_types(kind, is_active)
WHERE is_active = true;
```
The DEFAULT keeps existing inserts (admin tooling, snapshot tests) safe: any new row defaults to `proceeding`. The CHECK enforces the vocabulary at write time.
### 3.2 Data move — UPDATE statements, no INSERT/DELETE
```sql
-- Phases (per m's Q2 carve-out: upc.costs.cfi (176) is NOT a phase, it stays primary)
UPDATE paliad.proceeding_types
SET kind = 'phase'
WHERE id IN (173, 174, 175, 185); -- §0.4 Group B minus 176
-- Side-actions
UPDATE paliad.proceeding_types
SET kind = 'side_action'
WHERE id IN (178, 182, 177, 184, 165, 170, 180, 181, 187, 183); -- §0.4 Group C
-- Meta / cross-cutting
UPDATE paliad.proceeding_types
SET kind = 'meta'
WHERE id IN (162, 161, 163, 168, 164, 166, 167, 186, 169); -- §0.4 Group D
-- Primaries (incl. m's Q2 carve-out for upc.costs.cfi) stay on the DEFAULT
-- 'proceeding' value — no UPDATE needed.
-- Per m's Q9: deactivate the non-primary rows so the admin list surfaces only
-- primaries. The kind column carries the semantic info; is_active controls UI
-- visibility. Reversible — flip is_active back on if a row gains corpus.
UPDATE paliad.proceeding_types
SET is_active = false
WHERE kind IN ('phase', 'side_action', 'meta');
```
Per m's Q9, the `is_active=false` flip is mandatory in this mig. After it: 23 active rows (all `kind='proceeding'`), 23 inactive rows (the phase/side_action/meta set), in addition to the pre-existing inactive appeal-triplet + archived bucket. The `kind` column tells consumers what each row IS; `is_active` tells consumers whether to show it.
### 3.3 Optional integrity constraints
If m wants stronger guarantees that `projects.proceeding_type_id` can only point at primaries, add a deferrable FK validator. Cleanest pattern in Postgres:
```sql
-- Option A: trigger-based check (works for any kind set, deferred-friendly).
CREATE OR REPLACE FUNCTION paliad.assert_project_type_is_proceeding()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.proceeding_type_id IS NOT NULL THEN
PERFORM 1 FROM paliad.proceeding_types
WHERE id = NEW.proceeding_type_id AND kind = 'proceeding';
IF NOT FOUND THEN
RAISE EXCEPTION 'projects.proceeding_type_id must reference a kind=proceeding row, got id=%', NEW.proceeding_type_id
USING ERRCODE = '23514';
END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER projects_proceeding_type_kind_check
BEFORE INSERT OR UPDATE OF proceeding_type_id ON paliad.projects
FOR EACH ROW EXECUTE FUNCTION paliad.assert_project_type_is_proceeding();
```
Per m's Q8: **trigger on `projects` only**, no symmetric enforcement on `sequencing_rules`. Projects are written via the public app (the surface most exposed to operator error); rules are edited via the admin `/admin/procedural-events` surface which already validates against active+published lifecycle. The single trigger is enough.
### 3.4 Migration sequencing — single self-contained mig
One migration file:
```
internal/db/migrations/153_proceeding_types_kind.up.sql
internal/db/migrations/153_proceeding_types_kind.down.sql
```
Up does ALTER + UPDATE + (optional) trigger creation. Down does DROP COLUMN (cascading the trigger if present). No data loss on either direction — the kind column is purely additive.
Mig number depends on what knuth lands first; the coder reads `MAX(version)` at write time per the project's mig conventions.
---
## 4. FK reparenting tables
There is no reparenting to do. Below for completeness:
| Source table.column | Pointing at non-primary rows? | Action |
|---|---|---|
| `sequencing_rules.proceeding_type_id` | **0 active rules** (verified §0.1) | None |
| `sequencing_rules.spawn_proceeding_type_id` | **0 active rules** point at non-primaries; 4 active rules point at id=11 (inactive `upc.apl.merits`) | Pre-existing drift, out of scope (§8) |
| `projects.proceeding_type_id` | **0 projects** (all 6 distinct values are primaries) | None |
| `event_category_concepts.proceeding_type_code` | **0 concepts** point at non-primary codes; 30 concepts point at `upc.apl.merits/order/cost` codes (which are inactive but conceptually primaries) | Pre-existing drift, out of scope (§8) |
The "FK reparent" section of the acceptance criteria in m/paliad#147 is a no-op for this design: the 28 rows being re-classified have **no incoming references** to reparent. The migration is pure relabelling.
---
## 5. Worked example — `upc.cfi.interim` after the mig
### 5.1 Today (broken)
Someone created the row `upc.cfi.interim` (id 173, name "CFI - Zwischenverfahren") in `paliad.proceeding_types` with `category='fristenrechner'`. The intent was probably "we'll attach interim-phase rules here later". Result:
- The row appears in the Mode B R3 wizard chip strip (if R3 queries `WHERE is_active=true AND jurisdiction='UPC'`) — confusing to the user, because "Zwischenverfahren" is not a proceeding they pick; it's a stage their proceeding passes through.
- The row could be set as `projects.proceeding_type_id` (no FK constraint forbids it today) — corrupting the SmartTimeline's lane logic, which assumes the project's type is a primary.
- The row appears in admin /admin/proceeding-types lists, polluting the primary-proceedings overview.
### 5.2 After mig 153
The migration runs:
```sql
UPDATE paliad.proceeding_types SET kind = 'phase' WHERE id = 173;
-- Optionally: UPDATE paliad.proceeding_types SET is_active = false WHERE id = 173;
```
Now:
- Mode B R3 query becomes `WHERE is_active=true AND jurisdiction = $1 AND kind='proceeding'`. `upc.cfi.interim` is filtered out — it is not a "Verfahren" the user can pick.
- A future admin who tries to set a project's `proceeding_type_id = 173` either fails the optional trigger from §3.3 (with a clear error) or gets a code-level rejection from `ProjectService.SetProceedingType` (which the coder will harden to filter by `kind='proceeding'`).
- The `pkg/litigationplanner` snapshot generator filter becomes `WHERE is_active=true AND category='fristenrechner' AND kind='proceeding' AND jurisdiction IN ('UPC')`. The row never makes it into the youpc.org catalog.
The row itself stays in the database. Its id is stable. Future work that wants to *use* the phase row as a taxonomic label (e.g. "show me which event_kinds map to which UPC phases") gets a clean shape: query `WHERE kind='phase' AND code LIKE 'upc.cfi.%'`.
### 5.3 Where interim-phase deadlines actually live
The user-facing concept "interim phase" is already modelled correctly, just elsewhere:
- A `procedural_events` row like `upc.inf.cfi.soc` (Statement of Claim) has `event_kind='filing'`. The Fristenrechner overhaul (t-paliad-322 §4.2) groups follow-ups by priority + presents them under the trigger card. There is no UI element that needs a "Zwischenverfahren" proceeding-type label to operate.
- A future "show me the full ablauf of UPC inf, broken down by phase" feature can derive phases from `procedural_events.event_kind` ordering + the rule sequence_order. The `proceeding_types` table doesn't need to carry the phase labels.
---
## 6. Consumer impact
### 6.1 `projects.proceeding_type_id`
| Concern | Before | After mig 153 |
|---|---|---|
| Valid values | Any active proceeding_types row | Any `kind='proceeding'` active row (22 rows) |
| Enforcement | None at DB level | Optional trigger (§3.3 / §9 Q8) |
| Code-level filter in ProjectService | No filter on kind | Filter to `kind='proceeding'` when listing pickable types |
| Existing data | 6 distinct values (all in 22) | No change — all 6 are kind='proceeding' |
| SmartTimeline lane logic | Assumes primary-proceeding shape | Assumption now FK-enforceable |
**No data migration on existing projects.** The 6 currently-used proceeding types are all in the primary set.
### 6.2 `sequencing_rules.proceeding_type_id` + `spawn_proceeding_type_id`
| Concern | Before | After mig 153 |
|---|---|---|
| `proceeding_type_id` valid values | Any active row | Any active row (no enforcement change; admin curation suffices) |
| `spawn_proceeding_type_id` valid values | Any active row | Same — spawns conceptually must point at a primary, but enforcement stays in admin tooling |
| Existing data | 157 rules anchored on 18 primaries | No change — all 157 already on `kind='proceeding'` rows |
| `id=11 spawn pressure` (`upc.apl.merits`, inactive) | 4 active spawn rules point here | Pre-existing drift, out of scope (§8) |
No `sequencing_rules` table changes accompany this mig. The post-mig invariant **"every active rule's `proceeding_type_id` is a `kind='proceeding'` row"** holds without any UPDATE.
### 6.3 Fristenrechner Mode B R3 (t-paliad-322, knuth's S3+)
§3.2 R3 of the Fristenrechner overhaul says:
> Chips: every active `proceeding_type` whose jurisdiction matches R2 AND whose event roster contains at least one event with R1's kind.
After mig 153, the R3 query gains one more AND-clause:
```sql
SELECT pt.id, pt.code, pt.name, pt.name_en, pt.sort_order
FROM paliad.proceeding_types pt
WHERE pt.is_active = true
AND pt.kind = 'proceeding' -- NEW
AND pt.jurisdiction = $1 -- from R2
AND EXISTS (
SELECT 1 FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
WHERE sr.proceeding_type_id = pt.id
AND pe.event_kind = $2 -- from R1
AND sr.is_active = true
)
ORDER BY pt.sort_order, pt.code;
```
The `kind='proceeding'` filter is the only line that changes. Knuth's S3 implementation reads from this query; the chip pool shrinks from "all 35 active UPC types" to "the 14 primary UPC types that have rules" (still narrowed further by R1's event_kind via the EXISTS subquery).
No coder churn beyond adding the AND-clause. The mig 153 lands either alongside knuth's S3 work or independently (§7 sequencing decision).
### 6.4 Litigation Planner suite (t-paliad-292)
The package's catalog snapshot generator (`pkg/litigationplanner/scripts/snapshot/main.go`) currently filters:
```go
// scripts/snapshot/main.go
const proceedingTypesQuery = `
SELECT id, code, name, name_en, jurisdiction, default_color, sort_order, display_order,
trigger_event_label_de, trigger_event_label_en
FROM paliad.proceeding_types
WHERE is_active = true
AND category = 'fristenrechner'
AND jurisdiction = $1
`
```
After mig 153, this query gains the same `AND kind = 'proceeding'` line. The UPC snapshot shrinks from "potentially 35 rows" to a clean primary-only set. Today's snapshot probably already includes the phase/side-action/meta rows (since `is_active=true` is true for all of them) — depending on whether a snapshot has been regenerated since the 161-188 rows landed, the embedded JSON may be carrying decorative rows that the youpc.org catalog never resolves to rules. Mig 153 + a snapshot regen cleans this up.
The package's `Catalog.Proceeding(ctx, code, hint)` interface stays unchanged. A youpc-side call asking for `code='upc.cfi.interim'` previously returned the row + zero rules (technically valid but useless); after mig 153 the snapshot doesn't include it and the call returns `ErrUnknownProceedingType`. That's the correct shape — youpc users never had a reason to ask for a phase row.
The scenarios design (`paliad.scenarios.spec.proceedings[].code`) gains an integrity check at write time: the validator already asserts every code resolves to an active proceeding; now it additionally asserts `kind='proceeding'`. A user trying to compose a scenario with `code='upc.cfi.interim'` gets a clear error. (The validator is paliad-side, not library-side — see Litigation Planner doc §5 "Validatable at write time".)
### 6.5 Admin /admin/procedural-events list (recently shipped, t-paliad-321)
The proceeding-type column in the admin list (m/paliad#144 follow-up, just landed) renders one of the 46 active codes per row. Post-mig 153, the admin filter dropdown can:
- Default to showing only `kind='proceeding'` rows (clean primary view).
- Offer a "show all kinds" toggle for admins triaging the non-primary rows.
This is presentation-only — the underlying admin queries don't need to change immediately. The kind column is a forward-compat hook.
### 6.6 Knowledge-platform pages (Gerichtsverzeichnis, Patentglossar)
Untouched. None of those pages query `proceeding_types` directly.
### 6.7 Fristen export / paliad data export (t-paliad-279)
Untouched. The exporter dumps `proceeding_types` as a whole (no kind-filter); after mig 153 it dumps the same rows with the new kind column. Forward-compat by default.
---
## 7. Migration sequencing decision vs m/paliad#146
m/paliad#146 (Fristenrechner overhaul, t-paliad-322 / 323) is on the S1-S6 train under knuth. m's directive at task brief time: **knuth pauses at the S1+S2 seam waiting for this taxonomy decision**.
Three options were on the table:
(a) **Pause #146 until taxonomy clean** — knuth blocked, this design lands first, then knuth resumes S3+.
(b) **Land #146 against current shape, migrate later** — knuth ships S3-S6 against the current 46-row table, taxonomy mig follows.
(c) **Land taxonomy in parallel, knuth re-targets if needed** — both run, knuth's S3 picks up the new filter when mig 153 is ready.
**Recommendation: (c) parallel-land** with the following caveats:
- The taxonomy mig is **additive** (ADD COLUMN with safe DEFAULT, no DROP, no data move beyond UPDATEs that touch unreferenced rows). Knuth's S3 implementation can be written with or without the `kind='proceeding'` filter — adding the filter is a one-line patch the moment mig 153 lands.
- The R3 chip-pool query in knuth's S3 PR should be **future-proofed by also adding the `kind='proceeding'` filter behind a feature flag or an env-time SQL constant**, defaulting to "no filter" pre-mig and "filter" post-mig. (Or simpler: knuth writes the filter unconditionally; the migration lands first; ordering is mechanical.)
- The mig 153 PR should land **before** knuth's S3 PR ships to main, so the filter is never false-positive (chipping phase rows users can't actually pick). Both PRs can be drafted in parallel; the squeeze happens at merge time.
- Sequence on main: mig 153 → knuth S3 (with filter) → knuth S4-S6.
Option (c) keeps knuth productive (S3 work can start immediately after this design ratifies; doesn't have to wait for the mig to merge) and avoids the option (a) idle cost.
Option (b) was rejected because it leaves the Mode B R3 wizard chipping 35 UPC rows on initial release — exactly the bug m flagged in m/paliad#147 ("half of the 46 active proceeding_types are not primary proceedings"). The user would see phase rows in R3 day one of the Fristenrechner overhaul shipping; we'd be shipping the bug.
Option (a) was rejected as the safest but slowest path. The taxonomy mig is trivial enough (one ALTER + four UPDATE statements + optional trigger) that parallel-running has no real risk.
§9 Q10 gives m the chance to pick differently.
---
## 8. Out of scope (flagged for separate work)
- **`upc.apl.*` data drift.** 30 rows in `paliad.event_category_concepts` reference the inactive `upc.apl.merits` / `upc.apl.order` / `upc.apl.cost` codes (the pre-`upc.apl.unified` triplet). 4 active sequencing_rules reference `spawn_proceeding_type_id=11` (the inactive `upc.apl.merits` row). This is a pre-existing inconsistency from the appeal unification mig — needs its own follow-up ticket. Not blocking this design; can be cleaned up in a separate migration that retargets concepts + spawn FKs to `upc.apl.unified` (id=160).
- **Renaming or relabelling primary proceedings.** Out per m/paliad#147 acceptance — editorial work, not structural.
- **Adding new proceeding types beyond the existing corpus.** Out per m/paliad#147 acceptance.
- **The Fristenrechner UI overhaul itself (m/paliad#146).** Separate track; this design only tells knuth's S3 what set to chip.
- **The scenarios design (m/paliad#124).** Already ratified in `docs/design-litigation-planner-2026-05-26.md` §5; this design only refines the spec validator's "every code resolves to a primary" check.
- **DROPing the non-primary rows physically.** Reversible deactivation via `kind=...` + optional `is_active=false` is enough; physical deletion adds irreversibility risk for no functional gain.
- **Migration of `event_category_concepts.proceeding_type_code` to a real FK.** It's text today, joined softly; converting to FK is a separate hardening task.
---
## 9. Open questions for m (10 decision questions)
Sent via `AskUserQuestion` in 3 batches per inventor SKILL contract (4+3+3). m's picks land in §10 below after the round-trip.
| # | Topic | Recommended pick |
|---|---|---|
| Q1 | Model choice | Model 1 (kind discriminator) |
| Q2 | Phases — linear sub-phases of every CFI, or separately-elected? | Implicit: phases live in `procedural_events.event_kind`, not as proceeding_types |
| Q3.a | Side-actions — triggered by parent event, or initiated out-of-band? | Mixed; today's data has no rules, future rules anchor on the parent primary with `condition_expr` |
| Q3.b | `upc.pl.cfi` (Schutzschrift) — primary or side-action? | Primary (own RoP filing pathway) |
| Q4 | Collapse `de.inf.lg`/`olg`/`bgh` into one `de.inf` with instance_level qualifier? | No — keep discrete |
| Q5 | Collapse `de.null.bpatg`/`bgh` into one `de.null` with instance_level qualifier? | No — keep discrete |
| Q6 | Should DE follow the `upc.apl.unified` pattern? | No (= keep discrete, locks Q4+Q5) |
| Q7 | `upc.ccr.cfi` — proceeding row with routing (status quo), or `with_ccr` flag on `upc.inf.cfi`? | Keep as proceeding (status quo per t-paliad-204 S1) |
| Q8 | Enforce `projects.proceeding_type_id``kind='proceeding'` at the DB level? | Yes, via trigger (§3.3) |
| Q9 | Set `is_active=false` on the 28 non-primary rows after mig 153? | Yes (cleanest admin UX) |
| Q10 | Sequencing vs m/paliad#146 — pause / parallel / re-target? | (c) parallel-land — mig first, then knuth S3 with filter |
Q11 in the issue body ("how many rules need new condition_expr disambiguation?") is **empirically answered, no decision needed**: 0 rules need new condition_expr — every active rule is already correctly anchored to a primary. Surfaced in §4 + §6.2.
---
## 10. m's decisions (2026-05-27)
All 11 questions answered via `AskUserQuestion` on 2026-05-27 09:52 (3 batches of 4+4+3). 10 of 11 picks = recommendation; Q9 diverged at the chip-picker but m's follow-up instruction ("I follow your recommendation") flips Q9 to the recommendation as well. Q2 carries a precise carve-out captured verbatim below.
- **Q1 (Model): Model 1 — kind discriminator.** [= recommendation] One column + CHECK constraint + UPDATE statements. **Locks §1, §2, §3.1, §3.2.**
- **Q2 (Phases): Generally option 1 (implicit via `procedural_events.event_kind`), with carve-outs.** [≈ option 1 with carve-out] m's verbatim call:
> Generally 1, but I agree with costs which are not only a phase but also "standalone" side proceedings. But default decision application is not.
Concretely:
- `upc.cfi.interim` (173) → `kind='phase'`
- `upc.cfi.oral` (174) → `kind='phase'`
- `upc.cfi.decision` (175) → `kind='phase'`
- `upc.default.cfi` (185) → `kind='phase'` (m: "default decision application is not [a standalone side proceeding]")
- **`upc.costs.cfi` (176) → `kind='proceeding'`** (m: "costs are not only a phase but also standalone side proceedings"). The Separate Kostenentscheidung can be filed as its own application under R.151 RoP independently of the parent decision; m's read is that the standalone-application character outweighs the phase-of-CFI character.
Net: 4 phase rows (not 5 as in the strawman), 23 primary-proceeding rows (not 22). **Updates §0.4 Group B count, §0.5 totals row, §1 categorisation, §3.2 UPDATE statement IDs (drop 176 from the phase UPDATE).**
- **Q3.a (Side-actions): kind='side_action', rules anchor on parent primary.** [= recommendation] All 10 §0.4 Group C rows get `kind='side_action'`. When corpus arrives, rules attach to the parent primary with a `condition_expr` flag. **Locks §1.1, §3.2 side-action UPDATE.**
- **Q3.b (Schutzschrift): kind='proceeding'.** [= recommendation] `upc.pl.cfi` (188) stays in the primary set on the strength of its own RoP filing pathway. **Locks §0.3 unloaded-primary list.**
- **Q4 (DE inf collapse): Keep discrete.** [= recommendation] `de.inf.lg/olg/bgh` stay as 3 separate primaries. No collapse, no instance_level qualifier introduction. **Locks §0.2 + §1 DE-side categorisation.**
- **Q5 (DE null collapse): Keep discrete.** [= recommendation] `de.null.bpatg/bgh` stay separate. Symmetric with Q4. **Locks §0.2 + §1 DE-side categorisation.**
- **Q6 (DE follow upc.apl pattern): No — keep DE discrete.** [= recommendation] Locks Q4+Q5. The `upc.apl.unified` consolidation was about same-court appeal variants; DE appeals are different-court-instance appeals — different problem. **No code-rename work falls out of this design.**
- **Q7 (CCR shape): Keep status quo.** [= recommendation] `upc.ccr.cfi` stays as `kind='proceeding'` with the existing routing-to-`upc.inf.cfi` from t-paliad-204 §0.3 S1. **Locks §1.1.**
- **Q8 (DB trigger): Trigger on `projects` only.** [= recommendation] BEFORE INSERT/UPDATE trigger on `paliad.projects` enforces `proceeding_type_id → kind='proceeding'`. No trigger on `sequencing_rules` (admin tooling already gates). **Locks §3.3 — keep the `projects` trigger DDL, drop the optional `sequencing_rules` variant.**
- **Q9 (Deactivate non-primaries): Yes — deactivate.** [m's chip-pick was "keep active"; flipped to recommendation per m's "I follow your recommendation" instruction] All `kind IN ('phase', 'side_action', 'meta')` rows get `is_active=false` in mig 153. The admin `/admin/proceeding-types` list shows only the 23 active primaries. Rows stay in the table with their `kind` tag so future tooling that wants to surface them can flip `is_active` back on. **Updates §3.2 — uncomment the optional `UPDATE … SET is_active=false` block.**
- **Q10 (Sequencing vs #146): Parallel-land.** [= recommendation] Mig 153 + knuth's S3 PR drafted in parallel; mig merges first; knuth's S3 includes the `kind='proceeding'` filter in R3's chip query from day one. No idle cost; no bug shipped. **Locks §7.**
### 10.1 What changed from the strawman as a result
Two material edits flow from m's picks:
1. **§0.4 Group B (Phases) drops `upc.costs.cfi` (id 176)** — moved into the primary set. Phase count: 5 → 4. Primary count: 22 → 23. §0.2 picks up id 176 as an unloaded primary (zero rules today; future corpus will attach).
2. **§3.2 migration includes the `is_active=false` UPDATE** (was optional in the strawman, now mandatory):
```sql
UPDATE paliad.proceeding_types
SET is_active = false
WHERE kind IN ('phase', 'side_action', 'meta');
```
This is what the post-mig 153 cleanup looks like: 23 active rows (all `kind='proceeding'`), 23 inactive rows (4 phase + 10 side_action + 9 meta + the pre-existing 3 inactive appeal-triplet + 1 archived bucket = 27 inactive total, but 23 of those are the freshly-deactivated taxonomy rows).
These edits don't change the §7 sequencing decision or the §6 consumer-impact analysis. They tighten the mig file and shift one row's classification.
### 10.2 Final categorisation (post-decisions)
| `kind` | Count | Codes |
|---|---:|---|
| `proceeding` | **23** | upc.inf.cfi, upc.rev.cfi, upc.pi.cfi, upc.dmgs.cfi, upc.disc.cfi, upc.ccr.cfi, upc.apl.unified, upc.dni.cfi, upc.epo.review, upc.bsv.cfi, upc.pl.cfi, **upc.costs.cfi** (m's Q2 carve-out), de.inf.lg, de.inf.olg, de.inf.bgh, de.null.bpatg, de.null.bgh, epa.opp.opd, epa.opp.boa, epa.grant.exa, dpma.opp.dpma, dpma.appeal.bpatg, dpma.appeal.bgh |
| `phase` | **4** | upc.cfi.interim, upc.cfi.oral, upc.cfi.decision, upc.default.cfi |
| `side_action` | **10** | upc.evidence.cfi, upc.experiments.cfi, upc.security.cfi, upc.intervention.rop, upc.parties.change, upc.optout.cfi, upc.inspection.cfi, upc.freezing.cfi, upc.withdrawal.rop, upc.rehearing.coa |
| `meta` | **9** | upc.case.mgmt, upc.general.rop, upc.service.rop, upc.language.rop, upc.representation.rop, upc.fees.court, upc.legalaid.cfi, upc.special.cfi, upc.reestablishment.rop |
| **Total** | **46** | ✓ |
Post-mig 153: 23 active (all `kind='proceeding'`), 23 deactivated (the phase/side_action/meta set).
---
## 11. Synthesis links
- mBrian topic: `topic-fristenrechner` — file this design as a `[synthesis]` node, link `related_to` the proceeding-code-taxonomy doc (2026-05-18) and the Fristenrechner overhaul (2026-05-26), `triggered_by` t-paliad-324.
- Related design docs: `docs/design-proceeding-code-taxonomy-2026-05-18.md` (the code-shape doc), `docs/design-fristenrechner-overhaul-2026-05-26.md` (knuth's parent design), `docs/design-litigation-planner-2026-05-26.md` §5 (scenarios spec validator).
- Related migrations: 095 (fristen gap-fill, spawn FK invariant), 096 (proceeding code rename), 152 (sequencing_rule dedupe + admin column).

View File

@@ -0,0 +1,686 @@
# Project metadata rework — Client Role + auto-derived project codes
Status: design, ready for head review (2026-05-20)
Task: t-paliad-222
Issues: m/paliad#47 (Client Role) + m/paliad#50 (project codes)
Branch: `mai/kepler/inventorcoder-project`
Pairs two related changes because both touch `paliad.projects` schema, the
project form, and downstream consumers (Fristenrechner Determinator,
submission templates, Verlauf, picker / breadcrumb surfaces). One design,
two migrations, one coder shift.
---
## §1 Scope & non-goals
In scope:
- Drop "Wir vertreten" entirely on `type='client'`, `'litigation'`, `'patent'`.
- Rename to "Client Role" / "Mandantenrolle" on `type='case'` with new
option set (Active / Reactive / Third Party / Other).
- Widen `paliad.projects.our_side` CHECK to the new sub-role values; drop
`'court'` and `'both'`; backfill existing rows to NULL.
- Add `paliad.projects.opponent_code text` on `type='litigation'` rows
(segment source for project codes).
- New Go helper `services.BuildProjectCode(ctx, projectID) (string, error)`
that walks the ancestor chain via the existing ltree `path` and assembles
the dotted code. Custom `paliad.projects.reference` on the project itself
wins.
- Wire the helper into project header, breadcrumb, picker labels, the
submission-template variable bag (`{{project.code}}`), and the Excel
export `__meta` sheet.
Out of scope (handled separately or dropped):
- Reshaping `paliad.parties` (per-party role rows are unchanged).
- New analytics / reports breaking out sub-roles.
- Bulk-renaming user-facing copy that says "Klägerseite" /
"Beklagtenseite" outside the project form.
- Reverse lookup (project by code) — already works via `reference`.
- Audit-history for who changed an override and when — not requested.
- Bulk regeneration of existing `reference` strings — manual entries stay
intact; auto-derive only fills empty slots.
- Renaming the `our_side` DB column — see §2.2 / Q1.
---
## §2 Issue #47 — Client Role rework
### §2.1 Current state (verified 2026-05-20)
- Column: `paliad.projects.our_side text`, CHECK constraint
`projects_our_side_check` allows `('claimant','defendant','court','both',NULL)`
(mig 072).
- Live data audit (`SELECT our_side, count(*) FROM paliad.projects
GROUP BY our_side`): **all 12 rows are NULL**. Zero rows on
`'court'` or `'both'` — backfill is a no-op. The migration is risk-free
on the current dataset.
- Form: rendered for every project type by
`frontend/src/components/ProjectFormFields.tsx:156-168` (one
`<select id="project-our-side">` with five static `<option>`s, no
conditional render).
- Downstream consumers (verified by grep on `our_side` /
`OurSide` in `internal/` and `frontend/src/`):
- `frontend/src/client/fristenrechner.ts:2187,2734,3754-3776` —
Determinator Slice 3c, `ourSideToPerspective()` maps
`claimant → claimant`, `defendant → defendant`, anything else
(incl. `'court'`, `'both'`, NULL) → `null` (chip free-pick).
- `internal/services/submission_vars.go:276-278,390-418` —
`{{project.our_side_de}}` / `_en` legal-prose forms. `ourSideDE` /
`ourSideEN` switch on the 4 enum values.
- `internal/services/project_service.go:1083-1104` —
`our_side_changed` project-event row on writes.
- `internal/services/project_service.go:1228,1372,1955-` — CCR
counterclaim child default-inverts `our_side`; `nullableOurSide()`
and `isValidOurSide()` (`project_service.go:1915`) gate writes.
### §2.2 Decisions
**Q1 — Rename column `our_side → client_role`?**
**Pick: NO. Keep `our_side`.** Renaming forces churn in eleven Go files,
the Determinator client bundle (`fristenrechner.ts` type literal +
`ourSideToPerspective`), all submission-template tests
(`submission_render_test.go:275`), the project-event title key
(`event.title.our_side_changed`), and every `{{project.our_side*}}` template
that exists in the wild on user systems. The label is purely UI; the column
name is internal. Future grep stays clean because the new label
("Client Role") and the column (`our_side`) describe the same concept from
different perspectives ("which side the firm represents" =
"what role the client plays"). Keeping the column avoids a 200-line
mechanical rename with non-trivial risk for zero functional gain. The
i18n keys *do* rename (`projects.field.our_side` → `projects.field.client_role`)
so user-facing copy stays clean.
**Q2 — Sub-role granularity (7 distinct values vs 3 groups)?**
**Pick: 7 sub-roles** — `claimant, defendant, applicant, appellant,
respondent, third_party, other`. Lawyers care about the specific
procedural posture; Applicant ≠ Claimant in some UPC contexts (e.g. PI
applications use "Applicant"). Group-level aggregation is trivial at
display time (`switch role { case claimant, applicant, appellant:
return "Active" }`). Storing the group only would be a lossy choice we
cannot reconstruct from.
**Q3 — Project types where the field is visible?**
**Pick: ONLY `type='case'`.** m's wording is unambiguous ("only plays a
role in case projects — and even there the question should be 'Client
Role'"). Hide on `client`, `litigation`, `patent`, and the generic
`project` type. The client-level "industry / country" block stays as is
(those are client-attributes, not procedural roles). The form already
has `projekt-fields-case` conditional render (`ProjectFormFields.tsx:143`)
— moving the role select into that block is a 4-line change.
**Q4 — Existing `'court'` / `'both'` row backfill?**
**Pick: backfill to NULL** in the same migration that widens the CHECK.
Zero rows in production (verified 2026-05-20), so the backfill is a
no-op today; it's there for safety if any test fixture or
not-yet-deployed instance has them. No audit-event emission for the
backfill (it's schema cleanup, not user action).
**Q5 — Determinator perspective mapping for new sub-roles?**
**Pick: Active group → `claimant`, Reactive group → `defendant`, Third
Party / Other → `null` (chip free-pick).** Concretely:
- `claimant`, `applicant`, `appellant` → perspective `'claimant'`
- `defendant`, `respondent` → perspective `'defendant'`
- `third_party`, `other`, NULL → perspective `null`
This keeps the Determinator's existing claimant-rule / defendant-rule
filter logic unchanged; only `ourSideToPerspective()`'s switch widens.
**Q6 — Submission template `_de` / `_en` prose for new sub-roles?**
| value | `_de` (Nominativ) | `_en` |
|---------------|-------------------------------|---------------|
| `claimant` | Klägerin | Claimant |
| `defendant` | Beklagte | Defendant |
| `applicant` | Antragstellerin | Applicant |
| `appellant` | Berufungsklägerin | Appellant |
| `respondent` | Antragsgegnerin | Respondent |
| `third_party` | Streithelferin | Third Party |
| `other` | sonstige Verfahrensbeteiligte | other party |
Existing `'court'`/`'both'` switch arms get deleted (no live rows; if a
stale `our_side='court'` slipped through somehow, the function returns
`""` — same fallback as today for unknown values).
### §2.3 Migration `112_client_role_rework`
```sql
-- 112_client_role_rework.up.sql (renumbered 2026-05-20 — mig 110 was claimed by m/paliad#51, mig 111 by m/paliad#48)
-- t-paliad-222 / m/paliad#47.
-- Widens projects.our_side CHECK to seven sub-role values and drops
-- the legacy 'court' / 'both' entries. Backfill is a no-op on the
-- current dataset (verified 2026-05-20: all 12 rows are NULL), but
-- runs defensively in case any test fixture / staging instance still
-- carries the old values.
BEGIN;
-- 1. Backfill any 'court' / 'both' rows to NULL. Idempotent.
UPDATE paliad.projects
SET our_side = NULL
WHERE our_side IN ('court', 'both');
-- 2. Drop the old CHECK, add the widened one. Both are idempotent
-- against partially-applied state.
ALTER TABLE paliad.projects
DROP CONSTRAINT IF EXISTS projects_our_side_check;
ALTER TABLE paliad.projects
ADD CONSTRAINT projects_our_side_check
CHECK (our_side IS NULL OR our_side IN (
'claimant', 'defendant',
'applicant', 'appellant',
'respondent',
'third_party', 'other'
));
COMMENT ON COLUMN paliad.projects.our_side IS
'Which side the firm represents on this case project (renamed in '
'the UI to "Client Role" — t-paliad-222 / m/paliad#47). Allowed '
'sub-roles, grouped at display time: Active (claimant, applicant, '
'appellant); Reactive (defendant, respondent); Third Party / Other '
'(third_party, other). NULL = unknown. Hidden in the form on '
'non-case project types. Drives the Fristenrechner Determinator '
'perspective chip (Active→claimant, Reactive→defendant, else null).';
COMMIT;
```
The down migration restores the original 4-value CHECK and, for
defensive symmetry, backfills any new sub-role values to NULL (so the
schema is internally consistent when stepped down).
### §2.4 Frontend changes
`frontend/src/components/ProjectFormFields.tsx`:
1. Move the `<div className="form-field">` containing
`#project-our-side` from the always-visible block (line 156) into
the `projekt-fields-case` block (after the court / case-number
row).
2. Rename label `data-i18n="projects.field.our_side"` →
`projects.field.client_role`.
3. Replace the five flat `<option>`s with three `<optgroup>`s + the
seven new options + an "Unbekannt" empty option.
4. Update the hint text to mention the Determinator group mapping
(Active/Reactive).
`frontend/src/client/i18n.ts` — add new keys (DE + EN):
```
projects.field.client_role → "Mandantenrolle" / "Client Role"
projects.field.client_role.hint → "..."
projects.field.client_role.unset → "Unbekannt" / "Unknown"
projects.field.client_role.group.active → "Aktiv (wir greifen an)" / "Active (we initiate)"
projects.field.client_role.group.reactive → "Reaktiv (wir verteidigen)" / "Reactive (we defend)"
projects.field.client_role.group.other → "Dritte / Sonstige" / "Third Party / Other"
projects.field.client_role.claimant → "Klägerseite" / "Claimant"
projects.field.client_role.applicant → "Antragsteller" / "Applicant"
projects.field.client_role.appellant → "Berufungsführer" / "Appellant"
projects.field.client_role.defendant → "Beklagtenseite" / "Defendant"
projects.field.client_role.respondent → "Antragsgegner" / "Respondent"
projects.field.client_role.third_party → "Streithelfer / Dritter" / "Third Party"
projects.field.client_role.other → "Sonstige Beteiligte" / "Other party"
```
The legacy `projects.field.our_side.*` keys stay deprecated-but-present
for one release so any cached browser bundle keeps rendering. They get
deleted in a follow-up housekeeping shift once the rollout is confirmed.
`frontend/src/client/project-form.ts:182-230` — adjust the payload
read/write to only include `our_side` when the field is in the DOM
(non-case forms no longer emit it). The current code does
`if (v) payload.our_side = v` which already handles the "field absent"
case gracefully (osSel becomes `null`, no payload key set).
`frontend/src/client/fristenrechner.ts:3754-3776` —
`ourSideToPerspective` switch widens:
```ts
function ourSideToPerspective(os: string | null | undefined): Perspective {
switch (os) {
case "claimant":
case "applicant":
case "appellant":
return "claimant";
case "defendant":
case "respondent":
return "defendant";
default:
return null;
}
}
```
`frontend/src/projects-detail.tsx` Verlauf — the `our_side_changed`
event description currently renders the raw enum. Update the renderer
to use a label lookup so "Mandant: Beklagte → Antragsteller" reads
correctly. Same `event.title.our_side_changed` key stays (the *title*
is "Vertretene Seite geändert" / "Represented side changed", which is
still accurate semantically).
### §2.5 Backend changes
`internal/services/project_service.go:1915` — `isValidOurSide()` widens
its allowlist:
```go
case "", "claimant", "defendant",
"applicant", "appellant",
"respondent",
"third_party", "other":
return nil
```
`internal/services/project_service.go:1372` —
`derivedCounterclaimOurSide()` (CCR flip logic): widen the flip map to
mirror the Determinator grouping:
- claimant ↔ defendant (current behaviour)
- applicant ↔ respondent
- appellant → defendant (CCR against an appellant is rare; pick
the most-likely procedural posture; can be overridden by
explicit `flip_our_side=false`)
- third_party / other / NULL → keep as-is (no flip)
`internal/services/submission_vars.go:391-418` — `ourSideDE` /
`ourSideEN` switch arms add the five new values per the table in
§2.2 Q6. `'court'` and `'both'` arms get deleted.
`internal/services/project_service.go:1083-1104` — `our_side_changed`
audit emission unchanged (it just records old → new on the column).
`frontend/build.ts` — no change; bundling already picks up
`projects.field.client_role.*` i18n keys via `i18n-keys.ts` regeneration.
`frontend/src/i18n-keys.ts` — regenerate via existing scripted path
(adds the new keys, keeps the legacy ones as deprecated entries until
the housekeeping pass).
### §2.6 Tests
- `internal/services/submission_render_test.go:275` —
`TestOurSideTranslations` widens the table to cover the 7 new values
in both DE and EN.
- `internal/services/projection_service_unit_test.go:319` —
`TestDerivedCounterclaimOurSide` widens to cover the new flip map.
- New: `TestProjectFormHidesOurSideForNonCase` — unit test on the
project-form payload reader confirms `our_side` is silently dropped
when the form renders for a non-case project type.
### §2.7 Acceptance (issue #47)
- [x] Creating a project of `type='client'`, `'litigation'`, `'patent'`,
`'project'` does **not** show the field.
- [x] Creating a project of `type='case'` shows the field labelled
"Mandantenrolle" (DE) / "Client Role" (EN) with three optgroups
and seven options.
- [x] Existing `'court'` / `'both'` rows (none in prod, but defensive)
are migrated to NULL.
- [x] Submission templates referencing `{{project.our_side_de}}` /
`_en` render coherent prose for the five new values.
- [x] Determinator perspective chip pre-fills correctly from each
sub-role (Active→claimant, Reactive→defendant, Other→null).
- [x] CCR counterclaim flip yields a sensible child role for the new
sub-roles.
- [x] `go build && go test ./internal/... && cd frontend && bun run
build` clean.
---
## §3 Issue #50 — Auto-derived project codes
### §3.1 Current state (verified 2026-05-20)
- `paliad.projects.reference text` exists and is informally used (live
values: `EXMPL` on a client, `L-2026-001` on a litigation, `C-UPC-0001`
on a case, `P-EP1111222` on a patent). No format enforcement.
- `paliad.projects.path ltree` is maintained by a Postgres trigger
(`projects.path` joined UUIDs root-to-self). Walking ancestors in Go
is straightforward: `SELECT * FROM paliad.projects WHERE path @>
$1::ltree ORDER BY nlevel(path)`.
- No `opponent` field exists anywhere. Opponent text lives only inside
the litigation `title` (e.g. "Siemens AG ./. Huawei Technologies").
- `paliad.proceeding_types.code` is dot-separated:
`upc.inf.cfi`, `upc.rev.cfi`, `de.inf.lg`, `upc.apl.merits`, etc.
Splitting on `.` and upper-casing yields `INF`, `REV`, `LG`,
`APL.MERITS`. Suitable as the case segment.
- `paliad.projects.court text` is free-text on cases (live values:
`UPC`, `UPC CoA`, `LG München I`). Not normalised; use the
proceeding_type code instead — it carries the same info structurally.
### §3.2 Decisions
**Q1 — Litigation opponent source: new column or regex on title?**
**Pick: new column `paliad.projects.opponent_code text` on litigation
rows.** Regex on title is brittle ("./.", "v.", "vs", "—", varying
order) and the user already knows the short code at creation time. New
field with explicit validation (slug-cased, max 16 chars) is clean and
takes one form field + one migration. Title stays as the human-readable
caption; `opponent_code` is the machine-readable segment source.
NULL → segment skipped silently.
**Q2 — Patent segment: always last 3, or last-N variable?**
**Pick: last 3 digits when the digit-stream is ≥ 4 digits long; full
digit-stream when shorter.** m's example (`EP3456789 → 789`) is 7
digits last-3 = 789 ✓. UPC publication numbers (10+ digits) collapse to
their last 3 just fine — uniqueness inside the same litigation tree is
near-certain because the same litigation tree won't hold two patents
sharing the same last-3. If it ever does, the user can set a custom
`reference` (Q5). No need for last-4 / last-N logic.
The patent-number regex extracts the digit-stream from any common
format (`EP1234567`, `EP 1 234 567`, `EP1234567A1`, `WO2020/123456A1`):
strip non-digits, take last 3 (or whole if shorter), upper-cased.
**Q3 — Case segment from `proceeding_types.code`?**
**Pick: take `proceeding_types.code` (e.g. `upc.inf.cfi`), split on `.`,
drop the leading jurisdiction segment, uppercase the rest, join with
`.`.** Examples:
- `upc.inf.cfi` → `INF.CFI`
- `upc.rev.cfi` → `REV.CFI`
- `upc.pi.cfi` → `PI.CFI`
- `upc.apl.merits` → `APL.MERITS`
- `de.inf.lg` → `INF.LG`
- `de.inf.olg` → `INF.OLG` (appeal instance → segment already
encodes "OLG", so we get the appeal level for free; no separate
instance segment needed)
The jurisdiction is dropped because the parent client/patent already
implies the jurisdiction context. If the user wants explicit
jurisdiction in the code, custom `reference` wins.
If `proceeding_type_id` is NULL on the case, segment is omitted
silently. No fallback to `court` text — that's free-text and noisy.
**Q4 — Override semantics: wholesale or per-segment?**
**Pick: wholesale.** When `paliad.projects.reference` is non-empty on
the project the helper is asked about, that string is returned
verbatim — no auto-derivation, no string-concatenation, no merging.
Per-segment override doubles the implementation complexity for a UX
nobody asked for. Users who want partial overrides set the
`reference` on the relevant ancestor and let the rest auto-derive
naturally.
**Q5 — Where the user types the override?**
**Pick: existing `paliad.projects.reference` field.** Already there,
already labelled "Interne Referenz (optional)", already used by users.
Adding a second "project_code_override" alongside `reference` would
confuse the form. The hint text gets a small addendum: "Leer lassen
für automatischen Code aus dem Projekt-Baum."
**Q6 — Collision handling (two cases derive to the same code)?**
**Pick: advisory in v1; no disambiguator.** Codes are display-only
(not a primary key, not a unique constraint). Real-world collisions
inside the same litigation tree are vanishingly rare; if they happen,
the user notices in the picker and sets a custom `reference` on one.
Adding `-N` suffixes silently would mask a data issue the user should
see. A future surface could flag duplicates as a project-detail warning,
but it's not in v1.
**Q7 (new) — Helper signature and call site?**
**Pick: `ProjectService.BuildProjectCode(ctx context.Context, projectID
uuid.UUID) (string, error)`.** Lives on the existing ProjectService
(it needs DB access for the ancestor walk). Internally builds segments
with a small `projectCodeSegment(p Project) string` pure function per
type that's table-test-friendly. The helper is called from the
projection layer when a project gets serialised for the API
(adds a `code` field to the JSON), so every surface — header,
breadcrumb, picker, dashboard tile, Excel export — gets the code for
free without each surface re-walking the tree. Pricier than a
display-time call but eliminates N+1 walks in list views.
**Q8 (new) — Cache strategy?**
**Pick: no cache in v1.** Each ancestor walk is one indexed lookup
on `paliad.projects(path)`. With 12 projects in prod and order-of-100s
in any plausible firm-scale future, this is microsecond-cheap. If
profiling later shows it as a hotspot in list views (which fetch many
projects), introduce a materialised view
`paliad.projects_derived_codes(project_id, derived_code)` refreshed by
trigger on `projects` writes. Don't pre-optimise.
### §3.3 Migration `113_projects_opponent_code`
```sql
-- 113_projects_opponent_code.up.sql (renumbered 2026-05-20)
-- t-paliad-222 / m/paliad#50.
-- Add an opponent-code field on litigation projects. Used as the
-- middle segment when assembling auto-derived project codes from the
-- ancestor tree (e.g. EXMPL.OPNT.567.INF.CFI). NULL = segment is
-- skipped silently. No backfill — existing litigation rows simply
-- yield codes without an opponent segment until the user sets one.
BEGIN;
ALTER TABLE paliad.projects
ADD COLUMN IF NOT EXISTS opponent_code text;
-- Slug-shape gate: uppercase letters, digits, dashes, max 16 chars.
-- Matches the style of m's example "OPNT". Keeps the auto-code clean.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'projects_opponent_code_check'
AND conrelid = 'paliad.projects'::regclass
) THEN
ALTER TABLE paliad.projects
ADD CONSTRAINT projects_opponent_code_check
CHECK (opponent_code IS NULL
OR (opponent_code ~ '^[A-Z0-9-]{1,16}$'
AND type = 'litigation'));
END IF;
END $$;
COMMENT ON COLUMN paliad.projects.opponent_code IS
'Short slug for the opposing party on a litigation project '
'(uppercase letters, digits, dashes, max 16 chars). Used as the '
'middle segment when BuildProjectCode walks the ancestor tree to '
'assemble a dotted project code (t-paliad-222 / m/paliad#50). '
'NULL = segment skipped silently.';
COMMIT;
```
The down migration drops the constraint then the column.
### §3.4 Go helper
New file `internal/services/project_code.go`:
```go
// Package-level function (not a method) so it can be called from any
// service that already has a *sqlx.DB. ProjectService has a thin
// wrapper that calls into this.
//
// BuildProjectCode assembles the dotted ancestor code for projectID
// from the existing paliad.projects.path ltree. If the target row's
// reference column is non-empty, it wins outright (no derivation).
// Missing ancestor segments are skipped silently — there is no
// "unknown" placeholder.
func BuildProjectCode(ctx context.Context, db sqlx.QueryerContext, projectID uuid.UUID) (string, error)
// projectCodeSegment is the per-type segment derivation. Pure, table-
// test friendly, never touches the DB.
//
// client → opts.PreferShortReference (reference if set, else slug(title))
// litigation → opts.PreferShortReference (opponent_code if set, else "")
// patent → last 3 digits of patent_number (full digits if <4)
// case → uppercase tail of proceeding_types.code (jurisdiction segment dropped)
// project → "" (generic projects don't contribute a segment)
//
// proceedingCode is only needed for case rows; the caller resolves
// it via a single join (or a cached small lookup) before calling.
func projectCodeSegment(p models.Project, proceedingCode string) string
```
Sanitisation helpers live alongside as unexported funcs:
- `sanitizeClientShort(s string) string` — uppercase, strip diacritics
via `golang.org/x/text/unicode/norm` + filter, replace non-alnum
with `-`, trim, cap at 8 chars. Already similar to what
`internal/util/slug` does for the global slug helper.
- `patentLast3(s string) string` — strip non-digits, take last 3
characters (or the whole digit-stream when shorter); uppercase.
Empty → "".
- `proceedingTail(code string) string` — split on `.`, drop element 0
(jurisdiction), uppercase + join the rest. `""` → `""`.
`BuildProjectCode` SQL is a single round-trip:
```sql
SELECT p.id, p.type, p.title, p.reference, p.opponent_code,
p.patent_number, p.proceeding_type_id,
pt.code AS proceeding_code
FROM paliad.projects p
LEFT JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
WHERE p.path @> (SELECT path FROM paliad.projects WHERE id = $1)
ORDER BY nlevel(p.path);
```
It returns the chain root-to-target. The function:
1. If the last row (the target) has non-empty `reference` → return it
verbatim. Done.
2. Otherwise walk the chain top-to-bottom, call `projectCodeSegment`
on each row, skip empty segments, join with `.`, return.
### §3.5 Wiring into surfaces
- `internal/services/project_service.go` projection — add a `Code`
string field to the read-side struct and populate it in the single
fetch path. For list endpoints, do **one** ancestor-chain query per
page (CTE that groups by target id) rather than N+1.
- `internal/services/submission_vars.go:277` — add
`bag["project.code"] = derefString(p.Code)` so submission templates
can reference `{{project.code}}`.
- `frontend/src/components/ProjectHeader.tsx` (current header
component on `/projects/{id}`) — render `code` next to the title
(small monospace badge) if non-empty.
- `frontend/src/components/Breadcrumb*.tsx` — when rendering the
trail, use `project.code` as the trailing badge per segment if the
caller asks for it (opt-in to avoid breaking other consumers).
- `frontend/src/client/project-form.ts` and any project-picker
typeahead — show `code · title` in the dropdown labels when `code`
is non-empty.
- Excel `__meta` sheet — add a `Project Code` row (already enumerates
project metadata).
The "copy reference" affordance in the header gets a second line: if
both `reference` (user override) and the auto-derived code differ, both
are visible (override above, derived below, smaller).
### §3.6 Tests
- `TestProjectCodeSegment` (table) — every project type × multiple
shapes (with/without reference, NULL ancestors, patent_number
formats, proceeding codes with 1/2/3 segments).
- `TestBuildProjectCodeFullChain` — fixture tree
Client → Litigation → Patent → Case yields `EXMPL.OPNT.567.INF.CFI`.
- `TestBuildProjectCodeRespectsOverride` — non-empty `reference` wins
outright.
- `TestBuildProjectCodeMissingAncestors` — case directly under client
(no litigation, no patent) yields `EXMPL.INF.CFI`.
- `TestBuildProjectCodeCollisionDoesNotDisambiguate` — two sibling
cases with identical derived codes both return the same string (v1
contract per Q6).
- Migration sanity test (existing harness in
`internal/db/migrations_test.go` if present) — up → down → up.
### §3.7 Acceptance (issue #50)
- [x] `BuildProjectCode` returns `EXMPL.OPNT.567.INF.CFI` for the
reference tree (Client EXMPL → Litigation OPNT → Patent
EP1234567 → Case `upc.inf.cfi`).
- [x] Setting `projects.reference = 'CUSTOM-CODE'` on the case
returns `CUSTOM-CODE` verbatim.
- [x] Missing ancestor segments are skipped silently
(no `..` collapses, no "?" placeholder).
- [x] `{{project.code}}` resolves in submission templates.
- [x] Project header, breadcrumb, picker, Excel `__meta` all show the
code when set/derived.
- [x] Litigation form has a new "Opponent Code" field (DE:
"Gegner-Kürzel") with the slug pattern validation. Hidden on
non-litigation types.
- [x] `go build && go test ./internal/... && cd frontend && bun run
build` clean.
---
## §4 Open questions for the head
(Head: default to the §2.2 / §3.2 "Pick" recommendations unless something
material pushes back. Coder shift only after head signs off.)
1. **§2.2 Q1** — Keep column name `our_side`? (Recommend YES; rename
touches 11+ Go files + bundled-template wire format for zero gain.)
2. **§2.2 Q2** — Store 7 sub-roles? (Recommend YES; group-only is
lossy.)
3. **§2.2 Q3** — Hide the field on `litigation` and `patent` too, not
just on `client`? (Recommend YES per m's "only on case projects".)
4. **§2.2 Q6** — German prose forms use feminine grammatical gender
(Klägerin, Beklagte) per the existing translation table? Or
masculine / neutral? (Recommend feminine to match existing
`ourSideDE` — keeps consistency with already-rendered templates.)
5. **§3.2 Q1** — Add a dedicated `opponent_code` column on
litigations? (Recommend YES; regex-on-title is brittle.)
6. **§3.2 Q2** — Patent segment = last 3 digits (variable for
<4-digit numbers)? (Recommend YES, matches m's example.)
7. **§3.2 Q3** — Case segment drops the jurisdiction prefix from
`proceeding_types.code` (so `upc.inf.cfi` → `INF.CFI`, not
`UPC.INF.CFI`)? (Recommend YES — jurisdiction is implied by the
ancestor client/patent context.)
8. **§3.2 Q7** — `BuildProjectCode` populates a `code` field on every
projected Project JSON (not lazy per-render)? (Recommend YES;
simpler consumers, one DB round-trip per list page.)
9. **§3.2 Q8** — No cache / materialised view in v1? (Recommend YES;
profile later if list views get slow.)
---
## §5 Implementation order (coder phase)
1. **Mig 112** (client role widen + backfill) → mig 113 (opponent_code).
*Renumbered twice on 2026-05-20 — mig 110 claimed by m/paliad#51 project_type_other; mig 111 claimed by m/paliad#48 project_admin_and_select; boltzmann's gap-tolerant runner hard-fails on collisions so this is a strict rebump.*
Run `ls internal/db/migrations/ | tail` first to verify slot
availability (boltzmann's gap-tolerant runner means 110 is fine
even if 109 was the last applied).
2. **Backend** — `isValidOurSide`, `ourSideDE/EN`,
`derivedCounterclaimOurSide`, new `project_code.go` package
+ ProjectService wiring + projection `Code` field.
3. **Frontend** — `ProjectFormFields.tsx` (conditional render + new
options + opponent_code field on litigation block), `i18n.ts` keys,
`fristenrechner.ts` `ourSideToPerspective` widen, header /
breadcrumb / picker code-badge wiring.
4. **Tests** — pinning tests above; `go test ./internal/...` clean.
5. **Build verification** — `go build && cd frontend && bun run build`
clean.
6. **Commit per slice** — three commits (migration + backend, frontend,
tests) keep review tractable.
---
## §6 Risks & rollback
- **Submission templates in the wild.** Users may have downloaded /
customised submission templates that still reference
`{{project.our_side_de}}` for `our_side='court'` or `'both'`. After
this change those values are unreachable, so the template arm
returns `""`. Already the fallback behaviour for unknown values;
no breakage, just an empty render. Mention in release notes.
- **Browser cache.** Users with a stale bundle still see the old
"Wir vertreten" form for one cache-bust cycle. The legacy i18n keys
stay until housekeeping (§2.4), so labels still resolve.
- **Migration down path.** Stepping down from 110 restores the old
4-value CHECK; new sub-role rows would violate it. The down
migration backfills new sub-roles → NULL to stay consistent.
- **Per-tree opponent_code uniqueness.** Two litigations under the
same client with the same `opponent_code` would derive identical
case codes. Per Q6 we accept this; users see it in the picker and
customise `reference` if it bothers them.
- **No new env vars, no Dokploy compose change** — both changes are
pure code + schema; deploy is the existing main-push → webhook →
Dokploy auto-redeploy path.

View File

@@ -0,0 +1,306 @@
# Rubrum + Briefkopf auto-fill — gap map (templates ↔ var bag ↔ data model)
**Task:** t-paliad-357 · **Author:** kepler (researcher) · **Date:** 2026-06-01
**Status:** AUDIT + GAP-MAP ONLY. No code or template edits made. Head reviews
before any wiring.
m's ask (2026-06-01 12:01): the **current** document templates should fill the
**letterhead (Briefkopf)** and **recitals/Rubrum (case caption)** from project
data on generation — "filled depending on project". This is the
content-correctness layer, downstream of the (code-complete) docforge engine and
parallel to leibniz's nomen naming train.
---
## TL;DR — the one fork for m
**The basic Rubrum is *already* wired and works today** (party names,
representatives, role designations, case number, court, patent number — all
data-driven, in both the demo per-code template and the Composer caption seed).
**The letterhead is *not* data-driven at all** (the real HL letterhead is
hardcoded inside the firm-skeleton's Word header/footer parts; `firm.signature_block`
is empty). And the Rubrum we have is only a *basic* caption — a forum-correct one
needs structured data paliad does not capture.
So the decision is **how complete a Rubrum we target**:
| | **Option A — wire what data already supports** | **Option B — forum-correct Rubrum** |
|---|---|---|
| Rubrum content | name · representative · role · case no. · court · patent | + structured address · Rechtsform · Sitz (registered office) · gesetzl. Vertreter · service addresses · court chamber/address |
| Data model | **no new columns** — uses existing `parties.*` + `project.*` | **new structured fields** on `parties` (+ maybe `projects`) + capture UI |
| Letterhead | tidy the existing path (firm.name/signature_block) | same as A (letterhead is orthogonal to the A/B choice) |
| Effort | small — mostly template-seed wording + plug `firm.signature_block` | a proper feature — schema migration + party-form rework + Composer reseed |
| Forum-correctness | a *workable* caption, not a *filing-correct* one | meets UPC RoP r.13 / ZPO §253 party-designation requirements |
Everything in Slice 12 below is Option A and is independent of the decision.
Option B is Slice 3+ and is the part that needs m's go/no-go.
---
## 1. Architecture — there are THREE fill realities, not one
The audit's biggest correction to the starting mental model: "the templates" are
not one thing, and the letterhead does **not** live where the Rubrum lives.
### Path 1 — legacy one-click `/generate` → `merge.go` (`SubmissionRenderer.Render`)
- Handler `submissions.go:316``resolveSubmissionTemplate``RenderProjectSubmission`
`renderer.Render` (`pkg/docforge/docx/merge.go`).
- **Substitutes `{{key}}` tokens in `word/document.xml` *and* in `word/header*.xml`
/ `word/footer*.xml`** (`isWordXMLEntry`, merge.go:189). So this path *can* fill a
letterhead in a Word header — **if the header contains `{{placeholders}}`. None
of the shipped headers do** (see §2).
- Template chosen by a 6-tier fallback (`submission_drafts.go:1341`): per-(code,lang)
→ per-code → EN-skeleton → firm-skeleton → universal-skeleton → HL-Patents-Style.
### Path 2 — Composer → `compose.go` (`Composer.Compose`)
- Draft editor with a `base_id` set (t-paliad-313/315/317). Handler
`submission_drafts.go:712``submissionComposer.Compose`.
- Assembles `word/document.xml` from the draft's **`paliad.submission_sections`
rows** (one per section: letterhead, caption, …), splicing each into the
carrier's `{{#section:KEY}}` anchor, then substitutes `{{placeholder}}` inside the
section bodies.
- **Headers/footers pass through byte-for-byte UNTOUCHED** (compose.go:68, :188).
So a Composer doc keeps the base .docx's letterhead chrome verbatim — it is
never data-driven on this path.
- Section bodies are seeded on draft-create from the base's
`section_spec.defaults[*].seed_md_{de,en}` (migrations 146 / 150).
### Path 3 — skeleton as a direct merge fallback (a latent bug)
- For any submission_code **without** a per-code template, `/generate` (Path 1)
falls through to tiers 4/5 and renders the **firm/universal skeleton through
merge.go**. But those skeletons contain only `{{#section:letterhead}}`-style
*block markers*, which `placeholderRegex` (`[A-Za-z]…`) does **not** match (they
start with `#`). **Result: the output Word doc shows literal
`{{#section:letterhead}}` … text.** Only `de.inf.lg.erwidg` has a real per-code
template today, so every other code's one-click `/generate` is exposed to this.
⚠️ **Flag to verify with head** — may be masked if `/generate` is only surfaced
for codes that have a per-code template.
> **Implication for m's ask:** "fill the letterhead from project data" means
> different work on each path. On Path 1 it means *putting `{{firm.*}}` placeholders
> into a header part*. On Path 2 it means *the letterhead is a body section already*
> (and the chrome stays hardcoded in the base). These should be reconciled, not
> both wired blindly — see Slice 2.
---
## 2. Gap table — TEMPLATE side
Fetched live from mgit (`m/mWorkRepo`, `6 - material/Templates/Word/…`), unzipped,
inspected `document.xml` + every `header*.xml`/`footer*.xml`.
| Template | Has header/footer? | Letterhead | Rubrum / caption | Verdict |
|---|---|---|---|---|
| **`HLC/de.inf.lg.erwidg.docx`** (per-code, the only wired code) | no | *pseudo*-letterhead inline in body: `{{firm.name}} — Patentstreitsachen`, Bearbeiter `{{user.display_name}}`, `{{user.email}}`, `{{user.office}}`, `{{today.long_de}}`. `{{firm.signature_block}}` in closing (renders empty). | **full inline Rubrum, all data-driven**: `{{parties.claimant.name}}` / `.representative`, `— Klägerin —`, `gegen`, `{{parties.defendant.name}}` / `.representative`, `— Beklagte —`, `Weitere Beteiligte: {{parties.other.name}}`, `{{project.court}}`, `Aktenzeichen: {{project.case_number}}`, `{{project.patent_number}}`. | Works — but body-banner is **labelled "DEMO — interne Vorlage (nicht freigegeben)"**, not a real letterhead. |
| **`HLC/_firm-skeleton.docx`** (Composer base `hlc-letterhead`) | **yes** — header1/2, footer1/2 | **Real HL letterhead, fully HARDCODED**: footer firm name is a Word SDT content-control literal "Hogan Lovells"; footer2 = static HL entity boilerplate (registered office, 50+ office cities); header2 = logo image only. **Zero `{{placeholders}}` in any header/footer.** | body `document.xml` has only `{{#section:KEY}}` markers (empty). Caption content comes from the section seed (§Composer). | Letterhead present but **not data-driven & not firm-agnostic** (contradicts `branding.Name` goal). |
| **`HLC/_skeleton.docx`** (Composer base `neutral`) | no | none | `{{#section:KEY}}` markers only | Composer scaffold; unusable via merge.go (Path 3 bug). |
| **`Composer/lg-duesseldorf.docx`** (base `lg-duesseldorf`, de.inf.lg) | no | none | `{{#section:KEY}}` markers only | Composer scaffold; letterhead must come from a header it doesn't have, or the body section. |
| **`Composer/upc-formal.docx`** (base `upc-formal`, upc.inf.cfi) | no | none | `{{#section:KEY}}` markers only | same. |
| **`HL Patents Style.dotm`** (last-ditch tier 6) | yes (same HL header/footer as firm-skeleton) | hardcoded HL letterhead | no placeholders | letterhead-only fallback. |
| `HLC/_skeleton.en.docx` | **404 — does not exist** | — | — | EN drafts silently fall back to the DE skeleton (matches code comment at files.go:104). |
**Template-side takeaways**
1. The Rubrum is template-complete on the demo per-code path and is a DB seed (not
a template file) on the Composer path.
2. The real letterhead exists only in the firm-skeleton/`.dotm` headers and is
**100% hardcoded** — no placeholder, no `branding.Name`. A firm rename or a
non-HLC deployment ships the wrong letterhead.
3. The Composer caption/letterhead are **DB seeds (migrations 146/150)**, so
"adjusting the template" for the Composer path means editing the
`section_spec` seed Markdown, *not* the .docx.
---
## 3. Gap table — VAR-BAG side
For every placeholder a correct letterhead + Rubrum needs, is there a bag key?
Bag built in `internal/services/submission_vars.go`.
| Need (letterhead + Rubrum) | Bag key | Status |
|---|---|---|
| Firm name | `firm.name` (← `branding.Name`) | ✅ wired |
| Firm signature block | `firm.signature_block` | ⚠️ **key exists but emits `""`** (reserved "Phase 2", submission_vars.go:324). Template references it → renders blank. |
| Author name / email / office | `user.display_name` / `.email` / `.office` | ✅ wired |
| Date (today, long DE/EN, ISO) | `today` / `.long_de` / `.long_en` / `.iso` | ✅ wired |
| Claimant name / rep (first + indexed + joined) | `parties.claimant.name`, `parties.claimant.0.name` / `.representative`, `parties.claimants` / `.representatives` | ✅ wired (3 forms, addPartyVars) |
| Defendant name / rep | `parties.defendant.*` (same 3 forms) | ✅ wired |
| Other parties (Streithelfer, Patentinhaberin…) | `parties.other.*` / `parties.others` | ✅ wired |
| Case number | `project.case_number` | ✅ wired |
| Court (name) | `project.court` | ✅ wired (free-text string) |
| Patent number (DE + UPC forms) | `project.patent_number` / `.patent_number_upc` | ✅ wired |
| Proceeding type / instance | `project.proceeding.name(_de/_en/.code)`, `project.instance_level` | ✅ wired |
| Our side (DE/EN prose) | `project.our_side_de` / `_en` / raw | ✅ wired |
| Client / matter / internal ref | `project.client_number` / `.matter_number` / `.reference` | ✅ wired |
| **Party postal address** | — | ❌ **NO key** (needs data model) |
| **Party legal form (Rechtsform)** | — | ❌ **NO key** |
| **Party registered office / Sitz** | — | ❌ **NO key** (UPC r.13.1(a)/(b)) |
| **Statutory representative (gesetzl. Vertreter, e.g. Geschäftsführer)** | — | ❌ **NO key** |
| **Address/person for service (Zustellungsbevollmächtigter)** | — | ❌ **NO key** (UPC r.13.1(c)/(d)) |
| **Court full address / chamber / Spruchkörper** | — | ❌ **NO key** (only the court *name* string exists) |
| **Firm letterhead address / contact block** | — | ❌ **NO key** (hardcoded in .docx header) |
**Var-bag takeaways:** every placeholder the *current* templates use is wired,
with one dud: **`firm.signature_block` always renders empty** — the single cheapest
letterhead/closing win. Everything a *forum-correct* Rubrum additionally needs has
**no key, because the data isn't captured** (§4).
---
## 4. Gap table — DATA-MODEL side
`models.Party` (models.go:539) carries **only**: `Name`, `Role`, `Representative`,
`ContactInfo json.RawMessage`. `models.Project` carries `Court *string` (free text),
`CaseNumber`, `PatentNumber`, dates, `OurSide`, `InstanceLevel`, client/matter.
- **`parties.contact_info` is a dormant jsonb column**: `PartyService.Create`
defaults it to `{}` and **no UI ever writes it** (party form captures only
Name / Role / Representative — `frontend/src/projects-detail.tsx:436460`). It is
a ready-made parking spot, but it is structurally empty today.
- **No court registry / court-address table exists.** `project.court` is a plain
string a user types.
| Forum-correct Rubrum needs | Derivable from existing fields? | Park in `contact_info` jsonb? | Needs new column + capture UI? | Cost |
|---|---|---|---|---|
| Party **postal address** | ❌ | ✅ feasible (`{address:{street,zip,city,country}}`) | UI: add fields to party form | **LowMed** — jsonb, no migration; party-form + bag resolver |
| Party **Rechtsform** (GmbH, LLP…) | ❌ (sometimes inside Name string, unreliable) | ✅ | UI field | **Low** |
| Party **Sitz / registered office** (UPC r.13.1(a/b)) | ❌ | ✅ | UI field | **LowMed** |
| Party **statutory representative** (Geschäftsführer / vertreten durch …) | ⚠️ partial — `Representative` today means the *lawyer/Prozessbevollmächtigter*, not the *organ*; conflating them is wrong | ✅ (`{statutory_rep:…}`) | UI field + relabel existing `representative` | **Med** — semantic untangle |
| **Address for service / Zustellungsbevollmächtigter** (UPC r.13.1(c/d)) | ❌ | ✅ | UI field | **LowMed** |
| **Court full address** | ❌ | n/a (project-level) | new `projects.court_address` col **or** a courts lookup table | **Med** (col) / **High** (registry) |
| **Court chamber / Spruchkörper / panel** | ❌ | n/a | new `projects.court_chamber` col | **LowMed** |
| Firm letterhead address block | ❌ | n/a | `branding`-level config (env or table) | **Med** — touches firm-agnostic story |
**Recommendation on storage:** structured party attributes belong in **typed jsonb
under `contact_info`** with a small Go struct (`models.PartyContact`) decoding it —
not a column-per-attribute migration. It keeps the party table stable, is
forum-shape-agnostic, and the bag resolver can emit `parties.claimant.0.address`,
`.sitz`, `.rechtsform`, etc. Court chamber/address are project-level and small
enough for two nullable columns; a full court **registry** is a separate, larger
feature (nice for autofill + validation, not required for a correct caption).
---
## 5. Forum-dependence — does one parametric Rubrum cover UPC / LG / OLG / BPatG?
Grounded sources: **UPC RoP Rule 13** ("Contents of the Statement of claim") pulled
verbatim from the house laws corpus (`data.laws`, `UPCRoP.013.*`). German ZPO/PatG
caption conventions below are **standard German civil-procedure practice — these are
NOT in the youpc corpus** (which is UPC/EPC-only), so they are flagged as
practitioner-convention, to be confirmed by a DE-litigation reviewer (lexy) before
wording is finalised.
**What UPC RoP r.13.1 demands (verified):**
- (a) claimant name; if corporate, **location of registered office**; + claimant's representative
- (b) defendant name; if corporate, **location of registered office**
- (c) **postal + electronic addresses for service** on claimant + persons authorised to accept service
- (d) postal/electronic service addresses on defendant + persons authorised, if known
- (e) proprietor service addresses where claimant ≠ (sole) proprietor
- (g) details of the patent including the **number**
- (k) nature of the claim / order / remedy sought
→ paliad today supplies only **name** (a/b) and **patent number** (g). It captures
**none** of: registered office/Sitz, postal/electronic service address, persons
authorised. So a *filing-correct* UPC caption is firmly **Option B** territory.
**How the caption shape differs across forums (convention):**
| Forum | Heading | Party designations | "wegen" / subject | Court line |
|---|---|---|---|---|
| **DE LG** (Patentstreitkammer) | "In dem Rechtsstreit" / "In der Patentstreitsache" | Kläger(in) / Beklagte(r); parties need **Name, Anschrift, Rechtsform, ges. Vertreter** (ZPO §253 Abs. 2 Nr. 1, §130 Nr. 1 — *convention*) | "**wegen** Patentverletzung" | "an das Landgericht … , … Kammer" — court **name + chamber** |
| **DE OLG** (Berufung) | "In dem Rechtsstreit" | **Berufungskläger / Berufungsbeklagte** (roles flip vs. first instance) | "wegen …" | "an das Oberlandesgericht …, … Senat" |
| **BPatG** (Nichtigkeit/Beschwerde) | "In der Patentnichtigkeitssache" / "Beschwerdesache" | **Kläger/Beklagte** (nullity) or **Anmelder/Einsprechende**; patent-centric | patent + nullity ground | "an das Bundespatentgericht, … Senat" |
| **UPC CFI** | "In the matter / In der Sache" | **Claimant / Defendant (Kläger/Beklagte)**; name + **registered office** + service address (r.13) | claim nature (r.13.1(k)) | division + "Aktenzeichen" (UPC case-number format `ACT_xxxxx/2026`) |
**Answer:** one *parametric* Rubrum block covers the **basic** caption across forums
(swap designation labels + heading + court line from `our_side`/`instance_level`/
`proceeding.code` — values the bag already has). It does **not** cover the
forum-specific *content requirements* (UPC service addresses vs. ZPO Anschrift/
Rechtsform vs. BPatG patent-centric framing). For Option B, the cleanest design is
**one caption section whose seed Markdown is chosen per `proceeding_family`** (the
Composer already keys bases by `proceeding_family``de.inf.lg`, `upc.inf.cfi`),
i.e. **forum-specific caption seeds, shared resolver keys** — not a single
universal block, and not N hand-maintained .docx files.
---
## 6. Sliced wiring proposal (tracer-bullet first)
Ordered so each slice ships value alone; the A/B fork only bites at Slice 3.
**Slice 1 — plug the empty letterhead key (pure win, no schema, no fork).**
- Fill `firm.signature_block` in `addFirmVars` from `branding` (firm name + office /
a configured block) instead of hardcoding `""`. Today every template that
references it renders blank.
- Decide letterhead source of truth: either (a) inject `{{firm.name}}` /
`{{firm.address}}` placeholders into the firm-skeleton **header** parts (Path 1
fills them; Composer leaves them — acceptable since chrome is firm-fixed), or
(b) keep chrome hardcoded but make it firm-agnostic via `branding`. **Recommend
(a)** so a firm rename / non-HLC deploy doesn't ship "Hogan Lovells".
- Template edits: firm-skeleton `header1/footer1` get `{{firm.*}}` tokens. (mWorkRepo,
authored as mAi — not this repo.)
**Slice 2 — reconcile the letterhead duplication + kill the Path-3 junk.**
- The Composer seeds a body "letterhead" section *and* the base has a header
letterhead → a Composer doc can show both. Decide: drop the body letterhead
section for letterhead-bearing bases, or keep it only for `neutral`.
- Fix Path 3: either give the universal/firm skeleton a **merge-safe** variant
(real `{{key}}` Rubrum like the demo template) for non-Composer `/generate`, or
gate `/generate` to codes that have a per-code template. (Verify with head which
codes expose `/generate`.)
**Slice 3 — Option A "good basic Rubrum" (no new data).**
- Promote the demo per-code Rubrum wording into a **published, forum-labelled
caption** and align the Composer caption seeds (146/150) to the same wording.
Parametrise designation labels + heading + "wegen" + court line off
`our_side` / `instance_level` / `proceeding.code`. **No migration.**
- This is the natural stopping point if m picks **A**.
**Slice 4 — Option B data model (the feature; needs m's go).**
- Add `models.PartyContact` decoding typed `contact_info` jsonb:
`{address, rechtsform, sitz, statutory_rep, service_address, service_agent}`.
- Extend the party form (`projects-detail.tsx`) with those inputs; `PartyService`
writes them.
- Add `projects.court_address` + `projects.court_chamber` (nullable cols).
- New bag keys in `addPartyVars` / `addProjectVars`:
`parties.<role>.<i>.address|sitz|rechtsform|statutory_rep|service_address`,
`project.court_address|court_chamber`.
**Slice 5 — Option B forum-correct caption seeds.**
- Per-`proceeding_family` caption seed Markdown (UPC r.13 shape, DE-LG ZPO shape,
OLG appeal-role shape, BPatG nullity shape), consuming the Slice-4 keys.
- Reviewer (lexy) signs off DE conventions before publish.
**Slice 6 (optional) — court registry** for autofill/validation of court
name+address+chamber. Larger; not required for a correct caption.
---
## 7. Key files (for the wiring worker)
- Var bag: `internal/services/submission_vars.go` (addFirmVars:319, addPartyVars:412,
addProjectVars:349).
- Render (Path 1, fills headers): `pkg/docforge/docx/merge.go` (isWordXMLEntry:189).
- Compose (Path 2, headers pass-through): `pkg/docforge/docx/compose.go` (:68,:188);
`internal/services/submission_compose.go`.
- Template resolution: `internal/handlers/submission_drafts.go:1341`
(`resolveSubmissionTemplate`, 6 tiers); paths in `internal/handlers/files.go`.
- Composer base seeds (caption/letterhead Markdown): migrations
`internal/db/migrations/146_submission_bases.up.sql`,
`150_submission_bases_specialist.up.sql`.
- Data model: `internal/models/models.go` (Party:539, Project:80);
party form `frontend/src/projects-detail.tsx:436`.
- Live templates: `m/mWorkRepo` `6 - material/Templates/Word/Paliad/{HLC,Composer}/`.
---
## 8. Open questions for m / head
1. **A or B?** (the §TL;DR fork). A = ship a good basic caption now, no data work.
B = capture structured party/court data for a filing-correct Rubrum.
2. **Letterhead source of truth:** placeholderise the firm-skeleton header (firm-agnostic)
vs. keep hardcoded HL chrome? (Slice 1 recommends placeholderise.)
3. **Path-3 junk:** is one-click `/generate` exposed for codes lacking a per-code
template? If yes, the literal `{{#section:…}}` output is a live bug.
4. **`representative` semantics:** today it's the lawyer (Prozessbevollmächtigter).
A forum Rubrum also needs the party's *statutory* representative (Geschäftsführer).
Keep them as two distinct fields under Option B.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,668 @@
# Design — Dedicated Submission/Schriftsätze page (t-paliad-238)
**Author:** cronus (inventor)
**Date:** 2026-05-22
**Issue:** m/paliad (mai task t-paliad-238)
**Branch:** `mai/cronus/inventor-dedicated`
**Status:** DESIGN READY FOR REVIEW
**Prior art:** `docs/design-submission-generator-2026-05-19.md` (t-paliad-215). This doc deepens that design rather than replacing it — every section below references the corresponding §x there when the shape is reused.
---
## §0 TL;DR
Today's "Schriftsätze" tab on the project detail page lists each filing-type rule and offers a one-click [Generieren] that streams a clean (format-only) `.docx` of the universal HL Patents Style template. There is no customization — variables, parties, dates, optional passages: none of it is filled in. The lawyer downloads the firm style, opens it in Word, and types everything by hand.
This design adds a **dedicated submission page** at `/projects/{id}/submissions/{code}/draft` where the lawyer:
1. Picks (or creates) a named **draft** for one (project, submission_code).
2. Sees a sidebar with every `{{placeholder}}` the merge engine knows, pre-filled from the project's data (parties, court, case number, dates, legal_source) — editable inline. Auto-saved.
3. Sees a read-only preview pane showing the merged document body as HTML.
4. Clicks **Export → .docx** to download a fully-merged Word file (template + project + lawyer overrides), ready to edit.
Old [Generieren] button stays as the one-click "quick export with empty placeholders" path; the new [Bearbeiten] button next to it deep-links to the draft editor. Drafts persist as `paliad.submission_drafts` rows so the lawyer can come back next week, multiple drafts per submission code, RLS through `paliad.can_see_project`.
**Reuses** the deleted Slice 1 backend (`SubmissionVarsService` from commit `3677c81`, in-house `SubmissionRenderer` from `8ea3509`, `patent_number_upc` helper from `1765d5e`) wholesale — those files are salvageable from git history and slot back in with one new service (`SubmissionDraftService`) and the new schema. **Reuses** the `internal/handlers/files.go` Gitea proxy pattern for per-submission_code templates in `m/mWorkRepo/templates/{FIRM_NAME}/{code}.docx` (chain: firm → base/code → base/family → skeleton) — same fallback chain m locked in the 2026-05-19 design (§5).
Three slices: **A** = schema + new page + variables-only export against the universal `.dotm` (one slice; ships the editor end-to-end); **B** = per-`submission_code` `.docx` templates with the fallback-chain registry (template authoring is the bottleneck, not code); **C** = toggleable passages (boilerplate sections the lawyer can include/exclude before export).
Read-only inventor design. Implementation gate is m's go/no-go on this doc through head.
---
## §1 Premises verified live (2026-05-22)
Anchored against the running paliad codebase + youpc Supabase, not against CLAUDE.md or memory. Every claim that load-bears the design was checked against the live system.
| Claim | Verification |
|---|---|
| Today's `/api/projects/{id}/submissions` is **format-only**; no variables. | `internal/handlers/submissions.go:155-245`: handler fetches the universal `hl-patents-style.dotm` from the in-process `fileRegistry` cache, calls `services.ConvertDotmToDocx`, writes one `system_audit_log` row, streams. No project data merged. `frontend/src/client/submissions.ts` confirms the client side: POST → blob → download. The richer engine (`SubmissionVarsService` + `TemplateRegistry` + `SubmissionRenderer`) was reverted to format-only in commit `d86cac0` (t-paliad-230). |
| Original Slice 1 backend is preserved in git history and salvageable. | `git show 3677c81:internal/services/submission_vars.go` (484 LoC, 7-namespace placeholder bag), `git show 8ea3509:internal/services/submission_render.go` (in-house run-fragmentation-aware merger, ~315 LoC), `git show 3677c81:internal/services/submission_templates.go` (442 LoC fallback-chain registry), `git show 1765d5e:internal/services/submission_vars.go` (Slice 2 added `{{project.patent_number_upc}}` helper). All four files compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`) — no API drift since 2026-05-20. |
| `paliad.projects` carries everything the variable bag needs. | `internal/models/project.go:123``Title, Reference, CaseNumber, Court, PatentNumber, FilingDate, GrantDate, OurSide, InstanceLevel, ProceedingTypeID, ClientNumber, MatterNumber`. Unchanged since the original design. |
| `paliad.parties` carries party data scoped per project. | `internal/models/project.go:567` (`Party{ID, ProjectID, Name, Role, Representative, ContactInfo}`); `PartyService.ListForProject(ctx, userID, projectID)` already exists at `internal/services/party_service.go`. Visibility flows from `ProjectService.GetByID``can_see_project`. |
| `paliad.deadline_rules` published rows resolve by `submission_code`. | The same query the format-only handler uses (`internal/handlers/submissions.go:255-280`) — `lifecycle_state='published' AND is_active=true ORDER BY sequence_order LIMIT 1`. Today the corpus carries ~254 rules, ~214 published; covers DE-inf-LG, DE-inf-OLG, DE-inf-BGH, UPC-inf-CFI, DE-PatG-DPMA, DE-PatG-BPatG, EPO oppositions. |
| Migration tracker is at 106; file list extends to 118 (other-branch work) on the worktree. | `SELECT version FROM paliad.paliad_schema_migrations ORDER BY version DESC LIMIT 1` → 106. `ls internal/db/migrations/``…118_paliadin_aichat_conversation`. The next free number for *this* branch's migration is **119** (collisions only if another worktree commits 119 first, in which case the coder picks the next unused). |
| `paliad.can_see_project(uuid)` is the canonical RLS predicate. | Mig 055; every other table that gates on project visibility uses it. The new `submission_drafts` table follows the same pattern. |
| The Schriftsätze tab already exists on project detail. | `frontend/src/projects-detail.tsx:91` (`data-tab="submissions"`), section `#tab-submissions` at line 629. Empty / no-proceeding / table-of-rules states already wired. The page-level route `GET /projects/{id}/submissions` exists at `internal/handlers/handlers.go:472` and renders the same project detail page with the submissions tab pre-selected (`#tab-submissions` URL fragment + tab activation). **No new top-level route needed**; this design adds a *deeper* route `/projects/{id}/submissions/{code}/draft` and `/draft/{draftID}`. |
| `internal/handlers/files.go` carries the Gitea proxy + SHA-cache pattern. | Same template the original Slice 1 design lifted (in `templates/_base/de.inf.lg.erwidg.docx @ SHA 7f97b7f9` per memory). 5-min refresh, in-process cache, single-replica deployment. Reusable wholesale. |
| `lukasjarosch/go-docx` is NOT a deal we made. | The original 2026-05-19 design recommended it, but the shipped Slice 1 (commit `8ea3509`) went with an **in-house renderer** because the library refuses to replace sibling `{{a}} ./. {{b}}` placeholders in the same run. The in-house engine handles cross-run fragmentation in ~315 LoC. **This design reuses the in-house engine, no new Go dependency.** Memory entry `ca6de586` corroborates the engine decision verbatim. |
| `FIRM_NAME` defaults to "HLC", overridable. | `internal/branding.Name` (read once at process start). Templates land under `templates/HLC/...` for the default; the fallback chain handles per-firm overrides without code change. |
| The PoC Paliadin is owner-gated; the submission page is NOT. | `internal/services/paliadin.go:52``PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"`. This is the LLM-shell-out boundary, irrelevant to template-merge. Every paliad user who can see a project can edit its submission drafts. |
| The `entity-table` row contract is enforced. | `.claude/CLAUDE.md` → "Whole-card / whole-row click → use a JS row handler". The new draft list (when a submission_code has multiple drafts) follows the same pattern. |
**Doc-vs-live conflicts found:** none material. `docs/project-status.md` doesn't mention t-paliad-215 or t-paliad-230 yet — that's a documentation lag, not a design risk.
---
## §2 m's decisions (2026-05-22)
The task brief (mai task t-paliad-238 description) carries inventor recommendations (R) for ten open questions. Per project CLAUDE.md inventor → head escalation policy: inventor defaults to (R) unless the pick is materially expensive or risk-bearing, in which case the head escalates to m. The matrix below records the (R) defaults this design adopts; the four genuinely-material picks are escalated to head in §11.
| # | Question (from task brief) | Default adopted | Source |
|---|---|---|---|
| Q1 | Page location | **Deep page under project — `/projects/{id}/submissions/{code}/draft` and `…/draft/{draftID}`** | (R) — keeps URL self-describing and shareable with the project. |
| Q2 | State persistence | **Server-side draft, `paliad.submission_drafts` keyed on `(project_id, submission_code, user_id, name)` with autosave** | (R) — multiple drafts per code, named; resumable across sessions. |
| Q3 | Variable layer | **Resurrect `submission_vars.go` from commit `3677c81` + `1765d5e` (incl. `patent_number_upc`); resolve at export time** | (R) — proven shape, ~30 placeholders, 7 namespaces. |
| Q4 | Customization surface (UI) | **Structured sidebar (variable list, editable values) + read-only HTML preview pane** | (R) — sidebar drives the merge; preview reflects the result. |
| Q5 | Template source | **(a) per-`submission_code` `.docx` in `m/mWorkRepo/templates/{FIRM_NAME}/...` via Gitea proxy + fallback chain** | (R) — Word is the authoring surface lawyers know; mWorkRepo is the existing vehicle. |
| Q6 | Customization options beyond variables | **v1: variables only. Toggleable passages = Slice C** | (R) — citation insertion waits for the sources system. |
| Q7 | Migration / data shape | **See §4** | (R) — followed task brief's column list, refined for RLS + cascade + index. |
| Q8 | Export endpoint | **`POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export``.docx`** | (R) — reuses `ConvertDotmToDocx` strip-macros path but adds merge step. |
| Q9 | Schriftsätze tab integration | **Keep list + existing "Generieren" (one-click format-only); add new "Bearbeiten" button per row that deep-links to `/projects/{id}/submissions/{code}/draft`** | (R) — additive, no churn for users who only want the firm style. |
| Q10 | Variable-merge library | **In-house renderer from commit `8ea3509` (no new Go module)** | (R) — the 2026-05-19 design recommended `lukasjarosch/go-docx`, but the shipped Slice 1 reverted to an in-house engine because the library refused sibling placeholders. The in-house engine handles run-fragmentation in ~315 LoC and is already battle-tested against the corpus. |
Inventor-defaulted (not in the (R) matrix; clear right answer):
| # | Topic | Default | Reasoning |
|---|---|---|---|
| D1 | Authorization | `paliad.can_see_project(project_id)` only. No profession floor. | Matches every other write surface on a project. Draft is a Word doc; lawyer's substantive review happens downstream. |
| D2 | Missing-placeholder behaviour | `[KEIN WERT: {key}]` / `[NO VALUE: {key}]` in the rendered preview AND in the exported .docx, per `DefaultMissingMarker(lang)` from `8ea3509` | Same call the original design made. Lawyer sees the gap in Word, fixes in paliad, regenerates. Better than 400ing. |
| D3 | Editor surface for templates | Gitea-only for v1 (admin edits .docx in Word, commits to mWorkRepo). | Per the original design §5. A paliad-side uploader is a Slice C+ affordance only if Gitea round-trip is friction. |
| D4 | Audit trail | One `paliad.system_audit_log` row per export (`event_type='submission.exported'`) + one `paliad.project_events` row (`event_type='submission_exported'`) so the export surfaces in Verlauf / SmartTimeline. **Draft create/update do NOT audit** — autosave noise would dominate the log. | Mirrors the existing `submission.generated` row from `internal/handlers/submissions.go:333-339`. The Verlauf entry is the user-visible footprint; the system_audit_log entry is the admin-visible audit footprint. |
| D5 | Preview engine | Server-side merge → render to HTML for preview pane. Same `SubmissionRenderer` walks the .docx, but for the preview it strips `<w:r>` / `<w:p>` to plain HTML paragraphs (no styling beyond paragraph breaks + bold/italic carry-through). The export endpoint produces the real .docx with all formatting preserved. | Cheaper than client-side OOXML parsing; matches the read-only Q4 contract. The preview is a fidelity guide, not a WYSIWYG editor — final formatting comes from Word. |
| D6 | Draft autosave cadence | Debounce 500ms after the lawyer stops typing in a variable field; PATCH `…/drafts/{draftID}` with the diff. No optimistic locking — last-write-wins per (project, submission_code, user) draft, and we never multi-user a single draft (one row per `user_id`). | Standard textarea autosave; the data is the lawyer's own draft, not a shared object. |
| D7 | Variable contract surfacing | Each placeholder in the sidebar shows: dotted key (e.g. `project.case_number`), human label (DE/EN), current resolved value (from project state), and an editable override field. Override empty → fall back to project state at export. Override filled → carry the lawyer's value into the merge. | Lawyer never has to leave the page to fix a project-level field; AND lawyer can locally override (e.g. "Court is wrong on this draft, but I don't want to edit the project") without polluting project state. |
| D8 | Draft naming | First draft per (project, submission_code, user) auto-named "Entwurf 1" (DE) / "Draft 1" (EN). Lawyer can rename inline. Subsequent drafts auto-name "Entwurf 2", etc. The (project_id, submission_code, user_id, name) tuple is the unique constraint — two drafts can't share a name for the same submission of the same project. | Lets the lawyer keep a "submitted version" and a "scratch" version side-by-side. |
---
## §3 Architecture overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Project detail (existing) — /projects/{id} with #tab-submissions │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ Schriftsätze tab │ │
│ │ Klageerwiderung [Bearbeiten ↗] [Generieren ↓] │ │
│ │ Schriftsatz der Klägerin (SoC) [Bearbeiten ↗] [Generieren ↓] │ │
│ │ Replik [Bearbeiten ↗] [Generieren ↓] │ │
│ └────────┬──────────────────────────────────────────┬─────────────────────┘ │
│ │ "Bearbeiten" deep-link │ "Generieren" = │
│ │ │ existing one-click │
│ ▼ │ format-only export │
└───────────┼───────────────────────────────────────────┼──────────────────────┘
│ │
▼ │
┌──────────────────────────────────────────────────────────────────────────────┐
│ NEW Submission draft page — /projects/{id}/submissions/{code}/draft │
│ (lands on most-recent draft for this user, or creates "Entwurf 1") │
│ │
│ ┌──── Sidebar (sticky left) ────────┐ ┌──── Preview pane (right) ───────┐ │
│ │ Schriftsatz: Klageerwiderung │ │ [HTML-rendered merge of │ │
│ │ Entwurf 1 ▼ [+ Neuer Entwurf] │ │ template + variables + │ │
│ │ ───────────────────────────────── │ │ overrides] │ │
│ │ project.case_number │ │ │ │
│ │ 2 O 123/25 [override?] │ │ Klage gegen die │ │
│ │ parties.claimant.name │ │ Beklagte BMW AG, vertreten │ │
│ │ BMW AG [override?] │ │ durch … │ │
│ │ deadline.due_date │ │ │ │
│ │ 2026-06-12 [override?] │ │ Sehr geehrte Damen und Herren, │ │
│ │ ... │ │ │ │
│ │ │ │ [KEIN WERT: project.our_side] │ │
│ │ [✎ Bearbeiten] inline │ │ │ │
│ └───────────────────────────────────┘ └──────────────────────────────────┘ │
│ │
│ [⬇ Als .docx exportieren] │
└──────────────────────────────────┬───────────────────────────────────────────┘
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
┌──────────────────────────────────────────────────────────────────────────────┐
│ handlers/submission_drafts.go (NEW) │
│ 1. Auth: ProjectService.GetByID → can_see_project │
│ 2. Load draft row (RLS via project visibility) │
│ 3. SubmissionVarsService.Build (project + parties + rule + next-Frist) │
│ 4. Apply draft.overrides on top of bag │
│ 5. TemplateRegistry.Resolve(code) — fallback chain → bytes + SHA │
│ (Slice A: skips registry, fetches the universal .dotm directly) │
│ 6. SubmissionRenderer.Render(bytes, bag, missingMarker) → .docx bytes │
│ 7. Audit: system_audit_log + project_events │
│ 8. Stream .docx with Content-Disposition: attachment │
└──────────────────────────────────────────────────────────────────────────────┘
```
**No backend changes to today's Schriftsätze tab** — its list endpoint + one-click generate stay exactly as they are. The new page is additive.
---
## §4 Schema (`paliad.submission_drafts`)
Migration `119_submission_drafts.up.sql` (next free number on this branch; coder bumps if 119 is taken at write time).
```sql
CREATE TABLE paliad.submission_drafts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
submission_code text NOT NULL,
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
name text NOT NULL, -- "Entwurf 1", lawyer-renameable
overrides jsonb NOT NULL DEFAULT '{}'::jsonb, -- { "project.case_number": "2 O 999/25", ... }
-- empty value = "don't override, use bag"
-- present key = "use this verbatim"
last_exported_at timestamptz, -- NULL until first export
last_exported_sha text, -- template SHA at last export (audit aid)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT submission_drafts_unique_per_user
UNIQUE (project_id, submission_code, user_id, name)
);
CREATE INDEX submission_drafts_project_user_idx
ON paliad.submission_drafts (project_id, user_id, submission_code, updated_at DESC);
ALTER TABLE paliad.submission_drafts ENABLE ROW LEVEL SECURITY;
CREATE POLICY submission_drafts_visible
ON paliad.submission_drafts
FOR ALL
USING (paliad.can_see_project(project_id));
-- updated_at trigger pattern (same shape as paliad.notizen, etc.).
CREATE TRIGGER submission_drafts_updated_at
BEFORE UPDATE ON paliad.submission_drafts
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
```
**No changes to `paliad.deadline_rules`.** The task brief floats a `template_body_de/_en` Markdown column as alternative (b) — rejected per Q5 default. Templates stay in Gitea.
**No changes to `paliad.documents`.** The original Slice 1 design wrote an `audit-only` row (`file_path NULL`) per generation; this design **does not**`system_audit_log` + `project_events` carry the audit trail, and `paliad.documents` is reserved for actually-uploaded documents (a Phase 2 affordance per §13.5 of the 2026-05-19 design). If m wants the `documents` row for symmetry with future "I uploaded my edited version" UX, the coder can land it in a follow-up migration; it's not load-bearing for this design.
### 4.1 RLS read-vs-write
`can_see_project` is the only gate — the policy applies to FOR ALL operations. Anyone who can see a project can create / read / update / export drafts under that project, for their own user_id. Inter-user draft visibility (paralegal sees associate's drafts) is **NOT** a requirement in v1 — the unique constraint includes `user_id` and we don't expose a "drafts by other users on this project" endpoint. Multi-user collaboration on a single draft is out of scope.
### 4.2 Down migration
```sql
DROP TABLE IF EXISTS paliad.submission_drafts;
```
No data loss concern at design time — feature ships without legacy drafts.
---
## §5 Service layer
### 5.1 Resurrect from git (no new code)
```
internal/services/submission_vars.go RESURRECT from 3677c81 + Slice 2 patch from 1765d5e (patent_number_upc)
internal/services/submission_render.go REPLACE the format-only convert with the in-house renderer from 8ea3509.
KEEP the convert helper (ConvertDotmToDocx) — Slice A still needs it to
strip macros from the universal .dotm before the merge step runs.
internal/services/submission_templates.go RESURRECT from 3677c81 — Gitea-backed TemplateRegistry with fallback chain.
NOT wired in Slice A (universal .dotm only); wired in Slice B.
```
The three files were ~926 LoC + 350 LoC + 35 LoC patch when shipped. They compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`); zero API drift since their deletion. The resurrection is a copy-paste from `git show`, plus a one-line wiring in `cmd/server/main.go` + `internal/handlers/handlers.go`.
### 5.2 New service — `SubmissionDraftService`
```go
// internal/services/submission_draft_service.go (NEW, ~300 LoC)
type SubmissionDraftService struct {
db *sqlx.DB
projects *ProjectService
}
type SubmissionDraft struct {
ID uuid.UUID
ProjectID uuid.UUID
SubmissionCode string
UserID uuid.UUID
Name string
Overrides PlaceholderMap // jsonb → map[string]string
LastExportedAt *time.Time
LastExportedSHA *string
CreatedAt, UpdatedAt time.Time
}
// List returns every draft for (project, submission_code, user) ordered by updated_at DESC.
// Visibility flows through projects.GetByID.
func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uuid.UUID, submissionCode string) ([]SubmissionDraft, error)
// Get returns a single draft by ID, gated on project visibility.
func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error)
// EnsureLatest returns the user's most-recently-updated draft for (project, submission_code).
// Creates "Entwurf 1" / "Draft 1" if none exists. Idempotent on repeat calls.
func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
// Create makes a new draft with an auto-incremented "Entwurf N" name (lawyer can rename via Update).
func (s *SubmissionDraftService) Create(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
// Update patches the draft. Permitted fields: name, overrides. last_exported_* is set by the export handler.
func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uuid.UUID, patch DraftPatch) (*SubmissionDraft, error)
// Delete archives the draft. ON DELETE CASCADE from project takes care of project-archival fallout.
func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uuid.UUID) error
// MarkExported updates last_exported_at + last_exported_sha after a successful export.
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, sha string) error
```
`DraftPatch` is a `struct { Name *string; Overrides *PlaceholderMap }` — nil pointer = "no change", non-nil = "set to this". `Overrides` is replace-semantics (lawyer's sidebar sends the full map); the service does not merge.
### 5.3 Wiring
```go
// cmd/server/main.go (additions, no replacements)
draftSvc := services.NewSubmissionDraftService(db, projectSvc)
varsSvc := services.NewSubmissionVarsService(db, projectSvc, partySvc, userSvc)
// Slice B only:
tplRegistry := services.NewTemplateRegistry(os.Getenv("GITEA_TOKEN"), branding.Name)
```
No new env var. `GITEA_TOKEN` is already documented in CLAUDE.md and used by `internal/handlers/files.go`.
---
## §6 UI surface
### 6.1 Page layout
`/projects/{id}/submissions/{code}/draft` lands on the user's latest draft for that (project, code). `/projects/{id}/submissions/{code}/draft/{draftID}` opens a specific draft (e.g. "Entwurf 2"). Both routes call the same renderer + client bundle; the difference is which draft `EnsureLatest` vs `Get` returns.
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ ← Zurück zum Projekt: BMW AG ./. Bosch GmbH │
│ Schriftsatz: Klageerwiderung (DE.ZPO.276.1) • Entwurf 1 │
│ [⬇ Als .docx exportieren] │
├────────── Sidebar (sticky) ────────────────┬─────── Preview ────────────────┤
│ Entwurf 1 ▼ │ [HTML-rendered merge] │
│ • Entwurf 1 (zuletzt 23 Mai 2026) │ │
│ • Entwurf 2 (zuletzt 20 Mai 2026) │ An das Landgericht München I │
│ • [+ Neuer Entwurf] │ Pacellistr. 5 │
│ │ 80333 München │
│ ───────────────────────────────────────── │ │
│ Variablen │ In der Sache │
│ firm.name HLC │ │
│ project.case_number 2 O 123/25 [✎] │ BMW AG, vertreten durch … │
│ project.court LG München I [✎] │ — Klägerin — │
│ parties.claimant.name BMW AG [✎] │ │
│ parties.defendant.name Bosch GmbH [✎] │ gegen │
│ parties.defendant.representative │ │
│ Dr. Maria Schmidt [✎] │ Bosch GmbH … │
│ deadline.due_date 2026-06-12 [✎] │ — Beklagte — │
│ rule.legal_source_pretty │ │
│ § 276 Abs. 1 ZPO ✓ │ Sehr geehrte Damen und Herren,│
│ … │ │
│ │ [KEIN WERT: project.our_side] │
│ ───────────────────────────────────────── │ │
│ [Entwurf umbenennen] [Entwurf löschen] │ │
└────────────────────────────────────────────┴────────────────────────────────┘
```
Sidebar grouping (top-to-bottom, locale-aware labels):
1. **Schriftsatz** (rule.* — read-only metadata: name, legal_source_pretty, primary_party)
2. **Mandanten & Parteien** (parties.*)
3. **Verfahren** (project.* — case_number, court, patent_number, patent_number_upc, our_side, …)
4. **Frist** (deadline.* — due_date, computed_from)
5. **Kanzlei & Datum** (firm.*, user.*, today.*)
Each placeholder row shows: human label (DE/EN), resolved value, edit icon. Click [✎] expands an inline text input pre-filled with the current value. Blur or Enter → debounced autosave (500ms). Empty override → revert to bag value.
### 6.2 Preview pane
Read-only HTML. The same `SubmissionRenderer.Render(...)` call that produces the .docx for export ALSO produces a sidecar HTML preview (the in-house renderer walks `<w:p>` / `<w:r>` runs and emits `<p>` / inline `<strong>` / `<em>` based on `<w:b>` / `<w:i>` flags). Preview re-renders on every autosave round-trip (cheap: server-side merge, ~10ms for a 5-page brief). Loading state: ghost-skeleton paragraphs during the round-trip.
This is the SLIGHTLY non-trivial coder piece: the in-house renderer today emits .docx; the coder adds a parallel `RenderHTML` path that walks the same tree but emits HTML. Same regex, same run-merge logic, different writer. Coder estimates ~120 LoC on top of the resurrected `submission_render.go`.
### 6.3 Routing + handlers
```
GET /projects/{id}/submissions/{code}/draft → page (lands on latest, creates if none)
GET /projects/{id}/submissions/{code}/draft/{draftID} → page (specific draft)
GET /api/projects/{id}/submissions/{code}/drafts → list drafts for current user
POST /api/projects/{id}/submissions/{code}/drafts → create new draft → returns row + redirect target
GET /api/projects/{id}/submissions/{code}/drafts/{draftID} → single draft + resolved bag + HTML preview
PATCH /api/projects/{id}/submissions/{code}/drafts/{draftID} → update name / overrides; returns new preview
DELETE /api/projects/{id}/submissions/{code}/drafts/{draftID} → delete
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
→ .docx download (application/vnd.openxmlformats-officedocument.wordprocessingml.document)
```
Page-route handler is `handleSubmissionDraftPage` in `internal/handlers/submission_drafts.go` — calls `handleProjectsDetailPage` shape (returns HTML for an SSR layout) with a deep-route flag, OR ships an entirely new TSX page module. Inventor default: **new TSX page** at `frontend/src/submission-draft.tsx` rendering its own layout. Lighter than retrofitting the existing project-detail page with conditional panels, and the URL semantics demand it (`#tab-submissions` is the tab; `/draft/{id}` is a distinct page).
### 6.4 Schriftsätze tab — additive change
```diff
- // Each row: [Generieren ↓]
+ // Each row: [Bearbeiten ↗] [Generieren ↓]
```
`[Bearbeiten ↗]``window.location.href = "/projects/{id}/submissions/{code}/draft"`. `[Generieren ↓]` stays as today (one-click format-only export of the universal .dotm). For users who want zero-config "give me a clean firm style template", `[Generieren]` is the path; for users who want a merged draft, `[Bearbeiten]` is the path.
Per the `.entity-table` row contract in CLAUDE.md, the row itself becomes clickable (navigates to `/draft`), with the `Generieren` button stopping propagation. The `entity-table--readonly` modifier is removed.
---
## §7 Variable contract (v1 placeholder set)
Reproduced from the resurrected `submission_vars.go` (commits `3677c81` + `1765d5e`). The sidebar's "Variablen" section enumerates this list in the exact same order as `addProjectVars` / `addPartyVars` / etc., grouped per §6.1.
```
firm.name — branding.Name (HLC or FIRM_NAME override)
firm.signature_block — empty in v1 (Phase 2 affordance)
today — 2026-05-22 (ISO, Europe/Berlin)
today.iso — ISO short
today.long_de — "22. Mai 2026"
today.long_en — "22 May 2026"
user.display_name — paliad.users.display_name
user.email — paliad.users.email
user.office — paliad.users.office
project.title — paliad.projects.title
project.reference — paliad.projects.reference
project.case_number — paliad.projects.case_number
project.court — paliad.projects.court
project.patent_number — DE/inline form "EP 1 234 567 B1"
project.patent_number_upc — UPC parenthesised form "EP 1 234 567 (B1)" (Slice 2 helper, 1765d5e)
project.filing_date — ISO date
project.grant_date — ISO date
project.our_side — claimant | defendant
project.our_side_de — "Klägerin" | "Beklagte"
project.our_side_en — "Claimant" | "Defendant"
project.instance_level — lg | olg | bgh | cfi | …
project.client_number — paliad.projects.client_number
project.matter_number — paliad.projects.matter_number
project.proceeding.code — e.g. "de.inf.lg"
project.proceeding.name — locale-aware (DE: Verletzungsklage am Landgericht)
project.proceeding.name_de — explicit DE
project.proceeding.name_en — explicit EN
parties.claimant.name — first paliad.parties row with role='claimant'
parties.claimant.representative
parties.defendant.name — first row with role='defendant'
parties.defendant.representative
parties.other.name — first non-claimant/defendant row
parties.other.representative
rule.submission_code — "de.inf.lg.erwidg"
rule.name — locale-aware ("Klageerwiderung" / "Statement of Defence")
rule.name_de
rule.name_en
rule.legal_source — "DE.ZPO.276.1"
rule.legal_source_pretty — "§ 276 Abs. 1 ZPO" / "Section 276(1) ZPO"
rule.primary_party — claimant | defendant | court | both
rule.event_type — filing | hearing | decision
deadline.due_date — ISO of next pending deadline for this rule on this project
deadline.due_date_long_de — "12. Juni 2026"
deadline.due_date_long_en — "12 June 2026"
deadline.original_due_date — ISO if extended
deadline.computed_from — anchor description (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen")
deadline.title — deadline.title
deadline.source — "rule" | "manual" | …
```
Variable bag construction is `SubmissionVarsService.Build(ctx, in)` — exactly the function in `3677c81`'s `submission_vars.go`, no changes.
### 7.1 Override semantics
The lawyer's `overrides` map (jsonb in `submission_drafts`) shadows the bag at export time:
```go
bag := varsSvc.Build(ctx, ...).Placeholders // ~30 keys, resolved from project state
for k, v := range draft.Overrides { // lawyer's edits
if v == "" {
delete(bag, k) // empty override means "force missing marker"
} else {
bag[k] = v // non-empty override replaces
}
}
docx := renderer.Render(templateBytes, bag, missingMarker(lang))
```
Edge case: lawyer types empty string into a field that was resolved from the project. Decision: empty override forces the `[KEIN WERT: …]` marker. Lawyer's intent ("blank this out, I'll fill manually in Word") is honoured rather than silently falling back to project state. Sidebar UX: empty override field is annotated "→ [KEIN WERT: …]" so the lawyer sees the consequence before exporting.
---
## §8 Template authoring (mWorkRepo layout, naming, fallback chain)
### 8.1 Slice A — universal .dotm only
Slice A merges the variable bag into the same universal HL Patents Style .dotm that today's format-only convert ships. **The .dotm body must carry `{{placeholder}}` tokens** — currently it doesn't (it's a firm style template, not a per-submission template). m has two ways to seed Slice A:
- **8.1.a** — author one universal template (`m/mWorkRepo/templates/_base/_universal.docx` or similar) with `{{firm.name}}`, `{{rule.name}}`, `{{project.case_number}}`, `{{parties.claimant.name}}`, etc. The merge engine fills these and outputs a draft that's still a generic letter shape but pre-populated.
- **8.1.b** — author one Klageerwiderung-shaped template (`m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`) and route Slice A's export to that path for `submission_code='de.inf.lg.erwidg'`, with a hard-coded fall-back to `_universal.docx` for any other code. This is essentially Slice A + B's first template — wins both rounds.
Inventor recommendation: **8.1.b**. Strictly more useful, identical engine code, identical mWorkRepo round-trip. The Slice A → Slice B transition is then "add more templates", not "rewire the resolver".
### 8.2 Slice B — fallback-chain registry
Layout reproduced from the 2026-05-19 design §5.1:
```
m/mWorkRepo (existing repo, already proxied)
└── templates/
├── HLC/ # FIRM_NAME-keyed override dir
│ ├── de.inf.lg.erwidg.docx # Slice A target (per 8.1.b above)
│ ├── de.inf.lg.klage.docx # Slice B addition
│ ├── upc.inf.cfi.soc.docx # Slice B addition
│ └── upc.inf.cfi.sod.docx # Slice B addition
├── _base/ # Cross-firm baseline
│ ├── de.inf.lg.erwidg.docx # base equivalent (Slice C+)
│ ├── de.inf.lg.docx # proceeding-family fallback
│ ├── upc.inf.cfi.docx
│ ├── _skeleton.docx # ultra-generic fallback
│ └── _universal.docx # the v1 Slice A "any code" template
└── README.md # placeholder reference for template authors
```
Naming: `{submission_code}.docx`. Family fallback uses the first three dot-segments (`de.inf.lg` from `de.inf.lg.erwidg`). Skeleton is the ultra-generic fallback (letterhead + party block + court address + signature stub).
### 8.3 Lookup algorithm
```go
// services/submission_templates.go (resurrected from 3677c81)
func (r *TemplateRegistry) candidates(submissionCode string) []string {
family := familyOf(submissionCode)
out := []string{
fmt.Sprintf("templates/%s/%s.docx", r.firmName, submissionCode),
fmt.Sprintf("templates/_base/%s.docx", submissionCode),
}
if family != "" && family != submissionCode {
out = append(out, fmt.Sprintf("templates/_base/%s.docx", family))
}
out = append(out, "templates/_base/_skeleton.docx")
return out
}
```
Gitea proxy: same `internal/handlers/files.go` shape. 5-min SHA refresh, in-process cache, `GITEA_TOKEN` for auth. The original `submission_templates.go` already implements this end-to-end; the coder re-applies it from `git show 3677c81`.
### 8.4 No template at all — Slice A vs Slice B
Slice A: the universal template always resolves; `ErrNoTemplate` is impossible.
Slice B: if every candidate in the fallback chain 404s, the handler returns 503 + `"Vorlagen-Repository nicht erreichbar"` in the UI (same handling as the original Slice 1 design §5.4). Since the chain ends at `_skeleton.docx`, this only fires when the mWorkRepo itself is misconfigured.
### 8.5 Template authoring task lands outside this design
Inventor flags but does not assign: HLC must author the per-submission_code `.docx` templates. Slice A's `_universal.docx` is one document. Slice B adds Klageerwiderung, Klageerhebung, SoC, SoD, … iteratively. **Template authoring runs in parallel with engine code**; the coder ships the engine, m + HLC ships the templates. The two converge before the slice closes.
This is the m-escalated piece (see §11): without per-submission templates, Slice B is engine-only.
---
## §9 Slice plan
### Slice A — schema + new page + variables-only export against universal .docx
Ships the editor end-to-end with one template.
| Deliverable | Files |
|---|---|
| Migration 119 — `submission_drafts` table + RLS + trigger | `internal/db/migrations/119_submission_drafts.{up,down}.sql` |
| `SubmissionVarsService` resurrected | `internal/services/submission_vars.go` (from `3677c81` + Slice 2 patch `1765d5e`) |
| `SubmissionRenderer` resurrected with new `RenderHTML` | `internal/services/submission_render.go` (from `8ea3509`); adds `RenderHTML(...) string` for preview |
| `SubmissionDraftService` | `internal/services/submission_draft_service.go` (NEW) |
| Handlers (page + 7 API endpoints) | `internal/handlers/submission_drafts.go` (NEW) |
| Wiring | `cmd/server/main.go`, `internal/handlers/handlers.go` |
| Page TSX | `frontend/src/submission-draft.tsx` (NEW) |
| Client bundle | `frontend/src/client/submission-draft.ts` (NEW) |
| Schriftsätze tab update | `frontend/src/projects-detail.tsx` (rows get [Bearbeiten]), `frontend/src/client/submissions.ts` (handler) |
| i18n | new keys under `projects.detail.submissions.draft.*` and `submissions.draft.*` (page-level) |
| One template at `m/mWorkRepo/templates/_base/_universal.docx` (8.1.b → also `templates/HLC/de.inf.lg.erwidg.docx`) | mWorkRepo, separate PR by m |
| Tests | `internal/services/submission_render_test.go` (resurrected + RenderHTML), `internal/services/submission_vars_test.go` (round-trip), handler smoke |
Acceptance:
1. Opening `/projects/{id}/submissions/de.inf.lg.erwidg/draft` lands on the user's latest draft (or creates "Entwurf 1").
2. Sidebar renders ~30 placeholders, pre-filled from project state.
3. Editing a sidebar value autosaves within 500ms and updates the preview pane.
4. Multiple drafts per (project, code, user) supported; switcher in sidebar.
5. Clicking "Als .docx exportieren" downloads a merged `.docx` (universal template + project + lawyer overrides).
6. `system_audit_log` row appears on export (`event_type='submission.exported'`).
7. `project_events` row appears on export and surfaces in Verlauf.
8. RLS: caller without `can_see_project` gets 404 on the page and 404 on every draft API.
9. Schriftsätze tab on project detail shows [Bearbeiten] alongside [Generieren].
10. `go build ./... && go vet ./... && go test ./... && bun run build` clean.
### Slice B — per-submission_code templates + fallback chain
Engine is unchanged from Slice A; this slice wires `TemplateRegistry` into the export endpoint and lights up per-code templates.
| Deliverable | Files |
|---|---|
| `TemplateRegistry` resurrected | `internal/services/submission_templates.go` (from `3677c81`) |
| Handler swaps Slice A's `fetchHLPatentsStyleBytes` for `templateRegistry.Resolve(code)` | `internal/handlers/submission_drafts.go` |
| `has_template` boolean per row in Schriftsätze tab list (today: unconditionally true; under Slice B: depends on registry probe) | `internal/handlers/submissions.go` |
| Templates authored in mWorkRepo: at least Klageerwiderung + Klageerhebung + SoC + SoD | mWorkRepo PR by m |
| Tests for fallback chain | `internal/services/submission_templates_test.go` (resurrect from history if it existed; otherwise new) |
Acceptance:
1. Pushing `m/mWorkRepo/templates/HLC/upc.inf.cfi.soc.docx` makes the SoC draft page resolve that template within 5 min (or instantly via `POST /api/files/refresh`).
2. `has_template=false` rows in the Schriftsätze tab show [Keine Vorlage] instead of [Bearbeiten]/[Generieren]. Existing list ordering preserved.
3. `last_exported_sha` on `submission_drafts` records which SHA the lawyer exported against.
4. Misconfigured repo (every fallback 404s) → 503 with clear error.
### Slice C — toggleable passages
Lawyer can include/exclude boilerplate sections before export.
| Deliverable | Notes |
|---|---|
| `passages` jsonb column on `submission_drafts` | `migration 120` (or whatever's free at land time): `passages jsonb NOT NULL DEFAULT '{}'::jsonb``{"intro": true, "patent_validity_attack": false, "non_infringement": true}`. |
| Template syntax for passage blocks | `{{#passage intro}}…{{/passage}}` — start/end markers, merger drops the block when the corresponding `passages.{key}` is false. The in-house renderer's run-fragmentation handling extends to the new tokens cleanly. |
| Sidebar UI | "Passagen" group above "Variablen", per-passage toggle (on by default), help text per passage. |
| Template author API | `templates/README.md` documents the passage syntax + a worked example. |
Acceptance: turning off `non_infringement` in the sidebar of a Klageerwiderung draft removes the corresponding section from the exported .docx; preview reflects immediately.
Slices D+ (not detailed here): citation insertion from the sources system (waits for that surface), per-firm template overrides (registry already supports this), `/admin/submission-templates` variable contract sidebar.
---
## §10 Out of scope
- AI-drafted prose (the 2026-05-19 design §11 sketch; still deferred).
- PDF export. v1 ships `.docx` only; the lawyer's Word does the PDF step.
- Multi-user collaboration on a single draft. Each draft is owner-scoped (`user_id`).
- Real-time co-editing. Last-write-wins per draft; no operational transforms.
- An in-paliad WYSIWYG editor for `.docx` content. Preview is read-only; final edits happen in Word.
- A paliad-side template uploader. Gitea stays as the editor for templates until lawyers complain about the round-trip.
- Translation of templates DE↔EN. Templates are mono-locale; the variable bag is bilingual.
- Citation insertion from the sources system. Waits for the sources surface m parked.
- Frist-detail "Exportieren" button. The submission page is reachable only from the project's Schriftsätze tab in v1; a Frist-level deep-link is a Slice D+ affordance.
- Validation of the rendered draft against any legal rule. The engine produces text; the lawyer's substantive review is downstream.
- Sending the draft to court / e-filing. The lawyer downloads and handles transmission outside paliad.
---
## §11 Material picks escalated to head
Per project CLAUDE.md inventor → head policy, the four picks below carry enough cost or risk to deserve head's read. Head ratifies (or escalates to m) before the coder shift starts.
### Q-E1 — Template authoring effort
Slice A needs at least one custom-authored template (`_universal.docx` or `de.inf.lg.erwidg.docx`) carrying `{{placeholder}}` tokens. Slice B needs four more (Klageerhebung, SoC, SoD, Erwiderung). The engine ships independently of template content, but the feature is unfinished without lawyer-authored templates.
**Inventor pick:** ship Slice A with **one** lawyer-authored template (8.1.b: `templates/HLC/de.inf.lg.erwidg.docx`) + the universal fallback. m + HLC owns the authoring; the coder owns the engine. Slices A and template-1 land together.
**Material because:** without a template, the feature looks broken in user testing. Head decides: does m commit to authoring or reviewing the first template before Slice A merges, or does Slice A merge engine-only and we accept the "format-only export with placeholders" intermediate state for a week?
### Q-E2 — `paliad.documents` row on export
The original Slice 1 design wrote an audit-only `paliad.documents` row (`file_path NULL`, `doc_type='generated_submission'`) per generation, on the theory that "Documents" would become the canonical listing UI. This design defers that.
**Inventor pick:** **no** `paliad.documents` write. `system_audit_log` + `project_events` carry the audit trail. The `documents` table is reserved for actually-uploaded documents (Phase 2 of the broader docs roadmap).
**Material because:** if head agrees, we skip a column repurpose (`ai_extracted` jsonb being used for generation provenance — the 2026-05-19 design noted this was ugly). If head disagrees, the coder lands the row inside Slice A.
### Q-E3 — Preview render — server or client?
Server-side: `RenderHTML(...)` on the in-house renderer, round-trip per autosave. Cheaper to build, costs ~10ms server-side per keystroke (debounced 500ms).
Client-side: ship the merged document body as JSON of paragraph runs, render in TS. Faster preview, harder to build (parallel render path in TS), and **diverges** the preview from the export shape (export still goes server-side).
**Inventor pick:** **server-side**. Single source of truth for the merge logic. The 500ms debounce already absorbs the round-trip; a 10ms server merge plus 50ms HTTP RTT is sub-perceptible.
**Material because:** if head wants the client-side preview for fully-offline draft editing, the coder needs a TS port of `substituteInDocumentXML`. Bigger build, but no round-trip latency on every keystroke.
### Q-E4 — Inter-user draft visibility
Today's design: each user sees only their own drafts. If two associates on the same project both draft a Klageerwiderung, they don't see each other's drafts (each has their own row).
**Inventor pick:** **owner-scoped (status quo of this design)**. The unique constraint includes `user_id`; the `List` endpoint filters by current user.
**Material because:** if head wants project-team visibility ("paralegal sees associate's draft for review"), the unique constraint shifts to `(project_id, submission_code, name)` (drop `user_id`), the RLS already covers the read path (`can_see_project`), and `submission_drafts` becomes a project-team resource. **This is a Phase-shape change** — the lawyer model differs. Inventor flags it because the change is cheap to make now (one column + one constraint) and expensive to make later (drafts already accumulate per-user). Head's call.
---
## §12 Implementation notes
For the coder, not for head.
- **Resurrection is `git show`, not "re-write".** The four file revisions (`3677c81:internal/services/submission_vars.go`, `1765d5e:internal/services/submission_vars.go` for the Slice 2 patch, `8ea3509:internal/services/submission_render.go`, `3677c81:internal/services/submission_templates.go`) can be applied via `git checkout 3677c81 -- internal/services/submission_vars.go` etc. The coder should verify each compiles against today's `cmd/server/main.go` wiring before applying.
- **Renderer's `RenderHTML` is new.** The .docx walker today emits OOXML bytes; the HTML emitter walks the same tree and emits `<p>` / `<strong>` / `<em>` / `<br>`. ~120 LoC on top of the resurrected file. Same regex (`placeholderRegex`), same run-merge logic, different writer.
- **Sidebar variable schema needs a label table.** The variable contract from §7 is keyed by dotted paths; the sidebar UI needs DE/EN labels per key. Coder adds `services/submission_var_labels.go` with a `map[string]struct{LabelDE, LabelEN, HelpDE, HelpEN}` for the ~30 keys. (Mirrors `internal/services/email_template_variables.go` shape — same lawyer-facing pattern paliad already ships at `/admin/email-templates`.)
- **Autosave race.** The lawyer types fast → multiple PATCHes in flight. Coder uses a request-ID-debouncing pattern on the client (cancel in-flight PATCH when a new one starts) and last-write-wins on the server. No version column on the draft row in v1.
- **Empty-override semantics in the jsonb.** `overrides = {"project.case_number": ""}` means "force missing marker". `overrides = {}` (key absent) means "fall back to bag". The service code distinguishes — careful with `omitempty`.
- **i18n key audit.** Add `projects.detail.submissions.action.edit`, `submissions.draft.title`, `submissions.draft.export`, `submissions.draft.sidebar.{firm,project,parties,deadline,user}.group`, `submissions.draft.rename`, `submissions.draft.delete`, `submissions.draft.new`, etc. Roughly 35 new keys in DE + EN.
- **`entity-table` row contract.** Schriftsätze tab today carries `entity-table--readonly`. Slice A removes that modifier and adds a row-click handler that navigates to `/projects/{id}/submissions/{code}/draft`, skipping clicks on the inner [Generieren] button. Matches the pattern in `frontend/src/client/checklists.ts`, `client/projects-detail.ts`, `client/deadlines.ts`.
- **Migration 119 may collide.** Other worktrees (paliadin aichat, mig 118) may land 119 before this branch merges. Coder verifies at land time; bump to the next free number if needed.
---
## §13 Acceptance gate
Per inventor SKILL.md + project CLAUDE.md: this design needs head's go/no-go before any coder is hired. After head ratifies (with or without escalating §11 to m):
- The head decides whether to hire the same worker as `/mai-coder` with this design as the brief, or a fresh coder.
- A coder shift takes this doc as the spec, ships Slice A, opens a PR (no self-merge).
- Slices B and C are SEPARATE tasks — not auto-spawned.
Inventor parks here.

View File

@@ -0,0 +1,568 @@
# Design — Unified procedural-events tool (m/paliad#151)
**Task:** t-paliad-334
**Gitea:** m/paliad#151
**Inventor:** cronus (shift-1, fresh context — name-recycled, not the cronus from earlier today)
**Date:** 2026-05-27
**Branch:** `mai/cronus/inventor-unified`
**Status:** Draft — coder gate held; awaiting m's go on the unification approach
**Builds on:**
- `docs/assessment-deadline-system-2026-05-27.md` (athena, Phase 1 audit — premises)
- `docs/design-deadline-system-revision-2026-05-27.md` (atlas, model + per-surface revisions — pre-locked decisions)
- `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26 inventor, Mode A + B + result shipped via t-paliad-322 / m/paliad#146 S1-S6)
- `docs/design-event-card-choices-2026-05-25.md`, `docs/design-determinator-row-cascade-2026-05-13.md` (per-card choice + determinator routing — current Verfahrensablauf state)
m's framing (2026-05-27 19:13):
> There are many dimensions by which we can display and filter our procedural events. Maybe we should hire an inventor to find out the best methods from the ones we already have? It makes sense to narrow things, display them in sequence and context, make selections etc. It just needs to be done well, preferably in a unitary tool. There should be alternative means to derive at what you want to derive at.
---
## §0 Premises — what the inventor is and isn't doing
This is a **surface-layer** design. The **model layer** is locked by atlas's `design-deadline-system-revision-2026-05-27.md` (Q1-Q12 + 14:34/14:40 post-ratification additions, all m-decided 2026-05-27). The shipped Fristenrechner Mode A + Mode B + result view is the model-side foundation; the in-flight atlas P0-P5 train extends Verfahrensablauf and the scenario SSoT.
The inventor's question is **not** "how should the rule graph be modelled" — that's settled. It's: **of the 6 surfaces that read this model, do we have the right *surfaces*? Should they unify into one tool, two, or stay as today's set?**
Out of scope per the issue + paliadin/head brief:
- Calculator (`pkg/litigationplanner.CalculateRule`) — working.
- `/admin/procedural-events` as an editorial **write** surface — different audience, different action set, must stay separate.
- `/projects/{id}` Verlauf — per-Akte **actuals** surface, not the ablauf-tool. Sister tool, not subsumable.
- SmartTimeline projection — per-project read view that composes actuals + projections; sister to Verlauf, project-bound. Not subsumable.
- youpc.org/deadlines — cross-repo public surface. Snapshot consumer.
- Outlook / Calendar sync UI.
In-scope unification candidates (4 surfaces): the three Fristenrechner modes (A search + B wizard + result) **and** Verfahrensablauf — these read the *same model* (sequencing_rules + procedural_events + scenario_flags) to answer questions about the *same underlying graph*. The question is whether they're best presented as one URL with multi-mode entry, two URLs with shared vocabulary, or as today's split.
---
## §1 Audit of the 6 surfaces
For each surface: question it answers, dimensions it filters/anchors on, what it does well, what it does poorly, overlap with neighbours.
### §1.1 `/tools/fristenrechner` Mode A — "Direkt suchen" (shipped t-paliad-322 S3)
**Question shape:** "I know a procedural event happened (e.g. *Klageerwiderung*). What follow-ups come next?"
**Dimensions used:**
- *Filters* (top strip): `forum` (UPC/DE/EPA/DPMA), `proceeding_type`, `event_kind` (filing/hearing/decision/order), `primary_party`.
- *Anchor* (the search result): one `procedural_event` row → lock as trigger.
- *Inbox* secondary chip (CMS / beA / postal): auto-derives forum.
**Path:** Filter strip → free-text search → result row click → linear follow-up view (handed off to §1.3).
**Strengths:** Power-user surface; one box does everything; forgiving to misspellings via pg_trgm; deep-linkable via `?mode=search&q=…&forum=…`.
**Weaknesses:** Search returns *every* event including spawn-only and leaves (atlas §2.2 P0 fix in flight); no visualisation of *where* the picked event sits in the proceeding tree.
**Overlap:** Picks the same `procedural_event` rows that Mode B R4 lands on; picks the same proceeding chips that Verfahrensablauf shows. Filter strip is a subset of Verfahrensablauf's filter chips.
### §1.2 `/tools/fristenrechner` Mode B — "Geführt" wizard (shipped t-paliad-322 S4)
**Question shape:** Same as Mode A but for users who don't know how to phrase the question. Narrows by Q&A.
**Dimensions used:** All five Mode A filters reframed as wizard rows:
- R1 `event_kind` (Filter badge)
- R2 `forum` / jurisdiction (Filter, skipped if R1 narrows)
- R3 `proceeding_type` (Qualifier, auto-skipped on single match)
- R4 `procedural_event` (Qualifier — the landing question)
- R5 `primary_party` (Qualifier, only when follow-ups differ by side)
**Path:** Q-by-Q chip pick → R4 lock → linear follow-up view (handed off to §1.3).
**Strengths:** Onboarding-friendly; auto-prefills from Akte (`projects.proceeding_type_id` → R3, `projects.our_side` → R5); preserves compatible downstream picks on back-nav.
**Weaknesses:** No tree-context view of the answer; the user lands on a flat result with no zoom-out.
**Overlap:** Same R4 event set as Mode A's search results. Same downstream result view.
### §1.3 `/tools/fristenrechner` result view (shipped t-paliad-322 S2)
**Question shape:** Given a locked event + trigger date, what dated follow-ups exist?
**Dimensions used:**
- *Anchor:* one `sequencing_rule` (the trigger's anchor rule).
- *Linear walk:* one hop down via `parent_id` — children of the anchor, grouped by priority.
- *Display axes:* priority (4 groups: mandatory / recommended / optional / conditional), party, condition flag, court-set, spawn.
- *Persistent state:* per-rule checkboxes (selection for write-back), per-rule date overrides.
- *Write-back:* `POST /api/projects/{id}/deadlines/bulk` with audit_reason.
**Strengths:** Clear list + write-back footer; sticky trigger card; deep-linkable; cross-party detection in atlas P0 (S1 from t-paliad-327).
**Weaknesses:** Only shows *direct* children of the anchor. No visibility of where this slice fits in the proceeding's wider graph. No way to pivot to "show the whole ablauf around this".
**Overlap:** Selection state UI vocabulary (per-rule checkbox + chip) is conceptually identical to Verfahrensablauf's per-rule selection chips that atlas's P3 will ship.
### §1.4 `/tools/verfahrensablauf` (current state + atlas P3 in flight)
**Question shape (today):** "What does proceeding-type X look like in full?"
**Dimensions used:**
- *Anchor:* one `proceeding_type` (chip-picked).
- *Filters:* `side` (claimant/defendant), `target` (appeal-target — endentscheidung / kostenentscheidung / anordnung / schadensbemessung / bucheinsicht), `trigger_date`.
- *Scenario flags:* CCR / inf_amend / rev_amend / rev_cci, plus per-card choices (appellant / include_ccr / skip).
- *View toggle:* `columns` (3-column swimlane: Unsere Seite | Gericht | Gegnerseite) vs `timeline` (single-column chronological).
- *Detail-mode toggle (shipped today via m/paliad#149 P3):* `mandatory_only` / `selected` / `all_options`.
- *Per-card affordances:* `[Aufnehmen]` / `[Entfernen]` chips for optional/recommended rules, dotted-border for unselected, greyed for conditional-with-flag-off.
**Strengths:** The most data-rich surface — every rule for the proceeding rendered with computed dates against `trigger_date`. View-mode toggle gives detail-level control. URL params are clean (proceeding/side/target/trigger_date); noisy scenario flags live in localStorage (per `verfahrensablauf-state.ts`).
**Weaknesses:** The user must already know which proceeding to look at — no entry path from "an event happened" or "search by name". 3-column swimlane reads dense on desktop and unmanageably wide on mobile. Trigger-date is per-page (not per-rule), so the entire ablauf computes from one anchor — fine for kontextfrei browse, awkward for Akte where different rules have different real triggers.
**Overlap:** Detail-mode + per-rule selection chips share the design vocabulary that result view §1.3 *should* eventually adopt. Filter dimensions are a superset of Mode A's filter strip.
### §1.5 `/admin/procedural-events` (shipped, Slice B.5)
**Question shape:** "I need to edit / publish / audit rules."
**Dimensions used:** Lifecycle filter (draft/published/archived), proceeding chip, trigger-event filter, free-text. Per-row click → editor form. Separate tab for orphans (Slice 10 fuzzy-match staging).
**Strengths:** Lifecycle-aware; clone-publish workflow; audit log; orphan resolution.
**Weaknesses:** None for editors. *For readers,* it's the wrong tool — too much editor-state metadata in the table; no tree / sequence / dates / scenario filtering.
**Overlap:** None functional. Shares the rule corpus but its *action set* (edit/publish/audit/resolve-orphan) is disjoint from the reader surfaces.
**Verdict: keep separate.** Different audience (editors only — m today, the partner team eventually), different action set, different lifecycle vocabulary. Cross-linking is sufficient: every reader-surface row should have a "Diese Regel bearbeiten" link to `/admin/procedural-events/{id}/edit` for editor users.
### §1.6 `/projects/{id}` Verlauf — out of scope per brief
Project-bound timeline of *actual* deadlines + appointments + project_events for one Akte. Composes with SmartTimeline projections.
**Question shape:** "What's happened on my Akte and what's next *for this specific case*?"
This is conceptually downstream of the ablauf-tool: the ablauf-tool answers "what's the *shape* of proceeding X"; Verlauf answers "what's the *state* of *my Akte* that happens to be proceeding X". The shape becomes the actuals through user actions (write-back from Mode A result view, manual entry, CMS sync).
**Verdict: keep separate.** Different question, different data shape (instances vs templates).
### §1.7 SmartTimeline / `ProjectionService` — out of scope per brief
Per-project read view via `GET /api/projects/{id}/timeline` that returns merged actuals + projected future rows (via FristenrechnerService) + parent-node lane aggregation. The render shape is project-bound and lookahead-capped; the model knows about levels (Case / Patent / Litigation / Client) and bubble-up events.
**Verdict: keep separate.** SmartTimeline composes the ablauf-tool's output with project actuals; it's a consumer, not a peer.
### §1.8 `youpc.org/deadlines` — out of scope (cross-repo)
Public surface backed by the offline UPC snapshot (`cmd/gen-upc-snapshot`). Snapshot consumer only.
**Verdict: keep separate.** Different repo, different deploy.
---
## §2 The question→surface→dimension matrix
The single source of truth for "which dimension lives where". Two questions answer "which view does this surface show":
| User question | Today's surface | Anchor input | Output shape | Output detail |
|---|---|---|---|---|
| "What's the typical ablauf of upc.inf.cfi?" | Verfahrensablauf | `proceeding_type` | Tree-or-columns of all rules | Whole ablauf |
| "Was passiert nach Klageerhebung?" | Fristenrechner Mode A | `procedural_event` | Linear follow-ups (priority groups) | Slice through tree |
| "Was passiert nach… (don't know the event name)?" | Fristenrechner Mode B | Q&A → `procedural_event` | Same as Mode A | Same |
| "Welche Fristen für meine Akte ergeben sich?" | Fristenrechner Mode A/B + `?project=` | Akte + `procedural_event` | Linear follow-ups + write-back | Same + actions |
| "Wie sieht der gesamte Ablauf für meine Akte aus?" | Verfahrensablauf + `?project=` | Akte (derives `proceeding_type`) | Tree-or-columns + scenario | Whole ablauf + state |
| "Welche Regeln gibt's? Wie bearbeite ich sie?" | /admin/procedural-events | — | Editor table | Editor metadata |
| "Was steht auf meinem Akten-Plan?" | /projects/{id} Verlauf | Akte | Actuals timeline | Per-instance state |
Dimensions matrix — same dimension axis, varied surface presentation:
| Dimension | Cardinality | Mode A | Mode B | Result | Verfahrensablauf | Admin |
|---|--:|---|---|---|---|---|
| `forum` (jurisdiction) | 4 | top-chip filter | R2 | trigger-card badge | — (anchored by PT) | search facet |
| `proceeding_type` | 23 | top-chip filter | R3 (auto-skip on single) | trigger-card chip | chip strip (the anchor) | dropdown filter |
| `event_kind` | 5 | top-chip filter | R1 | trigger-card badge | — (in cards) | search facet |
| `primary_party` | 5 | top-chip filter | R5 (when needed) | per-rule chip | swimlane column / per-card | — |
| `priority` | 4 | — | — | group header | view-mode toggle + card style | column |
| `condition_expr` (gating) | bool | — | — | conditional group | greyed cards + flag strip | rule editor field |
| `is_spawn` | bool | hidden (atlas filter) | hidden | "⇲ Verfahren öffnen" CTA | leaf with ⇲ icon | column |
| `is_court_set` | bool | — | — | "vom Gericht" badge | greyed-date card | column |
| `parent_id` (chain depth) | derived | "Folgen: N" count | — | depth-1 only (children of anchor) | depth-N indentation / tree walk | "abhängig von" chip |
| selection state (scenario_flags `rule:<uuid>`) | per-rule | — | — | checkbox (write-back) | `[Aufnehmen]`/`[Entfernen]` chips | — |
| scenario flags (named: with_ccr, with_amend, …) | 3 | — | — | bound checkboxes (read-only) | flag strip (canonical edit surface) | rule editor field |
| view-mode (detail level) | 3 | — | — | — (always "selected") | top toggle | — |
| `trigger_date` | date | result view input | result view input | top of card | per-page input | — |
**Reading the matrix.** Every dimension lives at least two surfaces over. The user's mental model has to translate "the proceeding chip on Verfahrensablauf" to "R3 in Mode B" to "the proceeding filter strip in Mode A" — three names, same dimension. Same for forum, event_kind, party.
This is the friction m's framing pointed at: **the dimensions are shared, but the surface vocabulary is not.**
---
## §3 Consolidation proposal
### §3.1 The honest answer first
Of the 6 surfaces:
- **2 stay separate, correctly** — `/admin/procedural-events` (editorial audience) and `/projects/{id}` Verlauf + SmartTimeline (per-Akte actuals). They serve different question shapes and audiences. Cross-link liberally; do not merge.
- **4 are candidates for unification** — Fristenrechner Mode A + Mode B + result + Verfahrensablauf. Same underlying data, same dimensions, two zoom levels on one graph. Today they sit at two URLs (`/tools/fristenrechner` + `/tools/verfahrensablauf`) with separate filter vocabularies.
### §3.2 The unified surface: `/tools/procedures`
**Proposal:** consolidate the 4 reader surfaces into one page at `/tools/procedures` (the more general name; both "Fristenrechner" and "Verfahrensablauf" are sub-modes inside).
```
┌─────────────────────────────────────────────────────────────────────────┐
│ /tools/procedures │
│ ┌─ Akte / kontextfrei ─┐ ┌─ Filterleiste ────────────────────────────┐│
│ │ HL-2024-001 ▼ ohne │ │ Forum • Verfahren • event_kind • Partei ││
│ └──────────────────────┘ └───────────────────────────────────────────┘│
│ ┌─ Wie willst du einsteigen? ──────────────────────────────────────────┐│
│ │ (•) Verfahren wählen ( ) Direkt suchen ( ) Geführt ( ) Aus Akte ││
│ └─────────────────────────────────────────────────────────────────────┘│
│ ┌─ Ausgabe ── (Anzeige: Gewählt) ──────────────────────────────────────┐│
│ │ Either: TREE (proceeding-anchored) ││
│ │ │ 📥 Klageerhebung [claimant · M] ││
│ │ │ ├─ Klageerwiderung [defendant · M] ││
│ │ │ │ └─ Replik [claimant · M · ?with_ccr] ││
│ │ │ ├─ Widerklage [defendant · O · ?with_ccr] ││
│ │ │ └─ ⇲ Berufungsverfahren öffnen [SPAWN] ││
│ │ ││
│ │ Or: LINEAR (event-anchored, after locking) ││
│ │ │ 🎯 Klageerwiderung (defendant, 2026-04-01) ││
│ │ │ ───────────────────────────────────────────── ││
│ │ │ Pflicht: Replik (1 Monat) ☑ ││
│ │ │ Empfohlen: Vorl. Einwendungen ☑ ││
│ │ │ Optional: … ││
│ │ │ Bedingt: … ││
│ │ │ [In Akte speichern] ││
│ │ ││
│ │ Pivot: every card has "Im Ablauf zeigen" ↔ "Folge-Fristen anzeigen" ││
│ └─────────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────────┘
```
The page carries **one URL**, **one filter strip**, **one Akte picker**, **one selection-state store** (scenario_flags), **one view-mode toggle**, and **two output shapes** the user can toggle between:
1. **Tree output** (proceeding-anchored): the current Verfahrensablauf rendering — every rule of a proceeding, depth-indented via `parent_id`, with per-rule chips for selection and the three view-modes (Nur Pflicht / Gewählt / Alle Optionen).
2. **Linear output** (event-anchored): the current Mode A/B result view — sticky trigger card + 4 priority groups of follow-ups + write-back footer.
The **entry mode** selects *which output you land on*:
- "Verfahren wählen" + chip → tree of that proceeding.
- "Direkt suchen" + search → linear follow-ups of the picked event.
- "Geführt" wizard → linear follow-ups of the wizarded event.
- "Aus Akte" → tree of the Akte's proceeding, with scenario_flags pre-loaded.
The two outputs **share** the filter strip, the Akte context, the scenario state, the per-card UI vocabulary. Cross-pivoting is one click: from any rule card in the tree, "Folge-Fristen anzeigen" pivots to linear-from-that-anchor; from the linear view, "Im Ablauf zeigen" pivots back to the tree with the anchor highlighted.
### §3.3 Alternative — keep the URLs split, tighten alignment
The *minimum* unification, if m balks at folding two pages into one: keep `/tools/fristenrechner` and `/tools/verfahrensablauf` as distinct URLs but:
- Standardise the filter strip vocabulary (same chip names, same order, same colour coding).
- Share the entry-mode dropdown / tab UI components.
- Mutual deep-links: every result-view row has "Im Ablauf zeigen" → Verfahrensablauf URL with anchor; every Verfahrensablauf tree node has "Folge-Fristen" → Fristenrechner URL with event locked.
- Selection state already shared via `projects.scenario_flags` from atlas P0.
This is the conservative path. It preserves URL stability but accepts that "which tool for which question" remains a learned concept rather than a single-doorway tool.
### §3.4 Inventor's recommendation
**Unify (§3.2)** — m's framing ("preferably in a unitary tool") + the dimension matrix showing 6+ shared filters argue strongly. The cost of two URLs is two filter vocabularies, two mental models, two cmd-K targets. Folding them is a few weeks of frontend work after atlas's P3 lands; the data layer is already ready.
The risk is *not* the merge — it's the rename. `/tools/fristenrechner` is the name lawyers know. Naming choices in §11.Q2 below.
---
## §4 Multi-dimensional filter spec
Where each dimension lives in the unified surface. Categories: **anchor** (the thing the output is rooted on), **filter** (narrows what's rendered), **qualifier** (refines the anchor), **display** (per-card affordance), **state** (persists across surface).
| Dimension | Category | Where (entry mode) | Where (tree output) | Where (linear output) |
|---|---|---|---|---|
| `forum` | filter | top strip chip | top strip chip (narrows PT chips) | top strip chip + trigger-card badge |
| `proceeding_type` | anchor (tree) / filter (linear) | "Verfahren wählen" chip-grid; "Direkt"/"Geführt" filter strip | The anchor — header above tree | trigger-card chip |
| `event_kind` | filter | "Geführt" R1; Mode A filter chip | per-card icon | per-rule row icon |
| `primary_party` | filter | "Geführt" R5; Mode A filter chip; Akte (`our_side`) | swimlane column OR per-card chip (view-mode-dependent) | per-rule chip + Gegenseitig badge |
| `priority` | display | — | view-mode toggle + per-card style | group header (4 groups) |
| `condition_expr` (gating) | state | — | greyed + flag-strip activation | conditional group + read-only checkbox |
| `is_spawn` | display | filtered out of pickers (atlas §2.2) | leaf with ⇲ icon | "⇲ Verfahren öffnen" CTA, no date |
| `is_court_set` | display | — | greyed-date card with "vom Gericht" badge | "vom Gericht" badge, no date |
| `parent_id` (chain depth) | display | — | tree indentation | hidden (linear shows depth-1 only) |
| selection state `rule:<uuid>` | state | — | `[Aufnehmen]`/`[Entfernen]` chips | checkbox (write-back) |
| named scenario flags (`with_ccr`, …) | state | — | flag strip above tree | read-only mirror in conditional group |
| view-mode (detail level) | display | — | three-way segmented top toggle | — (always Gewählt) |
| `trigger_date` | anchor (linear) / display (tree) | linear: result view date input | tree: optional per-page input, defaults today | linear: top-card date input (canonical) |
| `is_cross_party` (derived) | display | — | muted style + Gegenseitig badge | muted style + Gegenseitig badge |
**Design principle:** dimensions stay in the **same chip / control**, regardless of which output is showing. The user learns the filter strip once. The output reacts.
---
## §5 Alternative paths spec — four ways to derive at the same outcome
m's "alternative means to derive at what you want" rendered explicitly. All four paths converge on the same underlying rule-set view; only the *entry experience* differs.
```
Path 1: PROCEEDING-FIRST (German-lawyer approach)
"Ich öffne ein UPC-Verletzungsverfahren — wie sieht das aus?"
1. Page open → "Verfahren wählen" tab (default if no Akte)
2. Chip-grid: pick `upc.inf.cfi`
3. Tree renders. User sees full ablauf.
4. (Optional) Click rule → drill to linear follow-ups of that rule.
Path 2: EVENT-FIRST (UPC-lawyer / paralegal)
"Das Gericht hat einen Hinweisbeschluss erlassen — was bedeutet das?"
1. Page open → "Direkt suchen" tab
2. Filter strip: Forum=UPC + event_kind=order
3. Search "Hinweis" → 3 hits
4. Click `upc.inf.cfi.cmo_review` → linear follow-ups (Antrag CMO-Überprüfung etc.)
Path 3: GUIDED (trainee PA)
"Es ist etwas passiert; ich weiß nicht wie die Frist heißt"
1. Page open → "Geführt" tab
2. R1 event_kind: filing
3. R2 forum: UPC (or skipped if R1 narrowed)
4. R3 proceeding_type: upc.inf.cfi (auto-skipped if only one)
5. R4 event chip-strip: pick the relevant event
6. R5 perspective (only if follow-ups differ)
7. Linear follow-ups render.
Path 4: AKTE-FIRST (senior partner / paralegal with project context)
"Auf HL-2024-001 ist heute Klageerwiderung zugegangen — was nun?"
1. Page open → Akte picker → HL-2024-001
2. Page auto-derives `proceeding_type` + `our_side` + `scenario_flags`
3. Default landing: TREE of upc.inf.cfi, scenario flags pre-loaded
4. Click "Klageerwiderung" card → linear follow-ups, write-back footer enabled
5. Tick rules → "In Akte speichern" → POST /api/projects/.../deadlines/bulk
```
All four paths share:
- the same filter strip (forum / proceeding / event_kind / party — values persist across paths in URL)
- the same view-mode toggle (when tree is showing)
- the same scenario_flags (when Akte is loaded)
- the same per-card vocabulary (`[Aufnehmen]` / `[Entfernen]` / `[Bedingt]` / `[Gegenseitig]` / `⇲`)
- the same cross-pivot affordance ("Im Ablauf zeigen" / "Folge-Fristen anzeigen")
The user can switch paths mid-task: started in Path 4, lost in the Akte's tree, jump to Path 2 (search) to find a specific event, then jump back to the tree via the cross-pivot. Tab state preserved.
---
## §6 Selection state spec
Already locked by atlas's `design-deadline-system-revision-2026-05-27.md` §2.3 + §2.4a. Briefly, in the unified tool's context:
- **Named flags** (`with_ccr`, `with_amend`, `with_cci`, plus catalog extensions) — top "Szenario-Flags" strip when proceeding is locked. Edits write to `projects.scenario_flags` (Akte) or localStorage (kontextfrei) and dispatch `scenario-flag-changed` CustomEvent. Both tree and linear views listen and re-render.
- **Per-rule deviations** (`rule:<uuid> = true|false`) — `[Aufnehmen]` / `[Entfernen]` chips on each tree card; identical to the result-view checkboxes in linear mode (linear's "checked" state literally is `rule:<uuid>=true`).
- **Default population:** none on project create. The flat-map only stores deviations from priority defaults.
**Cross-view sync.** When the user toggles "Klageerwiderung" in linear write-back, the tree's corresponding card immediately re-renders with the chip state updated — same CustomEvent. When the user clicks `[Aufnehmen]` on the tree's "Antrag CMO-Überprüfung", switching to linear shows it pre-checked.
**Kontextfrei vs Akte:** kontextfrei writes to `localStorage["paliad.verfahren.scenario.<proceeding_code>"]` (per-proceeding key — different proceedings have different selection sets, matching the existing `paliad.verfahrensablauf.scenario.*` convention). Akte writes to the DB column.
---
## §7 Sequence visualisation
Three candidate shapes. Issue brief lists "vertical tree, horizontal timeline, collapsible groups, per-priority lanes" as options. Today's surfaces use:
| Shape | Where today | What it does well | What it does poorly |
|---|---|---|---|
| **3-column swimlane** (Unsere / Gericht / Gegenseite) | Verfahrensablauf default view | Reads side-of-table cleanly; left = our action, right = opponent's | Dense at depth; mobile-hostile; cross-party hops zig-zag across columns |
| **Single-column linear timeline** | Verfahrensablauf alt view | Mobile-friendly; chronological | Loses parent-chain structure visually |
| **Vertical tree (indented)** | atlas P3 proposal; ASCII trees in design docs | Shows chain depth; clean on desktop + mobile; matches mental model | Less easy to read date-order at a glance |
| **Priority groups** | Mode A/B result view | Highlights what's urgent | Loses sequence; only works for one anchor |
**Recommendation:** make the tree the canonical desktop shape (atlas P3); the 3-column swimlane becomes an optional view ("Schwimmbahnen") when the user wants side-comparison; mobile defaults to the single-column linear timeline collapsed by depth. Per-priority groups stay as the linear-output sub-shape (only when an event is locked).
This is a strict superset of today's options — no shape is removed.
**Concrete rendering rules:**
- Each card carries 4 axes: priority, selection state, conditional gate, cross-party. Visual style composes them: priority = colour stripe; selection = solid vs dotted border; conditional-flag-off = greyed; cross-party = muted + Gegenseitig badge.
- Spawn rules render as **leaf chips** with `⇲` icon. In Akte mode, the chip becomes a CTA: click → create child project of the spawn target's PT, link via `parent_project_id`. Already wired via `/api/projects/{id}/timeline/counterclaim` for the CCR case.
- Court-set rules carry a "vom Gericht bestimmt" badge in place of the computed date. The card is still rendered (it's still part of the ablauf), just without a date column entry.
- Chain depth is rendered via **indentation + connector lines**, capped at depth-5 (today's max is 4 for the upc.inf.cfi CCR branch). Beyond depth-3 the lines fold to a "in 3 weiteren Schritten" collapsible hint — keeps long chains from running off the screen.
---
## §8 Context preservation when drilling
m: "when a user drills into a single rule from one entry, how to keep the surrounding sequence visible".
Three options:
1. **Split-pane** — left: tree of the proceeding; right: linear follow-ups of the focused rule. Tree highlights the focused node.
2. **Inline drawer** — clicking a rule expands an inline drawer beneath it showing follow-ups; tree stays in place; drawer is collapsible.
3. **Breadcrumb pivot** — single output shape at a time; pivoting linear→tree shows a breadcrumb chain "upc.inf.cfi > Klageerhebung > Klageerwiderung > [Klageerwiderung is here]"; tree renders with the breadcrumb highlighted.
**Inventor pick: option 2 (inline drawer)** for desktop, **option 3 (breadcrumb)** for mobile. Reasons:
- Split-pane (option 1) is the cleanest visualisation but burns half the screen on context the user might not want. Optional via a "Zwei Spalten" toggle for power users.
- Inline drawer (option 2) keeps everything in one column with progressive disclosure; the user scrolls through the tree, expands the rule they care about, sees follow-ups, collapses, moves on. Matches how the existing `<details>` flow already works on /admin pages.
- Breadcrumb (option 3) is the only sensible mobile pattern — split panes can't, drawers nest awkwardly.
When in the inline drawer, the focused rule's follow-ups render in the same priority-group shape as the linear view; the per-rule `[Aufnehmen]` / `[Entfernen]` chips work identically; write-back to Akte works identically. The drawer is the linear view embedded.
---
## §9 Mobile / narrow viewport
Today's Verfahrensablauf 3-column swimlane is desktop-heavy. The tree-output proposal collapses better, but still needs careful narrow-viewport rules.
Layout breakpoints:
- **< 640px (phone):** single-column. Filter strip collapses to a sticky "Filter" button bottom-sheet panel with the same chips. Entry-mode picker collapses to a sticky dropdown ("Verfahren wählen ▾"). Tree renders with no indentation lines; depth-N items get a leading "└ ".indent decoration only. Per-card chips ([Aufnehmen] etc.) move to a "..." menu on each card. View-mode toggle moves to a single icon button cycling PflichtGewähltAlle.
- **640-1024px (tablet):** filter strip stays at top but wraps; entry-mode picker becomes tabs; tree renders with proper indentation. View-mode toggle and Akte picker stay inline.
- **> 1024px (desktop):** full layout per §3.2. Optional "Zwei Spalten" toggle for the split-pane variant (§8.1).
**Mobile drill-down (§8 option 3):** clicking a card on phone pushes a new route `?focus=<rule_id>` and renders the linear follow-up view full-screen with a back-arrow breadcrumb. Back arrow restores the tree at the previous scroll position.
**Filter persistence across viewports:** URL params survive resize, the bottom-sheet panel reflects the same state as the desktop top-strip — same state machine.
---
## §10 Worked examples — 3 personas
### §10.1 Trainee PA — "what's next after Klageerwiderung?"
Persona: Anna, 6-month PA trainee, doesn't know which proceeding "Klageerwiderung" belongs to.
1. Opens `/tools/procedures`. No Akte. Lands on "Verfahren wählen" tab (default) but she doesn't want to browse — she wants to find one event.
2. Clicks "Geführt" tab. R1: was hat sich ereignet → **filing**. R2: forum → **UPC**. R3: proceeding_type → **upc.inf.cfi** (the only filing-forum option that has "Klage" in its events). R4: event chip-strip → **Klageerwiderung**. R5: perspective — wizard asks because the follow-ups differ → **defendant**.
3. Lands on linear follow-ups view. Sees: Pflicht: Replik (claimant, 1 Monat); Empfohlen: Vorl. Einwendungen; Optional: Widerklage; Bedingt: Antrag auf Patentänderung (greyed, with_amend off).
4. Wants to know: where does Klageerwiderung sit in the bigger picture? Clicks "Im Ablauf zeigen". Tree renders, with Klageerwiderung highlighted; she sees the SoC root above it, the CCR branch beside it, the cascade of Replik/Duplik below.
5. Anna learns the shape. Back to her task — she copies the Replik date into her notes.
### §10.2 Senior partner — brief client on full upc.inf.cfi ablauf
Persona: Dr. Becker, senior litigator, briefing a client on Friday about a new UPC matter that hasn't been filed yet.
1. Opens `/tools/procedures`. No Akte (matter not in Paliad yet).
2. Tab: "Verfahren wählen" → clicks `upc.inf.cfi` chip.
3. Tree renders. View-mode at default **Gewählt** — shows mandatory + recommended. Becker flips to **Alle Optionen** to brief the client on the full set including conditional branches.
4. CCR branch greyed (with_ccr off by default in kontextfrei). Becker ticks `with_ccr` in the flag strip. Tree re-renders; CCR branch lights up.
5. Becker wants to print this. Cmd-P / "PDF exportieren" (out of scope for this design but flagged). Tree-with-current-state renders cleanly because nothing depends on viewport hover.
6. After the call, Becker creates the Akte in Paliad. Returns to the page with `?project=HL-2025-031`. Same state preserved into the new project — `scenario_flags = {with_ccr: true}` writes to DB on first PATCH.
### §10.3 Paralegal — enter CMS-received Hinweisbeschluss into Akte
Persona: Sandra, paralegal, daily CMS triage. Today: a Hinweisbeschluss arrived on HL-2024-001 (upc.inf.cfi).
1. Opens `/tools/procedures` → picks HL-2024-001 from Akte picker.
2. Page auto-derives proceeding = upc.inf.cfi, our_side = claimant, scenario_flags = {with_ccr: true} (already on this matter).
3. Default landing: TREE of upc.inf.cfi, scenario state loaded. Sandra sees the full ablauf with the matter's actual selections.
4. She knows the event is a Hinweisbeschluss → uses the search box (top right corner of the unified page, available in any mode) → types "Hinweis".
5. Search popover shows 1 result: `upc.inf.cfi.cmo_review` (Antrag auf CMO-Überprüfung). Sandra clicks → tree scrolls + highlights the rule; drawer expands beneath it showing the follow-up rule `upc.inf.cfi.cmo_review_resp` with computed date (today + R.333.2 duration).
6. Drawer footer has "In Akte speichern" button. Sandra ticks the follow-up rule, sets trigger date = today, audit reason = "CMS-Hinweisbeschluss eingegangen", saves.
7. Deadline inserted into HL-2024-001. Sandra returns to her queue.
Total clicks: 5 (open tool, search, click result, tick, save). No mode-switching, no URL-jumping, no two-tab juggling.
---
## §11 Migration plan
Five-slice train. Each slice ships as one PR. P0 is the model layer atlas already designed; everything below is surface-layer on top.
| Slice | Mig | What ships | Reversible? |
|---|---|---|---|
| **U0 — Shared filter-strip component** | — | Extract Mode A's filter strip + Verfahrensablauf's filter chips into one `<FilterStrip>` component used by both pages (still two URLs). Standardise chip names, order, colour. Cross-link buttons in both directions. | Yes — code-only |
| **U1 — New unified page at `/tools/procedures`** | — | New route + page shell. Carries Akte picker, filter strip, entry-mode tab control. Initially shows TREE view only (lifts from /tools/verfahrensablauf without removing the original). | Yes — route addition |
| **U2 — Linear output + drawer + cross-pivot** | — | Embed the Mode A/B result-view rendering as an inline drawer in U1. Cross-pivot "Im Ablauf zeigen" / "Folge-Fristen anzeigen" wired. Search box top-right available in all modes. | Yes — code-only |
| **U3 — Entry mode tabs (Direkt / Geführt / Verfahren / Aus Akte)** | — | Wire Mode A search + Mode B wizard as additional entry tabs on `/tools/procedures`. All four entry paths converge on either tree or linear output depending on what the user picked. | Yes — code-only |
| **U4 — Redirects + deprecation** | — | **Per m's Q11 (§11.5): hard cut, no dual-shipping.** `/tools/fristenrechner?…` → 301 → `/tools/procedures?mode=direkt&…` (preserve query params). `/tools/verfahrensablauf?…` → 301 → `/tools/procedures?mode=ablauf&…`. Sidebar + cmd-K updated in the same PR. Old `*.tsx` files deleted. No `?legacy=1` escape. | Reversible only by revert PR |
**Constraint:** U0-U3 are independent of atlas P0-P3 and can ship in parallel (different files). U4 should land after atlas P3 (`/tools/verfahrensablauf` tree) so the redirect target carries the full tree shape from day 1. If atlas P3 slips, U4 stays in the queue.
**No DB migration.** All state lives in `projects.scenario_flags` (atlas P0) + localStorage. URL param schema is additive.
**Pre-deploy gauntlet:** kontextfrei + Akte modes × each entry path × tree + linear output = 16 path/output combinations. Plus mobile narrow viewport for all 4 entry paths. Plus URL deep-link restore for each saved-state shape.
---
## §11.5 m's decisions (2026-05-27)
All 12 questions answered via `AskUserQuestion` in 3 batches of 4. 9 picks on-recommendation; 3 diverged from the inventor pick. Decisions below; raw question list preserved in §12 as the historical record.
### Tier 1 — does the unification happen at all & what does it look like?
- **Q1 (Unify vs Align): Full unification — one URL.** [= recommendation] **Locks §3.2.** The four reader surfaces (Fristenrechner Mode A + Mode B + result + Verfahrensablauf) fold into a single page with entry-mode tabs and two output shapes. Aligned-but-separate (§3.3) is dropped from the plan.
- **Q2 (URL/Name): `/tools/procedures` — English.** [≠ recommendation; m diverged from inventor's `/tools/verfahren` pick] m's verbatim:
> just one, but english name - call it tools/procedures ...
**Locks §3.2 + §11 (renames `/tools/verfahren` → `/tools/procedures` throughout).** Rationale: the codebase convention is "English in code, German in UI" (project CLAUDE.md: "All code, table names, Go types, service names, URL paths, API endpoints, file names — English"). `/tools/procedures` follows that rule; the inventor's `/tools/verfahren` strawman broke it. The German sidebar entry stays "Verfahren & Fristen" (Q12) — the URL is the developer surface, the label is the user surface.
- **Q3 (Default entry / search shape): All entry modes as tabs + text search combined with dimension filters.** [≠ recommendation; m reframed the question] m's verbatim:
> yeah, different tabs, right?! I think we need to have all of the named ones. And we can combine a text search with filters for the dimensions of the event
**Locks §3.2 + §5 + reshapes §4.** All four named entry paths (Verfahren wählen / Direkt suchen / Geführt / Aus Akte) are visible as tabs simultaneously. The search box is part of the filter strip at the top of the page and composes with the chip filters (Forum / Verfahren / event_kind / Partei) at all times. The "Direkt suchen" tab still exists for the explicit search-first workflow, but the search input is also live in tree mode (top-of-page filter strip) — meaning a user browsing a proceeding can refine the tree's rendered set by typing into the same search box that filters Mode A. The default landing question ("which tab is active first") becomes a secondary concern: any of the four tabs is one click away. Default behaviour: first tab in the strip ("Verfahren wählen") is selected on cold open with no Akte, but the URL preserves the user's last-active tab if returning via a deep-link.
- **Q4 (Akte default behaviour): TREE of the Akte's proceeding.** [= recommendation] **Locks §3.2 + §10.3.** Akte picker triggers auto-derivation of `proceeding_type` + `our_side` + `scenario_flags`, lands on the tree view with the matter's state loaded.
### Tier 2 — tree mechanics + visual style
- **Q5 (Tree shape): Both vertical tree + 3-column swimlane, with a toggle.** [= recommendation] **Locks §7.** Default desktop = vertical indented tree (clean chain depth, mobile-translatable); "Schwimmbahnen" toggle reveals the 3-column swimlane (Unsere Seite | Gericht | Gegnerseite) for side-comparison. Toggle state in `localStorage["procedures:tree_shape"]` (per-user, not per-Akte).
- **Q6 (Cross-pivot): Inline drawer beneath the card.** [= recommendation] **Locks §8.** Clicking a rule card expands an inline drawer with the linear follow-up view (priority groups + write-back footer). Tree stays in place above. Multiple drawers can be open. Drawer carries the same per-rule selection chips as the tree, so writes propagate to scenario_flags identically.
- **Q7 (Search position): Always-visible search bar in the filter strip.** [= recommendation] **Locks §4 + §3.2.** Search input lives in the top filter strip next to the chip groups; available in every output mode. Composes with chip filters via AND semantics (chip filters narrow the corpus, search ranks within the narrowed set). This is what m's Q3 reframe asked for.
- **Q8 (Cross-party rows in tree): Show with Gegenseitig badge + muted style.** [= recommendation] **Locks §7.** Tree renders the full graph including opponent rows, muted + badged consistently with the linear view. Identical to atlas's locked treatment for the linear view (`design-deadline-system-revision-2026-05-27.md` §2.4).
### Tier 3 — mobile + migration
- **Q9 (Mobile tree shape): Single-column with `└` indent decorator.** [= recommendation] **Locks §9.** Phone-narrow render keeps depth via leading-marker indentation; SVG connector lines drop; cards stack vertically. Resize back to tablet/desktop restores the full tree with connector lines.
- **Q10 (Mobile drill): Push new route with breadcrumb back.** [= recommendation] **Locks §9.** Clicking a card on phone pushes `?focus=<rule_id>` and renders the full-screen linear follow-up view with a back-arrow breadcrumb. Tree scroll position preserved on back. Inline drawer is desktop-only.
- **Q11 (Migration window): Hard cut — no dual-shipping window.** [≠ recommendation; m diverged from "2 weeks 302"] m's verbatim:
> not at all
**Locks §11 (rewrites the U4 slice).** When `/tools/procedures` ships, `/tools/fristenrechner` and `/tools/verfahrensablauf` flip directly to redirects (301 permanent, no `?legacy=1` escape hatch). Sidebar entries swap to the new entry in the same release. cmd-K palette swaps to the new entry. No 2-week dual-shipping window. Rationale (interpreted): the audience is internal HLC lawyers (~50 users, all on the same release rhythm). A 2-week dual ship adds complexity for almost no benefit; m would rather flip and fix any broken bookmark via direct comm.
- **Q12 (Sidebar): One entry "Verfahren & Fristen".** [= recommendation] **Locks §11.** Single sidebar item (German label) pointing at `/tools/procedures` (English URL). cmd-K palette updated to one entry "Verfahren & Fristen" with `/tools/procedures` as the action.
### §11.5.1 Changes triggered by m's divergences
Three picks changed the design beyond ratification. Summarised here so the coder reads the *current* design, not the pre-grilling strawman.
1. **URL rename `/tools/verfahren` → `/tools/procedures`** (Q2). Replaces every URL reference in §3.2, §4, §5, §10, §11, §14. Page name in the codebase: `frontend/src/procedures.tsx`. Sidebar label stays German ("Verfahren & Fristen"). Internal Go types stay English (`ProceduresPage`, etc.).
2. **All-tabs-visible + search-as-filter** (Q3). Replaces the strawman's "pick a single default tab" wording in §3.2 + §4. The unified page now renders all four entry-mode tabs at all times (Verfahren wählen / Direkt suchen / Geführt / Aus Akte). The search box is in the filter strip alongside the chip filters and composes with them in every output mode (tree + linear). The "Direkt suchen" tab remains, but its function shifts: it's the *search-first cold start* tab; once the user has any output (tree or linear), the search box at the top of the page is the canonical re-narrowing affordance. The wizard tab ("Geführt") and the Akte tab still exist as explicit workflows.
3. **Hard cut, no dual-ship** (Q11). Slice U4 in §11 is rewritten: 301 redirects on `/tools/fristenrechner` + `/tools/verfahrensablauf` to the new page; no `?legacy=1` escape; the old `*.tsx` files are deleted in the same PR. Bookmarks resolve via the 301; no in-product affordance points at the legacy URL after the merge.
### §11.5.2 What stays unchanged
The other 9 picks (Q1, Q4-Q10, Q12) ratified the inventor proposal. The full unification at a single URL with two output shapes (tree + linear drawer), four entry paths, shared selection state via `projects.scenario_flags`, vertical tree + swimlane toggle, mobile `└` decorator + breadcrumb-back drill-down, single sidebar entry — all locked as drafted in §1-§11.
---
## §12 Open questions for m
Twelve questions, batched 4 + 4 + 4 for `AskUserQuestion`. The first batch is **must-answer** (decides the unification's existence + URL shape); the second is **shape** (tree mechanics + visual style); the third is **mobile + migration** (operational).
Will be answered via `AskUserQuestion` per the inventor SKILL; m's picks fold back into a `§12.5 m's decisions (2026-05-27)` section at the top of this file before the "DESIGN READY FOR REVIEW" signal.
### Batch 1 — does the unification happen at all & what does it look like?
- **Q1 (Unify vs Align):** Fold the four reader surfaces into `/tools/procedures` (full unification §3.2), or keep `/tools/fristenrechner` and `/tools/verfahrensablauf` as separate URLs and just tighten alignment (§3.3)?
- **Q2 (Naming):** If unifying — what's the page name? `/tools/verfahren` (generic German, my original pick), `/tools/fristenrechner` (lawyers know this one — repurpose as the supermarket), or `/tools/ablauf` (closest to what it does)? (m diverged with `/tools/procedures` — see §11.5.)
- **Q3 (Default entry mode):** When the user opens `/tools/procedures` with no URL params and no Akte, which entry tab is active? "Verfahren wählen" (browse, my pick), "Direkt suchen" (power), "Geführt" (onboarding).
- **Q4 (Akte default behaviour):** When user picks an Akte from the picker, default landing — TREE of the Akte's proceeding (my pick) or "remember last view" per-user.
### Batch 2 — tree mechanics + visual style
- **Q5 (Tree shape):** Desktop tree rendering — vertical indented tree (my pick), 3-column swimlane (current Verfahrensablauf default), or both with a "Schwimmbahnen" toggle.
- **Q6 (Cross-pivot affordance):** When clicking a rule card in the tree to see its follow-ups — inline drawer beneath the card (my pick), split-pane (tree left + linear right), or full-page push (replaces tree, breadcrumb back).
- **Q7 (Mode A search location):** The free-text "Direkt suchen" entry — only as a top-tab (my pick, with a small search icon always available in tree mode), always-visible search bar at top, or only inside the "Direkt" tab.
- **Q8 (Cross-party rows in linear):** Atlas locked "show with Gegenseitig badge, unchecked default, unconditionally excluded from write-back". In tree mode, same treatment (my pick) or hide cross-party rows entirely by default and surface via "Gegenseite einblenden" toggle.
### Batch 3 — mobile + migration
- **Q9 (Mobile tree shape):** On phones (< 640px) single-column indented list with leading "└" decorator (my pick), single-column flat list (no indentation), or chronological-timeline view (auto-pivots when narrow).
- **Q10 (Mobile drill-down):** Clicking a card on phone push new route with breadcrumb-back (my pick), inline drawer (cramped on small screens), or modal sheet.
- **Q11 (Migration window):** After the unified page ships 2-week dual-shipping with 302 redirects (my pick, matches t-paliad-322 S5 pattern), 1-week, or 4-week.
- **Q12 (Sidebar entries):** Sidebar today has "Fristenrechner" + "Verfahrensablauf" as separate items. Post-merge one entry "Verfahren & Fristen" (my pick), keep both with both same URL, or pick one ("Fristenrechner" or "Verfahrensablauf") as the canonical name.
---
## §13 Out of scope
- Calculator changes (`pkg/litigationplanner.CalculateRule`). Working.
- Editorial backfill (curie owns t-paliad-333 in parallel).
- /admin/procedural-events as a read surface different audience.
- /projects/{id} Verlauf per-Akte actuals; sister tool.
- SmartTimeline / `ProjectionService` per-project read view, downstream consumer.
- youpc.org/deadlines cross-repo snapshot consumer.
- Outlook / Calendar sync UI.
- PDF export of the tree (mentioned in §10.2 but not designed here).
- Bulk-write affordances beyond the existing `/deadlines/bulk` endpoint.
- Multi-project comparison views (would belong in SmartTimeline at Patent / Litigation / Client level, not in `/tools/procedures`).
- Translation between languages of free-text scenario flag names.
---
## §14 Synthesis links
- mBrian: file as `[synthesis]` linked `triggered_by` t-paliad-334; `related_to` athena's assessment + atlas's deadline-system-revision design + cronus's earlier Fristenrechner overhaul design.
- Cross-refs in this repo: `docs/assessment-deadline-system-2026-05-27.md` (athena), `docs/design-deadline-system-revision-2026-05-27.md` (atlas), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26), `docs/design-event-card-choices-2026-05-25.md` (existing per-card choice).
- Gitea: m/paliad#151 (this design), m/paliad#149 (atlas Phase 2), m/paliad#146 (cronus 2026-05-26 Fristenrechner overhaul, S1-S6 shipped).
- Coder phase (deferred per inventor SKILL): runs after m ratifies via AskUserQuestion. Slice ordering per §11. NOT cronus (parked at "DESIGN READY FOR REVIEW"). A pattern-fluent Sonnet coder picks up U0 first; U1-U3 sequential; U4 gated on atlas P3 landing.

View File

@@ -0,0 +1,918 @@
# User-authored checklists: authoring, sharing, admin-promotion
**Task:** t-paliad-225 — Gitea m/paliad#61
**Inventor:** dirac, 2026-05-20
**Branch:** `mai/dirac/user-checklists`
**Status:** DESIGN READY FOR REVIEW
## 1. Problem statement
Paliad ships a curated catalog of UPC / DE / EPA checklists today
(`internal/checklists/templates.go`, 6 templates). Users instantiate them
on Akten and check items off; per-instance state lives in
`paliad.checklist_instances` and is gated by the parent project's
team-based visibility.
m wants three new capabilities (m 2026-05-20 14:14):
1. **User-authored templates** — any non-`global_admin` can create a
checklist template they own (title, sections, items, references).
2. **Sharing** — author shares with specific colleagues, an Office, a
Dezernat (partner-unit), a project team, or the whole firm.
3. **Admin promotion to global**`global_admin` promotes an authored
template into the firm-wide catalog so it appears alongside the
curated UPC/DE/EPA templates for every user.
This design covers all three across three sequential slices.
## 2. Premises verified live (load-bearing findings)
The Gitea issue body says "Add `owner_id uuid NULL` to
`paliad.checklists`". That table **does not exist**. Verifying against
the live DB and the code corrected several premises:
- **`paliad.checklists` does NOT exist as a DB table.** Templates today
are pure Go data in `internal/checklists/templates.go` (6 entries,
~310 lines), served by `internal/handlers/checklists.go` via
`checklists.Summaries()` and `checklists.Find(slug)`. The DB has
`paliad.checklist_instances` (per-user state) and
`paliad.checklist_feedback` (a thumbs-up/down sink). That's it. The
design has to introduce `paliad.checklists` from scratch.
- **`paliad.checklist_instances.template_slug` is `text` with no FK** —
validity is enforced in `ChecklistInstanceService.Create` against the
static Go registry. This is what lets the design keep the static
catalog as one source of truth and add the DB catalog as a parallel
source: instance creation just resolves the slug against the merged
view and snapshots the template body.
- **Migration tracker live = 106; on-disk head = 111.** Five unapplied
on-disk migrations (107 caldav-binding-id, 108 mkcalendar-capability,
109 user_dashboard_layouts, 110 project_type_other, 111
project_admin_and_select — gauss's t-paliad-223 Slice A, m-locked
today). At inventor time the next free slot is **112**. The coder
MUST re-verify with `ls internal/db/migrations/ | tail` at shift
start — the slot can drift if other branches merge first.
- **`paliad.effective_project_admin(_user_id, _project_id)` lands with
migration 111** (gauss, today). Mirrors `can_see_project`'s shape:
STABLE SECURITY DEFINER, ltree ancestor walk against `projects.path`,
branches on global_admin shortcut + project_teams responsibility =
'admin'. **Used by this design** to gate the "Make global" button (we
reuse the global_admin shortcut, not the project-admin branch — see
§4.4) and as the precedent for any new STABLE SECURITY DEFINER
predicates we add.
- **`paliad.system_audit_log` (mig 102) is the org-scope audit sink.**
Columns: `event_type` (free-text), `actor_id`, `actor_email`,
`scope` ∈ {org, project, personal}, `scope_root uuid`,
`metadata jsonb`. RLS: self-read for the actor +
global_admin read-all. **Pattern to follow:** insert event row at
state transition (see `ExportService.WriteAuditRow` in
`internal/services/export_service.go:1120` for the canonical shape).
- **`paliad.project_events`** is the project-timeline audit sink and is
already wired for checklist instance lifecycle events
(`checklist_created`, `_renamed`, `_unlinked`, `_linked`, `_reset`,
`_deleted`). We do NOT need to invent a new event_type for instance
events; we'll add a few `_snapshot_taken` / template-level events to
`system_audit_log` and keep instance events on `project_events`.
- **`paliad.users.office`** is `text` (CHECK against the office key
list in `internal/offices/offices.go` — 8 keys: munich, duesseldorf,
hamburg, amsterdam, london, paris, milan, madrid). Multi-office users
have `additional_offices text[]`. Both are first-class columns; no
separate `offices` table.
- **`paliad.partner_units`** (cols: id, name, lead_user_id, office,
timestamps) is the Dezernat / practice-group table. Membership lives
in `paliad.partner_unit_members`. Projects attach via
`paliad.project_partner_units` (with derivation flags). All three
are referenceable from a share recipient.
- **`paliad.users.global_role`** is `text`; values include
`'global_admin'`. Used for the firm-wide promote/demote authority.
- **`paliad.project_teams`** (mig 111 just added) carries
`responsibility` ∈ {admin, lead, member, observer, external}. We
reuse `can_see_project` (visibility) for share-to-project recipients,
NOT `effective_project_admin`. The semantic of "share with a project
team" is "anyone on the matter sees it", not "anyone who can edit
membership sees it".
- **No precedent for entity-level sharing in paliad.** The personal-
sidecar tables (`user_views`, `user_dashboard_layouts`,
`user_pinned_projects`, `user_card_layouts`) are owner-only with no
share columns. Existing visibility predicates
(`paliad.can_see_project`) walk the project tree, not arbitrary
entities. This design introduces the first multi-axis share pattern
in the codebase (§3.2).
## 3. Architecture: hybrid templates + share table
### 3.1 Two template sources, one read layer
**KEEP** the static Go template registry as the firm's curated catalog.
It's version-controlled, code-reviewed, immutable at runtime, and the
right substrate for legally-curated content (RoP citations, EPC rule
references). Migrating those into DB rows would lose the git review
trail for content that requires lawyer eyes.
**ADD** `paliad.checklists` as the DB catalog for user-authored content.
Same Template shape (slug, titles, regime, court, groups[], items[])
but stored as JSONB so the schema doesn't have to chase content
evolution.
A `ChecklistCatalogService` unifies the two at read time:
- `ListVisible(user)` → static templates DB rows the user can see
- `Find(slug, user)` → static lookup first, then DB lookup with visibility check
- Slug-uniqueness enforced **across both spaces** at write time (DB slugs
rejected if they collide with a static slug).
Existing `/api/checklists` and `/api/checklists/{slug}` endpoints keep
their JSON shape — they just delegate to the catalog service instead of
the bare static registry.
### 3.2 Multi-axis sharing — checklist-specific table, polymorphism deferred
The task brief asks for a "modular / abstract" solution. I considered a
polymorphic `paliad.entity_shares(target_kind, target_id, recipient_kind,
recipient_*)` table that could later carry shares for views, dashboards,
saved searches, project templates, etc.
**Decision: keep it checklist-specific (`paliad.checklist_shares`) for
v1.** Reasons:
1. There is NO second entity in paliad that requests sharing today —
`user_views`, `user_dashboard_layouts`, `user_card_layouts`,
`user_pinned_projects` are all explicitly owner-only by design (see
migration comments). The "future reuse" is hypothetical.
2. Polymorphic FKs forfeit ON DELETE CASCADE — every recipient kind
needs its own deletion trigger. That complexity is real, the
reusability gain is not.
3. The CORRECT abstraction emerges by extracting *after* the second use
case shows up. Right now we don't know whether dashboards want the
same recipient axes (user / office / partner-unit / project) or a
different set (e.g. dashboards probably want "everyone on a project"
not "the whole firm").
The design IS modular in the sense that the recipient resolution logic
(below) is centralized in one SQL predicate (§4.3) which a future
polymorphic refactor can lift verbatim.
If the second entity asks for sharing within ~3 months, refactor to
`paliad.entity_shares` as a single-mig follow-up. Until then,
`paliad.checklist_shares` keeps the schema honest.
### 3.3 Visibility states
`paliad.checklists.visibility text` (CHECK enum):
| state | who sees | who edits |
|-----------|----------------------------------------------------|---------------------|
| `private` | owner only | owner |
| `shared` | owner + explicit recipients in checklist_shares | owner |
| `firm` | owner + every authenticated paliad user | owner |
| `global` | owner + every authenticated paliad user + catalog | owner + global_admin|
`firm` vs `global` distinction:
- `firm` = author self-published. Author can flip back to private/shared
any time. Does NOT appear in the main `/checklists` Vorlagen tab; only
in the new "Geteilte Vorlagen" / "Shared by colleagues" surface.
- `global` = admin-promoted into the firm catalog. Appears in the main
Vorlagen tab alongside the static templates. Author retains edit
authority by default; only `global_admin` can demote.
Demotion target: `global → firm` (preserves visibility for users who
already started instances). Author can subsequently narrow further.
### 3.4 Template snapshot on instance create
m's brief calls this out as a design decision: when an author edits a
template, do existing instances pick up the changes (propagate) or stay
on the version they were created from (snapshot)?
**Pick: snapshot.** Inventor pick (R). Rationale:
1. **Data integrity.** Instances are working artefacts. A user halfway
through a Klageerwiderung instance shouldn't have items disappear or
reorder under them because the author edited the template.
2. **Audit story.** The completed instance shows exactly what the
author saw when they started. Reconstruction without git-blame on
the template.
3. **Visibility narrowing safe by construction.** If author unshares
from a colleague who already has an instance, the instance survives
because the snapshot is local.
4. Cost is trivial: a typical template is <2 KB JSONB; instances rarely
exceed a few per user per template. Even 10× the row size of today
is fine.
Schema cost: one nullable `template_snapshot jsonb` column on
`paliad.checklist_instances`. Backfilled lazily existing instances
keep `NULL`, service falls back to looking the slug up in the catalog;
new instances always get a snapshot. Slice C can backfill the column
for already-existing rows via a one-off `UPDATE` if we want strict
consistency.
## 4. Schema (migration 112 — verify slot at coder shift)
Single migration file `internal/db/migrations/112_user_checklists.up.sql`
+ matching `.down.sql`. Idempotent throughout
(`CREATE TABLE IF NOT EXISTS`, `DO $$ … EXCEPTION` guards).
> Slot caveat: at design time, latest disk = 111, live tracker = 106
> (mig 107-111 pending deploy). Coder MUST re-verify
> `ls internal/db/migrations/ | tail` at shift start. If a higher
> number lands first (e.g. boltzmann's gap-tolerant runner ships as
> 112), bump to the next free slot.
### 4.1 `paliad.checklists` — authored template catalog
```sql
CREATE TABLE paliad.checklists (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
-- Authoring metadata
owner_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
regime text NOT NULL DEFAULT 'OTHER', -- UPC | DE | EPA | OTHER
court text NOT NULL DEFAULT '',
reference text NOT NULL DEFAULT '',
deadline text NOT NULL DEFAULT '',
lang text NOT NULL DEFAULT 'de', -- 'de' | 'en' — author's primary language
-- Body
body jsonb NOT NULL, -- { groups: [{ title, items: [{ label, note, rule }] }] }
-- Lifecycle
visibility text NOT NULL DEFAULT 'private'
CHECK (visibility IN ('private', 'shared', 'firm', 'global')),
promoted_at timestamptz, -- set on transition to 'global'
promoted_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
-- Timestamps
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX checklists_owner_idx ON paliad.checklists (owner_id);
CREATE INDEX checklists_visibility_idx ON paliad.checklists (visibility) WHERE visibility IN ('firm', 'global');
CREATE INDEX checklists_regime_idx ON paliad.checklists (regime);
```
**Slug-collision safety net:** application layer validates that the
chosen slug doesn't collide with a static template slug. The static
list is loaded into a `map[string]bool` at boot. New authored slugs
auto-prefixed with `u-` so collisions with static slugs are structurally
unlikely (`u-my-strategy-2026` vs `upc-statement-of-claim`).
### 4.2 `paliad.checklist_shares` — explicit grants
```sql
CREATE TABLE paliad.checklist_shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
checklist_id uuid NOT NULL REFERENCES paliad.checklists(id) ON DELETE CASCADE,
recipient_kind text NOT NULL CHECK (recipient_kind IN ('user', 'office', 'partner_unit', 'project')),
recipient_user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
recipient_office text,
recipient_partner_unit_id uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
recipient_project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
granted_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
granted_at timestamptz NOT NULL DEFAULT now(),
-- XOR check: exactly one recipient_* column populated per kind
CONSTRAINT checklist_shares_recipient_xor CHECK (
(recipient_kind = 'user' AND recipient_user_id IS NOT NULL AND recipient_office IS NULL AND recipient_partner_unit_id IS NULL AND recipient_project_id IS NULL)
OR (recipient_kind = 'office' AND recipient_office IS NOT NULL AND recipient_user_id IS NULL AND recipient_partner_unit_id IS NULL AND recipient_project_id IS NULL)
OR (recipient_kind = 'partner_unit' AND recipient_partner_unit_id IS NOT NULL AND recipient_user_id IS NULL AND recipient_office IS NULL AND recipient_project_id IS NULL)
OR (recipient_kind = 'project' AND recipient_project_id IS NOT NULL AND recipient_user_id IS NULL AND recipient_office IS NULL AND recipient_partner_unit_id IS NULL)
)
);
-- Avoid duplicates per recipient
CREATE UNIQUE INDEX checklist_shares_user_uniq ON paliad.checklist_shares (checklist_id, recipient_user_id) WHERE recipient_kind = 'user';
CREATE UNIQUE INDEX checklist_shares_office_uniq ON paliad.checklist_shares (checklist_id, recipient_office) WHERE recipient_kind = 'office';
CREATE UNIQUE INDEX checklist_shares_partner_unit_uniq ON paliad.checklist_shares (checklist_id, recipient_partner_unit_id) WHERE recipient_kind = 'partner_unit';
CREATE UNIQUE INDEX checklist_shares_project_uniq ON paliad.checklist_shares (checklist_id, recipient_project_id) WHERE recipient_kind = 'project';
-- Hot-path index for the visibility predicate
CREATE INDEX checklist_shares_lookup_idx ON paliad.checklist_shares (checklist_id);
```
### 4.3 `paliad.can_see_checklist(_user_id, _checklist_id)` predicate
```sql
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'paliad', 'public'
AS $$
-- Owner can always see
SELECT EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id
AND c.owner_id = _user_id
)
-- 'firm' / 'global' visible to all authenticated users
OR EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id
AND c.visibility IN ('firm', 'global')
)
-- Explicit share: user
OR EXISTS (
SELECT 1 FROM paliad.checklist_shares s
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'user'
AND s.recipient_user_id = _user_id
)
-- Explicit share: office (matches user.office OR additional_offices)
OR EXISTS (
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.users u ON u.id = _user_id
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'office'
AND (s.recipient_office = u.office
OR s.recipient_office = ANY(u.additional_offices))
)
-- Explicit share: partner_unit (caller is a member)
OR EXISTS (
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.partner_unit_members pum
ON pum.partner_unit_id = s.recipient_partner_unit_id
AND pum.user_id = _user_id
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'partner_unit'
)
-- Explicit share: project (caller can see the project via existing predicate)
OR EXISTS (
SELECT 1 FROM paliad.checklist_shares s
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'project'
AND paliad.can_see_project(s.recipient_project_id) -- reuses ltree walk
);
$$;
```
> Note on `can_see_project` self-reference: that function reads
> `auth.uid()` internally — when called from inside another SECURITY
> DEFINER body it picks up the caller's uid via search_path inheritance
> (same pattern as `effective_project_admin` reuse in mig 111).
### 4.4 RLS on `paliad.checklists`
```sql
ALTER TABLE paliad.checklists ENABLE ROW LEVEL SECURITY;
-- SELECT: owner OR visible via can_see_checklist
CREATE POLICY checklists_select
ON paliad.checklists FOR SELECT TO authenticated
USING (paliad.can_see_checklist(auth.uid(), id));
-- INSERT: caller can only create templates owned by themselves
CREATE POLICY checklists_insert
ON paliad.checklists FOR INSERT TO authenticated
WITH CHECK (owner_id = auth.uid());
-- UPDATE: owner always; global_admin if visibility='global' (for demotion)
CREATE POLICY checklists_update
ON paliad.checklists FOR UPDATE TO authenticated
USING (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
)
WITH CHECK (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
-- DELETE: owner OR global_admin
CREATE POLICY checklists_delete
ON paliad.checklists FOR DELETE TO authenticated
USING (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
```
### 4.5 RLS on `paliad.checklist_shares`
```sql
ALTER TABLE paliad.checklist_shares ENABLE ROW LEVEL SECURITY;
-- SELECT: caller can see if they own the checklist OR they are the recipient OR global_admin
CREATE POLICY checklist_shares_select
ON paliad.checklist_shares FOR SELECT TO authenticated
USING (
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
OR (recipient_kind = 'user' AND recipient_user_id = auth.uid())
OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
);
-- INSERT: only the checklist owner can grant
CREATE POLICY checklist_shares_insert
ON paliad.checklist_shares FOR INSERT TO authenticated
WITH CHECK (
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
AND granted_by = auth.uid()
);
-- DELETE: owner OR global_admin (no UPDATE policy — shares are immutable; revoke = delete + reinsert)
CREATE POLICY checklist_shares_delete
ON paliad.checklist_shares FOR DELETE TO authenticated
USING (
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
);
```
### 4.6 `paliad.checklist_instances.template_snapshot jsonb`
```sql
-- Idempotent — column NULL on existing rows; service handles fallback to catalog lookup.
ALTER TABLE paliad.checklist_instances
ADD COLUMN IF NOT EXISTS template_snapshot jsonb;
```
Existing RLS on `checklist_instances` untouched.
## 5. Service layer
### 5.1 `internal/services/checklist_catalog_service.go` (new)
Unified read facade over static + DB templates.
```go
type ChecklistCatalogService struct {
db *sqlx.DB
}
type CatalogEntry struct {
Slug string // matches checklists.Template.Slug or paliad.checklists.slug
Origin string // "static" | "authored"
OwnerID *uuid.UUID // nil for static
OwnerName string // empty for static
Visibility string // "static" | "private" | "shared" | "firm" | "global"
Template checklists.Template
}
// ListVisible returns every catalog entry the caller can see.
// Static entries are always returned. DB entries pass through RLS.
func (s *ChecklistCatalogService) ListVisible(ctx context.Context, userID uuid.UUID) ([]CatalogEntry, error)
// Find returns one entry by slug (static lookup first, then DB).
func (s *ChecklistCatalogService) Find(ctx context.Context, userID uuid.UUID, slug string) (*CatalogEntry, error)
// SnapshotBody returns the JSONB body for a slug — used at instance creation to capture the template state.
func (s *ChecklistCatalogService) SnapshotBody(ctx context.Context, userID uuid.UUID, slug string) (json.RawMessage, error)
```
### 5.2 `internal/services/checklist_template_service.go` (new — Slice A)
CRUD on `paliad.checklists`.
```go
type ChecklistTemplateService struct {
db *sqlx.DB
users *UserService
}
type CreateTemplateInput struct {
Title string
Description string
Regime string
Court string
Reference string
Deadline string
Lang string
Body checklists.Template // unmarshalled to body jsonb minus slug/titles/etc
}
func (s *ChecklistTemplateService) Create(ctx, userID, input) (*Template, error)
func (s *ChecklistTemplateService) Update(ctx, userID, slug, input) (*Template, error)
func (s *ChecklistTemplateService) Delete(ctx, userID, slug) error
func (s *ChecklistTemplateService) SetVisibility(ctx, userID, slug, visibility) error // private/firm only
func (s *ChecklistTemplateService) ListOwnedBy(ctx, userID) ([]Template, error)
```
Slug generation: lowercase, alphanumeric+hyphen, `u-` prefix, unique
suffix (collision retry up to 3x). Validator enforces
`^u-[a-z0-9][a-z0-9-]{2,62}$`. Reserved slugs from
`internal/checklists/checklists.go` Templates rejected at write time.
### 5.3 `internal/services/checklist_share_service.go` (new — Slice B)
```go
type ChecklistShareService struct { db *sqlx.DB }
type ShareGrantInput struct {
RecipientKind string
UserID *uuid.UUID
Office string
PartnerUnitID *uuid.UUID
ProjectID *uuid.UUID
}
func (s *ChecklistShareService) Grant(ctx, callerID, checklistID, input) (*Share, error)
func (s *ChecklistShareService) Revoke(ctx, callerID, shareID) error
func (s *ChecklistShareService) ListGrants(ctx, callerID, checklistID) ([]Share, error)
```
### 5.4 `internal/services/checklist_promotion_service.go` (new — Slice B)
`global_admin`-only operations.
```go
type ChecklistPromotionService struct { db *sqlx.DB, audit *SystemAuditLogService }
func (s *ChecklistPromotionService) Promote(ctx, callerID, checklistID) error
func (s *ChecklistPromotionService) Demote(ctx, callerID, checklistID, target /* 'firm' | 'private' */) error
```
Promote: assert caller.global_role = 'global_admin' UPDATE visibility =
'global', promoted_at = now(), promoted_by = caller audit row
`event_type='checklist.promoted_global'`.
Demote: assert caller is global_admin UPDATE visibility = target
(default 'firm') audit row `event_type='checklist.demoted'`.
### 5.5 Wire instance create to take snapshot
`ChecklistInstanceService.Create` extends to capture
`template_snapshot` at insert time via
`catalog.SnapshotBody(ctx, userID, slug)`. Existing instances unchanged
(NULL snapshot, fallback path in read layer).
### 5.6 Endpoints
| Method | Path | Slice | Purpose |
|--------|------|-------|---------|
| `GET` | `/api/checklists` | (existing)| Merged catalog list (static + visible DB) |
| `GET` | `/api/checklists/{slug}` | (existing)| Single template (static or DB) |
| `POST` | `/api/checklists/templates` | A | Create authored template |
| `GET` | `/api/checklists/templates/mine` | A | List own authored templates |
| `PATCH` | `/api/checklists/templates/{slug}` | A | Edit authored template |
| `DELETE` | `/api/checklists/templates/{slug}` | A | Delete authored template |
| `PATCH` | `/api/checklists/templates/{slug}/visibility` | A | Toggle private/firm |
| `GET` | `/api/checklists/templates/{slug}/shares` | B | List grants |
| `POST` | `/api/checklists/templates/{slug}/shares` | B | Grant share |
| `DELETE` | `/api/checklists/shares/{id}` | B | Revoke share |
| `POST` | `/api/admin/checklists/{slug}/promote` | B | Admin promote to global |
| `POST` | `/api/admin/checklists/{slug}/demote` | B | Admin demote |
| `GET` | `/api/checklists/gallery` | C | Browse all firm + global templates |
## 6. Instance snapshot lifecycle
**On Create (`ChecklistInstanceService.Create`):**
1. Resolve slug via `catalog.Find(userID, slug)` enforces visibility.
2. `snapshot = catalog.SnapshotBody(userID, slug)` captures the
template body (groups + items) at this moment, as JSONB.
3. Insert into `checklist_instances` with
`template_snapshot = snapshot`, `template_slug = slug`,
`state = '{}'::jsonb`.
**On Read (`ChecklistInstanceService.GetByID`):**
- Return the instance with `template_snapshot` if non-null.
- If NULL (legacy row created before mig 112), fall back to
`catalog.Find(slug)`. Logged at INFO; not a fatal path.
**On Template Edit (Slice A):**
- Owner edits template via PATCH DB row mutated `checklists.updated_at`
bumped no propagation. Existing instances continue rendering their
snapshot. New instances pick up the edit.
- Audit row `event_type='checklist.edited'`,
`metadata={ checklist_id, slug, changes:[...] }`.
**On Template Delete:**
- DB row deleted. Instances that snapshotted survive (snapshot is
local). Instances that DIDN'T snapshot (NULL) gracefully degrade
service detects "template not found in catalog" and returns the
instance with a sentinel "template withdrawn" body (renders a small
banner client-side; checkboxes still work because `state` is the
source of truth, not the template).
**On Visibility Narrow (firm → shared → private):**
- Existing instances unaffected (snapshot is local; visibility check is
on the template, not instance).
- New instance attempts fail with `ErrNotVisible` (the user can no
longer see the template to instantiate it).
## 7. Frontend (concise sketch — coder owns the detail)
### 7.1 `/checklists` (existing page) — Slice A adds "Meine Vorlagen"
Add a third tab between "Vorlagen" and "Vorhandene Instanzen":
```
[Vorlagen] [Meine Vorlagen] [Vorhandene Instanzen]
```
- **Vorlagen** (existing): static catalog + global-promoted DB
templates, grouped by Regime, filter pills (UPC/DE/EPA).
- **Meine Vorlagen** (NEW): caller's own authored templates + a "Neue
Vorlage" CTA. Each card shows title, description, visibility chip,
Aktions-Buttons (Bearbeiten / Teilen / Löschen).
- **Vorhandene Instanzen** (existing): unchanged behaviour; rows now
optionally render an "📌 Snapshot" badge when `template_snapshot` is
non-null (Slice A backfill marker).
Slice C adds a fourth tab: **Geteilte Vorlagen** (firm-level shared
templates not yet promoted discovery surface).
### 7.2 `/checklists/new` (NEW — Slice A)
Authoring wizard. Three steps:
1. Metadata title, description, regime (UPC/DE/EPA/OTHER), court,
reference, deadline.
2. Sections + items repeating editor (group title items[] of
{label, note, rule}).
3. Visibility radio: privat / firm-weit. (Sharing flow comes in
Slice B.)
Save POST `/api/checklists/templates` redirect to
`/checklists/{slug}` detail.
### 7.3 `/checklists/{slug}/edit` (NEW — Slice A)
Same wizard, prefilled. Owner-only (404 otherwise).
### 7.4 `/checklists/{slug}` detail page
Existing detail page renders the template (static OR authored).
Additions:
- Owner-only "Bearbeiten" / "Löschen" / "Teilen" buttons in the header.
- `global_admin`-only "Als Firmen-Vorlage hinterlegen" / "Aus Katalog
entfernen" button (Slice B).
- Provenance line under the title: "Erstellt von <author> · <date>"
(only for DB templates).
### 7.5 Share modal (Slice B)
Triggered by "Teilen" on owner's detail page. Four pickers stacked:
- Kollegen (user-picker, multi-select)
- Office (chip-select from `offices.All`)
- Dezernat (chip-select from `partner_units`)
- Projekt (autocomplete from owner-visible projects)
Footer: "Visibility" radio (privat / geteilt / firm-weit). Picking
"firm-weit" greys out the picker (firm-weit doesn't need grants).
Apply → POST grants individually → audit emits one
`event_type='checklist.shared'` per grant with
`metadata={ recipient_kind, recipient_id, checklist_id }`.
### 7.6 i18n keys
~28 new keys (DE+EN) under `checklisten.authoring.*`,
`checklisten.share.*`, `checklisten.promote.*`. Naming convention
matches existing `checklisten.tab.*` / `checklisten.instances.*`.
## 8. Audit events
Org-scope (`paliad.system_audit_log` via a small new helper
`SystemAuditLogService.WriteChecklistEvent`):
| event_type | actor | metadata keys |
|----------------------------------|-------------|----------------------------------------------------|
| `checklist.authored` | owner | checklist_id, slug, visibility |
| `checklist.edited` | owner | checklist_id, slug, changed_fields[] |
| `checklist.visibility_changed` | owner | checklist_id, slug, from, to |
| `checklist.shared` | owner | checklist_id, slug, recipient_kind, recipient_id |
| `checklist.unshared` | owner | checklist_id, slug, recipient_kind, recipient_id |
| `checklist.promoted_global` | global_admin| checklist_id, slug, owner_id |
| `checklist.demoted` | global_admin| checklist_id, slug, target_visibility |
| `checklist.deleted` | owner OR ga | checklist_id, slug, was_visibility |
Project-scope (`paliad.project_events` — existing helper
`insertProjectEventWithMeta`): existing checklist-instance events
unchanged. NO new project_events types for templates — templates are
not project-scoped.
`AuditService.ListEntries` already reads from `system_audit_log` via
the UNION ALL branch added in t-paliad-214 — no changes needed there;
new event_types surface automatically in the audit log UI.
## 9. Slice plan
### Slice A — Foundation (~700 LoC)
**Schema:** mig 112 §4.1 (`paliad.checklists`) + §4.3 predicate + §4.4
RLS + §4.6 instance snapshot column. **Skip** §4.2 / §4.5 in Slice A —
no share table yet; visibility limited to private/firm.
**Service:** `ChecklistCatalogService` (unified read), `ChecklistTemplateService`
(CRUD), `ChecklistInstanceService.Create` snapshot wiring,
`SystemAuditLogService.WriteChecklistEvent` helper.
**Endpoints:** `/api/checklists` (delegate to catalog), `POST/PATCH/DELETE
/api/checklists/templates`, `PATCH /api/checklists/templates/{slug}/visibility`.
**Frontend:** "Meine Vorlagen" tab on `/checklists`, `/checklists/new`,
`/checklists/{slug}/edit`, owner controls on detail page.
**Test pass:** unit tests for slug validation, snapshot capture,
visibility predicate (without share rows), audit emit, fallback to
catalog when snapshot NULL.
**No share, no admin promote, no gallery.** Ships immediately useful
for solo authoring + firm-wide publishing.
### Slice B — Sharing + Promotion (~600 LoC)
**Schema:** mig 113 — `paliad.checklist_shares` (§4.2) + revised RLS
(§4.5) + extend visibility CHECK to include 'shared' if Slice A used a
sub-enum (Slice A schema already includes 'shared' as valid value —
just no grants point at it yet).
**Service:** `ChecklistShareService`, `ChecklistPromotionService`.
**Endpoints:** shares endpoints + admin promote/demote.
**Frontend:** Share modal, "Make global" admin button on detail page,
share-grant chip list on detail page (owner-only).
**Audit:** new event_types (shared, unshared, promoted_global, demoted).
### Slice C — Discoverability + UX polish (~400 LoC)
**Gallery page** `/checklists/gallery`: browses every template the user
can see that's NOT their own, grouped by Regime / Author / Recency.
Filter pills. "Diese Vorlage verwenden" → instantiates with snapshot.
**Backfill** existing `checklist_instances` with `template_snapshot`
via a one-off migration (mig 114) — pure data move, no schema change.
After backfill, the catalog-fallback path can be removed (deferred to
Slice D / cleanup).
**Optional**:
- "Vorlage kopieren" action — clone an existing template (static OR
authored) into the caller's "Meine Vorlagen" for personal adaptation.
- Per-template instance counter ("12 Kollegen haben diese Vorlage
benutzt") — surfaced from `checklist_instances` group-by.
## 10. Trade-offs flagged
1. **Hybrid catalog (static + DB).** Two sources of truth means two
slug spaces to merge. Mitigated by `u-` prefix on authored slugs +
reserved-list rejection. Refactoring all static templates into DB
loses the git review trail; the hybrid is the right cost.
2. **Polymorphism deferred.** A future second sharable entity will need
to either copy the `checklist_shares` pattern (cheap but duplicative)
or refactor to `entity_shares` (one mig). The refactor is small;
premature abstraction now would pay complexity for no current
benefit.
3. **Snapshot semantics may surprise.** A user who edits their template
expecting downstream instances to update will be confused.
Mitigations: (a) UI banner on edit ("Bearbeitungen wirken nur auf
neue Instanzen"); (b) "Neu instantiieren" affordance on the instance
detail page that re-snapshots from the current template (preserves
the user's checkbox state to the extent items still match).
4. **Office membership is set-membership, not hierarchy.** Sharing to
"munich" reaches every user with `office='munich'` OR
`'munich' = ANY(additional_offices)`. There's no concept of "Munich
plus its sub-teams" because offices don't nest in paliad. Fine.
5. **Partner-unit membership join is N+1 on the predicate.** Each
visibility check touches `partner_unit_members` if any partner-unit
share exists. Indexes on `partner_unit_members(user_id, partner_unit_id)`
already exist (per mig 027 lineage); the join is single-row.
6. **Share-to-project recipient resolution uses
`can_see_project(s.recipient_project_id)`.** That predicate reads
`auth.uid()` from the session, so it works correctly inside our
SECURITY DEFINER body. Confirmed by reading `can_see_project`'s body
in `paliad.can_see_project` source — same pattern that
`effective_project_admin` uses in mig 111.
7. **`global_admin` UPDATE RLS on `paliad.checklists` is full-row.**
Means a global_admin can edit content of any user's template, not
just visibility. This is intentional for catalog hygiene
(correcting typos, removing inflammatory content) but should be used
sparingly and audited. The audit log captures every
global_admin-attributed edit via `checklist.edited` with actor_id.
8. **Instance snapshot fallback path lives indefinitely.** Existing
pre-mig-112 instances stay NULL until Slice C backfills. The
fallback code in `ChecklistInstanceService.GetByID` is ~10 LoC and
no hot-path concern — but it's "dead code" once the backfill runs.
Acceptable until Slice C.
9. **Cascade on owner deletion.** If an authored template's owner is
removed (`paliad.users.id` cascades), the template is wiped along
with all its shares. Existing instances survive via snapshot. The
alternative (transfer ownership to global_admin on user-delete) is
more polite but introduces governance questions ("which admin?")
that aren't worth Slice A complexity. Flag for Slice C if it bites.
10. **Slug uniqueness across origins enforced application-side.**
The static catalog is in-memory at boot. If a deploy adds a static
slug that collides with an existing DB slug, the deploy boots
cleanly but the DB row becomes unreachable via the catalog read
layer (static wins on slug lookup). Mitigation: a boot-time
integrity check in `cmd/server/main.go` logs WARN if collision
detected. Owner can rename their template manually via the edit UI.
## 11. m's decisions ledger (all defaulted to (R) per task brief)
Per task brief "NO AskUserQuestion. Defaults to (R). Escalate to head if
material." I have not escalated; all picks below default to (R).
| # | Question | (R) pick |
|---|---------------------------------------------------------|-------------------------------------------|
| 1 | Storage model for authored templates | Hybrid: keep static catalog + new `paliad.checklists` DB table |
| 2 | Instance lifecycle on template edit | **Snapshot** at instance create (NOT propagate) |
| 3 | Visibility enum values | `private`, `shared`, `firm`, `global` |
| 4 | Share recipients | user, office, partner_unit, project (4 axes) |
| 5 | Share-to-project resolution | Reuse `can_see_project` (visibility, not just team rows) |
| 6 | Promotion authority | `global_admin` only (no per-project admin promote in v1) |
| 7 | Demotion target | `global → firm` (preserves visibility for in-flight instances) |
| 8 | Slug strategy | `u-` prefix on authored, application-side collision check vs static |
| 9 | Polymorphic share table (`entity_shares`) vs scoped | **Scoped (`checklist_shares`).** Refactor to polymorphic *after* second sharable entity appears |
| 10| Authoring i18n | Author picks single language (DE or EN) per template via `lang` column; verbatim render |
| 11| Audit sink for template lifecycle | `paliad.system_audit_log` (org-scope); instance events stay on `paliad.project_events` |
| 12| Slice ordering | A (foundation) → B (share + promote) → C (gallery + backfill) |
Material escalation list: empty. If m disagrees with any of the above,
amend §11 in the next inventor shift; the schema is designed to be
forward-compatible with most reversals (e.g. flipping snapshot →
propagate is a service-layer change, not a schema change).
## 12. Acceptance criteria — Slice A
1. **Migration 112 applies cleanly on a fresh DB** and is idempotent
on re-apply (verified via `BEGIN…ROLLBACK` dry-run against the live
`paliad` schema).
2. **`/api/checklists` returns merged catalog** — static templates
plus DB templates the caller can see (visibility ∈ {firm, global}
OR owner = caller).
3. **POST `/api/checklists/templates`** creates a row, returns the
created template with auto-generated `u-…` slug, emits
`checklist.authored` audit row.
4. **PATCH `/api/checklists/templates/{slug}`** updates owner-only
fields, rejects 403 from non-owner non-admin, emits
`checklist.edited`.
5. **PATCH `/api/checklists/templates/{slug}/visibility`** toggles
private↔firm; rejects `shared` and `global` in Slice A (those land
in Slice B); emits `checklist.visibility_changed`.
6. **DELETE `/api/checklists/templates/{slug}`** removes the row;
existing instances survive via snapshot.
7. **Instance create snapshots the template body**
`template_snapshot` non-null on every new instance row.
8. **Legacy instances (NULL snapshot) still render** via catalog
fallback (covered by a regression test).
9. **"Meine Vorlagen" tab** lists owner's templates; "Neue Vorlage"
CTA navigates to `/checklists/new`; wizard saves successfully.
10. **`go build ./... && go vet ./... && go test ./internal/...`
clean.** `bun run build` clean (i18n key count incremented by ~20).
11. **Live smoke**: tester@hlc.de can create + edit + delete a private
template; setting visibility to `firm` makes it visible to a second
tester account; deleting the template doesn't break existing
instances.
## 13. Recommended implementer
Pattern-fluent **Sonnet coder**, NOT cronus (per project memory
directive 2026-05-06). Substrate is well-trodden:
- Migration shape mirrors mig 111 (gauss) for the predicate function +
policy replacement pattern.
- Service shape mirrors `ChecklistInstanceService` for CRUD + audit
emit + visibility check.
- Endpoint shape mirrors `internal/handlers/checklist_instances.go`.
- Frontend tab pattern mirrors the existing
`entity-tabs` / `entity-tab-panel` substrate in `checklists.tsx`.
Novel pieces:
- Catalog merge layer (~80 LoC) — the only logic the coder needs to
prototype before committing to the full slice. Pure function; easy
to unit-test.
- Share predicate (Slice B) — straightforward translation of §4.3 SQL
into a STABLE SECURITY DEFINER function; pattern matches mig 111
exactly.
Branch: keep on `mai/dirac/user-checklists`. Three slices = three PRs,
or one branch with three commits — coder's call. Each slice ends with
acceptance criteria; head merges between slices for fast feedback.
## 14. Out of scope (explicitly)
- Importing checklists from external sources (Notion, Trello, .docx).
- Approval-policy gating on checklist edits (admin pre-publish review).
- Cross-firm template marketplace.
- Translation workflow (de↔en) for authored templates — Slice A
ships single-language; if firm appetite shows up post-launch, file
a follow-up.
- Static-catalog editor UI (the static templates remain code-only).
- Versioning UI ("show me the version this instance was created from")
— snapshot is captured; surfacing it is Slice C polish.
---
**Inventor parked per gate protocol.** No auto-shift to coder. Head
decides: same worker as `/mai-coder` with this brief, fresh coder, or
rescope. Slice ordering A → B → C is independent enough that the head
can also greenlight Slice A alone and re-design B/C after Slice A
ships.

View File

@@ -0,0 +1,495 @@
# PRD — `docforge`: a modular document-generator engine
**Task:** t-paliad-349 (m/paliad#157) · **Author:** leibniz (inventor) · **Date:** 2026-05-29
**Status:** DESIGN — awaiting head's go/no-go on the coder shift.
**Supersedes nothing.** Extends and re-homes the submission generator designed in
`docs/design-submission-generator-2026-05-19.md`, `…-v2-2026-05-26.md`, and
`docs/design-submission-page-2026-05-22.md`.
---
## §0 Premises
### 0.1 What this is
m wants the paliad "doc generator" pulled apart into a clean, reusable engine.
Verbatim direction (2026-05-29):
> I want to be able to create and modify word documents, using variables inside
> the documents, "editing them live" and preview the results, export in the end.
> We should have all that modular to keep it clean. The editor is something else
> than the importing, exporting, variable exchange, data fetching etc.
>
> Currently I can't upload the base document to insert variables into to create a
> template — and then later I want to fill the template using data, modifying it
> manually where necessary, then exporting.
Two distinct user surfaces fall out of that:
- **Authoring** — upload a base `.docx` → place variable slots into it → save as a
reusable template. *This is the gap that does not exist today.*
- **Generation** — pick a template → bind variables to project data → manually edit
where needed (live editor + preview) → export `.docx`.
### 0.2 Today's state (audited 2026-05-29, verified against the live tree)
The current submission generator is ~250 KB of Go plus a 115 KB editor bundle:
- `internal/services/submission_vars.go` — variable resolution across **7 namespaces**
(`firm.*`, `today.*`, `user.*`, `project.*`, `parties.*`, `procedural_event.*`
+ `rule.*` legacy aliases, `deadline.*`). Resolution is a **push** model: each
namespace is a hardcoded `addXxxVars(bag PlaceholderMap, …)` function mutating a
shared `map[string]string`. There is **no interface and no registry** — adding a
namespace means hand-editing `Build` to call a new function.
- `internal/services/submission_merge.go` — placeholder substitution. The regex
(line 95, verified) is `\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`.
Two-pass: single-run replace inside each `<w:t>`, then
cross-run merge for fragmented placeholders. HTML preview wraps `(key,value)` in
Private-Use-Area sentinels so `emitTextWithDraftVars` can reconstruct
`<span class="draft-var" data-var="key">…</span>` for click-to-jump.
- `internal/services/submission_md.go` — Markdown → OOXML runs. `parseInlineSpans`
(lines 393446) tokenises bold/italic and **preserves `{{…}}` verbatim**.
- `internal/services/submission_compose.go` — assembles the final `.docx`: unzip base,
render each included section's Markdown to OOXML, splice between
`{{#section:KEY}}…{{/section:KEY}}` anchors, patch hyperlink rels, repack, then run
the placeholder pass.
- `internal/services/submission_{draft,section,building_block,base}_service.go` — the
draft/section/building-block/base data model + CRUD.
- `internal/handlers/submission_{drafts,sections,building_blocks,bases}.go` — the HTTP
wire (the 53 KB `submission_drafts.go` is the bulk).
- `frontend/src/client/submission-draft.ts` — the editor UI (**one `.ts` bundle; there is
no `submission-draft.tsx`** — the brief was wrong on this point).
**OOXML approach (verified):** pure `archive/zip` + string manipulation of
`word/document.xml`. **No third-party docx library**`go.mod` has none.
`lukasjarosch/go-docx` appears *only in a comment* (`submission_merge.go:13`)
documenting why it was rejected (it refuses sibling placeholders in one run). The base
stays byte-for-byte identical outside the regions we touch.
**Reference model:** `pkg/litigationplanner/` (t-paliad-292). The package **owns its
types** and exposes **interfaces for stateful inputs** (`Catalog`, `HolidayCalendar`,
`CourtRegistry`); paliad implements them against Postgres, youpc.org against an embedded
JSON snapshot. `doc.go` is the package doc; `types_wire_test.go` locks the JSON contract.
**docforge mirrors this packaging discipline exactly.**
### 0.3 Premise correction (load-bearing)
The brief lists **two consumers in scope: paliad + upc-commentary**. Verified against the
live repo: **`UPCommentary/upc-kommentar` is Bun + SvelteKit + TypeScript + PLpgSQL —
zero Go.** A SvelteKit app cannot `import` a Go `pkg/`. m's resolution (2026-05-29):
**upc-kommentar is out of scope as a live consumer for now.** docforge is a pure Go
package; paliad imports it in-process like `litigationplanner`. The interfaces are
designed so an HTTP veneer (for a future TS consumer) is *addable later* without rework —
but none is built now. See §4 D-P1 and §8.
### 0.4 Locked constraints (m, confirmed)
- One Go module: `pkg/docforge`. Same packaging model as `pkg/litigationplanner`.
- docforge **owns no database tables** — data flows in via interfaces.
- `.docx` first; engine designed format-pluggable for `.pdf`/`.html`/`.md` later.
- Authoring and Generation are **distinct pages**, but share the engine + the generic
editor plumbing.
- Generation must support **minor manual content edits** (live editor, not just
data-binding).
- Editor stays per-consumer; the **generic UX plumbing** is extracted into a reusable UI
package now.
- The neutral model must be **lossless for our own `.docx`** (the uploaded base is an
opaque carrier, preserved byte-for-byte outside touched regions).
### 0.5 Contracts that MUST survive the refactor
These are invariants. The migration (§6) protects each by moving it *with its file and its
test*, unchanged:
1. **`placeholderRegex`** = `` `\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}` `` — underscores
and dots legal in keys; whitespace inside braces trimmed; case-sensitive.
2. **Last night's underscore fix** (commit `b78a984`): `parseInlineSpans` short-circuits
the inline scanner on `{{` and copies the placeholder literally to `}}`, so
`{{project.case_number}}` is never mangled to `{{project.casenumber}}`.
3. **`data-var` contract** — `data-var="<key>"` on both `.draft-var` preview spans and
`.submission-draft-var-input` sidebar inputs; the click-to-jump and focus-highlight are
bijective across repaints.
4. **Missing-value markers**`[KEIN WERT: key]` (DE) / `[NO VALUE: key]` (EN) render
inline, never an error.
5. **Legacy aliases**`procedural_event.X ≡ rule.X` resolve identically
(`submission_vars_aliases_test.go`); party variables emit comma-joined, indexed, and
flat-legacy forms (`submission_vars_parties_test.go`).
6. **Section anchor syntax**`{{#section:KEY}}…{{/section:KEY}}`, `KEY` matched against
`[A-Za-z0-9_]+`.
7. **No binary retention** — exported `.docx` is regenerable from inputs; only audit rows
persist (`system_audit_log` `submission.exported` + `project_events`).
8. **V1 fallback path** — pre-Composer drafts (`base_id IS NULL`, no section rows) render
via the pure-placeholder path. No auto-upgrade.
9. **`{{…}}` pass-through** — the Markdown walker emits placeholders verbatim; the merge
pass substitutes them afterward. Order is load-bearing (substitution runs *inside*
compose, after section splicing).
---
## §1 Goals
**G1.** Extract the format-neutral document machinery (Markdown→OOXML walker, OOXML
merge/compose, placeholder engine, `.dotm``.docx`) into `pkg/docforge` with a clean
public surface and zero behavior change at the extraction step.
**G2.** Introduce a **neutral document/template model** so importers produce it, the engine
binds variables on it, and exporters render it out — with `.docx` as the first
importer+exporter pair, not the universe. Lossless for our own `.docx`.
**G3.** Replace the hardcoded `addXxxVars` push with a **`VariableResolver` interface per
namespace** + a `ResolverSet` that composes them, preserves aliases, and exposes the key
catalogue (label + group) so the frontend variable form/palette becomes data-driven
instead of hardcoded in TS.
**G4.** Build the **Authoring surface**: upload `.docx` → WYSIWYG render → click/select →
insert `{{slot}}` → save template. Closes the gap m named.
**G5.** Refactor **Generation** onto docforge + uploaded templates, preserving the live
editor, preview, manual-edit, and export — and every contract in §0.5.
**G6.** Extract the **generic editor UX** into `frontend/src/lib/docforge-editor/`,
consumed by both the generation and authoring shells.
**Non-goals (this PRD):** implementation, migration SQL, code. Formats beyond `.docx`
(interface only). Live upc-kommentar integration. Multi-user concurrent editing of one
draft. An HTTP service veneer.
---
## §2 User journeys
### 2.1 Authoring (new)
1. m opens **`/admin/templates`** (or `/templates/new`) and uploads a base `.docx`
(firm letterhead with caption layout, signature block, etc.).
2. docforge's `.docx` importer parses the upload into a **carrier** (opaque OOXML kept
intact) + a renderable preview. The page shows a **WYSIWYG-ish render** of the document.
3. m highlights a piece of text — e.g. `Az. 4c O 12/23` — and a **variable palette**
(sourced from the `ResolverSet.Keys()` catalogue, grouped DE/EN) lets him pick
`project.case_number`. The selection is **replaced with a `{{project.case_number}}`
slot**; a `template_slots` row records the slot key + its anchor position.
4. He repeats for every variable region, saves, and the template becomes pickable in
Generation. (Editing the template later creates a new **version** — see §4 D-A3.)
**Scope guard:** v1 authoring places **text-level slots in body paragraphs**. Slots in
headers/footers/tables/text-boxes are a flagged follow-up (§7 note), because the
click→OOXML-run mapping there is materially harder.
### 2.2 Generation (refactor of today)
1. Lawyer picks a template (uploaded template *or* a legacy Gitea base — both supported
during transition) for a submission code, optionally project-scoped.
2. A **draft** is created. Its template **structure is snapshotted** at create
(§4 D-A3) so later template edits don't shift an in-flight draft.
3. The sidebar shows the variable form (data-driven from `ResolverSet.Keys()`); the
resolved bag is merged with the lawyer's overrides; the live preview renders with
`data-var` click-to-jump; manual prose edits autosave (500 ms debounce).
4. Export → docforge binds the model + carrier + resolved variables → `.docx` bytes
stream as a download. Audit rows written. No binary retained.
### 2.3 upc-kommentar parallel journey (deferred — validates the abstractions)
Not built now, but the abstractions are sized for it: upc-kommentar authors work in
**Markdown** (and want to import **foreign doc/docx** as input — m, 2026-05-29 Q4). When
it becomes a consumer, it would: implement its own `VariableResolver`(s) over its Postgres
(commentary metadata), feed Markdown through docforge's **markdown importer** into the
neutral model, edit live in its own Svelte shell (reusing the *wire contract*, not Go
code), and export. The Go engine is reached over an HTTP veneer added at that point. This
journey is the litmus test for §3's seams: **a new consumer adds resolvers + a transport,
touches no engine internals.**
---
## §3 Module shape
### 3.1 Package tree
```
pkg/docforge/
doc.go // package doc (litigationplanner-style)
model.go // neutral model: Document, Block, InlineSpan, Slot
template.go // Template, TemplateSlot, Carrier
variables.go // VariableResolver interface, VariableKey, ResolverSet, alias registry
bind.go // binding engine: walk model, resolve slots, apply missing-marker policy
render.go // RenderHTML (preview w/ data-var spans) — format-neutral entry
importer.go // Importer interface
exporter.go // Exporter interface
store.go // TemplateStore interface (carrier bytes + slot persistence contract)
errors.go // sentinel errors (ErrUnknownTemplate, ErrUnboundSlot, …)
placeholder.go // placeholderRegex + substitution primitives (THE locked grammar)
types_wire_test.go // locks the JSON wire shape consumed by the TS editor
docx/ // the .docx adapter — first importer + exporter
importer.go // DocxImporter: parse .docx -> Carrier + detect/locate slots
exporter.go // DocxExporter: (model + carrier + vars) -> .docx bytes [today's compose+merge]
ooxml.go // archive/zip + document.xml manipulation [today's submission_merge/compose internals]
md_to_ooxml.go // Markdown -> OOXML runs [today's submission_md walker + the b78a984 fix]
dotm.go // ConvertDotmToDocx [today's pre-pass]
markdown/ // markdown importer (input content; foreign-docx import is a later sibling)
importer.go // parse Markdown -> neutral blocks
```
**What lives in docforge vs paliad:**
| Concern | Home | Why |
|---|---|---|
| Neutral model, binding, preview-render | `docforge` | format-neutral core |
| `VariableResolver` interface + `ResolverSet` | `docforge` | the seam m wants clean |
| Placeholder grammar + substitution | `docforge` | shared invariant (§0.5.1) |
| `.docx` importer + exporter, MD→OOXML walker | `docforge/docx` | first format adapter (ships *inside* the pkg, like litigationplanner's embedded snapshot) |
| Markdown importer | `docforge/markdown` | input-format adapter |
| Concrete resolvers (`project`, `parties`, `firm`, `user`, `today`, `deadline`, `procedural_event`) | **paliad** `internal/…` | they read paliad's DB/services |
| `TemplateStore` impl (Postgres bytea) | **paliad** | docforge owns no tables |
| Section / building-block model, submission codes | **paliad** | consumer-specific composition concepts |
| HTTP handlers, editor UI, authoring page | **paliad** | wire + per-consumer UI |
### 3.2 The neutral model + the carrier (resolving "intermediate, but lossless docx")
```go
// A Document is the format-neutral content model importers produce and exporters consume.
type Document struct {
Blocks []Block
}
type Block struct {
Kind BlockKind // paragraph | heading | list_item | blockquote | section_marker
Style string // logical style key (mapped to a base stylemap on export)
Spans []InlineSpan // text runs (bold/italic/link) + Slots
// …list level, section key, etc.
}
type InlineSpan struct {
Text string
Bold bool
Italic bool
Link string
Slot *Slot // non-nil => this span is a variable slot, not literal text
}
type Slot struct {
Key string // e.g. "project.case_number" — the placeholder grammar key
}
```
**The carrier keeps the lossless guarantee.** The uploaded `.docx` chrome
(letterhead, styles, caption, signature) is **never round-tripped through `Document`**.
It is held as an opaque `Carrier` (the original OOXML), and the exporter splices the
rendered neutral content into the carrier's named anchors, then substitutes slots — exactly
today's compose mechanism, now formalised:
```go
type Carrier struct {
Format string // "docx"
Bytes []byte // original upload, preserved byte-for-byte outside anchor regions
Anchors []Anchor // {{#section:KEY}}…{{/section:KEY}} positions + slot positions
}
```
So **two layers**: editable content = `Document` (neutral, format-pluggable); base chrome =
`Carrier` (opaque, lossless). Foreign-docx *import as input content* (Q4) does parse into
`Document` and **is inherently lossy** — flagged as a boundary (§8), distinct from the
lossless export of *our* templates.
### 3.3 The variable resolver seam (G3)
```go
// VariableResolver answers keys within one dotted namespace.
type VariableResolver interface {
Namespace() string // e.g. "project"
Resolve(key string) (value string, ok bool)// ok=false => unknown key => missing marker
Keys() []VariableKey // catalogue for the palette + sidebar form
}
type VariableKey struct {
Key, LabelDE, LabelEN, Group string
}
// ResolverSet composes namespaced resolvers, registers canonical<->legacy aliases,
// and offers BOTH a pull path (Resolve, used during binding) and a push path
// (BuildBag, preserving today's resolved_bag/merged_bag wire).
type ResolverSet struct{ /* … */ }
func (s *ResolverSet) Resolve(key string) (string, bool)
func (s *ResolverSet) BuildBag() map[string]string // == today's PlaceholderMap
func (s *ResolverSet) Catalogue() []VariableKey // drives the data-driven form/palette
func (s *ResolverSet) RegisterAlias(canonical, legacy string)
```
paliad's seven `addXxxVars` functions become seven resolver types implementing this
interface. `BuildBag()` reproduces today's flat map exactly (alias parity tests pin it).
`Catalogue()` kills the hardcoded `VARIABLE_GROUPS`/`VARIABLE_LABELS` in the TS bundle.
**Resolver model = hybrid** (pull-capable interface, push-driven `BuildBag` default —
inventor pick, §4 D-I1).
### 3.4 Wire contract (Go ↔ TS) — preserved, locked by test
The editor wire stays as-is; `types_wire_test.go` pins it:
- `GET draft``{ draft, resolved_bag, merged_bag, preview_html, rule, parties, sections }`
- preview HTML carries `<span class="draft-var" data-var="<key>">…</span>` (built by
docforge's `RenderHTML`, today's `emitTextWithDraftVars`).
- `PATCH draft``{ variables: PlaceholderMap, … }` (presence-tracked optional fields).
- export/preview endpoints unchanged.
- **New (authoring):** `POST /api/templates` (upload), `GET /api/templates/:id` (carrier
preview + slots), `POST /api/templates/:id/slots` (place slot), `GET /api/docforge/variables`
(the `Catalogue()`).
---
## §4 Decisions (m's picks, 2026-05-29)
### Prose-grill resolutions (core metaphor)
| # | Question | m's decision | Note |
|---|---|---|---|
| P1 | Cross-language sharing model | **Go pkg only; upc-kommentar out of scope for now, "reuse later somehow"** | Interfaces sized so an HTTP veneer is addable without rework. No service built. |
| P2 | Intermediate model? | **Yes — but lossless for our .docx** | → carrier (opaque OOXML) + neutral Document (editable content). §3.2. |
| P3 | Authoring slot mechanic | **(b) click-to-insert** | Upload → render → click/select → inject `{{…}}`. |
| P4 | Input formats | **Markdown primary; foreign doc/docx import later** | Markdown importer first; foreign-docx import is lossy (§8). |
| P5 | Editor sharing | **Build paliad's UI; extract generic UX into a UI package** | `frontend/src/lib/docforge-editor/`. |
### Structured decisions
| # | Decision | m's pick | Rationale / divergence |
|---|---|---|---|
| A1 | Authoring UX | **WYSIWYG inline** | Matches "insert variables into the document". Hardest part — render fidelity + click→run mapping — flagged §7. |
| A2 | Template storage | **Postgres bytea (interface-backed)** | m leans (1); flagged Supabase Storage as viable. Resolved: behind a `TemplateStore` interface, bytea impl now, Supabase Storage a one-impl swap later. No schema churn either way. |
| A3 | Versioning of existing drafts | **Snapshot at draft-create** | Lawyer's in-flight draft won't shift under them; matches today's section-seeding. |
| A4 | Migration strategy | **Extract-in-place, then extend** | Lowest risk to the recent fixes — they move with their files + tests; behavior identical at each step. |
| B1 | Package name | **`docforge`** | — |
| B2 | Schema scope | **New generic tables** (`templates`, `template_slots`, `template_versions`) | Authoring is domain-neutral; submission_bases (Gitea/section_spec) stays for legacy bases with a converge path. |
| B3 | UI package extraction | **Extract now** | Authoring reuses it this cycle — earns its keep, not speculative. |
| B4 | Exporter pluggability | **Interface now, docx-only impl** | Cheap insurance; matches "pluggable for later". |
### Inventor picks (m delegated — "whatever works best")
| # | Pick | Reasoning |
|---|---|---|
| I1 | `VariableResolver` = pull-capable interface, push `BuildBag()` default | Preserves today's flat-map wire while enabling on-demand resolution + the `Catalogue()` that data-drives the form. |
| I2 | `.docx` adapter ships **inside** `pkg/docforge/docx` | Mirrors litigationplanner shipping its embedded snapshot in-package; keeps the first adapter co-located with the engine it proves. |
| I3 | Carrier-vs-Document split (§3.2) | Only way to satisfy "intermediate model" AND "lossless our .docx" simultaneously. |
---
## §5 Data model deltas (paliad-side — docforge owns none)
**New tables** (additive; SQL drafted by the coder, not here):
- **`paliad.templates`** — `id`, `slug`, `name_de/en`, `kind` (`'submission'` | generic),
`source_format` (`'docx'`), `firm`, `is_active`, `created/updated_by`, timestamps,
`current_version_id` FK.
- **`paliad.template_versions`** — immutable snapshots: `id`, `template_id` FK,
`version` int, `carrier_blob` bytea (the `.docx`; or storage ref via `TemplateStore`),
`created_at`, `created_by`. Editing a template inserts a new version row.
- **`paliad.template_slots`** — `id`, `template_version_id` FK, `slot_key` (the variable
key, e.g. `project.case_number`), `anchor` (position encoding — see flag below),
`label`, `order_index`. Versioned alongside the carrier.
**Snapshot semantics (A3):** a draft pins `template_version_id`. Template edits create a
new version; existing drafts keep their pinned version. *(Flag for coder: pin
`template_version_id` on the draft vs. copy a `template_snapshot` jsonb onto the draft —
both satisfy A3; the version-table approach is preferred for auditability but the coder
picks based on query ergonomics.)*
**Touched existing tables:**
- `submission_drafts` — add nullable `template_version_id` for uploaded-template drafts;
**legacy `base_id` path preserved** (extract-in-place ⇒ no data migration of the 11
existing drafts; §0.5.8 fallback intact).
- `submission_bases`, `submission_sections`, `submission_building_blocks`**unchanged**.
They remain paliad consumer-specific concepts that map onto docforge's neutral model at
render time. submission_bases (Gitea-backed) coexists with the new uploaded-template
tables during transition; convergence is a later, separate task.
**Slot anchor encoding (flag for coder):** how a `template_slots.anchor` records *where*
in the carrier OOXML the slot sits (run index + offset, vs. a stable sentinel token
injected into the carrier at authoring time). The sentinel-token approach is likely
simpler and reuses the existing cross-run substitution machinery — resolve in
implementation chat.
---
## §6 Migration plan (protects working code + the recent fixes)
**Principle:** extract-in-place (A4). Each step **compiles, passes the moved tests, and
leaves observable behavior identical.** The recent fixes travel *with their files*:
- The **b78a984 underscore fix**`pkg/docforge/docx/md_to_ooxml.go` (was
`submission_md.go` `parseInlineSpans`), `submission_md_test.go` moves alongside.
- **`placeholderRegex`** → `pkg/docforge/placeholder.go`; its tests move.
- **`data-var` / `emitTextWithDraftVars`** → `pkg/docforge/render.go` (`RenderHTML`);
wire test moves and is pinned in `types_wire_test.go`.
- **Cross-run merge, `.dotm``.docx`, anchor splicing** → `pkg/docforge/docx/`; tests move.
- **Building-block + section model, submission codes, the 7 concrete resolvers** stay in
`internal/` (consumer-specific) — now calling into docforge.
**Safety rails per step:** (1) `go build ./...` green; (2) the moved test files green; (3)
a golden-export check — generate a known draft before and after the step, assert byte-equal
`.docx`; (4) the live preview HTML for a fixture draft is string-equal (the `data-var`
contract). No step ships until all four hold.
**What is explicitly NOT migrated:** the 11 pre-Composer drafts (`base_id IS NULL`) keep
the v1 fallback render path; no auto-upgrade (§0.5.8).
---
## §7 Slice train
Tracer-bullet vertical slices, each independently shippable. Slices 13 are pure
behavior-preserving refactors (the risky-to-working-code part, front-loaded under golden
checks); 47 build the new capability; 8 sets up the future.
1. **Extract the docx engine** — move MD→OOXML walker, OOXML merge/compose, placeholder
grammar, `.dotm``.docx` into `pkg/docforge/{placeholder.go, render.go, docx/}`.
paliad's `submission_*` services become thin adapters. Golden-export + preview checks
green. *Protects b78a984, the regex, the data-var contract.*
2. **Neutral model + binding** — introduce `Document`/`Block`/`Slot`/`Carrier` + `bind.go`;
refactor the docx exporter to consume the neutral model (sections → blocks → OOXML
spliced into carrier). Behavior identical (golden checks).
3. **`VariableResolver` interface** — refactor the 7 `addXxxVars` into resolver types +
`ResolverSet`; `BuildBag()` reproduces today's map (alias-parity tests pin it);
`Catalogue()` exposed. Frontend form switched to consume `Catalogue()` (kills hardcoded
`VARIABLE_GROUPS`).
4. **Template store + schema**`templates`/`template_versions`/`template_slots` +
Postgres-bytea `TemplateStore` impl. No UI yet. Additive migrations.
5. **UI package extraction** — pull generic plumbing (debounced autosave, data-var wiring,
preview/export round-trip, focus preservation, sticky collapse) into
`frontend/src/lib/docforge-editor/`; submission editor consumes it. Refactor, behavior
identical.
6. **Authoring page** — upload `.docx` → docforge docx-importer → WYSIWYG render → select
text → pick variable from `Catalogue()` palette → inject slot (writes
`template_slots` + new `template_version`). Reuses the UI package + docforge importer.
*(v1: body-paragraph text slots only.)*
7. **Generation on uploaded templates** — generation page picks an uploaded template
(`template_version_id` path) alongside legacy bases; snapshot-at-create; data-bind +
manual edit + export via docforge. Legacy base path still works.
8. **Markdown importer + exporter-interface finalisation**`docforge/markdown` importer
as input; `Exporter` interface locked (docx-only impl). Sets up future formats +
eventual upc-kommentar reuse.
**Flagged follow-ups (post-train, separate tasks):** slots in headers/footers/tables;
foreign-docx import fidelity; the HTTP veneer + a TS consumer; submission_bases →
templates convergence; auto-upgrade of pre-Composer drafts.
---
## §8 Out of scope
- **Implementation, migration SQL, code.** PRD only.
- **upc-kommentar as a live consumer** — deferred; abstractions sized for it, nothing built.
- **An HTTP service veneer** — addable later without engine rework; not now.
- **Formats beyond `.docx`** — `Exporter` interface defined (B4), only the docx impl built.
- **Lossless import of *foreign* `.docx`** — our own templates export losslessly via the
carrier; importing an arbitrary third-party Word doc as input content is best-effort and
inherently lossy. Distinct guarantee.
- **Multi-user concurrent editing** of one draft.
- **Re-proposing the current `submission_*.go` shape** — the point is to extract + clean it.
- **Slots outside body paragraphs** (headers/footers/tables/text-boxes) in authoring v1.
---
## Appendix — open flags for the coder (resolve in implementation chat)
1. **Slot anchor encoding** — run-index+offset vs. injected sentinel token (§5). Lean
sentinel.
2. **Snapshot mechanism** — pinned `template_version_id` vs. `template_snapshot` jsonb on
the draft (§5). Lean version-pin.
3. **Authoring render fidelity** — reuse the existing lossy `docXMLToHTML` preview for the
WYSIWYG surface, or invest in higher fidelity. Lean reuse for v1, accept that
complex layouts render approximately while slots still anchor correctly.
4. **Storage backend** — Postgres bytea now; Supabase Storage is a clean `TemplateStore`
swap if template volume/size grows.

View File

@@ -0,0 +1,354 @@
# PRD — Composable Name/Filename Generator Engine
**Task:** t-paliad-355 · **Author:** leibniz (inventor) · **Date:** 2026-06-01
**Status:** DESIGN — awaiting head go/no-go on coder shift
**Builds on:** t-paliad-352 / m/paliad#155 (draft title), t-paliad-354 (export filename, merged `94adeeb`)
**Related:** `docs/plans/prd-docforge-2026-05-29.md` (doc-generation engine — a future naming consumer)
---
## § m's decisions (2026-06-01)
All eight grilling questions answered; every pick matched the inventor recommendation.
**Batch 1 — model:**
- Q1 (Composition model): **Structured segments + string shorthand.** Canonical model is an ordered segment list with per-segment missing-rules; a token-template string is an optional authoring shorthand that compiles to segments.
- Q2 (v1 precedence): **System → Firm → User → per-document.** Mirrors the existing dashboard-layout chain exactly. Project-level deferred to v1.1.
- Q3 (Engine depth): **Reusable engine, wire 3 known consumers.** Real engine now; only draft-title, submission-.docx, and the non-project fix are wired. Other surfaces register as known artifacts but keep current code.
- Q4 (Non-project name): **`<date> <keyword>`**, falling back to `Entwurf N` only when no type context exists.
**Batch 2 — concrete:**
- Q5 (Missing-rule set): **omit + placeholder + literal**, per segment.
- Q6 (Date semantics): **Render-time "today", Europe/Berlin, `YYYY-MM-DD`.**
- Q7 (Settings UX): **Live-preview string field on `/settings`** + clickable `{token}` palette. Missing-rules use defaults (not user-editable in v1).
- Q8 (Artifact scope): **2 submission artifacts (`submission_draft_title`, `submission_docx_filename`) + extensible registry.** docforge-export, data-zip, projection-slug registered as known artifacts but unwired in v1.
These are necessary for a coder shift, **not** sufficient — the head still gates whether/who/when to implement (inventor→coder rule).
---
## §0 Premises (verified against the live system, 2026-06-01)
| # | Premise | How verified |
|---|---------|--------------|
| P1 | Draft title = `<date> <client> ./. <forum> ./. <opponent>`, project-bound only, missing segments dropped-with-separator. | Read `internal/services/submission_autoname.go` (`AutoSubmissionTitle`). |
| P2 | Non-project drafts fall back to `Entwurf N` / `Draft N` counter. | Read `submission_draft_service.go` `newDraftName`/`Create`. |
| P3 | Export filename = `<date> <keyword> (<case | "Az. folgt">).docx`; keyword overridable per-draft via `composer_meta.filename_keyword`. | Read `internal/handlers/submissions.go` `submissionFileName` + `submissionFilenameKeyword`. |
| P4 | Sanitiser `SanitiseSubmissionFileName` folds umlauts, maps `/\:*?<>|``_`, strips `"'`, **preserves spaces/parens**. Lives in `pkg/docforge/docx/dotm.go`, re-exported via `services.SanitiseSubmissionFileName` (`docforge_shims.go`). | Read `pkg/docforge/docx/dotm.go`. |
| P5 | `DashboardLayoutSpec` is a production precedent for a validated jsonb spec: code `FactoryDefaultLayout` → admin `firm_dashboard_default` (db row id=1) → per-user `user_dashboard_layouts`, with `Validate()` (write) + `SanitizeForRead()` (read). | Read `dashboard_layout_spec.go`, `firm_dashboard_default_service.go`. |
| P6 | `users.email_preferences jsonb` (per-user bag) and `projects.metadata jsonb` exist live. No dedicated `user_preferences` table — migration 017 only added the `email_preferences` column. | `information_schema.columns` query on live `paliad` schema. |
| P7 | Draft titles are de-duplicated at create time via `uniqueDraftName` (appends a counter on collision). | Read `newDraftName`. |
**Doc-is-the-bug flags raised:** none. The two shipped behaviours are exactly as the task described; `projects.metadata` exists so a project-level override needs no new column when v1.1 arrives (only a documented sub-key).
---
## §1 The problem
Two one-off naming functions shipped in successive tasks (#155, 354). Each hardcodes: a date format, an ordered set of segments, a separator, and a missing-value policy. m wants to stop re-deriving this per feature — "we will need a filename generator more often later on" — and to expose **defaults / compositions** as a **user (and maybe project) setting**. Plus one immediate gap: non-project drafts get no date-led name.
The design must:
1. Extract a **reusable composition engine** that renders a name from (template, variable-bag, render-target).
2. Reproduce **both shipped schemes byte-for-byte** as seed defaults (no behaviour regression).
3. Add **settings** with a clean precedence chain, built **on** the dashboard-spec pattern (P5), not beside it.
4. Fix the **non-project** gap inside the engine, not as another special case.
---
## §2 The engine
A new package **`pkg/nomen`** (Latin *nomen* = "name"; firm-agnostic, sits beside `pkg/docforge`). Pure, dependency-light, table-testable. No DB, no HTTP — consumers resolve variables and hand them in, exactly as `AutoSubmissionTitle` is pure today.
> **FLAG (coder + m):** package name `nomen` is the inventor pick. Alternatives: `pkg/naming`, `internal/services/namegen`. Pick at implementation; nothing downstream depends on the name.
### 2.1 Core types (interface sketch — not final Go)
```go
package nomen
// Segment is one piece of a composition: a variable reference, the
// separator that precedes it, and what to do when the variable resolves
// empty.
type Segment struct {
Var string // key into the variable catalog, e.g. "date", "keyword"
Sep string // TRAILING separator: emitted AFTER this segment iff a
// later segment also emits. The last emitted segment's
// Sep is never used. (See Slice-1 note below.)
Wrap [2]string // optional surrounding literals, e.g. {"(", ")"} for case-no.
Missing MissingRule // omit | placeholder | literal
}
type MissingRule struct {
Kind MissingKind // KindOmit | KindPlaceholder | KindLiteral
Value string // placeholder/literal text (e.g. "Az. folgt"); ignored for omit
}
// Composition is the canonical, validated model.
type Composition struct {
Version int // schema version (start at 1)
Segments []Segment
}
// VarResolver yields a variable's value for one render. Returns ("", false)
// when the variable is unavailable in this context (→ Missing rule applies).
type VarResolver func(key string) (string, bool)
// RenderTarget post-processes the assembled string (sanitisation, suffix).
type RenderTarget interface {
Name() string // "title" | "filename"
Transform(assembled string) string
}
func (c Composition) Render(resolve VarResolver, target RenderTarget) string
func (c Composition) Validate(catalog VarCatalog) error
```
> **Implementation note (Slice 1, 2026-06-01 — `Sep` is trailing, not leading).**
> This PRD originally sketched `Sep` as the separator emitted *before* a
> segment. During Slice 1 that model proved unable to reproduce #155
> byte-for-byte: the existing test `"no client — client segment omitted"`
> requires `2026-05-31 UPC ./. Novartis Pharma` — the date must join the
> *forum* with a single space when the client is absent, while the
> forum-to-opponent join stays ` ./. `. A separator owned by the right-hand
> segment would need two different values for the same segment depending on
> what was omitted before it. Making the separator **trailing** (owned by
> the left-hand segment) is the minimal faithful fix: the date's trailing
> ` ` is used whenever any identity segment follows, and each party's
> trailing ` ./. ` is used whenever another party follows. All shipped
> #155/354 tests pass unchanged. Implemented in `pkg/nomen/nomen.go`; the
> realised `RenderTarget` also splits `Transform` into `SanitiseValue`
> (per-variable) + `Finalise` (whole-string + suffix) per §2.3.
### 2.2 Render algorithm (reproduces both shipped schemes)
For each segment, in order:
1. `val, ok := resolve(seg.Var)`.
2. If `!ok || strings.TrimSpace(val) == ""`, apply `seg.Missing`:
- `KindOmit` → segment contributes nothing (and its `Sep` is suppressed).
- `KindPlaceholder``val = seg.Missing.Value` (treated as present).
- `KindLiteral``val = seg.Missing.Value` (same as placeholder; distinct *intent* in the model — "this is a fixed label", not "this is a stand-in for missing data" — so the settings UI can word them differently and future policy can diverge).
3. If the segment emits, prepend `seg.Sep` **iff at least one segment already emitted** (kills the leading-separator problem the #155 code solves by hand), then wrap with `seg.Wrap`.
4. Concatenate.
5. `target.Transform(assembled)` runs once on the whole string.
**Separator suppression** is the generalisation of #155's "drop segment + its leading separator". **Placeholder** is the generalisation of 354's `(Az. folgt)`.
### 2.3 Render targets
The **same** `Composition` renders to different targets:
| Target | `Transform` | Used by |
|--------|-------------|---------|
| `TitleTarget` | identity (spaces, umlauts, ` ./. ` all valid in a human title) | `submission_draft_title` |
| `FilenameTarget{ext: ".docx"}` | per-segment-aware: applies `services.SanitiseSubmissionFileName` to **variable values** (not the frame — preserve the spaces/parens/wrap), then appends `ext`. | `submission_docx_filename` |
> **Design note — where sanitisation runs.** 354 sanitises *each variable value* but keeps the assembled frame (`<date> <kw> (<case>)`) intact. To preserve that exactly, the `FilenameTarget` is **not** a dumb whole-string transform — the engine sanitises each resolved variable value *before* assembly when the target requests it, and the target only appends the extension at the end. So `RenderTarget` gains one more hook:
```go
type RenderTarget interface {
Name() string
SanitiseValue(v string) string // per-variable; identity for TitleTarget
Finalise(assembled string) string // whole-string; appends ".docx" for filename
}
```
This is the one subtlety that makes the engine faithful to 354. Both shipped schemes drop out of `(Composition, VarResolver, RenderTarget)` with no special-casing.
### 2.4 Variable catalog
A `VarCatalog` is an extensible registry: `key → VarDef{ Label, LabelEN, Description, Group }`. The catalog is **metadata only** (for validation + the settings palette); **values** come from the per-render `VarResolver` the consumer supplies. This keeps the engine pure — a consumer registers which keys it can resolve, the engine validates a composition only references known keys.
v1 catalog (the union of what the two schemes need + obvious near-neighbours):
| key | meaning | resolver source (submission consumer) |
|-----|---------|----------------------------------------|
| `date` | render-time today, Europe/Berlin, `YYYY-MM-DD` | engine-provided default resolver (see §2.5) |
| `keyword` | document/submission type; user-overridable | `composer_meta.filename_keyword` → rule name (lang-aware) → "submission" |
| `case_number` | project Aktenzeichen | `project.CaseNumber` |
| `client` | root-ancestor client name | project-tree walk (existing `autoNameForProject`) |
| `forum` | short forum label (UPC/EPA/LG/…) | `submissionForumShort(pt)` (existing) |
| `opponent` | primary opposing party name | `submissionOpponentName(parties, ourSide)` (existing) |
Registered-but-deferred keys (declared so compositions can reference them, resolvers added when a consumer needs them): `proceeding`, `lang`, `client_matter`, `project_name`, `draft_counter`.
**Extensibility contract:** a new consumer (e.g. docforge export) builds its own `VarCatalog` subset + `VarResolver` and registers an artifact (§4). It never edits the engine.
### 2.5 The `date` resolver
The engine ships a default `date` resolver: `time.Now()``Europe/Berlin``Format("2006-01-02")`. This is the **one** variable the engine resolves itself (both shipped schemes compute it identically), so a consumer that only wants the standard date doesn't re-implement it. A consumer may override `date` in its resolver (e.g. a created-at date) — but v1 does not.
---
## §3 Settings & precedence
### 3.1 Precedence chain (v1)
Resolution order for a given artifact, **first hit wins**:
```
per-document override → user override → firm default → system default
(highest priority) (always present)
```
- **System default** — code-resident, per artifact. The seed `Composition` literals (§5). Always exists; nothing can delete it.
- **Firm default** — optional admin-set row, mirrors `firm_dashboard_default` (P5). A firm can mandate a house naming convention. Cleared → reverts to system.
- **User override** — per-user, stored in a jsonb bag keyed by artifact id. Absent key → fall through.
- **Per-document override** — the **already-shipped** `composer_meta.filename_keyword`, generalised to a `composer_meta.name_overrides` map of `{var → value}` (back-compat: `filename_keyword` reads as `name_overrides.keyword` for the filename artifact). This is a *variable-value* override, not a *composition* override — the user is replacing one token's value for one document, not redefining the template.
> **Why per-document is a value override, not a template override:** the shipped "Stichwort" editor lets a lawyer change *what the keyword is* for one draft, not *the shape of the name*. Keeping per-document as value-only avoids giving every draft its own editable template (scope creep) while preserving the shipped UX exactly.
### 3.2 Storage
| Level | Where | Shape |
|-------|-------|-------|
| System | Go code (`nomen` consumer package) | `Composition` literals |
| Firm | **new** `paliad.firm_name_compositions` (id=1 singleton, mirrors `firm_dashboard_default`) | `jsonb`: `{ artifact_id: Composition }` map, validated |
| User | **new column** `paliad.users.name_compositions jsonb NOT NULL DEFAULT '{}'` (mirrors `email_preferences`) | `{ artifact_id: Composition }` map |
| Per-document | **existing** `submission_drafts.composer_meta` | `{ name_overrides: { var: value } }` (supersedes flat `filename_keyword`) |
A `NameCompositionSpec` type gets `Validate()` (write — references-known-vars, known-artifact, ≤ N segments) and `SanitizeForRead()` (read — drop segments referencing dropped vars, clamp version), exactly like `DashboardLayoutSpec`. This is the closest existing analog and the pattern is copy-shaped.
> **Project-level (v1.1, deferred):** when it lands, it slots between user and firm (`per-document → user → project → firm → system`) and stores under a documented `projects.metadata.name_compositions` sub-key — **no migration needed** (P6: column exists). The "project vs user, who wins?" call (Q2) is deferred with it; the v1.1 default is **user wins** (a lawyer's personal convention beats a matter's), but that's a v1.1 decision, flagged here so v1 storage doesn't preclude it.
---
## §4 Artifact registry
An **artifact** is a named thing that gets a name: it binds a default composition, an allowed-variable subset, and a render target.
```go
type Artifact struct {
ID string // "submission_draft_title", "submission_docx_filename"
Label string // for the settings UI
Catalog VarCatalog // which variables are available here
Target RenderTarget // title vs filename
SystemDefault Composition // the seed (§5)
}
```
v1 registry (`internal/services/namegen` — the paliad-side wiring; `pkg/nomen` stays pure):
| Artifact ID | Target | Wired in v1? |
|-------------|--------|--------------|
| `submission_draft_title` | title | **yes** |
| `submission_docx_filename` | filename `.docx` | **yes** |
| `docforge_export` | filename | registered, **unwired** (opts in when docforge ships) |
| `data_zip_export` | filename `.zip` | registered, **unwired** (keeps `ExportFilename` shape) |
| `projection_slug` | slug | registered, **unwired** |
Registering-but-not-wiring means: the artifact ID exists in the catalog so the settings UI *could* list it and a composition *could* be stored, but the consumer still calls its current code path until a follow-up task flips it. No dead behaviour, no speculative resolver code.
> **`data_zip_export` note:** `ExportFilename` (`paliad-export-project-<slug>-<short>-<ts>.zip`) is deliberately machine-shaped (UTC timestamp, uuid disambiguator) — it is **not** a legal title and should **not** inherit the legal-composition defaults. It is registered for *discoverability*, but its eventual opt-in would use a distinct catalog (slug/timestamp/uuid vars), confirming the engine generalises beyond the legal-title model without forcing that model on it.
---
## §5 Seed defaults (the two shipped schemes, as data)
### 5.1 `submission_draft_title` (reproduces `AutoSubmissionTitle`, #155)
```
Segments:
{ Var: "date", Sep: "", Missing: omit }
{ Var: "client", Sep: " ", Missing: omit }
{ Var: "forum", Sep: " ./. ", Missing: omit }
{ Var: "opponent", Sep: " ./. ", Missing: omit }
Target: TitleTarget
```
- All-omit + separator-suppression reproduces "drop empty segment with its leading separator".
- `date` with `Sep: ""` and the others' first-emitted-suppresses-Sep rule yields `2026-05-31 Bayer AG ./. UPC` when opponent is empty — identical to today.
- Non-project draft: `client`/`forum`/`opponent` resolve `("", false)` → all omitted → renders bare `<date>`. **This is the non-project fix** (§6).
### 5.2 `submission_docx_filename` (reproduces `submissionFileName`, 354)
```
Segments:
{ Var: "date", Sep: "", Missing: omit }
{ Var: "keyword", Sep: " ", Missing: literal("submission") }
{ Var: "case_number", Sep: " ", Wrap: {"(", ")"},
Missing: placeholder("Az. folgt") }
Target: FilenameTarget{ext: ".docx"}
```
- `keyword` missing → `literal("submission")` reproduces the `kw == "" → "submission"` fallback.
- `case_number` missing → `placeholder("Az. folgt")`, wrapped in parens → `(Az. folgt)`.
- `FilenameTarget` sanitises each value via `SanitiseSubmissionFileName`, preserves the frame, appends `.docx`. Output identical to 354.
**Faithfulness test (acceptance gate):** golden-file table tests assert the engine's output is byte-equal to the current `AutoSubmissionTitle` / `submissionFileName` across the existing test matrix (with/without opponent, with/without case-number, en/de, umlaut folding). The shipped funcs become thin wrappers over the engine, or are deleted once call-sites move.
---
## §6 The non-project fix
Currently `newDraftName` only calls `autoNameForProject` when `project != nil`; otherwise `nextDraftName``Entwurf N`. Under the engine:
- A non-project draft renders `submission_draft_title` with a resolver where `client/forum/opponent` are all `("", false)` → composition degrades to `<date>`.
- Per Q4, the default gains a `keyword` segment so non-project drafts read **`<date> <keyword>`** where `keyword` = submission/document type if the draft has a `submission_code` that maps to a rule, else falls back.
- **Fallback when no keyword context:** if `keyword` also resolves empty (project-less draft with no `submission_code`/rule), the title degrades to `<date> Entwurf N``Entwurf N` enters as the `keyword` segment's `literal` fallback **with** the existing counter, so uniqueness is preserved via `uniqueDraftName` (P7).
> **FLAG (coder):** confirm whether project-less drafts (t-paliad-243) carry a `submission_code`. If yes, `keyword` derives from the rule like the project path. If no, the `literal("Entwurf N")` fallback is the norm and non-project names read `<date> Entwurf N` — still satisfies "date first there" (m's ask). Resolve in implementation; both paths are handled by the same composition.
The non-project title is the **same** `submission_draft_title` artifact — not a separate composition. Degradation is data-driven, not a code branch. This is the payoff of the engine: the gap closes by *removing* the `project != nil` special-case, not adding another.
---
## §7 Settings UX (v1)
A section on the existing `/settings` page (017 surface):
- **Per artifact** (v1 lists the 2 wired ones): a single-line **token-template string** field, e.g. `{date} {keyword} ({case_number})`.
- A **token palette**: clickable chips (`{date}` `{client}` `{forum}` `{opponent}` `{keyword}` `{case_number}`) insert at cursor. Chips show the localised label (DE primary / EN secondary).
- A **live preview** rendered against a **sample project** (fixed fixture: client "Bayer AG", forum "UPC", opponent "Sandoz", case "UPC_CFI_123/2026", today's date) so the user sees the result instantly — and a second preview line with empties so they see the missing-rule behaviour.
- **Reset to firm/system default** button (mirrors the dashboard "reset layout").
**String ⇄ segments:** the field is the *shorthand* (Q1). A small parser compiles `{var}` tokens + surrounding literals into `Segments` (separators = the literal runs between tokens; `(…)` around a token → `Wrap`). Missing-rules are **not** in the string (Q7) — they come from the system default for that var and are not user-editable in v1. So a user can reorder/drop/re-add tokens and change literals, but can't (yet) flip case-number from placeholder to omit. That's a deliberate v1 boundary; the structured model already supports it, the UI just doesn't expose it.
> Parser edge: a `{token}` the catalog doesn't know → inline validation error ("Unknown variable {foo}"), preview shows nothing, save disabled. Mirrors `DashboardLayoutSpec.Validate` rejecting unknown widget keys.
---
## §8 Slice train
Sliced so a **tracer bullet** ships value before the settings UI exists.
- **Slice 1 — Engine + faithful refactor (no behaviour change).**
`pkg/nomen` (types, render, targets, catalog) + `internal/services/namegen` (artifact registry + the 2 seed compositions + resolvers built from existing `submission_autoname.go` helpers). Re-point `AutoSubmissionTitle` and `submissionFileName` call-sites at the engine. **Acceptance:** §5 golden-file byte-equality; all existing #155/354 tests green unchanged. *No user-visible change — this is the safety net.*
- **Slice 2 — Non-project date-first (§6).**
Remove the `project != nil` special-case in `newDraftName`; non-project drafts render `submission_draft_title`. **Acceptance:** project-less draft gets `<date> <keyword>` (or `<date> Entwurf N` fallback); existing project drafts unchanged. *First user-visible win, m's immediate ask.*
- **Slice 3 — Precedence: system → user (per-document already shipped).**
`users.name_compositions jsonb` column + `NameCompositionSpec` (`Validate`/`SanitizeForRead`) + resolution that prefers a user override over the system default. Generalise `composer_meta.filename_keyword``name_overrides.keyword` (back-compat read). *No UI yet — overrides settable via API/test.*
- **Slice 4 — Settings UX (§7).**
`/settings` token-template field + palette + live preview for the 2 wired artifacts. *User can now customise.*
- **Slice 5 — Firm default.**
`firm_name_compositions` singleton + admin surface, mirroring `firm_dashboard_default`. Slots into precedence below user. *Firm-wide convention.*
Slices 12 are the tracer bullet (engine proven on shipped behaviour + the gap closed). 35 layer settings without re-touching the engine.
---
## §9 Out of scope (this PRD)
- Implementation, migration SQL drafting, Go code.
- Re-litigating #155 / 354 behaviour — they are the seed defaults, reproduced not redesigned.
- **Project-level** compositions (v1.1; storage path reserved in §3.2, precedence call deferred).
- Wiring `docforge_export`, `data_zip_export`, `projection_slug` — registered, not migrated (each is its own follow-up when the surface needs it).
- Naming for non-doc-generation strings across the app.
- User-editable **missing-rules** in the settings UI (model supports it; UI deferred past v1).
---
## §10 Open questions (historical record — resolved in § m's decisions)
1. Composition representation — token-string vs structured-segments vs both. → **Q1: structured + string shorthand.**
2. v1 precedence levels. → **Q2: system → firm → user → per-document.**
3. Generalisation depth (YAGNI vs engine-now). → **Q3: reusable engine, 3 consumers wired.**
4. Non-project default name. → **Q4: `<date> <keyword>`.**
5. Missing-rule policy set. → **Q5: omit + placeholder + literal.**
6. Date semantics. → **Q6: render-time today, Europe/Berlin, `YYYY-MM-DD`.**
7. Settings UX shape. → **Q7: live-preview string field + palette.**
8. Artifact registry scope. → **Q8: 2 submission artifacts + extensible registry.**
**Remaining FLAGs for the coder (not blocking design approval):**
- Package name `pkg/nomen` (vs `naming`/`namegen`) — implementation pick.
- Whether project-less drafts carry a `submission_code` (decides `keyword` source in §6).
- `name_overrides` back-compat read of the existing `filename_keyword` key — confirm the one shipped draft-keyword row migrates cleanly (live round-trip test, like t-paliad-354's).

View File

@@ -0,0 +1,685 @@
# PRD — Procedures: Litigation Builder (m/paliad#153)
**Task:** t-paliad-339
**Gitea:** m/paliad#153
**Inventor:** edison (shift-1, Opus)
**Date:** 2026-05-27
**Branch:** `mai/edison/inventor-prd-columnar`
**Status:** Draft — DESIGN READY FOR REVIEW. Coder gate held.
**Builds on (read before extending this PRD):**
- `docs/design-procedures-workflow-tracker-2026-05-27.md` — atlas's reverted tracker design (m/paliad#152). The anchor+scope idea did not land; understand *why* before re-proposing.
- `docs/design-unified-procedural-events-tool-2026-05-27.md` — cronus's U0-U4 catalog, currently live on main @ `ed3c5d1` post-revert. Visual baseline for filter strip + tab control.
- `docs/design-deadline-system-revision-2026-05-27.md` — atlas Phase 2 model layer (scenario_flags SSoT, view-mode toggle, per-rule selection chips). Model layer is locked; this PRD is purely surface + new persistence tables.
- `docs/design-fristenrechner-overhaul-2026-05-26.md` — cronus 2026-05-26 inventor-pass (Mode A + B + result, shipped via t-paliad-322).
**Predecessor takeaway (atlas's debrief on #152):**
> "When the architecture is novel, default to grilling m in prose FIRST. The doc rewrites cost a commit; the bigger cost would have been wasting m's question-batch on the wrong architecture."
Followed here. This PRD captures the architecture m chose through **20 chip-picker decisions across 5 batches**, not an inventor-first strawman.
---
## §0 Premises
### §0.1 What is `/tools/procedures` today (live, post-revert)
The current page is cronus's 4-tab catalog (U0-U4, shipped via m/paliad#151):
- Sticky filter strip (search box + 4 chip rows: Forum / Verfahren / Ereignisart / Partei).
- 4 solid tabs: `Verfahren wählen` / `Direkt suchen` / `Geführt` / `Aus Akte`.
- Default-active tab = "Verfahren wählen" renders `VerfahrensablaufBody` (the legacy Verfahrensablauf wizard: proceeding picker → perspective + date → 3-step wizard → result in 3-column "Spalten" or single-column "Zeitstrahl").
- Other 3 tab panels are stubs (search/wizard/akte never wired in U0-U3).
m's blocking feedback (verbatim, 2026-05-27 22:18):
> I like to keep our current columnar layout with proactive / court / reactive. And it is good if we can select which side we want to simulate. […] There are basically three main approaches I see to this: Get an overview over proceedings, play around with options, build Scenarios. Another one where something specific happened and we just want to know what deadlines we need to note […]. A third one from a specific proceeding / case file where things take place / have taken place.
And the architecture-shifting follow-ups (2026-05-27 22:35-22:36, mid-grilling):
> I would prefer to have an interface where not every constellation is in the URL by the way. That seems limiting.
> We could just have a litigation builder. Sometimes we build a full scenario with multiple instances etc, sometimes we just want the next step.
> we should have ways to save these "litigation constellations" where we save which proceedings we have and which state they are in, which submissions were or were not filed. A small Scenario DB could work, dont you think?
These three statements upgraded the brief from "redesign a catalog" to "build a Litigation Builder backed by a Scenario DB". The PRD below is shaped by them.
### §0.2 Locked constraints (m's words, brief in #153)
- Columnar layout: `proaktiv | court | reaktiv` (perspective-flippable).
- Three approaches as entry modes: overview/scenarios, event-triggered, case-file driven.
- Filtering across all dimensions + text search.
- Optional follow-ups: toggleable, highlightable, with display-count setting.
- Modular *where it actually helps* (m: "I don't know — generally does not super apply here." — drop modular as a load-bearing goal).
- UPC v1, expand later.
### §0.3 Live data the builder works against
Verified 2026-05-27 against `paliad.sequencing_rules` (231 published / 242 total):
- 110 chained (`parent_id` not null).
- 78 trigger-rooted, 4 spawns (cross-PT), 47 court-set, 18 conditional.
- ~46 proceeding types total (UPC 35 / DE 5 / EPA 3 / DPMA 3). v1 focuses on UPC.
- `paliad.proceeding_types.kind` discriminator (atlas's t-paliad-324) filters non-proceeding rows (phases/side_actions/meta) from the picker.
- `paliad.deadlines` carries both `procedural_event_id` and `sequencing_rule_id` → Akte actuals overlay is a direct join.
- `paliad.projects.scenario_flags` jsonb (atlas P0) is the SSoT for project-level scenario state; the new `paliad.scenario_proceedings.scenario_flags` mirrors this shape per-proceeding-per-scenario.
### §0.4 Scope (in / out)
**In:**
- Replace `/tools/procedures` with the Litigation Builder.
- New `paliad.scenarios` + `paliad.scenario_proceedings` + `paliad.scenario_events` + `paliad.scenario_shares` tables.
- Promote-to-project flow (scenario → `paliad.projects` row).
- Bidirectional link from `/projects/{id}` (button: "Im Builder öffnen" — exports project state to a builder session).
**Out (deferred or owned elsewhere):**
- Calculator (`pkg/litigationplanner.CalculateRule`) — working.
- Editorial backfill (curie's t-paliad-333 owns the 7 compound rules + R.109).
- `/admin/procedural-events` (editor surface; different audience).
- `/projects/{id}` Verlauf / SmartTimeline (per-Akte actuals; sister tool).
- youpc.org / Outlook / PDF export.
- Multi-jurisdiction expansion (DE/EPA/DPMA) — UPC v1 first.
- Cross-proceeding peer triggers (UPC-inf judgment → EPA opp choice deadline) — v1.1.
- Multi-user concurrent editing on the same scenario (out of scope; sharing is read-only).
---
## §1 Goals
1. **One canvas, three entry modes.** Unify the 3 approaches into a single Litigation Builder surface. The entry modes (`Übersicht / Ereignis / Aus Akte`) shape the *initial* state of the canvas; once the user is working, the canvas itself is what they interact with.
2. **Persisted constellations.** A user can save a "litigation constellation" — multiple parallel proceedings with their flags, filed/skipped/planned event states, dates, and notes — as a named scenario. Scenarios live in the DB (not the URL).
3. **Auto-save by default.** No "unsaved changes" modals. The active scenario auto-persists. Anonymous scratch scenarios convert to named ones when the user clicks "Benennen".
4. **Promote-to-project.** A scenario can be turned into a real `paliad.projects` row via a 3-step wizard. Procedural shape, placeholder parties, notes, and filed-state all carry over; the user fleshes out client-bound metadata during the wizard.
5. **Share read-only with the team.** Each scenario is private by default; explicit "An Team teilen" grants named HLC users read-only access. Original owner stays sole editor.
6. **Columnar geometry restored.** The current "Spalten" view (claimant | court | defendant) returns as the canonical render — but now per-proceeding-triplet within a scenario, with perspective ("our side") flippable per proceeding so `proaktiv | court | reaktiv` reads correctly across multi-proceeding constellations.
7. **Per-event-card optional horizon.** Each event card on the canvas can dial in how many optional follow-ups to surface. Cards are the unit of optional-display control.
---
## §2 User journeys
### §2.1 Journey A — Cold-open builder ("Übersicht / Scenarios")
**Persona:** Dr. Becker, senior partner. Friday afternoon. New UPC matter not yet committed; she's briefing a client on Monday on the full procedural shape.
1. Opens `/tools/procedures`. No `?scenario` param. Cold-open canvas: empty workbench with a "Neues Szenario starten" CTA and a short list of her 5 most-recent scenarios.
2. Clicks the CTA → inline picker (Forum chip row → Verfahren chip row → `Hinzufügen`). Picks UPC + `upc.inf.cfi`.
3. Canvas now renders one proceeding triplet (`proaktiv | court | reaktiv`). Default perspective is empty (no party selected) — both sides render equally; the perspective radio in the page header sits unset.
4. She picks defendant perspective at the page header → triplet flips. The defendant column becomes `proaktiv` (her side); claimant becomes `reaktiv`.
5. She adds a second proceeding via `+ Verfahren hinzufügen` at the bottom: EPA `epa.opp.opd`. New triplet stacks below the first. New triplet's perspective defaults to "patentee" inheriting from her client's role across the two; she flips per-proceeding via the triplet header.
6. She turns on `with_ccr` on the UPC inf triplet's per-proceeding flag strip. The CCR child triplet auto-expands inline below the parent at the spawn node.
7. Auto-save kicks in (debounced 500ms). The page header shows "Gespeichert in Scratch · Benennen".
8. She clicks "Benennen", enters "Becker — UPC + EPA defensive". Side panel "Meine Szenarien" updates.
9. On Monday she opens the scenario from her recent list, walks the client through it, hits "Als Projekt anlegen" (when the client commits). 3-step wizard fires (§5.4).
### §2.2 Journey B — Event-triggered lookup ("Ereignis")
**Persona:** Sandra, paralegal. Today: a Hinweisbeschluss arrived on a CMS queue. She doesn't know yet which Akte it belongs to.
1. Opens `/tools/procedures`. Picks "Ereignis" entry mode at the top.
2. Page-header search box auto-focuses. She types "Hinweis" → universal search drops down: `5 Ereignisse · 1 Szenario · 0 Akten`. Picks the event `upc.inf.cfi.cmo_review` (Antrag CMO-Überprüfung).
3. Canvas renders one triplet of `upc.inf.cfi` with the Hinweisbeschluss event card auto-anchored (lime band + `━━ DU BIST HIER ━━` divider above the next-coming events).
4. She reads the follow-ups: "Antrag auf CMO-Überprüfung (claimant, R.333.2 · 1 Monat)" and 2 optional follow-ups. The Stichtag input in the page header defaults to today; she leaves it.
5. She doesn't save anything — this was a quick lookup. Scratch scenario auto-persists but she doesn't name it; it'll fall off her recent list after a while.
6. Later she identifies the matter (HL-2024-001), switches to "Aus Akte" mode, and continues there.
### §2.3 Journey C — Case-file driven ("Aus Akte")
**Persona:** Anna, senior associate. Working on HL-2024-001 (UPC infringement). The client just confirmed they want to file a CCR.
1. Opens `/tools/procedures`. Page-header Akte picker shows recent projects; she picks HL-2024-001.
2. Page header auto-fills: proceeding = `upc.inf.cfi`, perspective = defendant (from `projects.our_side`), scenario_flags = `{with_ccr: false}` (current state).
3. Builder loads: one `upc.inf.cfi` triplet, perspective-flipped. Event cards overlay actuals from `paliad.deadlines``Klageerhebung` is filed (2026-01-15), `Klageerwiderung` is planned (2026-04-01, computed), others are planned.
4. She turns on `with_ccr` on the triplet's flag strip. The CCR child triplet expands inline. **Crucially:** the scenario is *project-backed* — the flag write also patches `projects.scenario_flags` (via existing `PATCH /api/projects/{id}/scenario-flags` from atlas P0). When she walks away, the project's deadlines + flags reflect the builder's state.
5. She marks the `Widerklage auf Nichtigkeit` event card as "filed" with today's date. Builder writes a `paliad.deadlines` row with `status='done'` + `completed_at=today`, audit_reason "via Litigation Builder". Project's Verlauf reflects this.
6. The CCR child triplet's `Antrag Patentänderung (R.30)` event card surfaces. She marks it "planned" and ticks the per-card optional horizon to "+2" → 2 more optional R.30-adjacent rules surface.
7. Exit: she closes the tab. Project state persists in `paliad.projects` + `paliad.deadlines` as before; the scenario row tracks the builder-session view (so when she returns, the canvas state is restored — including her per-card optional-horizon picks).
### §2.4 Journey D — Promote scratch to a real project
**Persona:** Dr. Becker, follow-up from Journey A. The client committed; she wants to convert the scenario into a real matter.
1. With "Becker — UPC + EPA defensive" loaded, she clicks "Als Projekt anlegen" in the page header.
2. **Wizard step 1: Bestätigen.** Read-only summary of what's about to be promoted: 2 proceedings (UPC inf + EPA opp), CCR child, 3 scenario flags set, 0 events filed, 5 events planned, 2 notes. "Weiter".
3. **Wizard step 2: Parteien ergänzen.** Each proceeding's parties section shows whatever placeholder names she sketched in the scenario ("Klg X" / "Bekl Y"). She edits each into the real names. (Per m's Q11 pick — full carry — placeholder strings come in; the wizard's job is to clean them.)
4. **Wizard step 3: Akte-Metadaten.** Case number, client, litigation parent project (optional), our_side (auto-set from the scenario's primary triplet), team selection. "Anlegen".
5. New `paliad.projects` row written with `origin_scenario_id = <scenario.id>`. Scenario row's `status` flips to `promoted`, `promoted_project_id` points back. Builder navigates to `/projects/<new-id>`.
6. The scenario stays read-only in her "Meine Szenarien" list under "Promoted", reachable for historical reference (cf. "this is what we planned at briefing time").
### §2.5 Journey E — Share a scenario with a colleague
**Persona:** Anna shares the HL-2024-001 builder session with Dr. Becker (her supervising partner) for review before committing to the CCR strategy.
1. Anna opens the scenario, clicks "Teilen" in the page header.
2. Side panel slides in with a user-picker (HLC user search). She picks "Dr. Becker", clicks "Schreibgeschützt teilen".
3. `paliad.scenario_shares` row written. Anna remains sole editor.
4. Dr. Becker opens the tool. Her side panel "Meine Szenarien" has a new bucket "Geteilt mit mir"; Anna's scenario is listed. She opens it: canvas renders the same view but every mutating affordance (add proceeding, flag toggle, file/skip, promote, share) is disabled. Watermark: "Geteilt von Anna · schreibgeschützt".
5. Becker reads, drops Anna a note via existing comment infrastructure (out of scope — separate ticket). Decision made out-of-band. Anna proceeds.
---
## §3 The canvas shape
### §3.1 ASCII sketch
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ Paliad · Verfahren & Fristen — Litigation Builder [Mein Konto ▾] │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ Szenario: [Becker — UPC + EPA def. ▼] Gespeichert ✓ · [Benennen] [Teilen] [Als Projekt] │
│ Akte: [— ohne — ▼] Stichtag: [2026-04-01] │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ Filter: [🔍 Klageerwiderung, Hinweis, HL-2024… ] │
│ Forum [● UPC] [DE] [EPA] [DPMA] Verfahren [● upc.inf.cfi …] │
│ Partei [Klg] [● Bekl] Ereignisart [filing] [hearing] [decision] │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ Einstieg: [ Übersicht ● ][ Ereignis ○ ][ Aus Akte ○ ] │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─ upc.inf.cfi · Verletzungsverfahren UPC Bekl-Sicht [▾] [Detailgrad: Gewählt ▾]│
│ │ Optionen: ☑ with_ccr ☐ with_amend ☐ with_cci [─][×] │
│ ├─────────────────┬────────────────────┬─────────────────────────────────────────┤
│ │ Proaktiv (Bekl) │ Gericht │ Reaktiv (Klg) │
│ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ │ Klageerw. │ │ │ │ Klageerh. │ │
│ │ │ R.23 │ │ │ │ R.13 │ │
│ │ │ planned │ │ │ │ filed │ │
│ │ │ 2026-04-01 │ │ │ │ 2026-01-15 │ │
│ │ │ +3 Optionen ▾│ │ │ │ │ │
│ │ └─────────────┘ │ │ └─────────────┘ │
│ │ │ ┌──────────────┐ │ │
│ │ │ │ Mündl. Verh. │ │ │
│ │ │ │ planned │ │ │
│ │ │ │ [Gericht] │ │ │
│ │ │ └──────────────┘ │ │
│ │ ━━━━━━━━━ DU BIST HIER (Klageerwiderung) ━━━━━━━━━ │
│ └─────────────────┴────────────────────┴─────────────────────────────────────────┘ │
│ │
│ ┌── (spawn child) upc.ccr.cfi · Widerklage auf Nichtigkeit Klg-Sicht [▾] ───────┐│
│ │ Optionen: ☐ with_amend [─][×]││
│ ├────────────────────┬─────────────────┬───────────────────────────────────────┐ ││
│ │ Proaktiv (Klg) │ Gericht │ Reaktiv (Bekl) │ ││
│ │ ┌─────────────┐ │ │ │ ││
│ │ │ CCR-Antrag │ │ │ │ ││
│ │ │ R.49 │ │ │ │ ││
│ │ │ planned │ │ │ │ ││
│ │ └─────────────┘ │ │ │ ││
│ └────────────────────┴─────────────────┴───────────────────────────────────────┘ ││
│ │
│ ┌─ epa.opp.opd · Einspruchsverfahren EPA PatInh-Sicht [▾] [Detailgrad: Gewählt ▾]│
│ │ Optionen: (keine flags für EPA Opp) [─][×] │
│ ├─────────────────┬────────────────────┬─────────────────────────────────────────┤
│ │ Proaktiv │ EPA │ Reaktiv (Einsprechende) │
│ │ ┌─────────────┐ │ │ │
│ │ │ Erwiderung │ │ │ │
│ │ │ R.79(1) EPÜ │ │ │ │
│ │ │ planned │ │ │ │
│ │ └─────────────┘ │ │ │
│ └─────────────────┴────────────────────┴─────────────────────────────────────────┘ │
│ │
│ [ + Verfahren hinzufügen ] │
│ │
└──────────────────────────────────────────────────────────────────────────────────────┘
Side panel (collapsible, right-edge):
┌──── Meine Szenarien ────┐
│ ● Aktiv │
│ ▸ Becker — UPC+EPA def │ ← current
│ ▸ Test-CCR-Patent-X │
│ ○ Geteilt mit mir │
│ ▸ Becker UPC ply │
│ ○ Promoted │
│ ▸ HL-2023-118 │
│ ○ Archiviert (3) │
│ [+ Neues Szenario] │
└──────────────────────────┘
```
### §3.2 What each element does
| Element | Read | Write | Persists in |
|---|---|---|---|
| Page-header scenario picker | Current `scenarios.id` + `name` | Switch scenarios | URL `?scenario=<id>` + DB |
| `[Benennen]` button | Anonymous → named | `scenarios.name`, `status='active'` | DB |
| `[Teilen]` button | — | `scenario_shares` row(s) | DB |
| `[Als Projekt]` button | — | Opens promote wizard | (wizard → DB on commit) |
| Akte picker | User's projects | Loads project state into builder | URL `?project=<id>` + DB |
| Stichtag input | Scenario-level default | `scenarios.stichtag` | DB |
| Filter strip (search + chips) | Free-text + dimension filters | UI state | URL `?q`, `?forum`, … per-mode |
| Einstieg mode radio | Current entry mode | Resets filter strip on change | URL `?mode=` |
| Triplet header (jurisdiction badge + name + perspective + Detailgrad) | `scenario_proceedings.{primary_party, detailgrad}` | Edit | DB |
| Triplet flag strip | `scenario_proceedings.scenario_flags` | Toggle flags | DB |
| Event card (state, date, notes, optional-horizon) | `scenario_events.*` | Edit per-card | DB |
| `+ Verfahren hinzufügen` | — | New `scenario_proceedings` row | DB |
| Side panel | User's scenarios + shared scenarios | Switch + create + archive | DB |
### §3.3 Columns: `proaktiv | court | reaktiv`
The 3-column layout returns as the canonical desktop shape. Per m's locked constraint (and brief #153), it is a **stance grouping**, not a sequence anchor — time flows top-to-bottom (chronological), columns express *who is acting*.
- **Proaktiv**: the column for events the active perspective's party initiates (their `primary_party` matches the event's `primary_party`).
- **Court**: court-set events (`is_court_set=true`), neutral column.
- **Reaktiv**: the column for events the opposing party initiates.
The perspective is per-proceeding (per-triplet, via `scenario_proceedings.primary_party`). When no perspective is set (`null`), both party columns render equally with their natural party labels (Klg / Bekl), not Proaktiv / Reaktiv. This means kontextfrei browsing reads as "claimant column | court | defendant column" until the user picks a side.
This addresses m's reverted-design bug #3 verbatim: "Proaktiv/Gericht/Reaktiv columns are a stance grouping, not a sequence anchor." Time = vertical. Stance = horizontal. The triplet is the unit; multiple proceedings stack vertically.
### §3.4 Event card anatomy
```
┌─────────────────────┐
│ Klageerwiderung │ ← event name (procedural_event.name)
│ R.23 │ ← rule code
│ planned │ ← state: planned / filed / skipped
│ 2026-04-01 │ ← date (computed for planned, actual for filed)
│ +3 Optionen ▾ │ ← per-card optional horizon (only when card has optionals)
└─────────────────────┘
```
State machine (m's Q10 pick — 3-state):
- `planned` (default): future event, date is computed from anchor + duration_value + duration_unit. Click → choose `filed` or `skipped`.
- `filed`: past event, `actual_date` is set (defaults to computed, user can override). Visual: ✓ checkmark, slightly muted "past" tone.
- `skipped`: user chose not to file. Visual: strikethrough text + optional `skip_reason` (textarea). Optional rules are commonly skipped without rationale; mandatory rules with `skipped` state flag the scenario as "non-standard" but don't block.
No `overdue` state — user does the date arithmetic by eye against today. (Mandatory cards rendered in red when `actual_date < today AND state=planned` is a **render hint**, not a stored state.)
**Per-card optional horizon (m's Q4 pick).** Each card with children at `priority IN ('optional','recommended-skip-by-default')` carries a chip `+N Optionen ▾`. Default N=0 (hidden). Clicking opens an inline list of the optional children with `+`/`-` controls to surface/hide them on the canvas. Per-card horizon persists as `scenario_events.horizon_optional int`.
Filed-state cards persist the date in `scenario_events.actual_date date`. The card's notes field (textarea, lazy-loaded) lives in `scenario_events.notes text`.
### §3.5 Court-set events
`is_court_set=true` rules don't compute a date until the court picks one. Card renders with `[Gericht]` badge in place of the date and a small "Datum eintragen" affordance. Clicking `filed` opens a date picker (date is required for `filed` state when `is_court_set=true` — the user is asserting "the court set this date").
Downstream events that anchor on a court-set event render their dates as `[abhängig von <event>]` until the court date is filed, then auto-recompute.
### §3.6 Spawn (child) proceedings
When a triplet has a `with_<flag>` enabled and the flag's gating rule has `is_spawn=true`, the child proceeding (e.g. `upc.ccr.cfi` for `with_ccr` on `upc.inf.cfi`) renders inline as a child triplet **immediately below the parent triplet** in the canvas stack — visually nested via the spawn note in the parent triplet's header band.
`scenario_proceedings.parent_scenario_proceeding_id` FK self-references for the nesting; `scenario_proceedings.spawn_anchor_event_id` points at the gating sequencing_rule so the UI knows where in the parent the spawn happened.
The child triplet has its own perspective, scenario flags, Stichtag override, Detailgrad. It can itself spawn (depth N supported; today's data is 2-deep at most).
Cross-proceeding peer triggers (`upc.inf judgment → epa.opp choice deadline`) are **out of scope for v1** (m's Q14 pick). v1 ships independent triplets stacked vertically; the user mentally tracks cross-dependencies. A future `scenario_event_links` table is the path to peer triggers in v1.1.
---
## §4 Hard decisions table — m's 20 picks
| # | Topic | Pick | Locks |
|---|---|---|---|
| Q1 | Modular meaning | "doesn't super apply" — drop modular as a load-bearing goal | §0.2 |
| Q2 | Tab state semantics | Shared anchor + Akte across modes; filters reset per mode | §3.1, §3.2, §6 |
| Q3 | Case-file integration | Page-header Akte picker, persistent across modes | §3.1, §3.2, §2.3 |
| Q4 | Optional-display horizon | Per-event-card | §3.4 |
| Q5 | Builder shape | Unified builder, 3 entry modes (cold-open / event-triggered / Akte) | §0, §1, §2, §3 |
| Q6 | Scenario↔project relationship | Separate `paliad.scenarios` table + promote-to-project action | §5, §2.4 |
| Q7 | Scenario contents | Multi-proceeding constellation per scenario | §3, §5 |
| Q8 | Save model | Auto-save active scenario + "Meine Szenarien" list | §1, §3, §6.4 |
| Q9 | Multi-proceeding render | Vertical stacked column-triplets | §3 |
| Q10 | Per-event state | 3-state: planned / filed / skipped (no `overdue` state) | §3.4 |
| Q11 | Promote-to-project carry | Everything (incl. placeholder parties + free-form notes) | §2.4, §5.4 |
| Q12 | Sharing model | Private by default + explicit team-share (read-only) | §1, §5, §2.5 |
| Q13 | Scenario flags placement | Per-proceeding (each triplet owns its `scenario_flags`) | §5.1 |
| Q14 | Cross-proceeding peer triggers | Out of scope for v1 (defer to v1.1) | §3.6, §7 |
| Q15 | Perspective scope | Per-proceeding (each triplet has its own `primary_party`) | §3.3, §5.1 |
| Q16 | Add-proceeding flow | `+ Verfahren hinzufügen` button below the last triplet, inline picker | §3, §3.1 |
| Q17 | Cold-open canvas | Empty canvas + "Neues Szenario" CTA + recent-list | §2.1, §3 |
| Q18 | Search scope | Universal: events + scenarios + Akten, scoped by result type | §3.1, §6 |
| Q19 | Promote-to-project flow | 3-step wizard (Bestätigen → Parteien ergänzen → Akte-Metadaten) | §2.4, §5.4 |
| Q20 | Mobile treatment | Desktop v1, mobile basic-read (mutating actions prompt "Auf größerem Bildschirm öffnen") | §3, §7 |
### §4.1 Divergences from inventor recommendations
Three picks diverged from my recommendation. Captured here so future readers (m, the coder) see the *current* design, not the strawman.
- **Q1 — Modular.** Inventor recommended "plug-in widgets". m: "I don't know — generally does not super apply here." Modular is dropped as a goal; the natural decomposition (BuilderCanvas → ProceedingTriplet → EventCard → ScenarioListPanel → PromoteWizard) is documented in §6.2 as build hygiene, not as a load-bearing constraint.
- **Q10 — Event state.** Inventor recommended 4-state (planned / filed / skipped / overdue). m picked 3-state — no `overdue` enum. Rationale (interpreted): `overdue` is derived from `date < today AND state=planned`, not stored; this avoids stale state when the date is edited.
- **Q11 — Promote carry.** Inventor recommended carrying procedural shape + flags + filed-state + notes but **not** placeholder parties/case_number/billing. m picked "everything carries" — placeholder parties come in. Mitigation: Q19's 3-step wizard's step 2 (Parteien ergänzen) gives the user a chance to clean placeholders before commit, so the safety net m wanted on Q11 is folded into Q19.
### §4.2 Inventor picks not formally asked
A few decisions are inventor-set because they're either: (a) implementation details that don't change the architecture, or (b) clean defaults that match existing patterns. Listed here so they're visible; m can flag any.
- **Detailgrad ("Gewählt" / "Alle Optionen") scope**: per-proceeding (matches today's Verfahrensablauf pattern). State in `scenario_proceedings.detailgrad`.
- **Akte picker shape**: flat dropdown sorted by recently-viewed first, with a typeahead filter for case numbers/names. Same shape as today's project picker on /agenda.
- **Notes**: per-event-card (textarea on each card, lazy-loaded). Scenario-level notes also exist (`scenarios.notes text`) for cross-cutting commentary.
- **Read-only shared state UI**: every mutating affordance is disabled (greyed, no click handlers). Watermark "Geteilt von <X> · schreibgeschützt" at the top of the canvas. No "Fork to my workspace" affordance in v1.
- **URL contract**: minimal, view-state only — `?scenario=<id>&mode=<entry>&event=<sequencing_rule_id>` (deep-link to a specific anchor). Filter pills + chip state get URL params *per active entry mode* but explicitly NOT the constellation data (per m's "not every constellation in URL" guidance). The constellation lives in `paliad.scenario_*` tables.
- **Auto-save granularity**: debounced 500ms on every change. Indicator near scenario name: `Gespeichert ✓` (last successful save < 5s ago), `Speichert…` (in flight), `Letzte Speicherung fehlgeschlagen — erneut versuchen` (on error).
- **Soft delete**: archived scenarios stay in DB with `status='archived'`. No hard delete in v1.
- **Audit**: no audit log on scenario edits (they're exploratory). Audit on promote-to-project goes via the existing `projects.audit_log`.
- **Concurrent editing**: single-editor model. Owner is sole editor; shares are read-only. No locking / merge conflict UI needed in v1.
- **Bilingual**: German primary, English via existing `i18n.ts`. Scenario names: user-chosen, any language. Skip reasons + notes: free-text, any language.
---
## §5 Data model deltas
All new tables live in `paliad.*` schema, alongside existing `paliad.projects` / `paliad.deadlines` / `paliad.sequencing_rules`.
### §5.1 New tables
```sql
-- Scenario header. One row per saved scenario (named or scratch).
CREATE TABLE paliad.scenarios (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
name text NOT NULL DEFAULT 'Unbenanntes Szenario',
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','archived','promoted')),
origin_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
-- set when scenario was exported from a project
promoted_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
-- set when scenario was promoted to a project
stichtag date NULL,
-- scenario-level default Stichtag; per-triplet overrides take precedence
notes text NULL,
-- free-form scenario-level commentary
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX scenarios_owner_status_idx ON paliad.scenarios(owner_id, status);
CREATE INDEX scenarios_updated_idx ON paliad.scenarios(owner_id, updated_at DESC);
-- One row per proceeding inside a scenario. Multiple per scenario for
-- multi-proceeding constellations. parent_scenario_proceeding_id self-refs
-- for spawned children (CCR child of UPC inf etc.).
CREATE TABLE paliad.scenario_proceedings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_id uuid NOT NULL REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
proceeding_type_id uuid NOT NULL REFERENCES paliad.proceeding_types(id),
primary_party text NULL
CHECK (primary_party IN ('claimant','defendant')),
-- per-proceeding perspective; null = no perspective picked yet
scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb,
-- per-proceeding flags: {with_ccr: true, with_amend: false, …}
parent_scenario_proceeding_id uuid NULL REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
-- self-ref for spawned children (CCR child of UPC inf etc.)
spawn_anchor_event_id uuid NULL REFERENCES paliad.sequencing_rules(id),
-- which rule of the parent caused this spawn (for UI placement)
ordinal int NOT NULL DEFAULT 0,
-- stack order on canvas (top to bottom)
stichtag date NULL,
-- per-proceeding Stichtag override; falls back to scenarios.stichtag
detailgrad text NOT NULL DEFAULT 'selected'
CHECK (detailgrad IN ('selected','all_options')),
appeal_target text NULL,
-- applies_to_target for appeal proceedings; null for non-appeal triplets
collapsed boolean NOT NULL DEFAULT false,
-- user-collapsed triplet header (UI state)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX scenario_proceedings_scenario_idx ON paliad.scenario_proceedings(scenario_id, ordinal);
CREATE INDEX scenario_proceedings_parent_idx ON paliad.scenario_proceedings(parent_scenario_proceeding_id);
-- One row per event card on the canvas. Captures the card's state +
-- per-card attributes (filed date, skip reason, notes, optional horizon).
-- Most cards are sequencing-rule-backed; free-form events have a null
-- sequencing_rule_id and a non-null procedural_event_id (or text label).
CREATE TABLE paliad.scenario_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_proceeding_id uuid NOT NULL REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
sequencing_rule_id uuid NULL REFERENCES paliad.sequencing_rules(id),
procedural_event_id uuid NULL REFERENCES paliad.procedural_events(id),
-- one of {sequencing_rule_id, procedural_event_id, custom_label} must be set
custom_label text NULL,
-- free-form event name when neither sequencing_rule nor procedural_event apply
state text NOT NULL DEFAULT 'planned'
CHECK (state IN ('planned','filed','skipped')),
actual_date date NULL,
-- set when state='filed'; can also be set for state='planned' (court-set override)
skip_reason text NULL,
-- optional rationale when state='skipped'
notes text NULL,
-- per-card free-form
horizon_optional int NOT NULL DEFAULT 0,
-- per-card "show N more optionals" affordance
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (scenario_proceeding_id, sequencing_rule_id) WHERE sequencing_rule_id IS NOT NULL
);
CREATE INDEX scenario_events_proceeding_idx ON paliad.scenario_events(scenario_proceeding_id);
-- Read-only team shares. Owner is sole editor; shares grant view-only.
CREATE TABLE paliad.scenario_shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_id uuid NOT NULL REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
shared_with_user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid NOT NULL REFERENCES auth.users(id),
UNIQUE (scenario_id, shared_with_user_id)
);
CREATE INDEX scenario_shares_user_idx ON paliad.scenario_shares(shared_with_user_id);
```
### §5.2 Additions to existing tables
```sql
-- One nullable FK on paliad.projects to track which scenario spawned this
-- project (set on promote-to-project). Auditable origin trail.
ALTER TABLE paliad.projects
ADD COLUMN origin_scenario_id uuid NULL
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
CREATE INDEX projects_origin_scenario_idx ON paliad.projects(origin_scenario_id)
WHERE origin_scenario_id IS NOT NULL;
```
No other changes to existing schema. `paliad.deadlines` continues to be the authoritative source for project-bound actuals; the builder writes to `paliad.deadlines` (not `scenario_events`) when working in Akte mode against a project-backed scenario.
### §5.3 RLS
Same pattern as existing `paliad.projects`:
- `scenarios` readable by `owner_id` OR by users with a matching `scenario_shares.shared_with_user_id` row.
- `scenarios` writable only by `owner_id` (and only when `status != 'promoted'`).
- `scenario_proceedings` + `scenario_events` cascade from scenario visibility.
- `scenario_shares` readable by `shared_with_user_id` or `created_by`; writable only by the scenario owner.
Helper function `paliad.can_see_scenario(scenario_id)` mirrors the existing `paliad.can_see_project(project_id)` shape.
### §5.4 Promote-to-project: data flow
```
[Wizard step 1: Bestätigen]
Read: scenarios + scenario_proceedings + scenario_events
Action: none (read-only summary)
[Wizard step 2: Parteien ergänzen]
Read: scenario_proceedings.scenario_flags (for hints about placeholder party names)
Action: builds an in-memory parties payload (per proceeding, per role)
[Wizard step 3: Akte-Metadaten]
Read: user's clients + litigations + project tree (existing /projects API)
Action: builds an in-memory project metadata payload
[Commit]
Transaction:
1. INSERT into paliad.projects (carrying step-2 + step-3 payloads, + scenario notes)
SET origin_scenario_id = <scenario.id>
2. INSERT into paliad.parties from step-2 payload
3. For each scenario_proceeding (depth-first, parent before child):
a. INSERT scenario_flags as projects.scenario_flags (parent-level only;
children become sub-projects via parent_project_id)
b. For each filed scenario_event: INSERT paliad.deadlines row with
status='done', completed_at=actual_date, audit_reason='via Litigation Builder promotion'
c. For each planned scenario_event: INSERT paliad.deadlines row with
status='pending', due_date=computed (or actual_date override)
d. Skipped events: not inserted (no deadline row)
4. UPDATE paliad.scenarios SET status='promoted', promoted_project_id=<new>
5. Navigate to /projects/<new>
```
The deadlines write uses existing `POST /api/projects/{id}/deadlines/bulk` semantics under the hood no new bulk-deadline-from-scenario endpoint needed.
---
## §6 Modular boundaries (light)
m said modular "doesn't super apply" dropped as a load-bearing goal. The natural decomposition below is build-hygiene documentation, not a constraint the coder must enforce.
### §6.1 Front-end components
| Component | File | Responsibility |
|---|---|---|
| `BuilderCanvas` | `frontend/src/components/BuilderCanvas.tsx` | Root render of the builder. Receives the active scenario, renders triplet stack + cold-open empty state |
| `ProceedingTriplet` | `frontend/src/components/ProceedingTriplet.tsx` | One proceeding's render: header strip (jurisdiction + name + perspective + Detailgrad + collapse + remove) + flag strip + 3 columns + spawn child triplets recursively |
| `EventCard` | `frontend/src/components/EventCard.tsx` | One card in a column lane. State / date / optional-horizon / notes affordances |
| `ScenarioFlagsStrip` | `frontend/src/components/ScenarioFlagsStrip.tsx` | Per-triplet flag toggles. Reads scenario_flag_catalog, applies to scenario_proceedings.scenario_flags |
| `AddProceedingPicker` | `frontend/src/components/AddProceedingPicker.tsx` | Inline picker triggered by `+ Verfahren hinzufügen`. Forum chip row Verfahren chip row `Hinzufügen` |
| `ScenarioListPanel` | `frontend/src/components/ScenarioListPanel.tsx` | Side panel: Aktiv / Geteilt / Promoted / Archiviert buckets + new-scenario CTA |
| `PromoteToProjectWizard` | `frontend/src/components/PromoteToProjectWizard.tsx` | 3-step modal: Bestätigen / Parteien / Metadaten |
| `PageHeaderControls` | `frontend/src/components/PageHeaderControls.tsx` | Scenario picker + Benennen/Teilen/Promote buttons + Akte picker + Stichtag input |
| `EntryModeChrome` | `frontend/src/components/EntryModeChrome.tsx` | Cold-open / event-triggered / Akte mode radio; ephemeral UI affordance that fades into canvas state |
### §6.2 Client TS files
Mirror the React-ish component split:
- `frontend/src/client/builder.ts` root orchestrator (auto-save loop, URL state, mode routing, scenario fetch)
- `frontend/src/client/builder-scenario.ts` scenario CRUD against `/api/scenarios`
- `frontend/src/client/builder-event-card.ts` per-card state machine + optional-horizon control
- `frontend/src/client/builder-promote-wizard.ts` 3-step wizard state machine
- `frontend/src/client/builder-search.ts` universal search (events + scenarios + Akten)
- `frontend/src/client/builder-shares.ts` share-with-team UI
### §6.3 Backend services + routes
| Service | File | Endpoints |
|---|---|---|
| `ScenarioService` | `internal/services/scenario_service.go` | List / Get / Create / Update / Archive / Promote |
| `ScenarioProceedingService` | `internal/services/scenario_proceeding_service.go` | Add / Remove / Update (flags, perspective, ordinal, detailgrad) |
| `ScenarioEventService` | `internal/services/scenario_event_service.go` | List / Update state / Set date / Set notes / Set horizon |
| `ScenarioShareService` | `internal/services/scenario_share_service.go` | List / Add / Remove shares |
| `ScenarioPromoteService` | `internal/services/scenario_promote_service.go` | Wizard-driven transactional promote |
Routes (added under existing API namespace):
```
GET /api/scenarios — list user's scenarios (filtered by status)
POST /api/scenarios — create new scenario
GET /api/scenarios/{id} — get scenario + proceedings + events (deep)
PATCH /api/scenarios/{id} — update name / stichtag / notes / status
DELETE /api/scenarios/{id} — archive (soft delete; status='archived')
POST /api/scenarios/{id}/proceedings — add proceeding to scenario
PATCH /api/scenarios/{id}/proceedings/{pid} — update flags / perspective / ordinal / detailgrad
DELETE /api/scenarios/{id}/proceedings/{pid} — remove proceeding (cascades to events)
PATCH /api/scenarios/{id}/events/{eid} — update state / date / notes / horizon
POST /api/scenarios/{id}/shares — share with user (read-only)
DELETE /api/scenarios/{id}/shares/{sid} — revoke share
POST /api/scenarios/{id}/promote — promote to project (3-step wizard payload)
POST /api/scenarios/from-project/{project_id} — export project to a new scenario (what-if)
GET /api/search — universal search (events + scenarios + Akten)
```
Existing endpoints used unchanged:
- `GET /api/tools/fristenrechner/search?kind=events` for the events corpus.
- `GET /api/projects` Akte picker source.
- `POST /api/projects/{id}/deadlines/bulk` promotion writes deadlines through this.
- `PATCH /api/projects/{id}/scenario-flags` Akte-mode flag sync.
---
## §7 Migration plan from current live shape
Current live (`/tools/procedures` on main @ `ed3c5d1`) = cronus's U0-U4 4-tab catalog. Migration is a 6-slice train, every slice ships visibly. No feature flag (m's pattern preference per #152 Q7).
### §7.1 Slice train
| Slice | What ships | DB | Visible to user |
|---|---|---|---|
| **B0 — Scenario DB foundation** | New tables (scenarios + scenario_proceedings + scenario_events + scenario_shares) + RLS + minimal API (list / create / get). Scenarios writable from a developer-only test route at first. | Mig #N (new tables + RLS + `paliad.projects.origin_scenario_id`) | No user-visible change. |
| **B1 — Builder shell + cold-open mode** | New `/tools/procedures` page replaces the 4-tab catalog. Renders: page header (scenario picker + Akte picker + Stichtag + search), entry-mode radio (cold-open active), filter strip, empty canvas + "Neues Szenario starten" CTA + recent list. Add-proceeding picker works; first triplet renders with the existing Verfahrensablauf-core calc. Auto-save active scenario. Side panel "Meine Szenarien" with Aktiv bucket only. | | New page visible. Single triplet works end-to-end. |
| **B2 — Multi-triplet + spawn nesting + per-event state** | Vertical multi-triplet stack with `+ Verfahren hinzufügen`. Per-triplet perspective + flag strip. Spawn child triplets render inline. Event cards get the 3-state machine (planned/filed/skipped) + date editor + per-card optional horizon chip. Page-header Stichtag drives default dates. | | Full scenario builder works without Akte integration. |
| **B3 — Event-triggered mode + universal search** | "Ereignis" entry mode wires the search box to land on a single-triplet anchored view (scratch scenario). Universal search returns events + scenarios + Akten with type-scoped result groups. Filter pills (forum/proc/party/kind) reset on mode switch. | | Event lookup works. |
| **B4 — Akte mode + project-backed scenarios** | "Aus Akte" entry mode + page-header Akte picker. Loads project state into the builder (proceeding + perspective + scenario_flags + deadlines actuals). Akte-backed scenarios write through to `paliad.deadlines` + `paliad.projects.scenario_flags`; non-Akte scenarios write to `paliad.scenario_events`. Cross-surface scenario-flag-changed event listener reused from #152 T3. | | Akte integration works end-to-end. |
| **B5 — Share + Promote-to-project wizard** | "Teilen" button + user picker + share row. "Geteilt mit mir" bucket in side panel. "Als Projekt anlegen" opens the 3-step wizard (Bestätigen Parteien ergänzen Akte-Metadaten). Successful commit creates project + cascades deadlines + sets `origin_scenario_id`, navigates to /projects/{id}. "Promoted" bucket in side panel. | | Sharing + promotion work. |
| **B6 — Mobile basic-read + cleanup + i18n polish** | Mobile (<640px) shows scenarios + cards read-only; mutating affordances prompt "Auf größerem Bildschirm öffnen". Cleanup: delete dead U0-U4 catalog code (4-tab control, legacy `verfahrensablauf.ts`, etc.). All i18n keys finalised (DE + EN). | | Mobile works; codebase cleaner. |
### §7.2 Why this train shape
- **B0 is DB-only**. The schema can land independently and be exercised via test routes / Supabase MCP before any UI sees it. Keeps mig risk isolated.
- **B1-B2 are the MVP**. After B2, a user can build and save a multi-proceeding scenario fully kontextfrei. That alone replaces 60% of today's catalog use.
- **B3 adds the lookup path**. After B3, "what's next after Klageerwiderung?" works without saving.
- **B4 makes it real**. Akte integration is the load-bearing piece for daily use; ships once the foundation is stable.
- **B5 unlocks team value**. Sharing + promotion are the difference between "personal tool" and "team tool". Ship after the core works.
- **B6 is cleanup**. Mobile read + dead code removal land last to avoid coupling to in-flight features.
### §7.3 What stays unchanged
- URL `/tools/procedures` keeps it (the new builder lives there).
- Sidebar entry "Verfahren & Fristen" keeps it.
- cmd-K palette keeps it.
- `/tools/fristenrechner` + `/tools/verfahrensablauf` legacy redirects (from cronus's U4) stay alive: 301 `/tools/procedures` (the builder).
- `pkg/litigationplanner.CalculateRule` untouched.
- `/admin/procedural-events` untouched.
- `/projects/{id}` Verlauf untouched (new "Im Builder öffnen" button is the only addition).
### §7.4 Cleanup at B6
Dead code to delete (verify with grep before deletion):
- `frontend/src/components/VerfahrensablaufBody.tsx` (replaced by ProceedingTriplet)
- `frontend/src/client/verfahrensablauf.ts` (replaced by builder.ts orchestration)
- `frontend/src/client/views/verfahrensablauf-state.ts` (replaced by scenario-backed state)
- `frontend/src/client/views/verfahrensablauf-state.test.ts`
- ~~`frontend/src/client/verfahrensablauf-detail-mode.ts`~~ KEEP. Builder imports `filterByDetailMode` from it; per-triplet Detailgrad reuses this module.
- Existing scratch tab content in `frontend/src/client/procedures.ts` (4-tab toggling logic, mode routing)
**Kept**:
- `frontend/src/client/views/verfahrensablauf-core.ts` (calculation engine; reused by EventCard + ProceedingTriplet)
- Legacy URL redirects in Go (`/tools/fristenrechner` + `/tools/verfahrensablauf` `/tools/procedures`)
---
## §8 Open follow-ups (out of scope for v1)
Tracked for v1.1 / future tickets:
- **Cross-proceeding peer triggers** (UPC-inf judgment EPA opp choice deadline). New `paliad.scenario_event_links` table. UI: trigger-picker chip on event cards.
- **DE / EPA / DPMA full expansion**. v1 supports EPA + DPMA proceedings at the data layer (calc engine handles them), but the spawn flags and CCR-style nestings are UPC-specific. Other jurisdictions get proper coverage in v1.1.
- **Scenario versioning / snapshots**. m's Q8 alternative ("versioned snapshots") deferred. Add when scenarios start driving client briefings.
- **Multi-user concurrent editing**. Out of scope. Single-editor model with read-only shares is sufficient until usage shows otherwise.
- **Fork-a-shared-scenario**. Read-only sharing in v1 doesn't expose "fork into my workspace". Add when team usage demands it.
- **Comments on scenarios / event cards**. Out of scope (separate ticket).
- **PDF export of a scenario for client briefings**. Out of scope.
- **Mobile-parity edits**. v1.1 full mobile interaction loop.
- **Audit log on scenario edits**. Out of scope (exploratory data).
- **Cross-scenario comparison view**. ("Compare planned vs actual" lives on the project page via promote-then-compare; explicit comparison tool is v2.)
---
## §9 Synthesis links
- **mBrian**: file as `[synthesis]` linked `triggered_by` t-paliad-339; `related_to` atlas's reverted tracker design, cronus's unified-procedural-events-tool design, atlas's deadline-system-revision.
- **Cross-refs in this repo**: `docs/design-procedures-workflow-tracker-2026-05-27.md` (atlas, reverted), `docs/design-unified-procedural-events-tool-2026-05-27.md` (cronus, live), `docs/design-deadline-system-revision-2026-05-27.md` (atlas Phase 2), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26).
- **Gitea**: m/paliad#153 (this PRD), m/paliad#152 (atlas's tracker, reverted), m/paliad#151 (cronus U0-U4 shipped), m/paliad#149 (atlas Phase 2 in flight).
- **Coder phase** (deferred per inventor SKILL): runs after m ratifies this PRD. Slice ordering per §7.1. NOT edison (parked at DESIGN READY FOR REVIEW). NOT atlas (just-rejected tracker framing bias). NOT cronus (parked on Fristenrechner inventor branch). A pattern-fluent Sonnet coder picks up B0 first.
---
## §10 Coder hand-off notes
(Pre-emptive for whoever picks up B0.)
- **Migration number**: check `internal/db/migrations/` for the max slot at coder shift start. Two recent migrations (curie's t-paliad-336, ritchie's t-paliad-149 P0) are in flight; coordinate via paliadin/head before claiming a slot.
- **Akte integration nuance**: when the builder is in Akte mode and the scenario is project-backed, writes flow to `paliad.deadlines` / `paliad.projects.scenario_flags` instead of `paliad.scenario_*` tables the scenario row itself just records the canvas view-state (which triplets are visible, ordinal, collapsed state, per-card horizon). This dual-write rule is the load-bearing complexity of B4; design tests for it explicitly.
- **Auto-save throttling**: 500ms debounce per change. Avoid PATCH-per-keystroke on notes textareas (use blur-trigger + 2s debounce there).
- **Search performance**: universal search (events + scenarios + Akten) needs to stay snappy. Events corpus is ~3000 rows; scenarios/Akten are per-user. Use existing trgm indexes; avoid joining across all three for ranking.
- **B5 transactional promotion**: do the wizard's commit in a single Postgres transaction. If any of (project insert / parties / deadlines / scenario status update) fails, roll back atomically. No partial promotions.
- **Mobile rendering**: B6 is meant to be cheap. Column-triplet CSS grid that collapses to single-column at `@media (max-width: 640px)`. Mutating affordances get `pointer-events: none` + a click-handler that surfaces the "Auf größerem Bildschirm öffnen" toast keeps the desktop interaction code paths unchanged.
- **i18n keys**: every user-facing string gets `data-i18n` from B1. Don't accumulate i18n debt across slices.

View File

@@ -0,0 +1,956 @@
# Bulletproof completeness audit — paliad.deadline_rules vs statutory sources
**Author:** curie (researcher)
**Date:** 2026-05-25
**Task:** t-paliad-263 (m/paliad#94)
**Mode:** read-only research, no DB writes
**Branch:** `mai/curie/researcher-bulletproof`
Scope confirmed by head (paliad/head → paliad/curie, 2026-05-25 15:13):
**UPC Rules of Procedure + EPC + PatG / ZPO / GebrMG**, plus UPC Agreement /
Statute where they create time-limits. No HLC-internal checklists exist in
the current head's working tree.
Companion / prior audits this report supersedes-and-extends:
- `docs/audit-fristenrechner-completeness-2026-04-30.md` (curie, t-paliad-084) — youpc-vs-paliad gap analysis.
- `docs/audit-upc-rop-deadlines-2026-05-08.md` (curie, t-paliad-159) — first UPC RoP gap list (52 rules / 2 duration bugs).
- `docs/audit-fristen-logic-2026-05-13.md` (pauli, t-paliad-157) — schema audit; the codes used here (`upc.inf.cfi`, `de.inf.lg`, …) reflect the post-mig-096 rename.
Migration baseline: migration ≤ `122_deadlines_custom_rule_text` (live as of 2026-05-25 14:00 UTC).
---
## §0. TL;DR
- **20 active fristenrechner proceeding_types** (live, `is_active=true`,
`lifecycle_state='published'`) carry **132 active rules**. One extra
`_archived_litigation` row holds 40 retired Pipeline-A rules from
mig 093 — not surfaced anywhere, kept only for FK validity.
| Jurisdiction | Active types | Active rules | Statute-bound rules audited |
|---|---:|---:|---:|
| UPC (CFI + CoA) | 9 (incl. upc.ccr.cfi alias) | 67 | 67 |
| EPA | 3 | 23 | 23 |
| DPMA | 3 | 13 | 13 |
| DE (LG/OLG/BGH/BPatG) | 5 | 29 | 29 |
| **Total** | **20** | **132** | **132** |
- **5 high-impact bugs still live** that the prior May 8 audit
surfaced (2) plus 3 new ones identified here.
- 🔴 **`upc.rev.cfi.defence` 3 months, RoP.49.1 says 2 months.** Flagged
May 8; still live. ★★★ — every UPC_REV defendant.
- 🔴 **`upc.rev.cfi.rejoin` 2 months, RoP.52 says 1 month.** Flagged
May 8; still live. ★★★ — every UPC_REV proceeding.
- 🟠 **`upc.apl.merits.response` 2 months, RoP.235.1 says 3 months.**
New finding (May 8 audit recorded the rule as "3 months / present-wrong
rule_code only" — actually live data shows 2 months, so the audit
sample mis-recorded the duration too). ★★★ — every UPC main-track
appeal respondent.
- 🟠 **`de.inf.lg.beruf_begr` chains parent = berufung (1mo) + 2mo = 3mo
from urteil. ZPO §520(2) anchors the 2-month Begründungsfrist on
service of urteil, not on filing of Berufung.** New finding.
★★★ — every DE-first-instance appellant.
- 🟠 **`de.inf.lg.replik` + `.duplik` have `parent_id=NULL` so they fire
on the trigger date (Klageerhebung) — sequence-order says 30/40 but
the compute engine reads parent_id first.** Reported as live UI bug
by m via head (2026-05-25 13:13); confirmed by SQL. ★★★ — every
DE-LG-Verletzung timeline.
- **5 rule-code / citation drift bugs still live** from the May 8 audit
(`upc.apl.merits.notice`, `.grounds`, `.response`, `upc.rev.cfi.reply`,
`.rejoin`) — durations may or may not be right, but the cited
`legal_source` / `rule_code` points at the wrong rule. Pure
cosmetic on `.notice`/`.grounds` (durations are right); load-bearing on
`.rev.cfi.reply` / `.rejoin` because the cited rule is what tells
the lawyer where to look the rule up.
- **4 DPMA / DE citation bugs** new in this audit, all citing PatG / ZPO
sections that don't contain the cited deadline:
- `de.null.bpatg.erwidg` cites `DE.PatG.82.1`; the 2-month Erwiderung
is actually `§82(3)` (§82(1) is the 1-month Erklärungsfrist).
- `dpma.opp.dpma.erwiderung` cites `DE.PatG.59.3`; §59(3) is about
hearings, not a 4-month proprietor response. The 4-month figure is
DPMA-internal practice, not statutory — should be court-set.
- `dpma.appeal.bpatg.begruendung` cites `DE.PatG.75.1`; §75 is about
*aufschiebende Wirkung* — there is no Begründungsfrist in PatG §73-§80
for the BPatG-Beschwerde. The 1-month figure is also non-statutory.
- `de.null.bgh.begruendung` cites `DE.PatG.111.1`; §111 is about the
grounds-of-appeal *content* (Verletzung des Bundesrechts), not the
Begründungsfrist. `de.null.bgh.erwiderung` cites `DE.PatG.111.3`;
§111(3) doesn't exist in the deadline sense.
- **Wide UPC coverage gap inherited from May 8 audit, mostly un-closed:**
~25 missing UPC RoP rules. Mig 095 (t-paliad-205) closed 4 of them
(R.19 Preliminary Objection on UPC_INF and UPC_REV, R.220.1(a)
merits-appeal spawn on both). The other ~21 (R.20.2, R.118.4,
R.197.3, R.198, R.207.6.a, R.207.9, R.213, R.109.1/.4/.5, R.118.5,
R.144, R.155, R.224.2(b), R.229.2, R.235.2, R.245.x, R.262.2,
R.321.3, R.333.2, R.353, plus the DNI family R.63-R.69) are
unchanged.
- **EPC gaps:** EPA opposition + Beschwerde modelled at the
Article level only. Missing the entire Implementing Regulations
family that drives day-to-day deadlines — R.71(3) approval period
is half-modelled (the 4-month figure is there but the trigger
anchor is broken: parent_id=NULL), R.79(1) proprietor response
is modelled as a fixed 4-month period when it's actually
court-set, R.116 oral-proceedings cut-off is modelled as
duration-0/parent-NULL (works for some uses, not for others),
R.121 / R.135 Weiterbehandlung is missing entirely (concept
exists but no rule).
- **DE/DPMA gaps:** the entire Wiedereinsetzung family (PatG §123)
is absent on the proceeding-tree side. `weiterbehandlung` and
`wiedereinsetzung` concept slugs exist in the cascade (Pathway B)
but no `paliad.deadline_rules` row computes them. Same for
`versaeumnisurteil-einspruch` (ZPO §339 — 2 weeks).
- **15 ambiguities** that need m's judgement, not a coder's fix —
mostly around court-set vs statutory periods (e.g. richterliche
Fristen under ZPO §276(1) S.2, §283 Schriftsatznachreichung,
EPC R.79(1), §59(3) PatG) and around the "whichever is
longer / later" arithmetic primitives still missing
(R.198 / R.213 / R.245.2).
- **Recommended fixes (§10) — total 41 items** prioritised in 4
tiers. Tier 0 (5 hard duration bugs + 1 sequencing bug + 9
citation/anchor bugs) should ship first. Tier 1 (12 rule-fill
gaps, ★★★ / ★★) next. Tier 2 + 3 are coverage breadth that
needs scoping by m (Wiedereinsetzung, R.198 working-day
arithmetic, full Implementing Regulations port).
---
## §1. Methodology
For each of the 20 active proceeding_types I:
1. **Pulled the live rule set** via `mcp__supabase__execute_sql` against
the youpc Postgres on 2026-05-25 14:0015:00 UTC. Schema = `paliad`.
Filter: `is_active = true AND lifecycle_state = 'published'`.
2. **Enumerated the statutory deadlines** in the relevant code for the
proceeding's scope.
3. **Cross-referenced each statutory deadline against the live rule
set** on (a) duration + unit, (b) anchor / parent, (c) party,
(d) `rule_code` / `legal_source` citation, (e) sequencing.
4. **Marked status**: `present-correct`, `present-wrong (duration)`,
`present-wrong (citation)`, `present-wrong (anchor)`,
`present-wrong (party)`, `partial`, `missing`, `n/a`.
5. **Frequency tag** for prioritisation: ★★★ every case, ★★ common,
★ specialist.
### 1.1 Sources
All citations carry a date stamp and a URL. Where the text was checked
against more than one source, both are listed.
| Source | URL | Verified on | Used for |
|---|---|---|---|
| UPC Rules of Procedure (consolidated 18.05.2023, in force 2023-06-01) | https://www.unifiedpatentcourt.org/sites/default/files/upc_documents/rop_application_-_consolidated_18_05_2023.pdf | 2026-05-25 | All UPC RoP citations |
| UPC RoP verbatim text via `data.laws_contents` (youpc Postgres, law_type=`UPCRoP`, language=en) | youpc Supabase | 2026-05-25 | Cross-check on R.019.1, R.020.2, R.029.b/.c, R.049.1, R.051, R.051.p1, R.052, R.052.p1, R.220.1.a, R.224.1, R.224.1.a/.b, R.224.2, R.224.2.a/.b, R.235.1, R.235.2, R.237, R.238.1, R.238.2 |
| European Patent Convention (EPC, 17th ed. 2020) — Articles | https://www.epo.org/en/legal/epc/2020/index.html (verbatim text per youpc `data.laws_contents`, law_type=`EPC`) | 2026-05-25 | EPC Articles 93, 99, 108, 112a, 116, 121, 123, 135 |
| EPC Implementing Regulations — Rules (in force 2026 consolidated) | https://www.epo.org/en/legal/epc/2020/r71.html (and equivalents) | 2026-05-25 | EPC R.70(1), R.71(3), R.79(1)/(2), R.116(1), R.135 |
| Patentgesetz (PatG) — gesetze-im-internet.de | https://www.gesetze-im-internet.de/patg/ | 2026-05-25 | §59, §73, §75, §82, §83, §99 ff., §100, §102, §110, §111 |
| Zivilprozessordnung (ZPO) — gesetze-im-internet.de | https://www.gesetze-im-internet.de/zpo/ | 2026-05-25 | §253, §276, §277, §283, §296a, §339, §517, §520, §521, §524, §544, §548, §551, §554 |
| Gebrauchsmustergesetz (GebrMG) — gesetze-im-internet.de | https://www.gesetze-im-internet.de/gebrmg/ | 2026-05-25 | §17 (Löschung), §18 (Verfahren) — referenced only to confirm out-of-scope: no GebrMG-rooted proceeding_type exists in paliad today |
### 1.2 Conventions
- A **rule** here means a row in `paliad.deadline_rules`. paliad's local
identifier is `submission_code` (post mig 098), e.g.
`upc.rev.cfi.defence`.
- A **statutory deadline** means an obligation derived directly from the
text of a procedural code, with a fixed period.
- "**Court-set**" / "richterliche Frist" means the statute authorises the
court / DPMA / EPO to set the period — there is no fixed statutory
duration. paliad models these with `is_court_set = true`
(post mig ~079) or, legacy-style, `duration_value = 0`.
- "**Anchoring**" refers to which event the period runs from. paliad
models this via `parent_id` (chain anchor) or `anchor_alt` (e.g.
`priority_date`); a NULL parent_id with non-zero duration means the
deadline runs from the user-supplied trigger date.
### 1.3 Hard constraint: "no fabricated provisions"
Where I'm not 100% sure of a citation (because the youpc law DB only
covers UPC + EPC, not PatG / ZPO, and my web-fetch coverage of
PatG / ZPO is partial), I flag the finding as **"needs lawyer review"**
in §9 rather than asserting a fix. Five PatG / ZPO findings carry that
tag.
---
## §2. Current state inventory (per jurisdiction)
### 2.1 UPC
9 active types, 67 rules. `upc.ccr.cfi` is an alias proceeding that
holds zero rules — it points at `upc.inf.cfi` rules under the
`with_ccr` flag.
| Code | Name | Rule count | Audited against |
|---|---|---:|---|
| `upc.inf.cfi` | Verletzungsverfahren | 15 | RoP 19, 23, 25, 29.a-e, 30, 32, 151, 220.1(a) |
| `upc.rev.cfi` | Nichtigkeitsverfahren | 17 | RoP 19, 32, 42, 43.3, 49.1, 49.2.a, 49.2.b, 51, 52, 56.1/3/4, 220.1(a) |
| `upc.pi.cfi` | Einstweilige Maßnahmen | 4 | RoP 205, 207, 211 |
| `upc.disc.cfi` | Bucheinsicht | 4 | RoP 141, 142.2, 142.3 |
| `upc.dmgs.cfi` | Schadensbemessung | 4 | RoP 131.2, 137.2, 139 |
| `upc.apl.merits` | Berufung | 8 | RoP 220.1, 224.1.a, 224.2.a, 235.1, 237, 238.1 |
| `upc.apl.order` | Berufung gegen Anordnungen | 5 | RoP 220.1(c), 220.2, 220.3, 237, 238.2 |
| `upc.apl.cost` | Berufung gegen Kostenentscheidung | 2 | RoP 221.1 |
| `upc.ccr.cfi` | Widerklage auf Nichtigkeit (alias) | 0 | — |
### 2.2 EPA
3 active types, 23 rules.
| Code | Name | Rule count | Audited against |
|---|---|---:|---|
| `epa.grant.exa` | EP-Erteilung | 7 | EPC Art. 93, R.70(1), R.71(3) |
| `epa.opp.opd` | EPA Einspruch | 8 | EPC Art. 99(1), 108, 116, 123; R.79(1), R.79(2), R.116(1) |
| `epa.opp.boa` | EPA Beschwerde | 8 | EPC Art. 108, 112a; R.116(1); RPBA Art. 12 |
### 2.3 DPMA
3 active types, 13 rules.
| Code | Name | Rule count | Audited against |
|---|---|---:|---|
| `dpma.opp.dpma` | DPMA Einspruch | 4 | PatG §59(1), §59(3) |
| `dpma.appeal.bpatg` | BPatG-Beschwerde | 5 | PatG §73(2), §74 ff. |
| `dpma.appeal.bgh` | BGH-Rechtsbeschwerde | 4 | PatG §100, §102 |
### 2.4 DE (national patent / civil)
5 active types, 29 rules.
| Code | Name | Rule count | Audited against |
|---|---|---:|---|
| `de.inf.lg` | LG-Verletzungsklage | 8 | ZPO §253, §276, §283, §296a, §517, §520(2) |
| `de.inf.olg` | OLG-Berufung Verletzung | 7 | ZPO §517, §520(2), §521(2), §524(2) |
| `de.inf.bgh` | BGH-Revision Verletzung | 8 | ZPO §544, §548, §551, §554 |
| `de.null.bpatg` | BPatG-Nichtigkeitsklage | 10 | PatG §81 ff., §82, §83 |
| `de.null.bgh` | BGH-Nichtigkeitsberufung | 6 | PatG §110, §111 / ZPO ref via §117 PatG |
### 2.5 Cross-cutting: cascade vs proceeding-tree coverage
The cascade layer (`paliad.event_categories` + `…_concepts` +
`paliad.deadline_concepts`) carries 56 concept "nouns" and ~153
cascade-leaf → concept mappings. **9 concepts are orphans** (carry
zero rules, so the cascade card dead-ends): `counterclaim-for-revocation`,
`schriftsatznachreichung`, `versaeumnisurteil-einspruch`,
`weiterbehandlung`, `wiedereinsetzung`, `notice-of-defence-intention`,
plus 3 more. Inventory and recommendations live in
`docs/audit-fristen-logic-2026-05-13.md` §3.4 — this audit covers only
the proceeding-tree side.
---
## §3. Findings — Missing rules (statute defines, paliad doesn't)
### 3.1 UPC RoP — 21 missing rules (out of ~25 flagged 2026-05-08, 4 closed by mig 095)
Notation: ★★★ every case, ★★ common, ★ specialist. Verbatim RoP text
sampled from youpc `data.laws_contents` (law_type=`UPCRoP`, lang=en).
| RoP § | Period | Trigger | Freq | Notes |
|---|---|---|---|---|
| **R.20.2** | 14 days | Service of Preliminary Objection | ★ | Reply to PO. Companion to R.19 (which mig 095 added). Without R.20.2 the PO branch is half-modelled. |
| **R.118.4** | 2 months | Final decision on validity served | ★★ | Application for orders consequential on validity. Common after central-division revocation. |
| **R.118.5** | n/a UPC | n/a | n/a | UPC has no Versäumnisurteil-Einspruch; closest is R.355 (review of contumacy). |
| **R.144** | 0 (anchor) | Final decision on damages quantum | ★ | UPC_DAMAGES tree end-row missing. |
| **R.155** | 1mo / 14d | Cost-decision opposition chain | ★ | UPC_COST_APPEAL only has the leave-to-appeal step; no Defence-to-cost-app row. |
| **R.197.3** | 30 days | Saisie order served on respondent | ★ | Review application. Trigger event 65 exists; no rule attached. |
| **R.198** | 31 calendar days **OR 20 working days, whichever is longer** | Saisie executed | ★ | Start proceedings on the merits. Blocked on `working_days` + `combine='max'` primitives (see §7 + §9). |
| **R.207.6.a** | 14 days | Notification of deficiency in PI application | ★★ | Registry correction. |
| **R.207.9** | 6 months | PI filed | ★ | Renewal of protective letter. |
| **R.213** | 31 days OR 20 working days | PI granted | ★★ | Same arithmetic gap as R.198. |
| **R.109.1** | 1 month **before** | Oral hearing date | ★★ | Simultaneous translation request. `timing='before'` schema supported but no rule populates it (see §7 cross-cutting). |
| **R.109.4** | 2 weeks **before** | Oral hearing date | ★★ | Interpreter cost notification. `timing='before'`. |
| **R.109.5** | 2 weeks after | Order of judge-rapporteur to lodge translations | ★★ | trigger event 113 exists; no rule. |
| **R.224.2.b** | 15 days | Order under R.220.1(c) or decision under R.220.2/221.3 served | ★★ | Grounds-on-orders track. `upc.apl.order` has appeal-itself but no separate grounds row. Verified verbatim against `UPCRoP.224.2.b` (youpc DB). |
| **R.229.2** | 14 days | Notification of appeal-deficiency | ★ | Registry correction in appeal context. |
| **R.235.2** | 15 days | Statement of grounds (orders track) served | ★★ | Verified verbatim against `UPCRoP.235.2` (youpc DB): *"Within 15 days of service of grounds of appeal pursuant to Rule 224.2(b), any other party … may lodge a Statement of response"*. `upc.apl.order` has no standalone response row. |
| **R.245.1** | 2 months | Final decision served | ★ | Application for rehearing. |
| **R.245.2.a** | 2 months | Discovery of fundamental defect (or final decision service, whichever is later) | ★ | Outer cap 12mo. Needs multi-anchor + `max-of-two-anchors` arithmetic. |
| **R.245.2.b** | 2 months | Discovery of criminal offence (or final decision service, whichever is later) | ★ | Same shape as 245.2.a. |
| **R.262.2** | 14 days | Receipt of opposing party's confidentiality application | ★★ | Daily occurrence in HLC infringement work. Trigger event 25 exists; no rule. |
| **R.320** | 2 months (cap 12 mo) | Wegfall des Hindernisses (Wiedereinsetzung) | ★★ | Cascade card exists (mig 063) but no proceeding-tree rule computes the deadline. Bridges proceedings → no obvious home in any one tree. |
| **R.321.3** | 10 days | Preliminary objection referral to central division | ★ | |
| **R.333.2** | 15 days | Case-management order served | ★★ | Review-of-CMO. Routine in busy LDs. |
| **R.353** | 1 month | Decision / order delivered | ★ | Rectification application. |
| **DNI: R.63 / R.67.1 / R.69.1 / R.69.2** | 0 / 2mo / 1mo / 1mo | DNI cascade | ★ | No UPC_DNI proceeding_type exists. Fringe at HLC (zero published filings in 2026-Q1 per May 8 audit). |
| **Registry-correction family: R.16.3.a, R.27.2, R.89.2, R.253.2** | 14 days each | Various deficiency notifications | ★ | All same 14-day duration; different trigger codes. Most natural home is cascade not proceeding-tree (see audit-fristenrechner-completeness-2026-04-30.md §3.1). |
**Closed since May 8 audit (verified by SQL):**
- ✅ R.19 Preliminary Objection on UPC_INF — `upc.inf.cfi.prelim`, 1mo, RoP.019.1, flag-gated `with_po` — mig 095.
- ✅ R.19 Preliminary Objection on UPC_REV — `upc.rev.cfi.prelim`, 1mo, RoP.019.1, flag-gated `with_po` — mig 095 (cites R.19 i.V.m. R.46).
- ✅ R.220.1(a) merits-appeal spawn on UPC_INF — `upc.inf.cfi.appeal_spawn`, 2mo, is_spawn=true → upc.apl.merits — mig 095.
- ✅ R.220.1(a) merits-appeal spawn on UPC_REV — `upc.rev.cfi.appeal_spawn`, 2mo, is_spawn=true → upc.apl.merits — mig 095.
### 3.2 EPC Implementing Regulations — 4 missing rules
| EPC ref | Period | Trigger | Freq | Notes |
|---|---|---|---|---|
| **EPC R.135 (Weiterbehandlung)** | 2 months | Notification of loss of rights | ★★ | Concept `weiterbehandlung` exists in cascade (orphan); no rule. Applies broadly across `epa.grant.exa` and `epa.opp.opd`. |
| **EPC R.99(2) / Art. 121** | 2 months | Loss-of-rights notification (further processing) | ★★ | Same family as R.135. |
| **EPC Art. 112a(4)** | 2 months / 1 month | Discovery of grounds for review / decision served (whichever later) | ★ | paliad has `epa.opp.boa.r106` (2 months, parent=entsch2) — but the rule doesn't model the "whichever later" outer cap (12 months from decision per Art. 112a(4)). |
| **EPC Art. 99(1) — opposition fee paid** | 9 months (no extension) | Mention of grant in Patentblatt | ★★★ | `epa.opp.opd.frist` IS modelled correctly at 9 months. **Note however:** the rule is on `epa.opp.opd` but the *trigger* is opposition-fee-paid (per Art. 99(1) S.2 — "Notice of opposition shall not be deemed to have been filed until the opposition fee has been paid"). Not a gap, but a documentation note. |
### 3.3 PatG / ZPO — 5 missing rules
| Citation | Period | Trigger | Freq | Notes |
|---|---|---|---|---|
| **PatG §123 (Wiedereinsetzung)** | 2 months | Wegfall des Hindernisses (cap 1 year) | ★★ | Cascade concept `wiedereinsetzung` exists; no rule on any DE/DPMA proceeding tree. Same modelling problem as UPC R.320 — bridges proceedings. |
| **ZPO §339 (Versäumnisurteil-Einspruch)** | 2 weeks | Service of default judgment | ★ | Cascade concept `versaeumnisurteil-einspruch` orphan. |
| **ZPO §544 — Nichtzulassungsbeschwerde-Begründung** | 2 months | Service of OLG-Urteil (NB: NOT from filing of NZB) | ★★ | `de.inf.bgh.nzb_begr` lists `DE.ZPO.544.4`, duration 2mo, parent=urteil_olg — **modelled correctly**. Listed here only to flag that the *parent anchoring* differs from `de.inf.lg.beruf_begr` which is wrong (see §7.1). |
| **ZPO §283 (Schriftsatznachreichung) / §296a** | court-set | post-Verhandlung schriftsatzfrist | ★ | Cascade concept `schriftsatznachreichung` orphan. Court-set period — modelling as `is_court_set=true, duration=0` would suffice. |
| **PatG §17(2) GebrMG / §18 GebrMG** | 1 month (Beschwerdefrist) | DPMA-Beschluss | ★ | Out of scope per head's confirmation (no GebrMG-rooted proceeding_type yet). Listed to confirm the deliberate gap. |
### 3.4 DPMA — 0 missing rules
DPMA coverage is shallow but not gappy. The 3 active types (opposition,
BPatG-Beschwerde, BGH-Rechtsbeschwerde) cover the statutory steps. The
problems here are **citation drift** (§4.4) and **anchor modeling**
(§7.4) rather than missing rules.
---
## §4. Findings — Misattributed legal source
### 4.1 UPC RoP citation drift (5 still live from May 8)
| Rule | Live `rule_code` | Live `legal_source` | Should be | Source verified |
|---|---|---|---|---|
| `upc.apl.merits.notice` | `RoP.220.1` | `UPC.RoP.220.1` | `RoP.224.1.a` / `UPC.RoP.224.1.a` | `UPCRoP.224.1.a` youpc DB |
| `upc.apl.merits.grounds` | `RoP.220.1` | `UPC.RoP.220.1` | `RoP.224.2.a` / `UPC.RoP.224.2.a` | `UPCRoP.224.2.a` |
| `upc.apl.merits.response` | `null` | `null` | `RoP.235.1` / `UPC.RoP.235.1` | `UPCRoP.235.1` |
| `upc.rev.cfi.reply` | `null` | `null` | `RoP.051` / `UPC.RoP.51.p1` | `UPCRoP.051.p1` |
| `upc.rev.cfi.rejoin` | `null` | `null` | `RoP.052` / `UPC.RoP.52.p1` | `UPCRoP.052.p1` |
Note on cascade vs proceeding-tree drift on R.220.3 anchoring is in
`docs/audit-upc-rop-deadlines-2026-05-08.md` §5.4b — unchanged here.
### 4.2 UPC RoP citation drift on Rule 49.1 format (1 still live)
| Rule | Live `rule_code` | Should be |
|---|---|---|
| `upc.rev.cfi.defence` | `RoP.49.1` | `RoP.049.1` (canonical zero-padded form used by all other UPC rules) |
### 4.3 DPMA — 3 mis-attributed citations
| Rule | Live citation | Problem | Verified |
|---|---|---|---|
| `dpma.opp.dpma.erwiderung` | `§ 59 PatG` / `DE.PatG.59.3` | §59(3) PatG addresses *Anhörung*, not a 4-month response period. No statutory Erwiderungsfrist exists in §59. The 4-month figure is DPMA-internal practice. | WebFetch [gesetze-im-internet.de/patg/__59.html](https://www.gesetze-im-internet.de/patg/__59.html) 2026-05-25 |
| `dpma.appeal.bpatg.begruendung` | `§ 75 PatG` / `DE.PatG.75.1` | §75 PatG is exclusively about *aufschiebende Wirkung* (suspensive effect). It does not establish any Begründungsfrist. No fixed Begründungsfrist for BPatG-Beschwerde exists in PatG §§73-80 — it is set by the BPatG in the individual case. | WebFetch [gesetze-im-internet.de/patg/__75.html](https://www.gesetze-im-internet.de/patg/__75.html) + [§73](https://www.gesetze-im-internet.de/patg/__73.html) 2026-05-25 |
| `dpma.appeal.bpatg.beschwerde` | `§ 73 PatG` / `DE.PatG.73.2` | §73 contains the 1-month deadline correctly; the `.2` subscript however refers to §73(2) which is about Beschwerdebefugnis — the *Frist* is in §73(2) S.4 ("Die Beschwerdefrist beträgt einen Monat …"). Citation should be `DE.PatG.73.2.s4` or simply `DE.PatG.73.2`. **Borderline — flag, not a hard bug.** | gesetze-im-internet.de |
### 4.4 DE patent / civil — 4 mis-attributed citations
| Rule | Live citation | Problem | Verified |
|---|---|---|---|
| `de.null.bpatg.erwidg` | `§ 82 PatG` / `DE.PatG.82.1` | §82(1) is the 1-month *Erklärungsfrist* ("sich darüber zu erklären"); the 2-month full *Klageerwiderung* is in §82(3). Citation should be `DE.PatG.82.3`. Duration (2 months) is correct. | WebFetch [§82](https://www.gesetze-im-internet.de/patg/__82.html) 2026-05-25 |
| `de.null.bpatg.replik_klaeger` | `§ 83 PatG` / `DE.PatG.83.2` | §83(2) is about the *Hinweisbeschluss* form; the Replik / Schriftsatz windows fall under §83(2) S.3 (Reaktion auf Hinweis). Citation OK at section level but ambiguous. **Borderline — flag, not a hard bug.** | gesetze-im-internet.de |
| `de.null.bgh.begruendung` | `§ 111 PatG` / `DE.PatG.111.1` | §111 PatG defines the *Grounds* of Berufung (Verletzung des Bundesrechts), not a Begründungsfrist. The 3-month figure is supplied via §117 PatG → ZPO §520(2). Citation should be `DE.ZPO.520.2` (the actual time-limit source). | WebFetch [§111](https://www.gesetze-im-internet.de/patg/__111.html) 2026-05-25 |
| `de.null.bgh.erwiderung` | `§ 111 PatG` / `DE.PatG.111.3` | §111 has no Erwiderungsfrist clause. The actual Erwiderungsfrist for BGH-Nichtigkeitsberufung is set by the court per §117 PatG → ZPO §521(2) (court-discretionary). Duration (2 months) is approximate — typical court-set period is 2 months but it's not fixed. **Should be modelled as court-set.** | WebFetch [§111](https://www.gesetze-im-internet.de/patg/__111.html) + ZPO §521 2026-05-25 |
### 4.5 EPA — 1 mis-attributed citation
| Rule | Live citation | Problem |
|---|---|---|
| `epa.opp.opd.erwidg` | `R. 79(1) EPÜ` / `EU.EPC-R.79.1` | Duration (4 months) is correct as the *typical* EPO-set period under the 2016 streamlined-opposition guidelines, but **R.79(1) does not specify a fixed period** — the Opposition Division sets it. The 4 months is administrative practice (EPO Guidelines D-IV, 5.2). Should be modelled as court-set with 4 months as the default-display value. |
---
## §5. Findings — Wrong period (statute says X, paliad says Y)
| Rule | Live period | Statutory period | Source | Freq |
|---|---|---|---|---|
| **`upc.rev.cfi.defence`** | 3 months | **2 months** | RoP.049.1: *"The defendant shall lodge a Defence to revocation within two months of service of the Statement for revocation."* — verified verbatim from `UPCRoP.049.1` (youpc DB). Flagged 2026-05-08; still live. | ★★★ |
| **`upc.rev.cfi.rejoin`** | 2 months | **1 month** | RoP.052: *"Within one month of the service of the Reply the defendant may lodge a Rejoinder to the Reply to the Defence to revocation"* — verified verbatim from `UPCRoP.052.p1`. Flagged 2026-05-08; still live. | ★★★ |
| **`upc.apl.merits.response`** | 2 months | **3 months** | RoP.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"* — verified verbatim from `UPCRoP.235.1`. New finding — May 8 audit recorded the duration as 3 months but the live row has always been 2 (migration 012:153 originally seeded 2). | ★★★ |
| **`upc.pi.cfi.response`** | 0 / "court-set" (`is_court_set=false`, `duration=0`, `parent_id=NULL`) | court-set, judge-discretion under R.211.2 | RoP.211.2 — judge sets the inter-partes hearing date. Modelling is half-broken: `duration=0` with `parent_id=NULL` makes the calculator treat this as a root anchor rather than a court-set placeholder. Should set `is_court_set=true` and chain `parent_id=app`. | ★★ |
(All other rules audited have correct durations.)
---
## §6. Findings — Wrong party
No clear party mis-assignments found in the live data. Two notes worth
recording, not bugs:
- `upc.inf.cfi.app_to_amend` carries `primary_party='claimant'`. The
defendant in an INF case is the alleged infringer; the patent
proprietor (=claimant) is who would file an Application to Amend
the patent. **Correct.** Listed here only because R.30 reads "the
defendant" in some summaries — those refer to the claimant of the
CCR (= defendant of the INF), which loops back to the same person
who is the INF-claimant / patent-proprietor.
- `dpma.opp.dpma.erwiderung` carries `primary_party='defendant'`. In an
EPA-style opposition, the patent proprietor is the "defendant" of the
opposition. Consistent with EPA convention. **Correct.**
---
## §7. Findings — Wrong sequencing / anchoring
### 7.1 `de.inf.lg.beruf_begr` chains parent = `berufung`, should anchor on `urteil` directly
| Live | Per ZPO §520(2) |
|---|---|
| `de.inf.lg.beruf_begr.parent_id = de.inf.lg.berufung`, `duration = 2 months` → effective end = trigger + 1mo (Berufung) + 2mo = **3 months** after Urteil service | "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** after Urteil service |
Verified verbatim via WebFetch
[gesetze-im-internet.de/zpo/__520.html](https://www.gesetze-im-internet.de/zpo/__520.html)
2026-05-25.
The companion `de.inf.olg.begruendung` is **correct** — parent =
`urteil_lg`, 2mo, so end = Urteil + 2mo. Same statute, two paliad
rules, two different anchorings: this is a real bug in `de.inf.lg`.
### 7.2 `de.inf.lg.replik` and `de.inf.lg.duplik` have `parent_id = NULL`
This is the bug head flagged. Live data:
| submission_code | name | duration | parent_id | sequence_order |
|---|---|---|---|---|
| `de.inf.lg.klage` | Klageerhebung | 0 mo | NULL | 0 |
| `de.inf.lg.anzeige` | Anzeige Verteidigungsbereitschaft | 2 wk | `de.inf.lg.klage` | 10 |
| `de.inf.lg.erwidg` | Klageerwiderung | 6 wk | `de.inf.lg.klage` (court-set=true post mig 095) | 20 |
| **`de.inf.lg.replik`** | Replik | **4 wk** | **NULL** | 30 |
| **`de.inf.lg.duplik`** | Duplik | **4 wk** | **NULL** | 40 |
| `de.inf.lg.termin` | Haupttermin | 0 mo | NULL (court-set) | 50 |
| `de.inf.lg.urteil` | Urteil | 0 mo | NULL (court-set) | 60 |
| `de.inf.lg.berufung` | Berufungsfrist | 1 mo | NULL | 70 |
| `de.inf.lg.beruf_begr` | Berufungsbegründung | 2 mo | `de.inf.lg.berufung` | 80 |
With `parent_id = NULL` the calculator anchors Replik on the
triggerDate (= Klageerhebung), and same for Duplik. So both render
"4 Wochen ab Klageerhebung" — i.e. before the Klageerwiderung is
even due. Correct chain should be:
- `replik.parent_id = de.inf.lg.erwidg`, with `is_court_set = true` (richterliche Frist § 276(1) S.2 / § 283 ZPO — typ. 4 weeks default)
- `duplik.parent_id = de.inf.lg.replik`, same shape
Both rules lack `legal_source` and `rule_code`, which is consistent
with them being court-set Schriftsatzfristen (no statutory clamp).
Recommendation in §10.
### 7.3 `upc.apl.merits.grounds` has `parent_id = NULL`
This anchors Grounds on the user-supplied trigger date (=Entscheidung
service). **Correct** behaviour per RoP.224.2.a: *"within four months
of service of a decision referred to in Rule 220.1(a) and (b)"*.
If `parent_id` were set to `upc.apl.merits.notice` (as the May 8 audit
hypothesised), the chain would compound (1-day notice + 4mo grounds =
~4mo + 1 day), accidentally landing near the right end-date for the
common case but wrong by up to 2 months in the edge case (when notice
is filed early). **No fix needed; document the intent.** (This is
the change the May 8 audit recommended; it was applied in mig 097 or
earlier.)
### 7.4 DPMA Pathway-A anchors are partially modelled
- `dpma.appeal.bgh.begruendung` chains parent = `rechtsbeschwerde`
(1mo + 1mo = 2mo from BPatG-Entscheidung). Per PatG §102 the
Rechtsbeschwerdebegründungsfrist is 1 month from filing of the
Rechtsbeschwerde — **correct**.
- `dpma.appeal.bpatg.begruendung` chains parent = `beschwerde`
(1mo + 1mo = 2mo from DPMA-Entscheidung). **No statutory basis for
the 1-month figure** (see §4.3). Should be court-set.
### 7.5 EPA grant timeline — `epa.grant.exa.r71_3` and `.approval` have `parent_id = NULL`
Live:
| Rule | Duration | parent_id | Issue |
|---|---|---|---|
| `epa.grant.exa.r71_3` | 0 mo | NULL | Should chain on `exam_req` (after examination request is granted, EPO issues R.71(3) communication). NULL parent + 0 duration = root anchor at trigger date — works only if user enters the R.71(3) date as trigger; doesn't compose with the rest of the tree. |
| `epa.grant.exa.approval` | 4 mo | NULL | Per R.71(3) approval period: 4 months from notification. **Anchor should be `r71_3`**, not NULL. As-is, "Zustimmung + Übersetzung" appears as a free-standing 4-mo-from-trigger row that has nothing to do with the rest of the timeline. |
### 7.6 Summary
| # | Rule | Bug |
|---|---|---|
| 1 | `de.inf.lg.beruf_begr` | parent should be NULL (anchored on Urteil-trigger) not `berufung` — off by 1 month, ★★★ |
| 2 | `de.inf.lg.replik` | parent should be `erwidg` not NULL, ★★★ |
| 3 | `de.inf.lg.duplik` | parent should be `replik` not NULL, ★★★ |
| 4 | `dpma.appeal.bpatg.begruendung` | should be court-set; current 1-month period has no statutory basis, ★★ |
| 5 | `dpma.appeal.bpatg.beschwerde` parent is `entscheidung` — OK, just a citation issue (§4.3) | (citation only) |
| 6 | `epa.grant.exa.r71_3` parent | should chain on `exam_req`, ★ |
| 7 | `epa.grant.exa.approval` parent | should chain on `r71_3`, ★ |
| 8 | `upc.pi.cfi.response` | court-set placeholder with `parent_id=NULL` and `is_court_set=false` — should chain on `app` with `is_court_set=true`, ★★ |
---
## §8. Findings — Duplicates
No genuine duplicates. The closest cases:
- `upc.inf.cfi.reply` + `upc.inf.cfi.def_to_ccr` both fire at 2mo after
`sod` under `with_ccr`. They cover different actions (Reply to SoD
vs. Defence to CCR + Reply to SoD combined) per RoP.029.a vs .b.
**Not a duplicate** — distinct rule codes.
- `upc.rev.cfi.reply` (2mo, no rule_code) and the older `REV.rev_reply`
on the archived litigation type — the archived type is hidden
(`pt.is_active = false`) so this isn't a duplicate the user sees.
Recommendation in §10 to drop the archived corpus once mig 093's
audit window closes.
- `epa.opp.boa.r106` (Art. 112a review) appears only on
`epa.opp.boa`, not on `epa.opp.opd` — correct, since Art. 112a
review is only available against a Boards-of-Appeal decision.
---
## §9. Ambiguities — decisions m needs to make
These are not bugs the coder can fix. They are judgement calls about
how to model the law.
### 9.1 Court-set vs fixed-period for richterliche Fristen
The cleanest source-of-truth for these is "no statutory duration —
court sets the period in the individual case." Modelling them as a
fixed period with a wrong citation is the bug pattern we keep finding:
- `dpma.opp.dpma.erwiderung` (4 mo) — DPMA practice, not §59 PatG.
- `dpma.appeal.bpatg.begruendung` (1 mo) — no statutory basis.
- `de.inf.olg.erwiderung` (1 mo, §521(2)) — §521(2) is explicitly
discretionary ("Der Vorsitzende oder das Berufungsgericht **kann**
der Gegenpartei eine Frist … bestimmen"). Verified WebFetch
[gesetze-im-internet.de/zpo/__521.html](https://www.gesetze-im-internet.de/zpo/__521.html)
2026-05-25.
- `de.null.bgh.erwiderung` (2 mo, "§111(3) PatG") — court-set per §117
PatG → ZPO §521(2).
- `de.null.bpatg.duplik` (1 mo, §83 PatG) — court-set; the 1-month
default is BPatG practice.
- `de.inf.lg.replik`, `.duplik` (4 wk each) — court-set per
§283 / §296a ZPO + §276(1) S.2.
- `epa.opp.opd.erwidg` (4 mo, "R.79(1)") — EPO-set per Guidelines.
**Question (Q1):** Should paliad continue to display these with a
default duration but flag them as "richterliche Frist — vom Gericht
festgesetzt", OR should they all flip to `is_court_set=true,
duration=0` and force the user to enter the actual court-set date?
Head's 2026-05-25 13:13 signal confirms: m's preference is that "Frist
vom Gericht bestimmt" be flagged as needing case-by-case anchoring,
not displayed as a fixed period. So default answer = flip to
`is_court_set=true` and keep the typical period as the *Default*
display value (the calculator already supports this since the
mig 095 / `de.inf.lg.erwidg` patch). But the trade-off is a UX
regression: most users will not enter the actual court-set date
and the timeline will then show "vom Gericht bestimmt" everywhere.
### 9.2 R.198 / R.213 "31 days OR 20 working days, whichever is longer"
Two RoP rules need a primitive paliad doesn't have:
- A `working_days` duration unit (counts business-day arithmetic via
the holiday service).
- A `combine = 'max'` operator that compares two durations and picks
the later end-date.
**Question (Q2):** Implement the primitive (~120 LoC migration + ~80 LoC
Go), or document both rules as "manual calculation required, see RoP"
in the UI? Real R.198 / R.213 cases are rare (saisie + PI). The May 8
audit suggested deferring; pauli's 2026-05-13 audit §7.1 made the
case for adding `combine_op` as part of a broader Pipeline A/C merge.
### 9.3 R.245.2 rehearing "whichever is later" trigger
R.245.2.a/b: deadline 2 months from final decision OR from defect
discovery, whichever is *later*. Plus outer cap 12 months. Needs:
- Multi-anchor trigger event (user supplies 2 dates).
- `combine = 'max'` between anchors.
- Outer-cap arithmetic (separate concept from duration).
**Question (Q3):** Defer (specialist, vanishingly rare) or build the
primitives?
### 9.4 EPC Art. 112a review — outer cap
Same shape as R.245.2: 2 months from defect discovery, outer cap 12
months from decision. `epa.opp.boa.r106` models the 2-month period
but not the cap.
### 9.5 PatG §123 Wiedereinsetzung calendar arithmetic
Cascade card (slug `wiedereinsetzung`) exists. The 2mo / 1-year
arithmetic anchors on the *missed* deadline, not on a forward-looking
event. paliad's `paliad.deadline_rules` schema has no natural shape
for this — it would need either a special-case Go helper, or a
"backward-from-missed-deadline" mode that no rule today uses.
**Question (Q4):** Worth modelling? The cascade card already routes
the user to the concept; computing the calendar deadline is an
incremental win.
### 9.6 ZPO §339 Versäumnisurteil-Einspruch
Cascade card orphan. 2 weeks from service of the default judgment.
Trivial to add as a `de.inf.lg.einspruch_vu` rule (court-decision
anchor + 2wk fixed). **Question (Q5):** Add as a child of
`de.inf.lg.urteil` (with `condition_expr={"flag":"with_vu"}`), or
as a separate proceeding `de.inf.lg.vu`?
### 9.7 Litigation-vs-fristenrechner archived corpus
The 40 rules on `_archived_litigation` (mig 093 retirement holding pen)
still occupy the rule table. They're invisible to all UIs.
**Question (Q6):** Drop them now (data clean-up), or keep until the
mig 093 audit window closes formally?
### 9.8 R.79(2) further-party observations period
EPC R.79(2) creates a separate notification window for additional
opponents. paliad's `epa.opp.opd.r79_further` is modelled as
`duration=0, is_bilateral=true`. **Question (Q7):** Is this even worth
keeping? Real workflow: EPO sets a separate period in each
intervention case. Hard to template.
### 9.9 R.116(1) EPC oral-proceedings cut-off
paliad has it as `duration=0, parent_id=entsch` (`epa.opp.opd.r116`) /
`parent_id=oral` (`epa.opp.boa.r116`). R.116(1) actually says the
EPO sets a "final date for making written submissions" when issuing
the summons. So it's a court-set period, not zero-duration.
**Question (Q8):** flip to `is_court_set=true` like the §276(1) ZPO
fix in mig 095?
### 9.10 R.131.2 indication of damages period
paliad models `upc.dmgs.cfi.app` as a 0-duration root anchor (court
sets when the damages-determination phase opens, per R.131.2). This
is correct shape but means the entire damages tree is unanchored
until the user provides the trigger date manually.
**Question (Q9):** Wire `is_spawn` from `upc.inf.cfi.decision` to
`upc.dmgs.cfi.app` (parallel to the mig-095 appeal-spawn)?
### 9.11 PatG §17 GebrMG / §18 GebrMG
No GebrMG-rooted proceeding_type exists in paliad. Head confirmed
out-of-scope for this audit. **Question (Q10):** Add a `de.gm.lg`
proceeding for GebrMG-Löschungsverfahren if HLC sees them?
### 9.12 Proceeding-tree vs cascade parity
paliad has 9 cascade-only concepts with `rule_count = 0` (the orphans
listed in `audit-fristen-logic-2026-05-13.md` §3.4). The audit-fristen
audit covers this; restating here only to note that the parity gap
is the largest single source of "the cascade card promises a
calculation but doesn't deliver one."
**Question (Q11):** Same as the audit-fristen Q8 — priority order
for the 9 orphan concepts? My ranking: wiedereinsetzung >
schriftsatznachreichung > versäumnisurteil-einspruch >
weiterbehandlung > rest.
### 9.13 R.220.3 anchor
See `audit-upc-rop-deadlines-2026-05-08.md` §5.4b. paliad anchors
`upc.apl.order.discretion` on the original order (`order`), but
the 15-day clock per RoP.220.3 runs from the refusal-of-leave
date (or day-15 fall-back). Off by up to 15 days in the edge case.
**Question (Q12):** add an explicit `app_ord.refusal` court-set
intermediate node?
### 9.14 EP_GRANT publish date — priority vs filing
`epa.grant.exa.publish` correctly has `anchor_alt='priority_date'`.
This was open in the May 8 audit and is now closed. **No question —
listed to confirm.**
### 9.15 Cross-proceeding spawn execution
mig 095 added two `is_spawn=true` rules (`inf.appeal_spawn`,
`rev.appeal_spawn``upc.apl.merits`). The May 13 audit §1.6 +
§6.8 noted spawn execution is half-wired in `projection_service.go`.
**Question (Q13):** wire end-to-end now (so the spawned appeal
timeline appears in SmartTimeline), or accept the half-wired state?
---
## §10. Recommended fixes (prioritised)
### Tier 0 — hard duration / sequencing / anchor bugs (ship first)
| # | Rule | Fix | Reason / source | Freq |
|---|---|---|---|---|
| T0.1 | `upc.rev.cfi.defence` | `duration_value = 2` (was 3), `rule_code = 'RoP.049.1'`, `legal_source = 'UPC.RoP.49.1'` | §5 — every UPC_REV tracked in paliad today computes Defence at wrong month for the last ~3 months | ★★★ |
| T0.2 | `upc.rev.cfi.rejoin` | `duration_value = 1` (was 2), `rule_code = 'RoP.052'`, `legal_source = 'UPC.RoP.52.p1'` | §5 — same as T0.1 | ★★★ |
| T0.3 | `upc.apl.merits.response` | `duration_value = 3` (was 2), `rule_code = 'RoP.235.1'`, `legal_source = 'UPC.RoP.235.1'` | §5 — every main-track appellate respondent | ★★★ |
| T0.4 | `de.inf.lg.beruf_begr` | `parent_id = NULL` (was `de.inf.lg.berufung`) — runs 2 months from triggerDate (Urteil-service) per ZPO §520(2) | §7.1 — every DE-LG-Verletzung appeal | ★★★ |
| T0.5 | `de.inf.lg.replik` | `parent_id = de.inf.lg.erwidg`, `is_court_set = true` (richterliche Frist § 276(1) S.2 / § 283 ZPO), keep 4-week default | §7.2 — bug head flagged | ★★★ |
| T0.6 | `de.inf.lg.duplik` | `parent_id = de.inf.lg.replik`, `is_court_set = true` | §7.2 | ★★★ |
| T0.7 | `upc.rev.cfi.reply` | `rule_code = 'RoP.051'`, `legal_source = 'UPC.RoP.51.p1'` (duration 2mo unchanged) | §4.1 | ★★★ |
| T0.8 | `upc.rev.cfi.rejoin` (citation only) | covered in T0.2 | — | — |
| T0.9 | `upc.apl.merits.notice` | `rule_code = 'RoP.224.1.a'`, `legal_source = 'UPC.RoP.224.1.a'` (duration unchanged) | §4.1 | ★★ |
| T0.10 | `upc.apl.merits.grounds` | `rule_code = 'RoP.224.2.a'`, `legal_source = 'UPC.RoP.224.2.a'` (duration unchanged) | §4.1 | ★★ |
| T0.11 | `upc.rev.cfi.defence` rule_code zero-pad | covered in T0.1 | — | — |
| T0.12 | `dpma.opp.dpma.erwiderung` | flip to `is_court_set = true`, keep 4-month default-display value, drop the misleading `DE.PatG.59.3` citation (or replace with "DPMA-Richtlinien D-IV 5.2") | §4.3 + §9.1 | ★★ |
| T0.13 | `dpma.appeal.bpatg.begruendung` | flip to `is_court_set = true`, drop the `DE.PatG.75.1` citation, keep 1-month default | §4.3 + §9.1 | ★★ |
| T0.14 | `de.null.bpatg.erwidg` | citation `DE.PatG.82.3` (was 82.1); duration (2mo) correct | §4.4 | ★★ |
| T0.15 | `de.null.bgh.begruendung` | citation `DE.ZPO.520.2` via PatG §117 (was DE.PatG.111.1); duration (3mo) correct | §4.4 | ★★ |
| T0.16 | `de.null.bgh.erwiderung` | flip to `is_court_set = true`; citation `DE.ZPO.521.2 via PatG §117` (was DE.PatG.111.3); duration (2mo) becomes default-display | §4.4 + §9.1 | ★★ |
| T0.17 | `epa.opp.opd.erwidg` | flip to `is_court_set = true`, keep 4-month default | §4.5 + §9.1 | ★★ |
**16 hard fixes.** All within the existing schema (no new columns).
Each is a single-row UPDATE plus an audit-log entry.
### Tier 1 — high-value missing rules (★★ / ★★★)
| # | Rule | Add | Freq |
|---|---|---|---|
| T1.1 | `upc.inf.cfi.cmo_review` | 15 days from CMO service (R.333.2) | ★★ |
| T1.2 | `upc.inf.cfi.confidentiality_response` | 14 days from opp. confidentiality app (R.262.2) | ★★ |
| T1.3 | `upc.apl.order.grounds_orders` | 15 days from order service (R.224.2(b)) | ★★ |
| T1.4 | `upc.apl.order.response_orders` | 15 days from grounds service (R.235.2) | ★★ |
| T1.5 | `upc.inf.cfi.cons_orders` | 2 months from validity decision (R.118.4) | ★★ |
| T1.6 | `upc.inf.cfi.rectification` | 1 month from decision (R.353) | ★ |
| T1.7 | `upc.pi.cfi.deficiency` | 14 days from PI deficiency notification (R.207.6.a) | ★★ |
| T1.8 | `upc.pi.cfi.merits_start` | 31d OR 20wd from PI grant (R.213) — **blocked on Q2** | ★★ |
| T1.9 | `upc.inf.cfi.translation_request` | 1 month **before** oral hearing (R.109.1) | ★★ |
| T1.10 | `upc.inf.cfi.interpreter_cost` | 2 weeks **before** oral hearing (R.109.4) | ★★ |
| T1.11 | `upc.inf.cfi.translations_lodge` | 2 weeks after summons (R.109.5) | ★★ |
| T1.12 | `upc.pi.cfi.response` re-anchor | court-set, parent=`app` (currently a broken root) | ★★ |
**12 rule-adds.** T1.9/.10 are the only `timing='before'` rules in the
entire UPC corpus; schema already supports `before` but no rule
populates it. Verify the backward-snap-to-working-day logic in
`internal/services/deadline_calculator.go` before merging
(2026-04-30 audit §5.4 raised the concern).
### Tier 2 — broader coverage (★ specialist + Wiedereinsetzung family)
| # | Rule | Add | Notes |
|---|---|---|---|
| T2.1 | `de.inf.lg.einspruch_vu` | 2 weeks from service of Versäumnisurteil (ZPO §339) | Q5 — proceeding shape decision |
| T2.2 | `upc.inf.cfi.wiedereinsetzung` | 2 mo / 1-year-cap from Wegfall des Hindernisses (R.320) | Q4 — needs special arithmetic |
| T2.3 | `de.inf.lg.wiedereinsetzung` | 2 mo / 1-year-cap (PatG §123 / ZPO §233 ff.) | Q4 |
| T2.4 | `epa.grant.exa.weiterbehandlung` | 2 mo from loss-of-rights notification (EPC R.135) | — |
| T2.5 | `upc.inf.cfi.prelim_reply` | 14 days from PO service (R.20.2) | Companion to R.19 (mig 095 added it) |
| T2.6 | `upc.apl.order.discretion_anchor` | add explicit `refusal` intermediate node so R.220.3 anchors correctly (Q12) | |
| T2.7 | `upc.dmgs.cfi.app` spawn | `is_spawn=true` from `upc.inf.cfi.decision` (Q9) | |
| T2.8 | `upc.disc.cfi.app` spawn | same shape as T2.7 | |
| T2.9 | `epa.grant.exa.r71_3` re-anchor | parent = `exam_req` (§7.5) | |
| T2.10 | `epa.grant.exa.approval` re-anchor | parent = `r71_3` (§7.5) | |
| T2.11 | `upc.inf.cfi.appeal_spawn` cross-proc wiring | finish the half-wired spawn execution (Q13) | |
### Tier 3 — tooling primitives (block multiple rules)
| # | Primitive | Blocks | Notes |
|---|---|---|---|
| T3.1 | `duration_unit = 'working_days'` | R.198, R.213 | Schema already accepts the string; add to calculator + UI |
| T3.2 | `combine_op = 'max'` | R.198, R.213, R.245.2 | Column already exists per pauli's 2026-05-13 audit |
| T3.3 | Multi-anchor "whichever later" trigger | R.245.2.a/b | UI + service work |
| T3.4 | Outer-cap modelling (`outer_cap_value` + `outer_cap_unit`) | R.245.2 (12mo), R.320 (12mo), EPC Art.112a(4) (12mo) | Schema add |
| T3.5 | "Before"-mode backward snap to working day | R.109.1, R.109.4 | Calculator change (audit-fristenrechner-completeness-2026-04-30.md §5.4) |
| T3.6 | Cross-proceeding spawn end-to-end (`is_spawn`) | T2.7, T2.8, T2.11 | Pauli's §6.8 |
### Tier 4 — out-of-scope until separate prioritisation
- DNI family (R.63 / R.67.1 / R.69.1 / R.69.2). Zero published filings 2026-Q1.
- Registry-correction family (R.16.3.a, R.27.2, R.89.2, R.253.2). Most natural in cascade, not proceeding-tree.
- GebrMG (no proceeding_type today).
- R.245 rehearing family (specialist).
- R.155 cost-decision opposition chain (specialist).
- R.144 UPC_DAMAGES tree-end row (cosmetic).
- R.79(2) EPC further-parties period (modelling unclear — Q7).
---
## §11. Next-step proposals (suggested fix-task slicing)
The audit identifies **41 distinct actionable items.** Below is a
suggested decomposition into fix-tasks that can be assigned
independently. Sequence reflects "Wave 0 must precede Wave 1" only
where there's a real dependency (most slices are independent).
### Wave 0 — Tier 0 duration / sequencing / anchor fixes (single fix-task)
**Proposed task:** `t-paliad-264 — Tier 0 deadline-rule corrections
(duration, anchor, citation) from t-paliad-263 audit`
- 16 row UPDATEs (T0.1T0.17, deduplicated to 16 distinct rows since
T0.8 is covered by T0.2 and T0.11 by T0.1).
- One migration file (~120 LoC SQL).
- All within existing schema. No new columns.
- Idempotent guards on every UPDATE (only fire when the row still has
the old value, per the mig 095 convention).
- Adds 16 entries to `paliad.deadline_rule_audit` (per the mig 079
trigger).
- Verification block: `DO $$ … RAISE EXCEPTION …` per mig 095.
- **Branch:** `mai/<coder>/t-paliad-264-tier0-deadline-fixes`.
- **Owner:** coder.
- **Why first:** all 16 affect either calendar correctness (5 hard
duration/anchor bugs) or citation correctness (the 11 metadata
fixes are what a lawyer would cite-check against). T0.1T0.6 are
user-visible silent wrongs; ship them.
### Wave 1 — Tier 1 rule additions (single fix-task)
**Proposed task:** `t-paliad-265 — Tier 1 deadline-rule additions
(12 high-frequency rules)`
- 11 INSERTs + 1 UPDATE re-anchor (T1.12 `upc.pi.cfi.response`).
- T1.8 (`upc.pi.cfi.merits_start`) **excluded** — blocked on T3.1/T3.2.
- One migration file (~250 LoC SQL).
- Add cascade leaves + concepts where needed (each rule should be
reachable from Pathway B too).
- **Branch:** `mai/<coder>/t-paliad-265-tier1-rule-additions`.
- **Owner:** coder. **Legal review:** m must verify each rule before
merge (single round of grilling).
### Wave 2 — Q1 court-set audit decision (separate spike)
**Proposed task:** `t-paliad-266 — Decide court-set vs fixed-period
modelling for richterliche Fristen (Q1 in t-paliad-263 audit)`
- Inventor / pauli reviews §9.1 with m.
- Decision artefact: list of rules to flip vs keep, plus UX guideline
for what the timeline displays for `is_court_set=true` rules.
- **Owner:** pauli. **m signs off.**
### Wave 3 — Tier 3 tooling primitives (multi-task)
Each Tier 3 row is its own task because each touches schema + service +
calculator + UI:
- `t-paliad-267 — working_days unit + combine_op='max' (R.198, R.213)`
- `t-paliad-268 — Outer-cap modelling (R.245.2, R.320, Art.112a)`
- `t-paliad-269 — Multi-anchor "whichever later" triggers (R.245.2)`
- `t-paliad-270 — Backward-snap for `before`-mode rules (R.109.1/.4)`
- `t-paliad-271 — Cross-proceeding spawn end-to-end execution`
Each is foundational for multiple Tier 2 rules; can ship independently.
### Wave 4 — Tier 2 specialist rules (multi-task, after their primitives land)
Each Tier 2 row is its own task or batched into 2-3 tasks by topical
area:
- `t-paliad-272 — Wiedereinsetzung / Weiterbehandlung family (T2.2, T2.3, T2.4)` — depends on T3.4 (outer cap).
- `t-paliad-273 — UPC follow-on spawns (T2.7, T2.8, T2.11)` — depends on T3.6.
- `t-paliad-274 — UPC tail rules (T2.5, T2.6, R.353, etc.)`
- `t-paliad-275 — EPA grant timeline re-anchoring (T2.9, T2.10)`.
### Wave 5 — Concept-layer parity (separate audit)
The 9 orphan concepts (`audit-fristen-logic-2026-05-13.md` §3.4 + Q11
here) need a parallel audit pass to map cascade → rule. Recommend
spinning a `t-paliad-276 — Cascade-rule parity audit` task once the
above land.
### Wave 6 — Documentation + retire
- `t-paliad-277 — Drop `_archived_litigation` proceeding_type` once
mig 093's audit window closes (Q6).
- `t-paliad-278 — Document Tier 4 deferrals in
`docs/feature-roadmap.md`` so the gap-list isn't lost.
---
## Appendix A — file references
**Live state queried via Supabase MCP, 2026-05-25 14:0015:00 UTC:**
- `paliad.proceeding_types` — 21 active rows (20 fristenrechner + 1
archived).
- `paliad.deadline_rules` — 132 active + 40 archived rows
(`lifecycle_state='published'`).
- `paliad.deadline_rule_audit` — diff history.
- `data.laws_contents` (youpc) — UPC RoP + EPC verbatim text
(`law_type IN ('UPCRoP','EPC')`).
**paliad migrations consulted:**
- `internal/db/migrations/012_fristenrechner_rules.up.sql` — original
seed.
- `internal/db/migrations/043_de_instance_split_proceedings.up.sql`
— DE_INF_OLG / DE_INF_BGH split.
- `internal/db/migrations/052_event_categories_rop_audit.up.sql`
— first RoP audit fix-pass.
- `internal/db/migrations/079_*` — `paliad.deadline_rule_audit`
trigger.
- `internal/db/migrations/091_drop_legacy_rule_columns.up.sql` —
cleanup.
- `internal/db/migrations/093_retire_litigation_category.up.sql` —
archived 40 rules.
- `internal/db/migrations/095_fristen_gap_fill.up.sql` — t-paliad-205
R.19 + R.220.1(a) gap fill.
- `internal/db/migrations/096_proceeding_code_rename.up.sql` — code
rename to `<jurisdiction>.<proceeding>.<instance>` form.
- `internal/db/migrations/097_legal_citation_backfill.up.sql` —
legal_source / rule_code backfill.
- `internal/db/migrations/100_ccr_visible_rule.up.sql` —
`upc.ccr.cfi` alias.
- `internal/db/migrations/104_einspruch_name_and_ccr_priority.up.sql`
— Einspruch rename.
**Companion audits:**
- `docs/audit-fristenrechner-completeness-2026-04-30.md` — curie /
t-paliad-084.
- `docs/audit-upc-rop-deadlines-2026-05-08.md` — curie / t-paliad-159.
- `docs/audit-fristen-logic-2026-05-13.md` — pauli / t-paliad-157
(schema audit, ground-truth on column semantics).
- `docs/proposals/fristen-gap-fill-2026-05-18.md` — m's 0.3 decisions
that shipped as mig 095.
**Authoritative source URLs (all verified 2026-05-25):**
- UPC RoP consolidated 18.05.2023: https://www.unifiedpatentcourt.org/sites/default/files/upc_documents/rop_application_-_consolidated_18_05_2023.pdf
- EPC 17th ed.: https://www.epo.org/en/legal/epc/2020/index.html
- EPC R.71 (and other Implementing Reg Rules): https://www.epo.org/en/legal/epc/2020/r71.html
- PatG: https://www.gesetze-im-internet.de/patg/
- §59 https://www.gesetze-im-internet.de/patg/__59.html
- §73 https://www.gesetze-im-internet.de/patg/__73.html
- §75 https://www.gesetze-im-internet.de/patg/__75.html
- §82 https://www.gesetze-im-internet.de/patg/__82.html
- §110 https://www.gesetze-im-internet.de/patg/__110.html
- §111 https://www.gesetze-im-internet.de/patg/__111.html
- ZPO: https://www.gesetze-im-internet.de/zpo/
- §520 https://www.gesetze-im-internet.de/zpo/__520.html
- §521 https://www.gesetze-im-internet.de/zpo/__521.html
- GebrMG: https://www.gesetze-im-internet.de/gebrmg/
---
## Appendix B — coverage tally
| Status | Count | Share |
|---|---:|---:|
| present-correct | 78 | 59 % |
| present-wrong (DURATION) | 3 | 2 % |
| present-wrong (anchor/sequence) | 5 | 4 % |
| present-wrong (citation only) | 11 | 8 % |
| court-set-mismodelled-as-fixed | 6 | 5 % |
| **subtotal: still actionable** | **25** | **19 %** |
| missing (statute defines, paliad doesn't) | 30 | (gap, vs 132 baseline) |
| n/a (RoP / EPC / PatG section creates no time-limit) | 8 | 6 % |
| present-correct, no fix needed | (78 above) | |
**Headline figures for m:**
- Of the 132 statutory deadlines paliad currently models, **25 carry
an actionable bug** (19%). Of those, **5 are user-visible
calendar-correctness bugs** (the 3 duration bugs + the 2
sequencing/anchor bugs head flagged + me). The other 20 are
citation drift or court-set mismodelling — fix-them-quietly
category.
- An additional **30 statutory deadlines are not modelled at all**
(the missing list in §3). Of those, **~12 are ★★★ / ★★ frequency**
(Tier 1 in §10); the remaining ~18 are ★ specialist.
- The 5 duration / sequencing bugs alone are **the most important
takeaway**: every UPC_REV proceeding, every UPC main-track appeal
respondent, and every DE-LG-Verletzung timeline tracked in paliad
today computes wrong dates.
End of audit. Awaiting m's review of §9 Q1Q13 + Tier 0 sign-off
before fix-tasks (Wave 0) get cut.

280
exports/gen-deadline-list.py Executable file
View File

@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""Generate a markdown deadline-list export for UPC PA training (work/head delegation #2572).
Sorts by proceeding-type display_order then sequence_order. Sections by proceeding.
t-paliad-348 / yoUPC#178 update: matches the engine's `IncludeOptional=false`
default (`pkg/litigationplanner/engine.go`). Optional rules (priority='optional')
are SUPPRESSED by default so the manuscript shows the same "naked proceeding
backbone" the UI now renders. Pass `--include-optional` to opt back in for an
exhaustive catalog dump.
Usage:
uv run exports/gen-deadline-list.py [--include-optional] [-o OUT]
"""
# /// script
# requires-python = ">=3.10"
# dependencies = ["psycopg2-binary"]
# ///
import argparse
import os
import sys
from datetime import date
from pathlib import Path
import psycopg2
import psycopg2.extras
DSN = os.environ.get(
"PALIAD_DEADLINE_EXPORT_DSN",
"postgres://postgres:rpsak3yf4lu1izgefx9p9xweg3qroojw@100.99.98.201:11833/postgres?sslmode=disable",
)
# `priority` filter is wired at the SQL level (not post-filter in Python) so
# the row counter in the markdown header reflects what's actually in the
# manuscript — matching what the lawyer sees on /tools/procedures.
SQL_TEMPLATE = """
SELECT
pt.code AS pt_code,
pt.display_order,
COALESCE(pt.name_en, pt.name) AS pt_label_en,
pt.name AS pt_label_de,
COALESCE(pe.name_en, pe.name) AS event_en,
pe.name AS event_de,
sr.duration_value,
sr.duration_unit,
sr.timing,
sr.alt_duration_value,
sr.alt_duration_unit,
sr.combine_op,
sr.rule_code,
COALESCE(te.name, te.name_de) AS trigger_label,
te.code AS trigger_code,
sr.primary_party,
sr.is_court_set,
sr.is_spawn,
sr.priority,
sr.deadline_notes_en,
sr.deadline_notes,
sr.condition_expr,
sr.sequence_order
FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
LEFT JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
LEFT JOIN paliad.trigger_events te ON te.id = sr.trigger_event_id
WHERE sr.lifecycle_state = 'published'
AND sr.is_active = true
AND pt.id IS NOT NULL
{priority_filter}
ORDER BY pt.display_order NULLS LAST, pt.code, sr.sequence_order NULLS LAST, sr.rule_code, pe.name;
"""
def format_frist(duration_value, duration_unit, timing, alt_value, alt_unit, combine_op):
"""Format the deadline duration cleanly."""
if duration_value is None or duration_unit is None:
return ""
unit_map = {
"days": "d",
"weeks": "w",
"months": "M",
"years": "y",
"calendar_days": "CD",
"working_days": "WD",
}
unit = unit_map.get(duration_unit, duration_unit)
main = f"{duration_value} {unit}"
if alt_value is not None and alt_unit is not None:
alt_unit_short = unit_map.get(alt_unit, alt_unit)
op = combine_op or "or"
main = f"{main} {op} {alt_value} {alt_unit_short}"
if timing == "before":
main = f"{main} before"
elif timing == "after":
main = f"{main} after"
return main
def format_party(primary_party, is_court_set):
if is_court_set:
return "court-set"
if primary_party == "claimant":
return "claimant"
if primary_party == "defendant":
return "defendant"
if primary_party == "both":
return "either"
if primary_party == "court":
return "court"
return primary_party or ""
def detect_r94(notes_en, notes_de):
"""Flag R.9.4 non-extendable from notes text (heuristic — no DB field)."""
blobs = " ".join(filter(None, [notes_en or "", notes_de or ""])).lower()
if "r.9.4" in blobs or "r 9.4" in blobs or "r9.4" in blobs:
return ""
if "non-extendable" in blobs or "nicht verlängerbar" in blobs or "nicht verlaengerbar" in blobs:
return ""
return ""
def conditional_marker(condition_expr):
if condition_expr in (None, "", {}):
return ""
# condition_expr is JSONB → returns dict
if isinstance(condition_expr, dict):
if "flag" in condition_expr:
return f"if `{condition_expr['flag']}`"
if condition_expr.get("op") == "and" and "args" in condition_expr:
flags = [a.get("flag", "?") for a in condition_expr["args"]]
return "if " + " & ".join(f"`{f}`" for f in flags)
if condition_expr.get("op") == "or" and "args" in condition_expr:
flags = [a.get("flag", "?") for a in condition_expr["args"]]
return "if " + " | ".join(f"`{f}`" for f in flags)
return "cond"
def md_escape(s):
if s is None:
return ""
return str(s).replace("|", "\\|").replace("\n", " ")
def render(rows, *, include_optional: bool, generated_for: str) -> str:
by_pt = {}
for r in rows:
key = (r["display_order"] or 9999, r["pt_code"], r["pt_label_de"], r["pt_label_en"])
by_pt.setdefault(key, []).append(r)
out = []
today = date.today().isoformat()
out.append(f"# UPC + DE/EP Deadline Catalog — Stand {today}")
out.append("")
out.append(f"Source: `paliad.sequencing_rules` (lifecycle_state=published, is_active=true).")
out.append(f"Generated for {generated_for}. {len(rows)} rules across {len(by_pt)} proceedings.")
if include_optional:
out.append("")
out.append(
"**Mode:** `--include-optional` — every published rule, including "
"`priority='optional'` rules suppressed by the engine's default "
"(`IncludeOptional=false`). This is the exhaustive catalog dump."
)
else:
out.append("")
out.append(
"**Mode:** default — matches the engine's `IncludeOptional=false` "
"behaviour (pkg/litigationplanner/engine.go). `priority='optional'` "
"rules are suppressed; the manuscript shows only the mandatory "
"backbone the lawyer sees by default on /tools/procedures. "
"Re-run with `--include-optional` for the full catalog. "
"(t-paliad-348 / yoUPC#178)"
)
out.append("")
out.append("**Spalten:**")
out.append("- **Phase/Event** = procedural event (German primary)")
out.append("- **Frist** = duration + timing (`d` days, `w` weeks, `M` months, `CD` calendar days, `WD` working days; `before` = relative to anchor)")
out.append("- **Rule** = legal source (RoP / § ZPO / § PatG / Art. EPÜ)")
out.append("- **Anchor** = trigger event the deadline runs from")
out.append("- **Seite** = filing party (claimant / defendant / either / court-set)")
out.append("- **Priorität** = mandatory / recommended / optional / informational (only when `--include-optional`)")
out.append("- **R.9.4** = ✗ marked non-extendable in notes (heuristic — confirm against rule text)")
out.append("- **Bedingung** = scenario flag(s) that must be set for the rule to fire (blank = always)")
out.append("")
out.append("---")
out.append("")
for (order, pt_code, pt_de, pt_en) in sorted(by_pt.keys()):
prules = by_pt[(order, pt_code, pt_de, pt_en)]
out.append(f"## {pt_de} · `{pt_code}`")
out.append("")
if pt_en and pt_en != pt_de:
out.append(f"*{pt_en}*")
out.append("")
if include_optional:
out.append("| # | Phase / Event | Frist | Rule | Anchor | Seite | Priorität | R.9.4 | Bedingung |")
out.append("|---:|---|---|---|---|---|---|:---:|---|")
else:
out.append("| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |")
out.append("|---:|---|---|---|---|---|:---:|---|")
for i, r in enumerate(prules, 1):
event = md_escape(r["event_de"] or r["event_en"] or "")
frist = md_escape(
format_frist(
r["duration_value"], r["duration_unit"], r["timing"],
r["alt_duration_value"], r["alt_duration_unit"], r["combine_op"],
)
)
rule = md_escape(r["rule_code"] or "")
anchor = md_escape(r["trigger_label"] or "")
party = format_party(r["primary_party"], r["is_court_set"])
r94 = detect_r94(r["deadline_notes_en"], r["deadline_notes"])
cond = md_escape(conditional_marker(r["condition_expr"]))
spawn_marker = "" if r["is_spawn"] else ""
if include_optional:
priority = md_escape(r["priority"] or "")
out.append(
f"| {i} | {event}{spawn_marker} | {frist} | {rule} | {anchor} | {party} | {priority} | {r94} | {cond} |"
)
else:
out.append(
f"| {i} | {event}{spawn_marker} | {frist} | {rule} | {anchor} | {party} | {r94} | {cond} |"
)
out.append("")
out.append("---")
out.append("")
out.append("**Lesehilfe:**")
out.append("- ⤴ Spawn-Marker: event opens a sub-proceeding (e.g. CCR forks revocation track)")
out.append("- `with_ccr` = Widerklage auf Nichtigkeit gefilt | `with_amend` = Patentänderungsantrag | `with_cci` = Widerklage auf Verletzung (in rev.cfi)")
out.append("- Catalog ist work-in-progress: 7 compound-name rules + Patentänderung-Duplikation noch in m's split-review backlog (m/paliad#149).")
return "\n".join(out)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--include-optional",
action="store_true",
help="Include priority='optional' rules. Default false matches the engine's IncludeOptional=false default.",
)
parser.add_argument(
"-o",
"--out",
default="exports/upc-deadlines-2026-05-28.md",
help="Output path (relative to repo root).",
)
parser.add_argument(
"--generated-for",
default="PA-Schulung 2026-05-28",
help="Free-text label rendered in the markdown header.",
)
args = parser.parse_args()
priority_filter = "" if args.include_optional else "AND sr.priority != 'optional'"
sql = SQL_TEMPLATE.format(priority_filter=priority_filter)
conn = psycopg2.connect(DSN)
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql)
rows = cur.fetchall()
finally:
conn.close()
md = render(rows, include_optional=args.include_optional, generated_for=args.generated_for)
# Resolve out path relative to the repo root (= the script's grandparent).
out_path = Path(args.out)
if not out_path.is_absolute():
out_path = Path(__file__).resolve().parent.parent / out_path
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(md)
n_pt = len({(r["display_order"] or 9999, r["pt_code"]) for r in rows})
print(
f"WROTE {out_path} ({len(rows)} rules, {n_pt} proceedings, "
f"include_optional={args.include_optional})"
)
if __name__ == "__main__":
sys.exit(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

View File

@@ -0,0 +1,378 @@
# UPC + DE/EP Deadline Catalog — Stand 2026-05-28
Source: `paliad.sequencing_rules` (lifecycle_state=published, is_active=true).
Generated for PA-Schulung 2026-05-28. 178 rules across 25 proceedings.
**Mode:** default — matches the engine's `IncludeOptional=false` behaviour (pkg/litigationplanner/engine.go). `priority='optional'` rules are suppressed; the manuscript shows only the mandatory backbone the lawyer sees by default on /tools/procedures. Re-run with `--include-optional` for the full catalog. (t-paliad-348 / yoUPC#178)
**Spalten:**
- **Phase/Event** = procedural event (German primary)
- **Frist** = duration + timing (`d` days, `w` weeks, `M` months, `CD` calendar days, `WD` working days; `before` = relative to anchor)
- **Rule** = legal source (RoP / § ZPO / § PatG / Art. EPÜ)
- **Anchor** = trigger event the deadline runs from
- **Seite** = filing party (claimant / defendant / either / court-set)
- **Priorität** = mandatory / recommended / optional / informational (only when `--include-optional`)
- **R.9.4** = ✗ marked non-extendable in notes (heuristic — confirm against rule text)
- **Bedingung** = scenario flag(s) that must be set for the rule to fire (blank = always)
---
## Verletzungsverfahren · `upc.inf.cfi`
*Infringement Action*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Klageerhebung | 0 M after | RoP.013.1 | | claimant | | |
| 2 | Klageerwiderung | 3 M after | RoP.023 | | defendant | | |
| 3 | Replik | 2 M or 2 M after | RoP.029.b | | claimant | | if `with_ccr` |
| 4 | Duplik | 1 M or 2 M after | RoP.029.c | | defendant | | if `with_ccr` |
| 5 | Erwiderung auf Nichtigkeitswiderklage | 2 M after | RoP.029.a | | claimant | | if `with_ccr` |
| 6 | Replik auf Erwiderung zur Nichtigkeitswiderklage | 2 M after | RoP.029.d | | defendant | | if `with_ccr` |
| 7 | Duplik auf Replik zur Erwiderung Nichtigkeitswiderklage | 1 M after | RoP.029.e | | claimant | | if `with_ccr` |
| 8 | Antrag auf Patentänderung | 2 M after | RoP.030.1 | | claimant | | if `with_ccr` & `with_amend` |
| 9 | Erwiderung auf Patentänderungsantrag | 2 M after | RoP.032.1 | | defendant | | if `with_ccr` & `with_amend` |
| 10 | Replik auf Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | | claimant | | if `with_ccr` & `with_amend` |
| 11 | Duplik auf Replik zum Patentänderungsantrag | 1 M after | RoP.032.3 | | defendant | | if `with_ccr` & `with_amend` |
| 12 | Zwischenanhörung | 0 M after | RoP.105 | | court-set | | |
| 13 | Mitteilung Dolmetscherkosten | 2 w before | RoP.109.4 | Oral hearing | court | | |
| 14 | Übersetzungen einreichen | 2 w after | RoP.109.5 | | either | | |
| 15 | Mündliche Verhandlung | 0 M after | RoP.112 | | court-set | | |
| 16 | Entscheidung | 0 M after | RoP.118.1 | | court-set | | |
| 17 | Duplik zur Replik auf die Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | Reply to the Defence to an Application to amend the patent | defendant | | if `with_ccr` & `with_amend` |
| 18 | Einreichung von Übersetzungen von Schriftstücken | 1 M after | RoP.007.4 | Order of the judge-rapporteur to lodge translations | either | | |
| 19 | Antrag auf Simultanübersetzung | 1 M before | RoP.109.5 | Oral hearing | either | | |
| 20 | Antrag auf Folgemaßnahmen aus einer rechtskräftigen Validitätsentscheidung | 2 M after | RoP.118.4 | Final decision of the central division, Court of Appeal or EPO on the validity of the patent | either | | if `with_ccr` |
| 21 | Antrag auf Überprüfung einer verfahrensleitenden Anordnung | 15 d after | RoP.333 | Case management order (Service) | either | | |
| 22 | Mängelbeseitigung / Einreichung schriftlicher Stellungnahme | 14 d after | RoP.019 | Preliminary Objection | either | | |
| 23 | Mängelbeseitigung / Zahlung | 14 d after | RoP.016 | Notification by the Registry to correct deficiencies | either | | |
| 24 | Antrag auf Verweisung an die Zentralkammer | 10 d after | RoP.323 | Information by the Court not to approve Application to use the patent's language as language of the proceedings | either | | |
| 25 | Mitteilung über Beauftragung eines Dolmetschers auf Kosten der Partei | 2 w before | RoP.109.5 | Oral hearing | either | | |
| 26 | Klärung von Übersetzungsfragen | 2 w after | RoP.109 | Summons to Oral Hearing | court | | |
| 27 | Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit | 14 d after | RoP.262.2 | Opponent Submission | either | | |
| 28 | Wiedereinsetzungsantrag (UPC R.320) | 2 M after | RoP.320 | Removal of obstacle (UPC R.320) | either | | |
## Verletzungsverfahren (LG) · `de.inf.lg`
*Infringement (Regional Court)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Klageerhebung | 0 M after | § 253 ZPO | | claimant | | |
| 2 | Anzeige der Verteidigungsbereitschaft | 2 w after | § 276 ZPO | | defendant | | |
| 3 | Klageerwiderung | 6 w after | § 276 ZPO | | court-set | | |
| 4 | Replik | 4 w after | § 282 ZPO | | court-set | | |
| 5 | Duplik | 4 w after | § 282 ZPO | | court-set | | |
| 6 | Haupttermin | 0 M after | § 279 ZPO | | court-set | | |
| 7 | Urteil | 0 M after | § 300 ZPO | | court-set | | |
| 8 | Berufungsfrist | 1 M after | § 517 ZPO | | either | | |
| 9 | Berufungsbegründung | 2 M after | § 520 ZPO | | either | | |
| 10 | Wiedereinsetzungsantrag (§ 233 ZPO) | 2 w after | § 233 ZPO | Removal of obstacle (ZPO §233) | — | | |
| 11 | Einspruch gegen Versäumnisurteil (§ 339 ZPO) | 2 w after | § 339 ZPO | Service of default judgment | — | | |
| 12 | Schriftsatznachreichung (§ 296a ZPO) | 3 w after | § 296a ZPO | End of oral hearing | — | | |
## Nichtigkeitsverfahren · `upc.rev.cfi`
*Revocation Action*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Nichtigkeitsklage | 0 M after | RoP.044 | | claimant | | |
| 2 | Klageerwiderung | 2 M after | RoP.049.1 | | defendant | | |
| 3 | Antrag auf Patentänderung | 0 M after | RoP.049.2.a | | defendant | | if `with_amend` |
| 4 | Verletzungswiderklage | 0 M after | RoP.049.2.b | | defendant | | if `with_cci` |
| 5 | Replik | 2 M after | RoP.051 | | claimant | | |
| 6 | Erwiderung auf Patentänderungsantrag | 2 M after | RoP.043.3 | | claimant | | if `with_amend` |
| 7 | Erwiderung auf Verletzungswiderklage | 2 M after | RoP.056.1 | | claimant | | if `with_cci` |
| 8 | Duplik | 1 M after | RoP.052 | | defendant | | |
| 9 | Replik auf Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | | defendant | | if `with_amend` |
| 10 | Replik auf Erwiderung zur Verletzungswiderklage | 1 M after | RoP.056.3 | | defendant | | if `with_cci` |
| 11 | Duplik auf Replik zum Patentänderungsantrag | 1 M after | RoP.032.3 | | claimant | | if `with_amend` |
| 12 | Duplik auf Replik zur Erwiderung Verletzungswiderklage | 1 M after | RoP.056.4 | | claimant | | if `with_cci` |
| 13 | Zwischenanhörung | 0 M after | RoP.105 | | court-set | | |
| 14 | Mündliche Verhandlung | 0 M after | RoP.112 | | court-set | | |
| 15 | Entscheidung | 0 M after | RoP.118.3 | | court-set | | |
| 16 | Duplik zur Replik auf die Erwiderung zur Nichtigkeitsklage | 1 M after | RoP.052 | Reply to the Defence to revocation | — | | |
| 17 | Verletzungswiderklage | 2 M after | RoP.053 | Statement for Revocation | — | | |
| 18 | Antrag auf Patentänderung | 2 M after | RoP.050 | Statement for Revocation | — | | |
## Nichtigkeitsverfahren (BPatG) · `de.null.bpatg`
*Nullity (Federal Patent Court)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Nichtigkeitsklage | 0 M after | § 81 PatG | | claimant | | |
| 2 | Klageerwiderung | 2 M after | § 82 Abs. 3 PatG | | defendant | | |
| 3 | Replik | 2 M after | § 83 PatG | | claimant | | |
| 4 | Hinweisbeschluss | 0 M after | § 83 PatG | | court-set | | |
| 5 | Stellungnahme zum Hinweisbeschluss | 0 M after | § 83 PatG | | either | | |
| 6 | Duplik | 1 M after | § 83 PatG | | defendant | | |
| 7 | Mündliche Verhandlung | 0 M after | § 80 PatG | | court-set | | |
| 8 | Urteil | 0 M after | § 84 PatG | | court-set | | |
| 9 | Berufungsfrist | 1 M after | § 110 PatG | | either | | |
| 10 | Berufungsbegründung | 3 M after | § 111 PatG | | either | | |
| 11 | Wiedereinsetzungsantrag (§ 123 PatG) | 2 M after | § 123 PatG | Removal of obstacle (PatG §123) | — | | |
## Einspruchsverfahren · `epa.opp.opd`
*Opposition Proceedings*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Veröffentlichung der Erteilung | 0 M after | Art. 97 EPÜ | | either | | |
| 2 | Einspruchsfrist | 9 M after | Art. 99 EPÜ | | either | | |
| 3 | Erwiderung des Patentinhabers | 4 M after | R. 79(1) EPÜ | | court-set | | |
| 4 | Entscheidung | 0 M after | Art. 102 EPÜ | | court-set | | |
| 5 | Beschwerdefrist | 2 M after | Art. 108 EPÜ | | either | | |
| 6 | Beschwerdebegründung | 4 M after | Art. 108 EPÜ | | either | | |
| 7 | Stellungnahme weiterer Beteiligter | 0 M after | R. 79 EPÜ | | either | | |
| 8 | Eingaben vor mündl. Verhandlung | 0 M after | R. 116 EPÜ | | either | | |
| 9 | Wiedereinsetzungsantrag (Art. 122 EPÜ) | 2 M after | Art. 122 EPÜ | Removal of obstacle (EPC Art.122) | — | | |
## Beschwerdeverfahren · `epa.opp.boa`
*Appeal Proceedings*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung der Beschwerdeentscheidung | 0 M after | R. 124 EPÜ | | either | | |
| 2 | Beschwerdeeinlegung | 2 M after | Art. 108 EPÜ | | either | | |
| 3 | Beschwerdebegründung | 4 M after | Art. 108 EPÜ | | either | | |
| 4 | Beschwerdeerwiderung | 4 M after | RPBA Art. 12 | | either | | |
| 5 | Mündliche Verhandlung | 0 M after | Art. 116 EPÜ | | court-set | | |
| 6 | Entscheidung | 0 M after | Art. 111 EPÜ | | court-set | | |
| 7 | Eingaben vor mündl. Verhandlung | 0 M after | R. 116 EPÜ | | either | | |
| 8 | Antrag auf Überprüfung | 2 M after | Art. 112a EPÜ | | either | | |
## Einspruchsverfahren DPMA · `dpma.opp.dpma`
*Opposition DPMA*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Veröffentlichung der Erteilung | 0 M after | § 58 PatG | | either | | |
| 2 | Einspruchsfrist | 9 M after | § 59 PatG | | either | | |
| 3 | Erwiderung des Patentinhabers | 4 M after | § 59(2) PatG | | court-set | | |
| 4 | DPMA-Entscheidung | 0 M after | § 61 PatG | | court-set | | |
| 5 | Wiedereinsetzungsantrag (DPMA) | 2 M after | § 123 PatG | Removal of obstacle (DPMA, PatG §123) | — | | |
## Berufungsverfahren · `upc.apl.merits`
*Appeal*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Berufungseinlegung | 2 M after | RoP.224.1.a | | either | | |
| 2 | Berufungsbegründung | 4 M after | RoP.224.2.a | | either | | |
| 3 | Berufungserwiderung | 3 M after | RoP.235.1 | | either | | |
| 4 | Mündliche Verhandlung | 0 M after | RoP.240 | | court-set | | |
| 5 | Entscheidung | 0 M after | RoP.235.4 | | court-set | | |
| 6 | Anschlussberufung | 3 M after | RoP.237 | | either | | |
| 7 | Erwiderung Anschlussberufung | 2 M after | RoP.238.1 | | either | | |
| 8 | Berufungsschrift gegen eine in Regel 220.1(a) und (b) genannte Entscheidung | 2 M after | RoP.224.1(a) | Decision referred to in Rule 220.1(a) and (b) | — | | |
| 9 | Berufungsbegründung gegen eine in Regel 220.1(a) und (b) genannte Entscheidung | 4 M after | RoP.224.1(a) | Decision referred to in Rule 220.1(a) and (b) | — | | |
| 10 | Anfechtung einer Entscheidung über die Verwerfung der Berufung als unzulässig | 1 M after | RoP.245 | Decision to reject an appeal as inadmissible | — | | |
| 11 | Antrag auf Wiederaufnahme (schwerwiegender Verfahrensmangel) | 2 M after | RoP.247.2 | Final decision (Service) / Discovery of the fundamental defect (whichever is later) | — | | |
| 12 | Antrag auf Wiederaufnahme (Straftat) | 2 M after | RoP.247.1 | Final decision (Service) / Court decision on criminal offence (whichever is later) | — | | |
## Berufungsverfahren OLG (Verletzung) · `de.inf.olg`
*Appeal OLG (Infringement)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung LG-Urteil | 0 M after | § 540 ZPO | | either | | |
| 2 | Berufungsschrift | 1 M after | § 517 ZPO | | either | | |
| 3 | Berufungsbegründung | 2 M after | § 520 ZPO | | either | | |
| 4 | Berufungserwiderung | 1 M after | § 521 ZPO | | either | | |
| 5 | Anschlussberufung | 0 M after | § 524 ZPO | | either | | |
| 6 | Mündliche Verhandlung | 0 M after | § 540 ZPO | | court-set | | |
| 7 | OLG-Urteil | 0 M after | § 540 ZPO | | court-set | | |
## Revisions-/NZB-Verfahren BGH (Verletzung) · `de.inf.bgh`
*Revision / Non-admission Appeal BGH (Infringement)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung OLG-Urteil | 0 M after | § 555 ZPO | | either | | |
| 2 | Nichtzulassungsbeschwerde | 1 M after | § 544 ZPO | | either | | |
| 3 | Nichtzulassungsbeschwerde-Begründung | 2 M after | § 544 ZPO | | either | | |
| 4 | Revisionsfrist | 1 M after | § 548 ZPO | | either | | |
| 5 | Revisionsbegründung | 2 M after | § 551 ZPO | | either | | |
| 6 | Revisionserwiderung | 1 M after | § 554 ZPO | | either | | |
| 7 | Mündliche Verhandlung BGH | 0 M after | § 555 ZPO | | court-set | | |
| 8 | BGH-Urteil | 0 M after | § 555 ZPO | | court-set | | |
## Berufungsverfahren BGH (Nichtigkeit) · `de.null.bgh`
*Appeal BGH (Nullity)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung BPatG-Urteil | 0 M after | § 110 PatG | | either | | |
| 2 | Berufungsschrift | 1 M after | § 110 PatG | | either | | |
| 3 | Berufungsbegründung | 3 M after | § 520 Abs. 2 ZPO i.V.m. § 117 PatG | | either | | |
| 4 | Berufungserwiderung | 2 M after | § 521 Abs. 2 ZPO i.V.m. § 117 PatG | | court-set | | |
| 5 | Mündliche Verhandlung BGH | 0 M after | § 121 PatG | | court-set | | |
| 6 | BGH-Urteil | 0 M after | § 122 PatG | | court-set | | |
## EP-Erteilungsverfahren · `epa.grant.exa`
*EP Grant Procedure*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Anmeldung | 0 M after | Art. 75 EPÜ | | claimant | | |
| 2 | Recherchenbericht | 6 M after | Art. 92 EPÜ | | court-set | | |
| 3 | Veröffentlichung (A1) | 18 M after | Art. 93 EPÜ | | court-set | | |
| 4 | Prüfungsantrag | 6 M after | R. 70(1) EPÜ | | claimant | | |
| 5 | Mitteilung nach R. 71(3) | 0 M after | R. 71(3) EPÜ | | court-set | | |
| 6 | Zustimmung + Übersetzung | 4 M after | R. 71(3) EPÜ | | claimant | | |
| 7 | Erteilung (B1) | 0 M after | Art. 97 EPÜ | | court-set | | |
## Beschwerdeverfahren BPatG (DPMA) · `dpma.appeal.bpatg`
*Appeal BPatG (against DPMA Decision)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung DPMA-Entscheidung | 0 M after | § 65 PatG | | either | | |
| 2 | Beschwerde | 1 M after | § 73 PatG | | either | | |
| 3 | Beschwerdebegründung | 1 M after | § 75 PatG | | court-set | | |
| 4 | Mündliche Verhandlung BPatG | 0 M after | § 78 PatG | | court-set | | |
| 5 | BPatG-Entscheidung | 0 M after | § 78 PatG | | court-set | | |
## Rechtsbeschwerdeverfahren BGH · `dpma.appeal.bgh`
*Legal Appeal BGH*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung BPatG-Entscheidung | 0 M after | § 100 PatG | | either | | |
| 2 | Rechtsbeschwerde | 1 M after | § 100 PatG | | either | | |
| 3 | Rechtsbeschwerdebegründung | 1 M after | § 102 PatG | | either | | |
| 4 | BGH-Entscheidung | 0 M after | § 100 PatG | | court-set | | |
## Berufungsverfahren Anordnungen · `upc.apl.order`
*Order Appeal (15-day track)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Anordnung / angegriffene Entscheidung | 0 M after | RoP.220 | | court-set | | |
| 2 | Berufung mit Zulassung | 15 d after | RoP.220.2 | | either | | |
| 3 | Antrag auf Ermessensüberprüfung | 15 d after | RoP.220.3 | | either | | |
| 4 | Berufungsbegründung (Orders Track) | 15 d after | RoP.224.2.b | | either | | |
| 5 | Anschlussberufung | 15 d after | RoP.237 | | either | | |
| 6 | Erwiderung Anschlussberufung | 15 d after | RoP.238.2 | | either | | |
| 7 | Berufungsschrift gegen eine in Regel 220.1(c) genannte Anordnung oder eine in Regel 220.2 oder 221.3 genannte Entscheidung | 15 d after | RoP.224.1(b) | Order referred to in Rule 220.1(c) or a decision referred to in Rule 220.2 or 221.3 | — | | |
| 8 | Berufungsbegründung gegen eine in Regel 220.1(c) genannte Anordnung oder eine in Regel 220.2 oder 221.3 genannte Entscheidung | 15 d after | RoP.224.1(b) | Order referred to in Rule 220.1(c) or a decision referred to in Rule 220.2 or 221.3 | — | | |
## Schadensbemessungsverfahren · `upc.dmgs.cfi`
*Damages Determination*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag auf Schadensbemessung | 0 M after | RoP.125 | | claimant | | |
| 2 | Klageerwiderung | 2 M after | RoP.137.2 | | defendant | | |
| 3 | Replik | 1 M after | RoP.139 | | claimant | | |
| 4 | Duplik | 1 M after | RoP.139 | | defendant | | |
## Bucheinsichtsverfahren · `upc.disc.cfi`
*Lay-open Books / Discovery*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag auf Bucheinsicht | 0 M after | RoP.190 | | claimant | | |
| 2 | Klageerwiderung | 2 M after | RoP.142.2 | | defendant | | |
| 3 | Replik | 14 d after | RoP.142.3 | | claimant | | |
| 4 | Duplik | 14 d after | RoP.142.3 | | defendant | | |
## Einstweilige Maßnahmen · `upc.pi.cfi`
*Provisional Measures*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag | 0 M after | RoP.206 | | claimant | | |
| 2 | Erwiderung | 0 M after | RoP.211.2 | | court-set | | |
| 3 | Mündliche Verhandlung | 0 M after | RoP.195 | | court-set | | |
| 4 | Mängelbeseitigung Antrag | 14 d after | RoP.207.6.a | | claimant | | |
| 5 | Beschluss | 0 M after | RoP.211 | | court-set | | |
| 6 | Klage in der Hauptsache erheben | 31 d max 20 WD after | RoP.213 | | claimant | | |
## Schutzschrift · `upc.pl.cfi`
*Protective Letter*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Einreichung der Schutzschrift | 0 M after | RoP.207 | | defendant | | |
| 2 | Erneuerung der Schutzschrift | 6 M after | RoP.207.9 | Protective Letter | — | | |
## Berufungsverfahren Kosten · `upc.apl.cost`
*Cost-Decision Appeal*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Kostenfestsetzungsbeschluss | 0 M after | RoP.221.4 | | court-set | | |
| 2 | Antrag auf Berufungszulassung | 15 d after | RoP.221.1 | | either | | |
| 3 | Antrag auf Berufungszulassung gegen Kostenentscheidungen | 15 d after | RoP.220.2 | Decision on fixation of costs (Rule 157) | — | | |
## Negative Feststellungsklage · `upc.dni.cfi`
*Declaration of Non-Infringement*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Klage auf negative Feststellung der Nichtverletzung | 0 M after | RoP.063 | | claimant | | |
| 2 | Erwiderung auf die negative Feststellungsklage | 2 M after | RoP.066 | Statement for a declaration of non-infringement | — | | |
| 3 | Replik auf die Erwiderung zur negativen Feststellungsklage | 1 M after | RoP.067 | Defence to the Statement for a declaration of non-infringement | — | | |
| 4 | Duplik zur Replik auf die Erwiderung zur negativen Feststellungsklage | 1 M after | RoP.068 | Reply to the Defence to the Statement for a declaration of non-infringement | — | | |
## Überprüfung von EPA-Entscheidungen · `upc.epo.review`
*Review of EPO decisions*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag auf Überprüfung der EPA-Entscheidung | 0 M after | RoP.088 | | — | | |
| 2 | Antrag auf Aufhebung einer Entscheidung des EPA, mit der ein Antrag auf einheitliche Wirkung zurückgewiesen wurde | 3 w after | RoP.097 | Decision of the EPO not to grant unitary effect | — | | |
## Separate Kostenentscheidung · `upc.costs.cfi`
*Separate Cost Decision*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag auf Kostenfestsetzung | 1 M after | RoP.151 | | claimant | | |
## Beweissicherung / saisie · `upc.bsv.cfi`
*Evidence Preservation*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag auf Beweissicherung | 0 M after | RoP.192 | | court-set | | |
| 2 | Antrag auf Überprüfung der Beweissicherungsanordnung | 30 d after | RoP.197.3 | Execution of measures to preserve evidence | — | | |
| 3 | Beginn des Hauptsacheverfahrens | 31 d max 20 WD after | RoP.198 | Date specified in the Court's order to preserve evidence | — | | |
## Widerklage auf Nichtigkeit · `upc.ccr.cfi`
*Counterclaim for Revocation*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Widerklage auf Nichtigkeit | 3 M after | RoP.025 | | defendant | | |
---
**Lesehilfe:**
- ⤴ Spawn-Marker: event opens a sub-proceeding (e.g. CCR forks revocation track)
- `with_ccr` = Widerklage auf Nichtigkeit gefilt | `with_amend` = Patentänderungsantrag | `with_cci` = Widerklage auf Verletzung (in rev.cfi)
- Catalog ist work-in-progress: 7 compound-name rules + Patentänderung-Duplikation noch in m's split-review backlog (m/paliad#149).

View File

@@ -3,13 +3,13 @@ import { join, relative } from "path";
import { renderIndex } from "./src/index";
import { renderLogin } from "./src/login";
import { renderKostenrechner } from "./src/kostenrechner";
import { renderFristenrechner } from "./src/fristenrechner";
import { renderVerfahrensablauf } from "./src/verfahrensablauf";
import { renderProcedures } from "./src/procedures";
import { renderDownloads } from "./src/downloads";
import { renderLinks } from "./src/links";
import { renderGlossary } from "./src/glossary";
import { renderGebuehrentabellen } from "./src/gebuehrentabellen";
import { renderChecklists } from "./src/checklists";
import { renderChecklistsAuthor } from "./src/checklists-author";
import { renderChecklistsDetail } from "./src/checklists-detail";
import { renderChecklistsInstance } from "./src/checklists-instance";
import { renderCourts } from "./src/courts";
@@ -17,13 +17,15 @@ import { renderProjects } from "./src/projects";
import { renderProjectsNew } from "./src/projects-new";
import { renderProjectsDetail } from "./src/projects-detail";
import { renderProjectsChart } from "./src/projects-chart";
import { renderSubmissionDraft } from "./src/submission-draft";
import { renderTemplatesAuthoring } from "./src/templates-authoring";
import { renderSubmissionsIndex } from "./src/submissions-index";
import { renderSubmissionsNew } from "./src/submissions-new";
import { renderEvents } from "./src/events";
import { renderDeadlinesNew } from "./src/deadlines-new";
import { renderDeadlinesDetail } from "./src/deadlines-detail";
import { renderDeadlinesCalendar } from "./src/deadlines-calendar";
import { renderAppointmentsNew } from "./src/appointments-new";
import { renderAppointmentsDetail } from "./src/appointments-detail";
import { renderAppointmentsCalendar } from "./src/appointments-calendar";
import { renderSettings } from "./src/settings";
import { renderDashboard } from "./src/dashboard";
import { renderAgenda } from "./src/agenda";
@@ -38,15 +40,16 @@ import { renderAdminTeam } from "./src/admin-team";
import { renderAdminAuditLog } from "./src/admin-audit-log";
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
import { renderAdminSubmissionBuildingBlocks } from "./src/admin-submission-building-blocks";
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
import { renderAdminEventTypes } from "./src/admin-event-types";
import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
import { renderAdminRulesList } from "./src/admin-rules-list";
import { renderAdminRulesEdit } from "./src/admin-rules-edit";
import { renderAdminRulesExport } from "./src/admin-rules-export";
import { renderPaliadin } from "./src/paliadin";
import { renderAdminPaliadin } from "./src/admin-paliadin";
import { renderAdminBackups } from "./src/admin-backups";
import { renderNotFound } from "./src/notfound";
const DIST = join(import.meta.dir, "dist");
@@ -238,13 +241,13 @@ async function build() {
join(import.meta.dir, "src/client/index.ts"),
join(import.meta.dir, "src/client/login.ts"),
join(import.meta.dir, "src/client/kostenrechner.ts"),
join(import.meta.dir, "src/client/fristenrechner.ts"),
join(import.meta.dir, "src/client/verfahrensablauf.ts"),
join(import.meta.dir, "src/client/procedures.ts"),
join(import.meta.dir, "src/client/downloads.ts"),
join(import.meta.dir, "src/client/links.ts"),
join(import.meta.dir, "src/client/glossary.ts"),
join(import.meta.dir, "src/client/gebuehrentabellen.ts"),
join(import.meta.dir, "src/client/checklists.ts"),
join(import.meta.dir, "src/client/checklists-author.ts"),
join(import.meta.dir, "src/client/checklists-detail.ts"),
join(import.meta.dir, "src/client/checklists-instance.ts"),
join(import.meta.dir, "src/client/courts.ts"),
@@ -252,13 +255,15 @@ async function build() {
join(import.meta.dir, "src/client/projects-new.ts"),
join(import.meta.dir, "src/client/projects-detail.ts"),
join(import.meta.dir, "src/client/projects-chart.ts"),
join(import.meta.dir, "src/client/submission-draft.ts"),
join(import.meta.dir, "src/client/templates-authoring.ts"),
join(import.meta.dir, "src/client/submissions-index.ts"),
join(import.meta.dir, "src/client/submissions-new.ts"),
join(import.meta.dir, "src/client/events.ts"),
join(import.meta.dir, "src/client/deadlines-new.ts"),
join(import.meta.dir, "src/client/deadlines-detail.ts"),
join(import.meta.dir, "src/client/deadlines-calendar.ts"),
join(import.meta.dir, "src/client/appointments-new.ts"),
join(import.meta.dir, "src/client/appointments-detail.ts"),
join(import.meta.dir, "src/client/appointments-calendar.ts"),
join(import.meta.dir, "src/client/settings.ts"),
join(import.meta.dir, "src/client/dashboard.ts"),
join(import.meta.dir, "src/client/agenda.ts"),
@@ -274,12 +279,12 @@ async function build() {
join(import.meta.dir, "src/client/admin-partner-units.ts"),
join(import.meta.dir, "src/client/admin-email-templates.ts"),
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
join(import.meta.dir, "src/client/admin-submission-building-blocks.ts"),
join(import.meta.dir, "src/client/admin-event-types.ts"),
join(import.meta.dir, "src/client/admin-approval-policies.ts"),
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
join(import.meta.dir, "src/client/admin-rules-list.ts"),
join(import.meta.dir, "src/client/admin-rules-edit.ts"),
join(import.meta.dir, "src/client/admin-rules-export.ts"),
join(import.meta.dir, "src/client/paliadin.ts"),
// t-paliad-161 — inline Paliadin widget. Loaded via the
// PaliadinWidget component on every authenticated page, so the
@@ -287,6 +292,7 @@ async function build() {
// skip the re-fetch.
join(import.meta.dir, "src/client/paliadin-widget.ts"),
join(import.meta.dir, "src/client/admin-paliadin.ts"),
join(import.meta.dir, "src/client/admin-backups.ts"),
join(import.meta.dir, "src/client/notfound.ts"),
],
outdir: join(DIST, "assets"),
@@ -363,13 +369,13 @@ async function build() {
await Bun.write(join(DIST, "index.html"), renderIndex());
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
await Bun.write(join(DIST, "kostenrechner.html"), renderKostenrechner());
await Bun.write(join(DIST, "fristenrechner.html"), renderFristenrechner());
await Bun.write(join(DIST, "verfahrensablauf.html"), renderVerfahrensablauf());
await Bun.write(join(DIST, "procedures.html"), renderProcedures());
await Bun.write(join(DIST, "downloads.html"), renderDownloads());
await Bun.write(join(DIST, "links.html"), renderLinks());
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
await Bun.write(join(DIST, "gebuehrentabellen.html"), renderGebuehrentabellen());
await Bun.write(join(DIST, "checklists.html"), renderChecklists());
await Bun.write(join(DIST, "checklists-author.html"), renderChecklistsAuthor());
await Bun.write(join(DIST, "checklists-detail.html"), renderChecklistsDetail());
await Bun.write(join(DIST, "checklists-instance.html"), renderChecklistsInstance());
await Bun.write(join(DIST, "courts.html"), renderCourts());
@@ -377,6 +383,10 @@ async function build() {
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
await Bun.write(join(DIST, "submission-draft.html"), renderSubmissionDraft());
await Bun.write(join(DIST, "templates-authoring.html"), renderTemplatesAuthoring());
await Bun.write(join(DIST, "submissions-index.html"), renderSubmissionsIndex());
await Bun.write(join(DIST, "submissions-new.html"), renderSubmissionsNew());
// t-paliad-115 — shared EventsPage at the canonical /events URL.
// One HTML output; defaultType="all" baked in. Sidebar Fristen /
// Termine entries point at /events?type=… and events.ts re-highlights
@@ -384,10 +394,8 @@ async function build() {
await Bun.write(join(DIST, "events.html"), renderEvents());
await Bun.write(join(DIST, "deadlines-new.html"), renderDeadlinesNew());
await Bun.write(join(DIST, "deadlines-detail.html"), renderDeadlinesDetail());
await Bun.write(join(DIST, "deadlines-calendar.html"), renderDeadlinesCalendar());
await Bun.write(join(DIST, "appointments-new.html"), renderAppointmentsNew());
await Bun.write(join(DIST, "appointments-detail.html"), renderAppointmentsDetail());
await Bun.write(join(DIST, "appointments-calendar.html"), renderAppointmentsCalendar());
await Bun.write(join(DIST, "settings.html"), renderSettings());
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
await Bun.write(join(DIST, "agenda.html"), renderAgenda());
@@ -403,14 +411,15 @@ async function build() {
await Bun.write(join(DIST, "admin-partner-units.html"), renderAdminPartnerUnits());
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
await Bun.write(join(DIST, "admin-submission-building-blocks.html"), renderAdminSubmissionBuildingBlocks());
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
await Bun.write(join(DIST, "admin-approval-policies.html"), renderAdminApprovalPolicies());
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
await Bun.write(join(DIST, "admin-rules-list.html"), renderAdminRulesList());
await Bun.write(join(DIST, "admin-rules-edit.html"), renderAdminRulesEdit());
await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport());
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
await Bun.write(join(DIST, "admin-backups.html"), renderAdminBackups());
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in

Binary file not shown.

View File

@@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="de">
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HL Patents Style</title>
<title>HLC Patents Style</title>
<style>
:root {
--bg: #002236;
@@ -81,31 +81,34 @@
<body>
<main>
<h1>HL <span class="accent">Patents Style</span></h1>
<p class="lead">Das Word-Template fuer Patentschriftsaetze bei Hogan Lovells.</p>
<h1>HLC <span class="accent">Patents Style</span></h1>
<!-- Lead line confirmed final by m (work/head #2685): HLC-only, no Hogan Lovells mention. -->
<p class="lead">The Word template for patent submissions at HLC.</p>
<h2>Was es kann</h2>
<h2>What it does</h2>
<ul>
<li>Vorlagen-Stile fuer alle gaengigen Schriftsatz-Bausteine (Headings, Randnummern, Antraege, Exhibits)</li>
<li>BuildingBlocks: ueber das Ribbon vorgefertigte Abschnitte einfuegen</li>
<li>Sprachumschaltung DE / EN per Ribbon-Toggle</li>
<li>Scaffolding: kompletter Schriftsatz-Aufbau mit einem Klick</li>
<li>Margin Numbers, Exhibit-Nummerierung, SEQ-Felder</li>
<li>Auto-Update ueber das Ribbon (siehe unten)</li>
<li>Document styles for every common submission building block (headings, margin numbers, motions, exhibits)</li>
<li>BuildingBlocks: insert ready-made sections straight from the ribbon</li>
<li>DE / EN language switch via a ribbon toggle</li>
<li>Scaffolding: build a complete submission with one click</li>
<li>Margin numbers, exhibit numbering, SEQ fields</li>
<li>Auto-update from the ribbon (see below)</li>
</ul>
<h2>Aktualisierungen</h2>
<p>Im Ribbon-Tab <em>HL Patent</em> &rarr; Gruppe <em>Manage</em> &rarr; <kbd>Check for Updates</kbd>. Holt das aktuelle Manifest von diesem Server, prueft die Version, laedt die neue <code>.dotm</code> nur bei Bedarf, verifiziert per SHA256, installiert. Nach dem Update Word neu starten.</p>
<h2>Updates</h2>
<p>In the ribbon tab <em>HLC Patent</em> &rarr; group <em>Manage</em> &rarr; <kbd>Check for Updates</kbd>. It fetches the current manifest from this server, checks the version, downloads the new <code>.dotm</code> only when needed, verifies it via SHA256, and installs it. Restart Word after updating.</p>
<h2>Frische Installation</h2>
<p>Wer das Template noch nicht installiert hat, laedt einmal manuell die aktuelle Version und kopiert sie in den Word-Startup-Ordner. Den Rest macht die <code>InstallTemplate</code>-Routine im Template selbst.</p>
<p><a class="download" href="HL-Patents-Style.dotm" download>HL Patents Style.dotm herunterladen</a></p>
<h2>Fresh install</h2>
<p>If you haven&rsquo;t installed the template yet, download the current version once manually and copy it into the Word startup folder. The <code>InstallTemplate</code> routine inside the template handles the rest.</p>
<!-- HLC-Patents-Style.dotm published + verified live (work/head #2697/#2698); href flipped.
HL-Patents-Style.dotm kept on disk for now, dropped on a later publish. -->
<p><a class="download" href="HLC-Patents-Style.dotm" download>Download HLC Patents Style</a></p>
<h2>Hilfe &amp; Feedback</h2>
<p>Fehler, Wuensche, Stilfragen, Build-Probleme: <a href="mailto:matthias.siebels@hoganlovells.com?subject=HL%20Patents%20Style">matthias.siebels@hoganlovells.com</a></p>
<h2>Help &amp; feedback</h2>
<p>Bugs, requests, style questions, build problems: <a href="mailto:matthias.siebels@hoganlovells.com?subject=HLC%20Patents%20Style">matthias.siebels@hoganlovells.com</a></p>
<footer>
<p>Update-Endpoint: <code>paliad.msbls.de/patentstyle/</code> &middot; Mirror: <code>hihlc.msbls.de/patentstyle/</code></p>
<p>Update endpoint: <code>paliad.msbls.de/patentstyle/</code> &middot; Mirror: <code>hihlc.msbls.de/patentstyle/</code></p>
<p id="ver"></p>
</footer>
@@ -115,7 +118,7 @@
.then(r => r.ok ? r.json() : null)
.then(j => {
if (j && j.version) {
document.getElementById('ver').textContent = 'Aktuell ausgeliefert: ' + j.version;
document.getElementById('ver').textContent = 'Currently served: ' + j.version;
}
})
.catch(() => {});

View File

@@ -1,5 +1,5 @@
{
"version": "v0.260518",
"dotm_url": "https://paliad.msbls.de/patentstyle/HL-Patents-Style.dotm",
"sha256": "5CEA98A29D2FD6D9970B9A2499054DF52685A1116459E07F9290B0D0ADD521F4"
}
"version": "v0.260601-1442",
"dotm_url": "https://paliad.msbls.de/patentstyle/HLC-Patents-Style.dotm",
"sha256": "074ED1339FFBC5514E9C347E28C3477F531306ABEA30EC3083B23E6095BD7E05"
}

View File

@@ -0,0 +1,96 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Backup Mode admin page (t-paliad-246 / m/paliad#77 Slice A).
//
// global_admin only — gated by adminGate(...) in handlers.go. Shows the
// chronological list of backup runs (one row per kind in
// {scheduled, on_demand}) plus a button to kick off an on-demand backup.
// Catalog rows + the "run now" action are fetched client-side via
// /api/admin/backups.
export function renderAdminBackups(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.backups.title">Backups &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/backups" />
<BottomNav currentPath="/admin/backups" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.backups.heading">Backups</h1>
<p className="tool-subtitle" data-i18n="admin.backups.subtitle">
Vollst&auml;ndige Snapshots aller Daten &mdash; manuell oder zeitgesteuert.
</p>
</div>
<div>
<button
className="btn-primary"
id="admin-backups-run-btn"
type="button"
data-i18n="admin.backups.run_now"
>
Backup jetzt erstellen
</button>
</div>
</div>
<div id="admin-backups-feedback" className="form-msg" style="display:none" />
<div className="entity-table-wrap">
<table className="entity-table entity-table--readonly">
<thead>
<tr>
<th data-i18n="admin.backups.col.started">Erstellt</th>
<th data-i18n="admin.backups.col.kind">Auslöser</th>
<th data-i18n="admin.backups.col.status">Status</th>
<th data-i18n="admin.backups.col.requested_by">Angefordert von</th>
<th data-i18n="admin.backups.col.size">Gr&ouml;&szlig;e</th>
<th data-i18n="admin.backups.col.rows">Zeilen</th>
<th data-i18n="admin.backups.col.actions">Aktion</th>
</tr>
</thead>
<tbody id="admin-backups-tbody">
<tr>
<td colspan={7} data-i18n="admin.backups.loading">Lade &hellip;</td>
</tr>
</tbody>
</table>
</div>
<div className="entity-empty" id="admin-backups-empty" style="display:none">
<p data-i18n="admin.backups.empty">Noch keine Backups vorhanden.</p>
</div>
<p className="tool-footer-note" id="admin-backups-footer">
<span data-i18n="admin.backups.footer.note">
Geplante Backups werden in einer sp&auml;teren Slice aktiviert. Manuelle Backups stehen jetzt zur Verf&uuml;gung.
</span>
</p>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-backups.js"></script>
</body>
</html>
);
}

View File

@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/rules/{id}/edit — Slice 11b (t-paliad-192). Form for the full
// /admin/procedural-events/{id}/edit — Slice 11b (t-paliad-192). Form for the full
// 37-column rule row plus a side panel with the preview widget and the
// audit-log timeline. Lifecycle action bar at the bottom adapts to the
// rule's current state (draft/published/archived). Every write goes
@@ -26,12 +26,12 @@ export function renderAdminRulesEdit(): string {
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.rules.edit.title">Regel bearbeiten &mdash; Paliad</title>
<title data-i18n="admin.procedural_events.edit.title">Regel bearbeiten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/rules" />
<BottomNav currentPath="/admin/rules" />
<Sidebar currentPath="/admin/procedural-events" />
<BottomNav currentPath="/admin/procedural-events" />
<main>
<section className="tool-page">
@@ -39,7 +39,7 @@ export function renderAdminRulesEdit(): string {
<div className="tool-header admin-rules-edit-header">
<div>
<p className="admin-rules-breadcrumb">
<a href="/admin/rules" data-i18n="admin.rules.edit.breadcrumb">&larr; Regeln verwalten</a>
<a href="/admin/procedural-events" data-i18n="admin.procedural_events.edit.breadcrumb">&larr; Regeln verwalten</a>
</p>
<h1 id="rules-edit-heading" data-i18n="admin.rules.edit.heading.loading">Regel laden...</h1>
<div className="admin-rules-edit-meta">
@@ -71,7 +71,7 @@ export function renderAdminRulesEdit(): string {
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-submission-code" data-i18n="admin.rules.edit.field.submission_code">Submission Code / Einreichung-Kennung</label>
<label htmlFor="f-submission-code" data-i18n="admin.procedural_events.edit.field.code">Submission Code / Einreichung-Kennung</label>
<input type="text" id="f-submission-code" className="admin-rules-input" readonly placeholder="z. B. upc.inf.cfi.soc" />
</div>
<div className="form-field">
@@ -103,7 +103,7 @@ export function renderAdminRulesEdit(): string {
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-parent" data-i18n="admin.rules.edit.field.parent">Parent-Regel (UUID)</label>
<label htmlFor="f-parent" data-i18n="admin.procedural_events.edit.field.parent">Parent-Regel (UUID)</label>
<input type="text" id="f-parent" className="admin-rules-input" placeholder="UUID oder leer" />
</div>
<div className="form-field">
@@ -184,7 +184,7 @@ export function renderAdminRulesEdit(): string {
<input type="text" id="f-primary-party" className="admin-rules-input" />
</div>
<div className="form-field">
<label htmlFor="f-event-type" data-i18n="admin.rules.edit.field.event_type">Event-Typ (frei)</label>
<label htmlFor="f-event-type" data-i18n="admin.procedural_events.edit.field.event_kind">Event-Typ (frei)</label>
<input type="text" id="f-event-type" className="admin-rules-input" />
</div>
</div>

View File

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

View File

@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/rules — Slice 11b (t-paliad-192). Filterable rule table + an
// /admin/procedural-events — Slice 11b (t-paliad-192). Filterable rule table + an
// Orphans tab that surfaces the Slice 10 fuzzy-match staging rows so an
// admin can hand-bind each legacy deadline to one of the candidate
// rule_ids. Both surfaces share the same page shell to keep navigation
@@ -21,28 +21,25 @@ export function renderAdminRulesList(): string {
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.rules.list.title">Regeln verwalten &mdash; Paliad</title>
<title data-i18n="admin.procedural_events.list.title">Regeln verwalten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/rules" />
<BottomNav currentPath="/admin/rules" />
<Sidebar currentPath="/admin/procedural-events" />
<BottomNav currentPath="/admin/procedural-events" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.rules.list.heading">Regeln verwalten</h1>
<h1 data-i18n="admin.procedural_events.list.heading">Regeln verwalten</h1>
<p className="tool-subtitle" data-i18n="admin.rules.list.subtitle">
Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft &rarr; published &rarr; archived.
</p>
</div>
<div className="admin-rules-header-actions">
<a href="/admin/rules/export" className="btn-secondary" data-i18n="admin.rules.list.export">
Migrations exportieren
</a>
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.rules.list.new">
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.procedural_events.list.new">
+ Neue Regel
</button>
</div>
@@ -80,9 +77,9 @@ export function renderAdminRulesList(): string {
<div className="admin-rules-filter admin-rules-filter-chips">
<span className="admin-rules-filter-label" data-i18n="admin.rules.filter.lifecycle">Lifecycle</span>
<div className="admin-rules-chips" id="rules-filter-lifecycle">
<button type="button" className="admin-rules-chip active" data-state="" data-i18n="admin.rules.filter.lifecycle.any">Alle</button>
<button type="button" className="admin-rules-chip" data-state="" data-i18n="admin.rules.filter.lifecycle.any">Alle</button>
<button type="button" className="admin-rules-chip" data-state="draft" data-i18n="admin.rules.lifecycle.draft">Draft</button>
<button type="button" className="admin-rules-chip" data-state="published" data-i18n="admin.rules.lifecycle.published">Published</button>
<button type="button" className="admin-rules-chip active" data-state="published" data-i18n="admin.rules.lifecycle.published">Published</button>
<button type="button" className="admin-rules-chip" data-state="archived" data-i18n="admin.rules.lifecycle.archived">Archived</button>
</div>
</div>
@@ -104,10 +101,10 @@ export function renderAdminRulesList(): string {
<table className="entity-table admin-rules-table">
<thead>
<tr>
<th data-i18n="admin.rules.col.submission_code">Submission Code</th>
<th data-i18n="admin.procedural_events.col.code">Submission Code</th>
<th data-i18n="admin.procedural_events.col.proceeding">Verfahren</th>
<th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th>
<th data-i18n="admin.rules.col.name">Name</th>
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>
<th data-i18n="admin.rules.col.priority">Priorit&auml;t</th>
<th data-i18n="admin.rules.col.lifecycle">Lifecycle</th>
<th data-i18n="admin.rules.col.modified">Zuletzt ge&auml;ndert</th>

View File

@@ -0,0 +1,77 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/submission-building-blocks — Composer building-blocks library
// editor (t-paliad-315 Slice C). Three-pane layout: list on the left,
// edit form in the middle, version log on the right. Hydrated by
// client/admin-submission-building-blocks.ts from
// GET /api/admin/submission-building-blocks.
export function renderAdminSubmissionBuildingBlocks(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.building_blocks.title">Bausteine &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/submission-building-blocks" />
<BottomNav currentPath="/admin/submission-building-blocks" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.building_blocks.heading">Bausteine</h1>
<p className="tool-subtitle" data-i18n="admin.building_blocks.subtitle">
Wiederverwendbare Textbausteine f&uuml;r Composer-Abschnitte.
</p>
</div>
<div className="tool-header-actions">
<button
type="button"
id="admin-bb-new-btn"
className="btn-primary btn-cta-lime"
data-i18n="admin.building_blocks.action.new">
+ Neuer Baustein
</button>
</div>
</div>
<div id="admin-bb-feedback" className="form-msg" style="display:none" />
<div className="admin-bb-layout">
<aside className="admin-bb-list" id="admin-bb-list">
<div className="admin-bb-loading" data-i18n="admin.building_blocks.loading">L&auml;dt&hellip;</div>
</aside>
<section className="admin-bb-editor" id="admin-bb-editor">
<p className="admin-bb-empty" data-i18n="admin.building_blocks.editor.empty">
W&auml;hlen Sie einen Baustein aus der Liste &mdash; oder erstellen Sie einen neuen.
</p>
</section>
<aside className="admin-bb-versions" id="admin-bb-versions" />
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-submission-building-blocks.js"></script>
</body>
</html>
);
}

View File

@@ -33,6 +33,9 @@ export function renderAdminTeam(): string {
</p>
</div>
<div className="admin-team-actions">
<button className="btn-primary" id="admin-team-add-full" type="button" data-i18n="admin.team.add.full">
Konto direkt anlegen
</button>
<button className="btn-primary" id="admin-team-direct-add" type="button" data-i18n="admin.team.add.direct">
Bestehendes Konto onboarden
</button>
@@ -132,6 +135,67 @@ export function renderAdminTeam(): string {
</div>
</div>
{/* t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal.
Creates BOTH the auth.users row (via Supabase Admin API) and
the paliad.users row in one click. New user is visible in
dropdowns immediately. */}
<div className="modal-overlay" id="admin-add-full-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="admin.team.add_full.title">Konto direkt anlegen</h2>
<button className="modal-close" id="admin-af-close" type="button" aria-label="Close">&times;</button>
</div>
<p data-i18n="admin.team.add_full.body" className="invite-modal-body">
Legt sowohl das Login-Konto als auch das Paliad-Profil an. Die neue Person erh&auml;lt eine E-Mail mit einem Link, &uuml;ber den sie ein Passwort setzt.
</p>
<form id="admin-add-full-form" className="entity-form" autocomplete="off">
<div className="form-field">
<label htmlFor="admin-af-email" data-i18n="admin.team.add_full.email">E-Mail</label>
<input type="email" id="admin-af-email" name="email" required autocomplete="off" />
</div>
<div className="form-field">
<label htmlFor="admin-af-name" data-i18n="admin.team.add_full.name">Anzeigename</label>
<input type="text" id="admin-af-name" name="display_name" required />
</div>
<div className="form-field">
<label htmlFor="admin-af-office" data-i18n="admin.team.add_full.office">Standort</label>
<select id="admin-af-office" name="office" required />
</div>
<div className="form-field">
<label htmlFor="admin-af-profession" data-i18n="admin.team.add_full.profession">Profession</label>
<select id="admin-af-profession" name="profession">
<option value="partner" data-i18n="projects.team.profession.partner">Partner</option>
<option value="of_counsel" data-i18n="projects.team.profession.of_counsel">Of Counsel</option>
<option value="associate" selected data-i18n="projects.team.profession.associate">Associate</option>
<option value="senior_pa" data-i18n="projects.team.profession.senior_pa">Senior PA</option>
<option value="pa" data-i18n="projects.team.profession.pa">PA</option>
<option value="paralegal" data-i18n="projects.team.profession.paralegal">Paralegal</option>
</select>
</div>
<div className="form-field">
<label htmlFor="admin-af-job-title" data-i18n="admin.team.add_full.job_title">Berufsbezeichnung</label>
<input type="text" id="admin-af-job-title" name="job_title" placeholder="Associate" />
</div>
<div className="form-field">
<label htmlFor="admin-af-lang" data-i18n="admin.team.add_full.lang">Sprache</label>
<select id="admin-af-lang" name="lang">
<option value="de" selected>Deutsch</option>
<option value="en">English</option>
</select>
</div>
<label className="form-checkbox">
<input type="checkbox" id="admin-af-send-welcome" checked />
<span data-i18n="admin.team.add_full.send_welcome">Willkommens-E-Mail mit Login-Link senden</span>
</label>
<div id="admin-af-feedback" className="form-msg" style="display:none" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="admin-af-cancel" data-i18n="admin.team.add_full.cancel">Abbrechen</button>
<button type="submit" className="btn-primary" id="admin-af-submit" data-i18n="admin.team.add_full.submit">Anlegen</button>
</div>
</form>
</div>
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-team.js"></script>

View File

@@ -95,7 +95,7 @@ export function renderAdmin(): string {
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Pr&uuml;fung pro Projekt und Partner Unit konfigurieren.</p>
</a>
<a href="/admin/rules" className="card card-link">
<a href="/admin/procedural-events" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_TABLE }} />
<h2 data-i18n="admin.card.rules.title">Regeln verwalten</h2>
<p data-i18n="admin.card.rules.desc">Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.</p>

View File

@@ -1,103 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderAppointmentsCalendar(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="appointments.kalender.title">Terminkalender &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/events?type=appointment" />
<BottomNav currentPath="/events?type=appointment" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="entity-header-row">
<div>
<h1 data-i18n="appointments.kalender.heading">Terminkalender</h1>
<p className="tool-subtitle" data-i18n="appointments.kalender.subtitle">
Monats&uuml;bersicht aller Termine.
</p>
</div>
<div className="fristen-header-actions">
<a href="/events?type=appointment" className="btn-secondary" data-i18n="appointments.kalender.list">Listenansicht</a>
<a href="/appointments/new" className="btn-primary btn-cta-lime" data-i18n="appointments.list.new">Neuer Termin</a>
</div>
</div>
</div>
<div className="frist-calendar-controls">
<button type="button" id="cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">&larr;</button>
<h2 id="cal-month-label" className="frist-cal-month-label" />
<button type="button" id="cal-next" className="btn-secondary btn-small" aria-label="N&auml;chster Monat">&rarr;</button>
<button type="button" id="cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
</div>
<div className="termin-cal-legend">
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-hearing" />
<span data-i18n="appointments.type.hearing">Verhandlung</span>
</span>
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-meeting" />
<span data-i18n="appointments.type.meeting">Besprechung</span>
</span>
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-consultation" />
<span data-i18n="appointments.type.consultation">Beratung</span>
</span>
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-deadline_hearing" />
<span data-i18n="appointments.type.deadline_hearing">Fristverhandlung</span>
</span>
</div>
<div className="frist-calendar" id="appointment-calendar">
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
<div id="appointment-cal-grid" className="frist-cal-grid" />
</div>
<p className="entity-events-empty" id="appointment-cal-empty" style="display:none" data-i18n="appointments.kalender.empty">
Keine Termine im ausgew&auml;hlten Zeitraum.
</p>
<div className="modal-overlay" id="cal-popup" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="cal-popup-date" />
<button className="modal-close" id="cal-popup-close" type="button">&times;</button>
</div>
<ul className="frist-cal-popup-list" id="cal-popup-list" />
</div>
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/appointments-calendar.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,120 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Authoring wizard for paliad.checklists. Both /checklists/new and
// /checklists/templates/{slug}/edit serve this same bundle; the client reads
// window.location.pathname to decide create vs edit mode.
export function renderChecklistsAuthor(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="checklisten.author.title">Vorlage erstellen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/checklists" />
<BottomNav currentPath="/checklists" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 id="author-heading" data-i18n="checklisten.author.heading.new">Neue Checklisten-Vorlage</h1>
<p className="tool-subtitle" data-i18n="checklisten.author.subtitle">
Erstellen Sie eine eigene Checkliste mit Sektionen und Punkten.
</p>
</div>
<form id="author-form" className="form-stack" autoComplete="off">
<div className="form-row">
<label className="form-label" htmlFor="title" data-i18n="checklisten.author.field.title">Titel</label>
<input className="form-input" id="title" name="title" type="text" required maxLength="200" />
<p className="form-hint" data-i18n="checklisten.author.field.title.hint">z.B. &bdquo;UPC SoC &mdash; interne Checkliste&ldquo;.</p>
</div>
<div className="form-row">
<label className="form-label" htmlFor="description" data-i18n="checklisten.author.field.description">Kurzbeschreibung</label>
<textarea className="form-input" id="description" name="description" rows="3" maxLength="2000" />
</div>
<div className="form-grid form-grid-2">
<div className="form-row">
<label className="form-label" htmlFor="regime" data-i18n="checklisten.author.field.regime">Regime</label>
<select className="form-input" id="regime" name="regime">
<option value="UPC">UPC</option>
<option value="DE">DE</option>
<option value="EPA">EPA</option>
<option value="OTHER" selected>OTHER</option>
</select>
</div>
<div className="form-row">
<label className="form-label" htmlFor="lang" data-i18n="checklisten.author.field.lang">Sprache</label>
<select className="form-input" id="lang" name="lang">
<option value="de" selected>Deutsch</option>
<option value="en">English</option>
</select>
</div>
</div>
<div className="form-grid form-grid-2">
<div className="form-row">
<label className="form-label" htmlFor="court" data-i18n="checklisten.author.field.court">Gericht / Beh&ouml;rde</label>
<input className="form-input" id="court" name="court" type="text" maxLength="200" />
</div>
<div className="form-row">
<label className="form-label" htmlFor="reference" data-i18n="checklisten.author.field.reference">Rechtsgrundlage</label>
<input className="form-input" id="reference" name="reference" type="text" maxLength="200" />
</div>
</div>
<div className="form-row">
<label className="form-label" htmlFor="deadline" data-i18n="checklisten.author.field.deadline">Deadline (optional)</label>
<input className="form-input" id="deadline" name="deadline" type="text" maxLength="200" />
</div>
<fieldset className="form-fieldset">
<legend data-i18n="checklisten.author.field.visibility">Sichtbarkeit</legend>
<label className="form-radio">
<input type="radio" name="visibility" value="private" checked />
<span><strong data-i18n="checklisten.mine.visibility.private">Privat</strong> &mdash; <span data-i18n="checklisten.author.visibility.private.hint">Nur f&uuml;r Sie sichtbar.</span></span>
</label>
<label className="form-radio">
<input type="radio" name="visibility" value="firm" />
<span><strong data-i18n="checklisten.mine.visibility.firm">Firmenweit</strong> &mdash; <span data-i18n="checklisten.author.visibility.firm.hint">F&uuml;r alle angemeldeten Kolleginnen und Kollegen sichtbar.</span></span>
</label>
</fieldset>
<fieldset className="form-fieldset">
<legend data-i18n="checklisten.author.groups.heading">Sektionen und Punkte</legend>
<div id="groups-container" />
<button type="button" className="btn btn-secondary" id="add-group" data-i18n="checklisten.author.groups.add">+ Sektion hinzuf&uuml;gen</button>
</fieldset>
<p id="author-error" className="form-error" style="display:none" role="alert" />
<div className="form-actions">
<button type="submit" className="btn btn-primary" id="author-save" data-i18n="checklisten.author.save">Speichern</button>
<a className="btn btn-secondary" href="/checklists?tab=mine" data-i18n="checklisten.author.cancel">Abbrechen</a>
</div>
</form>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/checklists-author.js"></script>
</body>
</html>
);
}

View File

@@ -39,12 +39,28 @@ export function renderChecklistsDetail(): string {
<div>
<h1 id="checklist-title">&nbsp;</h1>
<p className="tool-subtitle" id="checklist-subtitle">&nbsp;</p>
{/* Provenance line — visible only for authored
templates; populated by the client from the
catalog response's owner_display_name. */}
<p className="checklist-provenance" id="checklist-provenance" style="display:none" />
<dl className="checklist-meta" id="checklist-meta" />
</div>
<div className="checklist-actions">
<button type="button" id="btn-new-instance" className="btn-primary btn-cta-lime" data-i18n="checklisten.newInstance">
Neue Instanz
</button>
{/* Owner controls (Slice B) — toggled on by the
client once /api/checklists/{slug} returns
origin='authored' AND owner_email matches the
logged-in user. Kept hidden by default so
guests / non-owners never see them. */}
<a id="btn-edit-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.edit">Bearbeiten</a>
<button type="button" id="btn-share-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.share">Teilen</button>
<button type="button" id="btn-delete-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.mine.delete">L&ouml;schen</button>
{/* global_admin controls — revealed by the client
when /api/me reports global_role='global_admin'. */}
<button type="button" id="btn-promote-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.promote">Als Firmen-Vorlage hinterlegen</button>
<button type="button" id="btn-demote-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.demote">Aus Katalog entfernen</button>
<button type="button" id="btn-feedback" className="btn-cta-lime btn-outline">
<span data-i18n="checklisten.feedback.btn">Feedback</span>
</button>
@@ -122,6 +138,65 @@ export function renderChecklistsDetail(): string {
</div>
</div>
{/* Share modal (Slice B) — owner-only, hidden until btn-share-template
opens it. Four recipient kinds in a single modal: pick the kind,
then the matching entity (user / office / partner_unit / project). */}
<div className="modal-overlay" id="share-modal" style="display:none">
<div className="modal-card modal-card-wide">
<div className="modal-header">
<h2 data-i18n="checklisten.share.title">Vorlage teilen</h2>
<button className="modal-close" id="share-close" type="button">&times;</button>
</div>
<div className="form-field">
<label data-i18n="checklisten.share.kind">Empf&auml;ngertyp</label>
<div className="filter-pills" id="share-kind-pills">
<button type="button" className="filter-pill active" data-kind="user" data-i18n="checklisten.share.kind.user">Kollege</button>
<button type="button" className="filter-pill" data-kind="office" data-i18n="checklisten.share.kind.office">Office</button>
<button type="button" className="filter-pill" data-kind="partner_unit" data-i18n="checklisten.share.kind.partner_unit">Dezernat</button>
<button type="button" className="filter-pill" data-kind="project" data-i18n="checklisten.share.kind.project">Projekt</button>
</div>
</div>
<div className="form-field share-kind-section" data-kind="user">
<label htmlFor="share-user" data-i18n="checklisten.share.kind.user">Kollege</label>
<select id="share-user">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-field share-kind-section" data-kind="office" style="display:none">
<label htmlFor="share-office" data-i18n="checklisten.share.kind.office">Office</label>
<select id="share-office">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-field share-kind-section" data-kind="partner_unit" style="display:none">
<label htmlFor="share-partner-unit" data-i18n="checklisten.share.kind.partner_unit">Dezernat</label>
<select id="share-partner-unit">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-field share-kind-section" data-kind="project" style="display:none">
<label htmlFor="share-project" data-i18n="checklisten.share.kind.project">Projekt</label>
<select id="share-project">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="share-cancel" data-i18n="checklisten.share.cancel">Abbrechen</button>
<button type="button" className="btn-primary btn-cta-lime" id="share-submit" data-i18n="checklisten.share.submit">Freigeben</button>
</div>
<p className="form-msg" id="share-msg" />
{/* Existing grants — populated on open from
/api/checklists/templates/{slug}/shares. */}
<h3 className="share-grants-heading" data-i18n="checklisten.share.grants.heading">Bestehende Freigaben</h3>
<ul className="share-grants-list" id="share-grants-list">
<li className="entity-events-empty" id="share-grants-empty" data-i18n="checklisten.share.grants.empty">Keine Freigaben.</li>
</ul>
</div>
</div>
{/* Feedback modal */}
<div className="modal-overlay" id="feedback-modal" style="display:none">
<div className="modal-card">

View File

@@ -58,6 +58,10 @@ export function renderChecklistsInstance(): string {
</div>
<p className="tool-subtitle" id="instance-template-title">&nbsp;</p>
<dl className="checklist-meta" id="instance-meta" />
{/* Slice C: 'template updated since this instance
was created' banner. Populated by the client
when instance.template_version &lt; template.version. */}
<div id="instance-outdated-slot" />
</div>
<div className="checklist-actions">
<button type="button" id="btn-print" className="btn-ghost" data-i18n="checklisten.print">Drucken</button>
@@ -118,6 +122,21 @@ export function renderChecklistsInstance(): string {
</div>
</div>
{/* Slice C: template-diff modal — opened from the
"Änderungen anzeigen" button on the outdated banner. */}
<div className="modal-overlay" id="instance-diff-modal" style="display:none">
<div className="modal-card modal-card-wide">
<div className="modal-header">
<h2 data-i18n="checklisten.instance.diff.title">Ge&auml;nderte Punkte</h2>
<button className="modal-close" id="instance-diff-close" type="button">&times;</button>
</div>
<div id="instance-diff-body" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="instance-diff-close-bottom" data-i18n="checklisten.instance.diff.close">Schlie&szlig;en</button>
</div>
</div>
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/checklists-instance.js"></script>

View File

@@ -34,6 +34,8 @@ export function renderChecklists(): string {
<nav className="entity-tabs" id="checklists-tabs" aria-label="Checklisten-Ansichten">
<a className="entity-tab active" data-tab="templates" href="/checklists" data-i18n="checklisten.tab.templates">Vorlagen</a>
<a className="entity-tab" data-tab="mine" href="/checklists?tab=mine" data-i18n="checklisten.tab.mine">Meine Vorlagen</a>
<a className="entity-tab" data-tab="gallery" href="/checklists?tab=gallery" data-i18n="checklisten.tab.gallery">Geteilte Vorlagen</a>
<a className="entity-tab" data-tab="instances" href="/checklists?tab=instances" data-i18n="checklisten.tab.instances">Vorhandene Instanzen</a>
</nav>
@@ -49,6 +51,36 @@ export function renderChecklists(): string {
<div className="checklist-grid" id="checklist-grid" />
</section>
{/* Meine Vorlagen tab — caller's own authored templates */}
<section className="entity-tab-panel" id="tab-mine" style="display:none">
<div className="tool-actions" style="margin-bottom:1rem">
<a href="/checklists/new" className="btn btn-primary" data-i18n="checklisten.mine.new">Neue Vorlage</a>
</div>
<p className="entity-events-empty" id="checklists-mine-loading" data-i18n="checklisten.mine.loading">L&auml;dt&hellip;</p>
<p className="entity-events-empty" id="checklists-mine-empty" style="display:none" data-i18n="checklisten.mine.empty">
Sie haben noch keine eigene Vorlage angelegt.
</p>
<div className="checklist-grid" id="checklists-mine-grid" style="display:none" />
</section>
{/* Geteilte Vorlagen tab — discovery surface for templates
that aren't owned by the caller (firm-published,
globally-promoted, or explicitly shared). Slice C. */}
<section className="entity-tab-panel" id="tab-gallery" style="display:none">
<div className="checklist-filters" id="checklist-gallery-filters">
<button className="filter-pill active" data-regime="all" type="button" data-i18n="checklisten.filter.all">Alle</button>
<button className="filter-pill" data-regime="UPC" type="button">UPC</button>
<button className="filter-pill" data-regime="DE" type="button" data-i18n="checklisten.filter.de">DE</button>
<button className="filter-pill" data-regime="EPA" type="button">EPA</button>
<button className="filter-pill" data-regime="OTHER" type="button" data-i18n="checklisten.filter.other">Sonstige</button>
</div>
<p className="entity-events-empty" id="checklists-gallery-loading" data-i18n="checklisten.mine.loading">L&auml;dt&hellip;</p>
<p className="entity-events-empty" id="checklists-gallery-empty" style="display:none" data-i18n="checklisten.gallery.empty">
Noch keine geteilten Vorlagen sichtbar.
</p>
<div className="checklist-grid" id="checklists-gallery-grid" style="display:none" />
</section>
{/* Instances tab — every visible instance across templates */}
<section className="entity-tab-panel" id="tab-instances" style="display:none">
<p className="entity-events-empty" id="checklists-instances-loading" data-i18n="checklisten.instances.all.loading">L&auml;dt&hellip;</p>

View File

@@ -0,0 +1,192 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
// Backup Mode admin client (t-paliad-246 / m/paliad#77 Slice A).
//
// Reads /api/admin/backups (chronological list) and wires the
// "Backup jetzt erstellen" button to POST /api/admin/backups/run.
// Synchronous: the server holds the connection for the duration of
// the backup (sub-second at firm-scale today), then returns the new
// catalog row inline. No polling needed at v1's data shape; if the
// run takes > 5 minutes the handler returns 500 and the UI surfaces
// the error.
interface BackupRow {
id: string;
kind: "scheduled" | "on_demand";
status: "running" | "done" | "failed";
requested_by?: string;
requested_by_email: string;
audit_id?: string;
storage_uri?: string;
size_bytes?: number;
row_counts?: unknown; // jsonb passes through as raw bytes; we don't read it
sheet_count?: number;
warnings?: unknown;
error?: string;
started_at: string;
finished_at?: string;
deleted_at?: string;
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
await refreshList();
wireRunButton();
});
function wireRunButton(): void {
const btn = document.getElementById("admin-backups-run-btn") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", async () => {
btn.disabled = true;
const originalText = btn.textContent;
btn.textContent = t("admin.backups.running") || "Läuft …";
clearFeedback();
try {
const r = await fetch("/api/admin/backups/run", {
method: "POST",
credentials: "same-origin",
});
if (!r.ok) {
const body = await r.json().catch(() => ({ error: "request failed" }));
showFeedback("error", body.error || `HTTP ${r.status}`);
return;
}
// The created row is in the response; refresh the list to land it.
await refreshList();
showFeedback("success", t("admin.backups.success") || "Backup erfolgreich erstellt.");
} catch (e) {
showFeedback("error", (e as Error).message || "network error");
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
});
}
async function refreshList(): Promise<void> {
const rows = await fetchJSON<BackupRow[]>("/api/admin/backups?limit=200");
const tbody = document.getElementById("admin-backups-tbody") as HTMLTableSectionElement | null;
const empty = document.getElementById("admin-backups-empty") as HTMLElement | null;
if (!tbody) return;
if (!rows || rows.length === 0) {
tbody.innerHTML = "";
if (empty) empty.style.display = "";
return;
}
if (empty) empty.style.display = "none";
tbody.innerHTML = rows.map(renderRow).join("");
}
function renderRow(b: BackupRow): string {
const started = formatTimestamp(b.started_at);
const kind =
b.kind === "scheduled"
? t("admin.backups.kind.scheduled") || "Geplant"
: t("admin.backups.kind.on_demand") || "Manuell";
const status = renderStatus(b);
const requestedBy =
b.kind === "scheduled" ? "—" : escapeHTML(b.requested_by_email);
const size = b.size_bytes != null ? formatBytes(b.size_bytes) : "—";
const rows = b.sheet_count != null ? String(b.sheet_count) : "—";
const action = renderAction(b);
return `<tr>
<td>${started}</td>
<td>${kind}</td>
<td>${status}</td>
<td>${requestedBy}</td>
<td>${size}</td>
<td>${rows}</td>
<td>${action}</td>
</tr>`;
}
function renderStatus(b: BackupRow): string {
switch (b.status) {
case "done":
return `<span class="status-done">${escapeHTML(t("admin.backups.status.done") || "✓ Fertig")}</span>`;
case "running":
return `<span class="status-running">${escapeHTML(t("admin.backups.status.running") || "Läuft …")}</span>`;
case "failed":
const label = t("admin.backups.status.failed") || "✗ Fehlgeschlagen";
const tip = b.error ? ` title="${escapeAttr(b.error)}"` : "";
return `<span class="status-failed"${tip}>${escapeHTML(label)}</span>`;
default:
return escapeHTML(b.status);
}
}
function renderAction(b: BackupRow): string {
if (b.status !== "done" || !b.storage_uri || b.deleted_at) {
return "—";
}
const label = t("admin.backups.download") || "Download";
return `<a class="btn-link" href="/api/admin/backups/${encodeURIComponent(b.id)}/file">${escapeHTML(label)}</a>`;
}
// --- helpers ---
async function fetchJSON<T>(url: string): Promise<T | null> {
try {
const r = await fetch(url, { credentials: "same-origin" });
if (!r.ok) return null;
return (await r.json()) as T;
} catch {
return null;
}
}
function formatTimestamp(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return escapeHTML(iso);
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
const hh = String(d.getUTCHours()).padStart(2, "0");
const mi = String(d.getUTCMinutes()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${mi} UTC`;
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function escapeHTML(s: string): string {
return s.replace(/[&<>"']/g, (c) => {
switch (c) {
case "&": return "&amp;";
case "<": return "&lt;";
case ">": return "&gt;";
case '"': return "&quot;";
case "'": return "&#39;";
default: return c;
}
});
}
function escapeAttr(s: string): string {
return escapeHTML(s);
}
function showFeedback(kind: "success" | "error", text: string): void {
const el = document.getElementById("admin-backups-feedback") as HTMLElement | null;
if (!el) return;
el.textContent = text;
el.classList.remove("form-msg-success", "form-msg-error");
el.classList.add(kind === "success" ? "form-msg-success" : "form-msg-error");
el.style.display = "";
}
function clearFeedback(): void {
const el = document.getElementById("admin-backups-feedback") as HTMLElement | null;
if (!el) return;
el.style.display = "none";
el.textContent = "";
el.classList.remove("form-msg-success", "form-msg-error");
}

View File

@@ -1,7 +1,7 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// admin-rules-edit.ts — /admin/rules/{id}/edit. Loads a single rule
// admin-rules-edit.ts — /admin/procedural-events/{id}/edit. Loads a single rule
// row, drives every form field, the preview widget, the audit-log
// timeline and the lifecycle action bar. Every write is gated behind
// a reason modal — the ≥10-char rule is enforced client-side per
@@ -51,7 +51,10 @@ interface Rule {
interface ProceedingType {
id: number;
code: string;
name_de: string;
// `name` is the German display name on the wire; the Go `ProceedingType`
// model serialises `db:"name"` as JSON key `name`. Don't reach for
// `name_de` — that field does not exist in this payload (m/paliad#113).
name: string;
name_en: string;
}
@@ -103,8 +106,10 @@ function fmtDateTime(iso: string): string {
}
function parseRuleIDFromPath(): string {
// /admin/rules/{uuid}/edit
const m = /^\/admin\/rules\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
// /admin/procedural-events/{uuid}/edit (canonical, post Slice B.6 rename)
// /admin/rules/{uuid}/edit (legacy, 301-redirected by the backend but
// still matched here in case a stale tab or bookmark hits it).
const m = /^\/admin\/(?:procedural-events|rules)\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
return m ? decodeURIComponent(m[1]) : "";
}
@@ -169,13 +174,14 @@ function fillProceedingSelect(selectId: string, list: ProceedingType[]) {
for (const pt of list) {
const opt = document.createElement("option");
opt.value = String(pt.id);
opt.textContent = `${pt.code} · ${getLang() === "en" ? pt.name_en : pt.name_de}`;
const name = getLang() === "en" ? pt.name_en : pt.name;
opt.textContent = name ? `${pt.code} · ${name}` : pt.code;
sel.appendChild(opt);
}
}
async function loadRule(): Promise<void> {
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`);
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`);
if (!resp.ok) {
if (resp.status === 404) {
showFeedback(t("admin.rules.edit.error.not_found") || "Regel nicht gefunden.", true);
@@ -194,7 +200,7 @@ async function loadAudit(reset: boolean = true): Promise<void> {
auditEntries = [];
auditOffset = 0;
}
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
if (!resp.ok) return;
const body = await resp.json();
const rows = Array.isArray(body) ? body as AuditEntry[] : [];
@@ -504,7 +510,7 @@ async function doSaveDraft(reason: string) {
return;
}
payload.reason = reason;
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`, {
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -526,7 +532,7 @@ async function doSaveDraft(reason: string) {
async function doLifecycle(op: "publish" | "archive" | "restore", reason: string) {
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/${op}`, {
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/${op}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
@@ -548,7 +554,7 @@ async function doLifecycle(op: "publish" | "archive" | "restore", reason: string
async function doClone(reason: string) {
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/clone-as-draft`, {
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/clone-as-draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
@@ -561,7 +567,7 @@ async function doClone(reason: string) {
return;
}
const newRule = await resp.json() as Rule;
window.location.href = `/admin/rules/${encodeURIComponent(newRule.id)}/edit`;
window.location.href = `/admin/procedural-events/${encodeURIComponent(newRule.id)}/edit`;
}
// --------------------------------------------------------------------
@@ -587,7 +593,7 @@ async function runPreview() {
if (flagsRaw) qs.set("flags", flagsRaw);
out.innerHTML = `<p class="admin-rules-loading">${esc(t("admin.rules.edit.preview.running") || "Berechne...")}</p>`;
out.style.display = "";
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(body.error || (t("admin.rules.edit.preview.error") || "Preview fehlgeschlagen."))}</p>`;

View File

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

View File

@@ -1,16 +1,23 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// admin-rules-list.ts — /admin/rules. Drives the rule table (filterable
// admin-rules-list.ts — /admin/procedural-events. Drives the rule table (filterable
// by proceeding type, trigger event, lifecycle state, free-text query)
// plus the Orphans tab (Slice 10 backfill staging rows). Row click on
// a rule routes to /admin/rules/{id}/edit; orphan cards have their own
// a rule routes to /admin/procedural-events/{id}/edit; orphan cards have their own
// "Pick" affordance with an inline reason prompt that posts to
// /admin/api/orphans/{id}/resolve.
interface Rule {
id: string;
proceeding_type_id?: number | null;
// proceeding_type_code is the joined paliad.proceeding_types.code
// for proceeding_type_id, populated server-side by the
// /admin/api/procedural-events LIST handler (t-paliad-321). Lets the
// table show the 3-segment proceeding code (e.g. "upc.inf.cfi") at
// a glance without depending on the FILTER-dropdown's limited
// proceeding list. NULL on event-rooted rules.
proceeding_type_code?: string | null;
// submission_code is the proceeding-prefixed identifier of this rule
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
// rule_code (the legal citation, e.g. `RoP.013.1`).
@@ -29,7 +36,11 @@ interface Rule {
interface ProceedingType {
id: number;
code: string;
name_de: string;
// `name` is the German display name on the wire; the Go `ProceedingType`
// model serialises `db:"name"` as JSON key `name` (the schema treats DE
// as primary). EN lives in `name_en`. Don't reach for `name_de` — that
// field does not exist in this payload (cf. m/paliad#113).
name: string;
name_en: string;
category: string;
}
@@ -69,7 +80,7 @@ let triggerEvents: TriggerEvent[] = [];
let activeProceeding = "";
let activeTrigger = "";
let activeLifecycle = "";
let activeLifecycle = "published";
let activeQuery = "";
let searchDebounce: number | undefined;
@@ -125,10 +136,28 @@ function proceedingLabel(id: number | null | undefined): string {
if (id == null) return "—";
const pt = proceedings.find((p) => p.id === id);
if (!pt) return `#${id}`;
const name = getLang() === "en" ? pt.name_en : pt.name_de;
const name = getLang() === "en" ? pt.name_en : pt.name;
// Guard against a proceeding row that's missing the active-language
// name (or against a stale field-name mismatch slipping back in).
// Show the code on its own rather than "code · undefined" — that
// literal string is the smell that surfaced this bug (m/paliad#113).
if (!name) return pt.code;
return `${pt.code} · ${name}`;
}
// proceedingCodeCell renders the LIST table's Proceeding column. Uses
// the server-side joined proceeding_type_code when available
// (t-paliad-321), falling back to the dropdown-lookup proceedingLabel
// for older API responses or for rules whose proceeding_type_id
// resolves but proceeding_type_code didn't (defence-in-depth). NULL
// proceeding_type_id renders as the em-dash placeholder used
// elsewhere in the admin table.
function proceedingCodeCell(r: Rule): string {
if (r.proceeding_type_code) return r.proceeding_type_code;
if (r.proceeding_type_id == null) return "—";
return proceedingLabel(r.proceeding_type_id);
}
function buildFilterURL(): string {
const qs = new URLSearchParams();
if (activeProceeding) qs.set("proceeding_type_id", activeProceeding);
@@ -136,7 +165,7 @@ function buildFilterURL(): string {
if (activeLifecycle) qs.set("lifecycle_state", activeLifecycle);
if (activeQuery) qs.set("q", activeQuery);
qs.set("limit", "500");
return "/admin/api/rules?" + qs.toString();
return "/admin/api/procedural-events?" + qs.toString();
}
async function loadProceedings(): Promise<void> {
@@ -153,7 +182,8 @@ async function loadProceedings(): Promise<void> {
for (const pt of proceedings) {
const opt = document.createElement("option");
opt.value = String(pt.id);
opt.textContent = `${pt.code} · ${getLang() === "en" ? pt.name_en : pt.name_de}`;
const name = getLang() === "en" ? pt.name_en : pt.name;
opt.textContent = name ? `${pt.code} · ${name}` : pt.code;
sel.appendChild(opt);
}
}
@@ -223,9 +253,9 @@ function renderRulesTable() {
tbody.innerHTML = rules.map((r) => `
<tr data-row-id="${esc(r.id)}" class="admin-rules-row">
<td class="admin-rules-col-code"><code>${esc(r.submission_code || "")}</code></td>
<td class="admin-rules-col-proceeding"><code>${esc(proceedingCodeCell(r))}</code></td>
<td class="admin-rules-col-legal"><code>${esc(r.rule_code || "")}</code></td>
<td>${esc(name(r))}</td>
<td>${esc(proceedingLabel(r.proceeding_type_id ?? null))}</td>
<td><span class="admin-rules-priority admin-rules-priority-${esc(r.priority)}">${esc(priorityLabel(r.priority))}</span></td>
<td><span class="${lifecycleClass(r.lifecycle_state)}">${esc(lifecycleLabel(r.lifecycle_state))}</span></td>
<td class="admin-rules-col-modified">${esc(fmtDateTime(r.updated_at))}</td>
@@ -238,7 +268,7 @@ function renderRulesTable() {
if (target && (target.closest("a") || target.closest("button"))) return;
const id = row.dataset.rowId;
if (!id) return;
window.location.href = `/admin/rules/${encodeURIComponent(id)}/edit`;
window.location.href = `/admin/procedural-events/${encodeURIComponent(id)}/edit`;
});
});
}
@@ -382,7 +412,7 @@ async function submitReasonModal(ev: Event) {
submit.disabled = false;
return;
}
const resp = await fetch("/admin/api/rules", {
const resp = await fetch("/admin/api/procedural-events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -406,7 +436,7 @@ async function submitReasonModal(ev: Event) {
return;
}
const created = await resp.json();
window.location.href = `/admin/rules/${encodeURIComponent(created.id)}/edit`;
window.location.href = `/admin/procedural-events/${encodeURIComponent(created.id)}/edit`;
return;
}

View File

@@ -0,0 +1,429 @@
import { initI18n, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
function isEN(): boolean { return getLang() === "en"; }
// /admin/submission-building-blocks — Composer building-blocks admin
// editor (t-paliad-315 Slice C). Three-pane layout: list → editor →
// version log. CRUD via /api/admin/submission-building-blocks/*.
//
// Per Q2 ratification (m, 2026-05-26): building blocks are plain text
// paste sources. The editor here is curator-only — no per-section
// lineage to surface, no "where is this block used" view.
interface BuildingBlockJSON {
id: string;
slug: string;
firm?: string | null;
section_key: string;
proceeding_family?: string | null;
title_de: string;
title_en: string;
description_de?: string | null;
description_en?: string | null;
content_md_de: string;
content_md_en: string;
author_id?: string | null;
visibility: string;
is_published: boolean;
created_at: string;
updated_at: string;
}
interface VersionJSON {
id: string;
building_block_id: string;
content_md_de: string;
content_md_en: string;
title_de: string;
title_en: string;
edited_by?: string | null;
note?: string | null;
created_at: string;
}
const VISIBILITIES = ["private", "team", "firm", "global"];
// Section keys must match what the Composer base spec declares for
// each section (see internal/db/migrations/146_submission_bases.up.sql).
const SECTION_KEYS = [
"letterhead", "caption", "introduction", "requests",
"facts", "legal_argument", "evidence", "exhibits",
"closing", "signature",
];
const state = {
blocks: [] as BuildingBlockJSON[],
selectedID: null as string | null,
versions: [] as VersionJSON[],
dirty: false,
};
async function boot(): Promise<void> {
initI18n();
initSidebar();
await loadList();
document.getElementById("admin-bb-new-btn")?.addEventListener("click", onNew);
}
async function loadList(): Promise<void> {
try {
const res = await fetch("/api/admin/submission-building-blocks", { credentials: "include" });
if (!res.ok) {
feedback(`HTTP ${res.status}`, true);
return;
}
const body = await res.json() as { blocks?: BuildingBlockJSON[] };
state.blocks = body.blocks ?? [];
paintList();
} catch (err) {
feedback(String(err), true);
}
}
function paintList(): void {
const host = document.getElementById("admin-bb-list");
if (!host) return;
host.innerHTML = "";
if (state.blocks.length === 0) {
const empty = document.createElement("p");
empty.className = "admin-bb-empty";
empty.textContent = isEN() ? "No blocks yet." : "Noch keine Bausteine.";
host.appendChild(empty);
return;
}
for (const b of state.blocks) {
const row = document.createElement("button");
row.type = "button";
row.className = "admin-bb-list-row";
if (b.id === state.selectedID) row.classList.add("admin-bb-list-row--active");
const title = isEN() ? b.title_en : b.title_de;
row.innerHTML = `
<span class="admin-bb-list-title">${escapeHTML(title || b.slug)}</span>
<span class="admin-bb-list-meta">
<span class="admin-bb-list-section">${escapeHTML(b.section_key)}</span>
<span class="admin-bb-list-vis admin-bb-list-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
${b.is_published ? "" : `<span class="admin-bb-list-draft">${isEN() ? "draft" : "Entwurf"}</span>`}
</span>`;
row.addEventListener("click", () => onSelect(b.id));
host.appendChild(row);
}
}
async function onSelect(id: string): Promise<void> {
state.selectedID = id;
state.dirty = false;
paintList();
const b = state.blocks.find(x => x.id === id);
if (!b) return;
paintEditor(b);
await loadVersions(id);
}
function onNew(): void {
state.selectedID = null;
state.versions = [];
state.dirty = false;
paintList();
paintEditor(null);
paintVersions();
}
function paintEditor(b: BuildingBlockJSON | null): void {
const host = document.getElementById("admin-bb-editor");
if (!host) return;
const isNew = b === null;
const data = b ?? {
id: "",
slug: "",
firm: "",
section_key: "requests",
proceeding_family: "",
title_de: "",
title_en: "",
description_de: "",
description_en: "",
content_md_de: "",
content_md_en: "",
visibility: "firm",
is_published: false,
} as Partial<BuildingBlockJSON>;
host.innerHTML = "";
const form = document.createElement("form");
form.className = "admin-bb-form";
form.addEventListener("submit", (e) => { e.preventDefault(); onSave(isNew); });
form.appendChild(textField("slug", isEN() ? "Slug" : "Slug", data.slug ?? "", true));
form.appendChild(textField("firm", "Firm", data.firm ?? "", false, isEN() ? "leer = firmenagnostisch" : "leer = firmenagnostisch"));
form.appendChild(selectField("section_key", isEN() ? "Section key" : "Abschnitts-Slug", data.section_key ?? "requests", SECTION_KEYS, false));
form.appendChild(textField("proceeding_family", isEN() ? "Proceeding family" : "Verfahrensfamilie", data.proceeding_family ?? "", false, "z. B. de.inf.lg"));
form.appendChild(textField("title_de", "Titel (DE)", data.title_de ?? "", true));
form.appendChild(textField("title_en", "Title (EN)", data.title_en ?? "", true));
form.appendChild(textareaField("description_de", "Beschreibung (DE)", data.description_de ?? "", 2));
form.appendChild(textareaField("description_en", "Description (EN)", data.description_en ?? "", 2));
form.appendChild(textareaField("content_md_de", isEN() ? "Content (DE Markdown)" : "Inhalt (DE Markdown)", data.content_md_de ?? "", 10));
form.appendChild(textareaField("content_md_en", isEN() ? "Content (EN Markdown)" : "Inhalt (EN Markdown)", data.content_md_en ?? "", 10));
form.appendChild(selectField("visibility", isEN() ? "Visibility" : "Sichtbarkeit", data.visibility ?? "firm", VISIBILITIES, false));
form.appendChild(checkboxField("is_published", isEN() ? "Published" : "Veröffentlicht", Boolean(data.is_published)));
if (!isNew) {
form.appendChild(textField("note", isEN() ? "Save note (optional)" : "Speicher-Notiz (optional)", "", false));
}
const actions = document.createElement("div");
actions.className = "admin-bb-form-actions";
const save = document.createElement("button");
save.type = "submit";
save.className = "btn-primary btn-cta-lime";
save.textContent = isEN() ? "Save" : "Speichern";
actions.appendChild(save);
if (!isNew) {
const del = document.createElement("button");
del.type = "button";
del.className = "btn-link-danger";
del.textContent = isEN() ? "Delete" : "Löschen";
del.addEventListener("click", () => onDelete());
actions.appendChild(del);
}
form.appendChild(actions);
host.appendChild(form);
}
function textField(name: string, label: string, value: string, required: boolean, hint?: string): HTMLElement {
const wrap = document.createElement("label");
wrap.className = "admin-bb-form-row";
const lab = document.createElement("span");
lab.textContent = label + (required ? " *" : "");
wrap.appendChild(lab);
const input = document.createElement("input");
input.type = "text";
input.name = name;
input.className = "entity-form-input";
input.value = value;
if (required) input.required = true;
wrap.appendChild(input);
if (hint) {
const h = document.createElement("small");
h.className = "admin-bb-form-hint";
h.textContent = hint;
wrap.appendChild(h);
}
return wrap;
}
function textareaField(name: string, label: string, value: string, rows: number): HTMLElement {
const wrap = document.createElement("label");
wrap.className = "admin-bb-form-row";
const lab = document.createElement("span");
lab.textContent = label;
wrap.appendChild(lab);
const ta = document.createElement("textarea");
ta.name = name;
ta.className = "entity-form-input";
ta.rows = rows;
ta.value = value;
wrap.appendChild(ta);
return wrap;
}
function selectField(name: string, label: string, value: string, options: string[], required: boolean): HTMLElement {
const wrap = document.createElement("label");
wrap.className = "admin-bb-form-row";
const lab = document.createElement("span");
lab.textContent = label + (required ? " *" : "");
wrap.appendChild(lab);
const sel = document.createElement("select");
sel.name = name;
sel.className = "entity-form-input";
for (const opt of options) {
const o = document.createElement("option");
o.value = opt;
o.textContent = opt;
if (opt === value) o.selected = true;
sel.appendChild(o);
}
wrap.appendChild(sel);
return wrap;
}
function checkboxField(name: string, label: string, value: boolean): HTMLElement {
const wrap = document.createElement("label");
wrap.className = "admin-bb-form-row admin-bb-form-row--checkbox";
const input = document.createElement("input");
input.type = "checkbox";
input.name = name;
input.checked = value;
wrap.appendChild(input);
const lab = document.createElement("span");
lab.textContent = label;
wrap.appendChild(lab);
return wrap;
}
async function onSave(isNew: boolean): Promise<void> {
const form = document.querySelector(".admin-bb-form") as HTMLFormElement | null;
if (!form) return;
const data = new FormData(form);
const payload: Record<string, unknown> = {};
for (const key of ["slug", "section_key", "title_de", "title_en", "content_md_de", "content_md_en", "visibility"]) {
const v = data.get(key);
if (v !== null) payload[key] = String(v);
}
for (const key of ["firm", "proceeding_family", "description_de", "description_en"]) {
const v = data.get(key);
if (v !== null) {
const s = String(v).trim();
payload[key] = s === "" ? null : s;
}
}
payload.is_published = (data.get("is_published") === "on");
if (!isNew) {
const note = data.get("note");
if (note) payload.note = String(note);
}
try {
const url = isNew
? "/api/admin/submission-building-blocks"
: `/api/admin/submission-building-blocks/${state.selectedID}`;
const method = isNew ? "POST" : "PATCH";
const res = await fetch(url, {
method,
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.json().catch(() => ({} as { error?: string }));
feedback(body.error ?? `HTTP ${res.status}`, true);
return;
}
const saved = await res.json() as BuildingBlockJSON;
feedback(isEN() ? "Saved." : "Gespeichert.", false);
await loadList();
state.selectedID = saved.id;
paintList();
paintEditor(saved);
await loadVersions(saved.id);
} catch (err) {
feedback(String(err), true);
}
}
async function onDelete(): Promise<void> {
if (!state.selectedID) return;
const sure = confirm(isEN() ? "Delete this block?" : "Diesen Baustein löschen?");
if (!sure) return;
try {
const res = await fetch(`/api/admin/submission-building-blocks/${state.selectedID}`, {
method: "DELETE",
credentials: "include",
});
if (!res.ok && res.status !== 204) {
feedback(`HTTP ${res.status}`, true);
return;
}
feedback(isEN() ? "Deleted." : "Gelöscht.", false);
state.selectedID = null;
await loadList();
paintEditor(null);
state.versions = [];
paintVersions();
} catch (err) {
feedback(String(err), true);
}
}
async function loadVersions(blockID: string): Promise<void> {
try {
const res = await fetch(`/api/admin/submission-building-blocks/${blockID}/versions`, { credentials: "include" });
if (!res.ok) {
state.versions = [];
paintVersions();
return;
}
const body = await res.json() as { versions?: VersionJSON[] };
state.versions = body.versions ?? [];
paintVersions();
} catch {
state.versions = [];
paintVersions();
}
}
function paintVersions(): void {
const host = document.getElementById("admin-bb-versions");
if (!host) return;
host.innerHTML = "";
if (state.versions.length === 0) return;
const h = document.createElement("h3");
h.textContent = isEN() ? "History" : "Verlauf";
host.appendChild(h);
for (const v of state.versions) {
const row = document.createElement("div");
row.className = "admin-bb-version-row";
const date = new Date(v.created_at).toLocaleString();
row.innerHTML = `
<div class="admin-bb-version-meta">${escapeHTML(date)}${escapeHTML(v.note ?? "")}</div>`;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn-small btn-secondary";
btn.textContent = isEN() ? "Restore" : "Wiederherstellen";
btn.addEventListener("click", () => onRestore(v.id));
row.appendChild(btn);
host.appendChild(row);
}
}
async function onRestore(versionID: string): Promise<void> {
if (!state.selectedID) return;
try {
const res = await fetch(
`/api/admin/submission-building-blocks/${state.selectedID}/restore/${versionID}`,
{ method: "POST", credentials: "include" },
);
if (!res.ok) {
feedback(`HTTP ${res.status}`, true);
return;
}
const restored = await res.json() as BuildingBlockJSON;
feedback(isEN() ? "Restored." : "Wiederhergestellt.", false);
paintEditor(restored);
await loadVersions(restored.id);
await loadList();
} catch (err) {
feedback(String(err), true);
}
}
function feedback(msg: string, isError: boolean): void {
const host = document.getElementById("admin-bb-feedback");
if (!host) return;
host.style.display = "";
host.className = "form-msg " + (isError ? "form-msg--error" : "form-msg--ok");
host.textContent = msg;
if (!isError) {
setTimeout(() => { host.style.display = "none"; }, 3000);
}
}
function escapeHTML(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Silence unused-import warning when t() isn't called directly — i18n
// is initialised so data-i18n attrs render on first paint.
void t;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
void boot();
}

View File

@@ -468,11 +468,125 @@ function initInviteButton() {
});
}
// t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal. Creates both
// the auth.users row (via Supabase Admin API) and the paliad.users row in
// one POST. New user appears in dropdowns immediately. Welcome email with
// magic-link is sent by default; admin can opt out via the checkbox.
function openAddFullModal() {
const modal = document.getElementById("admin-add-full-modal")!;
const fb = document.getElementById("admin-af-feedback")!;
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
fb.style.display = "none";
emailField.value = "";
nameField.value = "";
jobTitleField.value = "";
profSel.value = "associate";
langSel.value = "de";
sendWelcome.checked = true;
officeSel.innerHTML = officeOptions("munich");
modal.style.display = "flex";
emailField.focus();
}
function closeAddFullModal() {
document.getElementById("admin-add-full-modal")!.style.display = "none";
}
function initAddFullModal() {
document.getElementById("admin-team-add-full")!.addEventListener("click", openAddFullModal);
document.getElementById("admin-af-close")!.addEventListener("click", closeAddFullModal);
document.getElementById("admin-af-cancel")!.addEventListener("click", closeAddFullModal);
document.getElementById("admin-add-full-modal")!.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeAddFullModal();
});
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
// Pre-fill the display name from the email local-part the first time the
// admin tabs out of the email field — mirrors the existing onboard flow.
emailField.addEventListener("blur", () => {
if (nameField.value || !emailField.value) return;
const local = emailField.value.split("@")[0] ?? "";
nameField.value = local
.split(/[._-]/)
.map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s))
.join(" ")
.trim();
});
const form = document.getElementById("admin-add-full-form") as HTMLFormElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const fb = document.getElementById("admin-af-feedback")!;
fb.style.display = "none";
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
const submitBtn = document.getElementById("admin-af-submit") as HTMLButtonElement;
const payload: Record<string, unknown> = {
email: emailField.value.trim().toLowerCase(),
display_name: nameField.value.trim(),
office: officeSel.value,
job_title: jobTitleField.value.trim() || "Associate",
profession: profSel.value,
lang: langSel.value,
send_welcome_mail: sendWelcome.checked,
};
submitBtn.disabled = true;
try {
const resp = await fetch("/api/admin/users/full", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
// Map two friendly cases inline; everything else surfaces the
// server message so the admin can act on it.
if (resp.status === 503) {
fb.textContent = t("admin.team.add_full.error.unavailable")
|| "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY fehlt am Server).";
} else if (resp.status === 409) {
fb.textContent = body.error
|| (t("admin.team.add_full.error.email_exists")
|| "Es existiert bereits ein Konto für diese E-Mail — bitte 'Bestehendes Konto onboarden' verwenden.");
} else {
fb.textContent = body.error || (t("admin.team.add_full.error.generic") || "Fehler.");
}
fb.className = "form-msg form-msg-error";
fb.style.display = "block";
return;
}
const created = (await resp.json()) as User;
users = users.concat(created);
closeAddFullModal();
showFeedback(t("admin.team.add_full.feedback.added") || "Konto angelegt.", false);
render();
} finally {
submitBtn.disabled = false;
}
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initSearch();
initDirectAddModal();
initAddFullModal();
initInviteButton();
onLangChange(() => {
buildOfficeFilters();

View File

@@ -1,193 +0,0 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Appointment {
id: string;
project_id?: string;
title: string;
start_at: string;
end_at?: string;
appointment_type?: string;
project_reference?: string;
project_title?: string;
}
let allAppointments: Appointment[] = [];
let viewYear = 0;
let viewMonth = 0;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtMonth(year: number, month: number): string {
return `${tDyn(`cal.month.${month}`)} ${year}`;
}
function isoDate(year: number, month: number, day: number): string {
const m = String(month + 1).padStart(2, "0");
const d = String(day).padStart(2, "0");
return `${year}-${m}-${d}`;
}
async function loadAppointments() {
// Pull a wide window (current month plus a little buffer either side).
// We could narrow this, but the user typically navigates ±1-2 months
// and the dataset is small.
try {
const resp = await fetch("/api/appointments");
if (resp.ok) allAppointments = await resp.json();
} catch {
/* non-fatal */
}
}
function appointmentsForDate(iso: string): Appointment[] {
return allAppointments.filter((t) => t.start_at.slice(0, 10) === iso);
}
function typeClass(t?: string): string {
return t ? `termin-type-${t}` : "termin-type-default";
}
function fmtTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleTimeString(getLang() === "de" ? "de-DE" : "en-GB", {
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function render() {
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
const firstDay = new Date(viewYear, viewMonth, 1);
const jsWeekday = firstDay.getDay();
const offset = (jsWeekday + 6) % 7;
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const today = new Date();
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
const cells: string[] = [];
for (let i = 0; i < offset; i++) {
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
}
for (let day = 1; day <= daysInMonth; day++) {
const iso = isoDate(viewYear, viewMonth, day);
const items = appointmentsForDate(iso);
const isToday = iso === todayISO;
const dots = items
.slice(0, 4)
.map((tt) => `<span class="termin-dot ${typeClass(tt.appointment_type)}" title="${esc(tt.title)}"></span>`)
.join("");
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
cells.push(
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
<span class="frist-cal-day">${day}</span>
<div class="frist-cal-dots">${dots}${more}</div>
</div>`,
);
}
const grid = document.getElementById("appointment-cal-grid")!;
grid.innerHTML = cells.join("");
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
});
const monthStart = isoDate(viewYear, viewMonth, 1);
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
const hasInMonth = allAppointments.some((tt) => {
const iso = tt.start_at.slice(0, 10);
return iso >= monthStart && iso <= monthEnd;
});
const empty = document.getElementById("appointment-cal-empty")!;
empty.style.display = hasInMonth ? "none" : "";
}
function openPopup(iso: string) {
const items = appointmentsForDate(iso);
if (items.length === 0) return;
const popup = document.getElementById("cal-popup")!;
const dateEl = document.getElementById("cal-popup-date")!;
const list = document.getElementById("cal-popup-list")!;
const d = new Date(iso + "T00:00:00");
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
list.innerHTML = items
.map((tt) => {
const akteRef = tt.project_id
? `<a href="/projects/${esc(tt.project_id)}" class="frist-cal-popup-project">${esc(tt.project_reference ?? "")}</a>`
: `<span class="termin-personal-tag">${esc(t("appointments.personal"))}</span>`;
return `<li class="frist-cal-popup-item">
<span class="termin-dot ${typeClass(tt.appointment_type)}"></span>
<span class="frist-cal-popup-time">${esc(fmtTime(tt.start_at))}</span>
<a href="/appointments/${esc(tt.id)}" class="frist-cal-popup-title">${esc(tt.title)}</a>
${akteRef}
</li>`;
})
.join("");
popup.style.display = "flex";
}
function initPopup() {
const popup = document.getElementById("cal-popup")!;
const close = document.getElementById("cal-popup-close")!;
close.addEventListener("click", () => (popup.style.display = "none"));
popup.addEventListener("click", (e) => {
if (e.target === e.currentTarget) popup.style.display = "none";
});
}
function initNav() {
document.getElementById("cal-prev")!.addEventListener("click", () => {
viewMonth -= 1;
if (viewMonth < 0) {
viewMonth = 11;
viewYear -= 1;
}
render();
});
document.getElementById("cal-next")!.addEventListener("click", () => {
viewMonth += 1;
if (viewMonth > 11) {
viewMonth = 0;
viewYear += 1;
}
render();
});
document.getElementById("cal-today")!.addEventListener("click", () => {
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
render();
});
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
initNav();
initPopup();
onLangChange(render);
await loadAppointments();
render();
});

View File

@@ -2,6 +2,7 @@ import { initI18n, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { initNotes } from "./notes";
import { projectIndent } from "./project-indent";
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
interface Appointment {
id: string;
@@ -25,6 +26,9 @@ interface PendingApprovalRequest {
requested_at: string;
required_role: string;
requester_name?: string;
// t-paliad-252 — used by the withdraw warning modal to pick the right
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
lifecycle_event?: string;
}
interface Me {
@@ -43,6 +47,10 @@ let project: Project | null = null;
let allProjects: Project[] = [];
let pendingRequest: PendingApprovalRequest | null = null;
let me: Me | null = null;
// t-paliad-252 — see deadlines-detail.ts. Routes Save to the new
// /api/approval-requests/{id}/edit-entity endpoint when the user picked
// "Termin bearbeiten" in the withdraw warning modal.
let pendingEditMode = false;
function parseAppointmentID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
@@ -207,10 +215,14 @@ function renderHeader() {
}
// Freeze the edit form + delete button while a request is in flight.
// t-paliad-252 — when the user picked "Termin bearbeiten" in the
// withdraw modal, pendingEditMode unfreezes the form so Save can route
// to /edit-entity (which keeps the request pending + merges payload).
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
if (form) {
const freeze = isPending && !pendingEditMode;
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
.forEach((el) => { el.disabled = isPending; });
.forEach((el) => { el.disabled = freeze; });
}
const deleteBtn = document.getElementById("appointment-delete-btn") as HTMLButtonElement | null;
if (deleteBtn) deleteBtn.disabled = isPending;
@@ -263,6 +275,39 @@ async function saveEdit(ev: Event) {
submitBtn.disabled = true;
try {
// t-paliad-252 — pending-edit mode routes through /edit-entity which
// keeps the request pending + merges fields into payload. clear_project
// and project_id are NOT in the counter-allowlist (yet) — the requester
// can't move projects on a pending request from this surface.
if (pendingEditMode && pendingRequest) {
const editFields = { ...payload };
delete editFields.clear_project;
const resp = await fetch(
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: editFields }),
},
);
if (resp.ok) {
const fresh = await fetch(`/api/appointments/${appointment.id}`);
if (fresh.ok) appointment = await fresh.json();
await loadPendingRequest();
// Exit pending-edit mode so the form re-freezes (still pending).
pendingEditMode = false;
renderHeader();
fillEditForm();
msg.textContent = t("appointments.detail.saved");
msg.className = "form-msg form-msg-ok";
} else {
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
msg.textContent = data.message || data.error || t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
}
return;
}
const resp = await fetch(`/api/appointments/${appointment.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -312,12 +357,37 @@ async function deleteAppointment() {
}
}
// t-paliad-252 — withdraw warning modal replaces the old confirm().
// Returns:
// "edit" → unfreeze the edit form (pending-edit mode); Save will
// route through /api/approval-requests/{id}/edit-entity
// "withdraw" → destructive: the existing /revoke endpoint
// null → user cancelled
async function withdrawAppointmentRequest() {
if (!appointment || !pendingRequest) return;
if (!confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
if (btn) btn.disabled = true;
try {
const action = await openWithdrawWarningModal({
entityType: "appointment",
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
});
if (action === null) {
if (btn) btn.disabled = false;
return;
}
if (action === "edit") {
pendingEditMode = true;
if (btn) btn.disabled = false;
// renderHeader re-evaluates the freeze and unfreezes the form now
// that pendingEditMode is set. Focus the first editable field so the
// user can type immediately.
renderHeader();
const titleEl = document.getElementById("appointment-title-edit") as HTMLInputElement | null;
titleEl?.focus();
return;
}
// action === "withdraw" → destructive path.
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -328,9 +398,12 @@ async function withdrawAppointmentRequest() {
if (fresh.ok) {
appointment = await fresh.json();
await loadPendingRequest();
renderHeader();
fillEditForm();
} else {
// CREATE lifecycle: entity gone → back to the list.
window.location.href = "/events?type=appointment";
}
renderHeader();
fillEditForm();
} else {
const data = await resp.json().catch(() => ({}) as { message?: string; error?: string });
const msg = document.getElementById("appointment-edit-msg")!;

View File

@@ -0,0 +1,262 @@
// Akte-mode wiring for the Litigation Builder (m/paliad#153 B4,
// t-paliad-347).
//
// PRD §2.3 + §3.1 + §3.2: the page-header Akte picker lists every
// project (`type='case'`) the user can see. Picking one POSTs to
// /api/builder/scenarios/from-project, which mints a project-backed
// scenario (origin_project_id pinned) seeded with the project's
// proceeding + scenario_flags + completed deadlines. Subsequent
// builder edits dual-write through to paliad.deadlines + projects.
// scenario_flags via the server-side dual-write hooks.
//
// The picker is its own module so the builder.ts orchestrator only
// has to expose two hooks:
//
// - `onProjectChosen(projectId)` — called when the user picks a
// project. Builder calls the from-project endpoint and loads the
// returned scenario.
// - `setSelectedProject(scenario)` — called after a scenario loads
// so the picker reflects the current Akte (or "— ohne —" for
// kontextfrei scenarios).
//
// Cross-surface scenario-flag-changed (mig 154 ssoT, m/paliad#149):
// the builder listens to the existing CustomEvent so any peer surface
// that PATCHes /api/projects/{id}/scenario-flags triggers a re-fetch
// on the builder's active proceeding when the projectId matches the
// scenario's origin_project_id. The dispatch direction is already
// covered by patchScenarioFlags inside scenario-flags.ts — the
// builder's own PATCH /api/projects/.../scenario-flags goes through
// that helper so peer surfaces stay in sync without a separate dispatch.
import { t } from "./i18n";
export interface AkteProjectMeta {
id: string;
title: string;
reference?: string | null;
case_number?: string | null;
proceeding_type_id?: number | null;
our_side?: string | null;
}
export type OnProjectChosen = (projectId: string) => void | Promise<void>;
interface State {
projects: AkteProjectMeta[];
loaded: boolean;
}
const state: State = {
projects: [],
loaded: false,
};
// fetchAkteProjects pulls every type=case project the caller can see.
// Visibility is enforced by /api/projects via the project_teams /
// can_see_project predicate. We filter client-side to projects with a
// proceeding_type_id — those are the ones the builder can render. We
// don't filter server-side because /api/projects' filter param doesn't
// accept proceeding_type_id_not_null and round-tripping for that one
// reason isn't worth a new endpoint.
export async function fetchAkteProjects(): Promise<AkteProjectMeta[]> {
try {
const resp = await fetch("/api/projects?type=case", {
headers: { Accept: "application/json" },
});
if (!resp.ok) {
console.warn("builder-akte: /api/projects", resp.status);
return [];
}
const rows = (await resp.json()) as Array<{
id: string;
title: string;
reference?: string | null;
case_number?: string | null;
proceeding_type_id?: number | null;
our_side?: string | null;
status?: string;
}>;
return rows
.filter((r) => r.proceeding_type_id != null && (r.status ?? "active") === "active")
.map((r) => ({
id: r.id,
title: r.title,
reference: r.reference ?? null,
case_number: r.case_number ?? null,
proceeding_type_id: r.proceeding_type_id ?? null,
our_side: r.our_side ?? null,
}));
} catch (e) {
console.error("builder-akte: fetch projects failed", e);
return [];
}
}
// formatProjectLabel renders the dropdown row for a project. Reference
// + title are the primary anchors; the case_number tail disambiguates
// when two cases share a reference family.
function formatProjectLabel(p: AkteProjectMeta): string {
const parts: string[] = [];
if (p.reference) parts.push(p.reference);
parts.push(p.title);
if (p.case_number) parts.push("(" + p.case_number + ")");
return parts.join(" · ");
}
// renderAktePicker fills the existing <select id="builder-akte-picker">
// with the project list + a "— ohne —" sentinel. Idempotent.
function renderAktePicker(selectedId: string | null): void {
const sel = document.getElementById("builder-akte-picker") as HTMLSelectElement | null;
if (!sel) return;
const none = t("builder.akte.none");
const opts: string[] = [`<option value="" data-i18n="builder.akte.none">${escHtml(none)}</option>`];
for (const p of state.projects) {
const selected = p.id === selectedId ? " selected" : "";
opts.push(
`<option value="${escAttr(p.id)}"${selected}>${escHtml(formatProjectLabel(p))}</option>`,
);
}
sel.innerHTML = opts.join("");
}
// mountAktePicker is the entry point. It fetches the project list once,
// wires the dropdown change event to the supplied callback, and
// returns a controller exposing setSelectedProject so the builder can
// keep the picker reflective of the active scenario's Akte.
//
// The picker re-enables itself the moment projects load. While
// loading, the existing `disabled` attribute (set in procedures.tsx)
// stays so users don't pick during the fetch — but if the user lands
// on the page after the catalog is cached this is essentially
// instantaneous.
export interface AktePickerHandle {
setSelectedProject: (projectId: string | null) => void;
isAkteMode: () => boolean;
reload: () => Promise<void>;
}
export async function mountAktePicker(onChosen: OnProjectChosen): Promise<AktePickerHandle> {
const sel = document.getElementById("builder-akte-picker") as HTMLSelectElement | null;
if (!sel) {
return {
setSelectedProject: () => {},
isAkteMode: () => false,
reload: async () => {},
};
}
// First load — fill the dropdown, enable it, wire change.
state.projects = await fetchAkteProjects();
state.loaded = true;
renderAktePicker(null);
sel.disabled = false;
sel.addEventListener("change", () => {
const id = sel.value;
if (!id) {
// "— ohne —" reset is intentional; the builder treats this as
// "leave the current scenario alone, just clear the picker".
// Switching the active scenario to a non-Akte one happens via
// the scenario picker, not by clicking the empty Akte option.
return;
}
void onChosen(id);
});
return {
setSelectedProject: (projectId: string | null) => {
const next = projectId ?? "";
// Renderless quick-sync when the option is present; otherwise
// re-render so the option appears (covers freshly created
// projects since this picker last loaded).
const optEl = sel.querySelector<HTMLOptionElement>(`option[value="${cssEscape(next)}"]`);
if (next && !optEl) {
renderAktePicker(next);
} else {
sel.value = next;
}
},
isAkteMode: () => sel.value !== "",
reload: async () => {
state.projects = await fetchAkteProjects();
renderAktePicker(sel.value || null);
},
};
}
// createScenarioFromProject posts to the B4 entry point. Returns the
// new scenario's deep payload on success (id + proceedings + events),
// null on failure. Caller is expected to load the returned scenario
// via the builder's existing fetchScenarioDeep / state.active path.
export async function createScenarioFromProject(projectId: string): Promise<{ id: string } | null> {
try {
const resp = await fetch("/api/builder/scenarios/from-project", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ project_id: projectId }),
});
if (!resp.ok) {
console.warn("builder-akte: from-project", resp.status, await resp.text().catch(() => ""));
return null;
}
const out = await resp.json();
return out && typeof out.id === "string" ? { id: out.id } : null;
} catch (e) {
console.error("builder-akte: from-project failed", e);
return null;
}
}
// renderAkteBanner toggles the "Aus Akte: <code>" badge next to the
// scenario picker. The badge is a <span class="builder-akte-banner">
// inserted/removed by this helper; CSS gives it a lime tint to match
// the Akte affordance throughout the app. Pass `null` (or omit
// projectId) to hide.
export function renderAkteBanner(projectId: string | null): void {
const host = document.querySelector(".builder-pageheader") as HTMLElement | null;
if (!host) return;
let badge = document.getElementById("builder-akte-banner");
if (!projectId) {
if (badge) badge.remove();
return;
}
const meta = state.projects.find((p) => p.id === projectId);
const label = meta ? formatProjectLabel(meta) : projectId.slice(0, 8);
const text =
t("builder.akte.banner.prefix") + " " + label;
if (!badge) {
badge = document.createElement("span");
badge.id = "builder-akte-banner";
badge.className = "builder-akte-banner";
badge.setAttribute("role", "note");
host.appendChild(badge);
}
badge.textContent = text;
}
// ────────────────────────────────────────────────────────────────────────────
// helpers
// ────────────────────────────────────────────────────────────────────────────
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// cssEscape is a small fallback for browsers that don't yet expose
// CSS.escape. UUIDs only contain [0-9a-f-] so even the naïve replacer
// keeps us safe; the function exists to make intent obvious.
function cssEscape(s: string): string {
if (typeof CSS !== "undefined" && typeof (CSS as { escape?: (s: string) => string }).escape === "function") {
return (CSS as { escape: (s: string) => string }).escape(s);
}
return s.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
}

View File

@@ -0,0 +1,147 @@
// Add-proceeding inline picker for the Litigation Builder.
//
// PRD §3 + §3.1: "+ Verfahren hinzufügen" button at the bottom of the
// triplet stack opens an inline picker. Forum chip row (UPC for v1)
// gates the Verfahren chip row, click → callback. Designed for B1's
// single-triplet flow and B2's multi-triplet stacking with no shape
// change between slices.
import { t } from "./i18n";
export interface ProceedingTypeMeta {
id: number;
code: string;
name: string;
nameEN: string;
// group / jurisdiction. The proceeding-types API returns "UPC" /
// "DE" / etc. as the canonical jurisdiction; for v1 the picker
// only renders UPC.
group?: string;
jurisdiction?: string;
}
type OnPick = (meta: ProceedingTypeMeta) => void | Promise<void>;
let activePopover: HTMLElement | null = null;
export function mountAddProceedingPicker(
anchor: HTMLElement,
types: ProceedingTypeMeta[],
onPick: OnPick,
): void {
closeActive();
const pop = document.createElement("div");
pop.className = "builder-picker-popover";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-label", t("builder.picker.aria"));
const header = document.createElement("div");
header.className = "builder-picker-header";
header.innerHTML = `
<strong class="builder-picker-title">${escHtml(t("builder.picker.title"))}</strong>
<button type="button" class="builder-picker-close" aria-label="${escAttr(t("builder.picker.close"))}">×</button>
`;
pop.appendChild(header);
// Forum row — UPC only for v1. Disabled chips render greyed.
const forumRow = document.createElement("div");
forumRow.className = "builder-picker-row";
forumRow.innerHTML = `
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.forum"))}</span>
<div class="builder-picker-chips">
<button type="button" class="builder-picker-chip is-active" data-forum="UPC">UPC</button>
<button type="button" class="builder-picker-chip" data-forum="DE" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DE</button>
<button type="button" class="builder-picker-chip" data-forum="EPA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">EPA</button>
<button type="button" class="builder-picker-chip" data-forum="DPMA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DPMA</button>
</div>
`;
pop.appendChild(forumRow);
const procRow = document.createElement("div");
procRow.className = "builder-picker-row";
procRow.innerHTML = `
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.proc"))}</span>
<div class="builder-picker-chips builder-picker-chips--wrap" id="builder-picker-proc-chips"></div>
`;
pop.appendChild(procRow);
const empty = document.createElement("p");
empty.className = "builder-picker-empty";
empty.hidden = true;
empty.textContent = t("builder.picker.empty");
pop.appendChild(empty);
const procHost = pop.querySelector("#builder-picker-proc-chips") as HTMLElement;
const lang = document.documentElement.lang === "en" ? "en" : "de";
for (const meta of types) {
const label = lang === "en" ? (meta.nameEN || meta.name) : meta.name;
const chip = document.createElement("button");
chip.type = "button";
chip.className = "builder-picker-chip builder-picker-chip--proc";
chip.setAttribute("data-code", meta.code);
chip.innerHTML = `<span class="builder-picker-chip-code">${escHtml(meta.code)}</span>
<span class="builder-picker-chip-name">${escHtml(label)}</span>`;
chip.addEventListener("click", () => {
closeActive();
void onPick(meta);
});
procHost.appendChild(chip);
}
if (types.length === 0) empty.hidden = false;
header.querySelector(".builder-picker-close")?.addEventListener("click", () => {
closeActive();
});
// Position the popover under the anchor button.
positionUnder(pop, anchor);
document.body.appendChild(pop);
activePopover = pop;
document.addEventListener("click", onOutsideClick, true);
document.addEventListener("keydown", onEscape, true);
}
function positionUnder(pop: HTMLElement, anchor: HTMLElement): void {
const rect = anchor.getBoundingClientRect();
pop.style.position = "absolute";
const top = rect.bottom + window.scrollY + 6;
// Default left = anchor's left; clamp so popover stays in viewport.
const left = Math.max(8, rect.left + window.scrollX);
pop.style.top = `${top}px`;
pop.style.left = `${left}px`;
pop.style.maxWidth = "min(640px, calc(100vw - 24px))";
pop.style.zIndex = "60";
}
function onOutsideClick(ev: Event): void {
if (!activePopover) return;
const target = ev.target as Node;
if (activePopover.contains(target)) return;
closeActive();
}
function onEscape(ev: KeyboardEvent): void {
if (ev.key === "Escape") closeActive();
}
function closeActive(): void {
if (activePopover) {
activePopover.remove();
activePopover = null;
}
document.removeEventListener("click", onOutsideClick, true);
document.removeEventListener("keydown", onEscape, true);
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -0,0 +1,370 @@
// Litigation Builder — promote-to-project wizard (m/paliad#153 PRD §2.4
// + §5.4, B5).
//
// 3 steps: Bestätigen (read-only summary) → Parteien ergänzen (party
// names) → Akte-Metadaten (title, reference, case number, our_side,
// litigation parent, team). Commit POSTs the merged payload to
// /api/builder/scenarios/{id}/promote — a single server-side transaction
// (no partial promotions) that creates the paliad.projects 'case' row,
// cascades deadlines, and flips the scenario to 'promoted'. On success
// the wizard navigates to /projects/{new-id}.
import { t } from "./i18n";
interface ProjectOption {
id: string;
title: string;
type: string;
reference?: string;
}
interface UserOption {
id: string;
email: string;
display_name?: string;
office?: string;
}
interface PartyRow {
name: string;
role: string;
representative: string;
}
export interface PromoteContext {
scenarioId: string;
ownerId?: string;
proceedingLabel: string;
filedCount: number;
plannedCount: number;
flagCount: number;
extraTopLevel: number;
defaultOurSide: "claimant" | "defendant" | null;
defaultTitle: string;
onSuccess: (projectId: string) => void;
}
export async function openPromoteWizard(ctx: PromoteContext): Promise<void> {
// Parallel fetch: litigation parents + HLC users (both optional pickers).
const [parents, users] = await Promise.all([
fetchProjects("litigation"),
fetchUsers(),
]);
let step = 1;
const parties: PartyRow[] = [];
const meta = {
title: ctx.defaultTitle || "",
reference: "",
caseNumber: "",
clientNumber: "",
ourSide: (ctx.defaultOurSide ?? "") as "" | "claimant" | "defendant",
parentId: "",
teamIds: new Set<string>(),
};
const backdrop = document.createElement("div");
backdrop.className = "builder-modal-backdrop";
const modal = document.createElement("div");
modal.className = "builder-modal builder-promote-modal";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-label", t("builder.promote.title"));
backdrop.appendChild(modal);
const close = () => {
document.removeEventListener("keydown", onEsc, true);
backdrop.remove();
};
const onEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
document.addEventListener("keydown", onEsc, true);
function stepHeader(): string {
const steps = [
t("builder.promote.step1"),
t("builder.promote.step2"),
t("builder.promote.step3"),
];
const dots = steps.map((label, i) => {
const n = i + 1;
const cls = n === step ? " is-active" : n < step ? " is-done" : "";
return `<li class="builder-promote-step${cls}"><span class="builder-promote-step-n">${n}</span>` +
`<span class="builder-promote-step-label">${escHtml(label)}</span></li>`;
}).join("");
return `<ol class="builder-promote-steps">${dots}</ol>`;
}
function renderStep1(): string {
const rows = [
`<li><span>${escHtml(t("builder.promote.summary.proceeding"))}</span><strong>${escHtml(ctx.proceedingLabel)}</strong></li>`,
`<li><span>${escHtml(t("builder.promote.summary.events_filed"))}</span><strong>${ctx.filedCount}</strong></li>`,
`<li><span>${escHtml(t("builder.promote.summary.events_planned"))}</span><strong>${ctx.plannedCount}</strong></li>`,
`<li><span>${escHtml(t("builder.promote.summary.flags"))}</span><strong>${ctx.flagCount}</strong></li>`,
].join("");
const extra = ctx.extraTopLevel > 0
? `<p class="builder-promote-note">${escHtml(
t("builder.promote.summary.note_extra").replace("{n}", String(ctx.extraTopLevel)),
)}</p>`
: "";
return (
`<h3 class="builder-promote-section-title">${escHtml(t("builder.promote.summary.heading"))}</h3>` +
`<ul class="builder-promote-summary">${rows}</ul>${extra}`
);
}
function renderStep2(): string {
const list = parties.length === 0
? `<p class="builder-promote-empty">${escHtml(t("builder.promote.parties.empty"))}</p>`
: parties.map((p, i) => (
`<div class="builder-promote-party" data-idx="${i}">` +
`<input class="builder-promote-party-name" placeholder="${escAttr(t("builder.promote.parties.name"))}" value="${escAttr(p.name)}" />` +
`<input class="builder-promote-party-role" placeholder="${escAttr(t("builder.promote.parties.role"))}" value="${escAttr(p.role)}" />` +
`<input class="builder-promote-party-rep" placeholder="${escAttr(t("builder.promote.parties.representative"))}" value="${escAttr(p.representative)}" />` +
`<button type="button" class="builder-promote-party-remove" aria-label="${escAttr(t("builder.promote.parties.remove"))}">×</button>` +
`</div>`
)).join("");
return (
`<p class="builder-promote-hint">${escHtml(t("builder.promote.parties.hint"))}</p>` +
`<div class="builder-promote-parties">${list}</div>` +
`<button type="button" class="builder-promote-party-add">${escHtml(t("builder.promote.parties.add"))}</button>`
);
}
function renderStep3(): string {
const parentOpts = [`<option value="">${escHtml(t("builder.promote.meta.parent.none"))}</option>`]
.concat(parents.map((p) => {
const sel = p.id === meta.parentId ? " selected" : "";
const label = p.reference ? `${p.title} (${p.reference})` : p.title;
return `<option value="${escAttr(p.id)}"${sel}>${escHtml(label)}</option>`;
})).join("");
const sideSel = (v: string) => (meta.ourSide === v ? " selected" : "");
const team = users
.filter((u) => u.id !== ctx.ownerId)
.slice(0, 40)
.map((u) => {
const checked = meta.teamIds.has(u.id) ? " checked" : "";
const label = (u.display_name || "").trim()
? ((u.office ? `${u.display_name} · ${u.office}` : u.display_name) as string)
: u.email;
return (
`<label class="builder-promote-team-item">` +
`<input type="checkbox" class="builder-promote-team-cb" data-user-id="${escAttr(u.id)}"${checked} />` +
`<span>${escHtml(label)}</span></label>`
);
}).join("");
return (
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.title"))}</span>` +
`<input class="builder-promote-title" placeholder="${escAttr(t("builder.promote.meta.title.placeholder"))}" value="${escAttr(meta.title)}" /></label>` +
`<div class="builder-promote-field-row">` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.reference"))}</span>` +
`<input class="builder-promote-reference" value="${escAttr(meta.reference)}" /></label>` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.case_number"))}</span>` +
`<input class="builder-promote-casenumber" value="${escAttr(meta.caseNumber)}" /></label>` +
`</div>` +
`<div class="builder-promote-field-row">` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.client_number"))}</span>` +
`<input class="builder-promote-clientnumber" value="${escAttr(meta.clientNumber)}" /></label>` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.our_side"))}</span>` +
`<select class="builder-promote-ourside">` +
`<option value=""${sideSel("")}>${escHtml(t("builder.promote.meta.our_side.none"))}</option>` +
`<option value="claimant"${sideSel("claimant")}>${escHtml(t("builder.promote.meta.our_side.claimant"))}</option>` +
`<option value="defendant"${sideSel("defendant")}>${escHtml(t("builder.promote.meta.our_side.defendant"))}</option>` +
`</select></label>` +
`</div>` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.parent"))}</span>` +
`<select class="builder-promote-parent">${parentOpts}</select></label>` +
`<div class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.team"))}</span>` +
`<p class="builder-promote-team-hint">${escHtml(t("builder.promote.meta.team.hint"))}</p>` +
`<div class="builder-promote-team">${team}</div></div>` +
`<p class="builder-promote-error" hidden></p>`
);
}
function render(): void {
let body = "";
if (step === 1) body = renderStep1();
else if (step === 2) body = renderStep2();
else body = renderStep3();
const backLabel = t("builder.promote.back");
const cancelLabel = t("builder.promote.cancel");
const nextLabel = step < 3 ? t("builder.promote.next") : t("builder.promote.commit");
modal.innerHTML = `
<header class="builder-modal-header">
<h2 class="builder-modal-title">${escHtml(t("builder.promote.title"))}</h2>
<button type="button" class="builder-modal-close" aria-label="${escAttr(cancelLabel)}">×</button>
</header>
${stepHeader()}
<div class="builder-promote-body">${body}</div>
<footer class="builder-promote-footer">
<button type="button" class="builder-promote-cancel">${escHtml(cancelLabel)}</button>
<span class="builder-promote-footer-spacer"></span>
${step > 1 ? `<button type="button" class="builder-promote-backbtn">${escHtml(backLabel)}</button>` : ""}
<button type="button" class="builder-promote-nextbtn builder-action-btn--primary">${escHtml(nextLabel)}</button>
</footer>`;
wire();
}
function captureStep2(): void {
modal.querySelectorAll<HTMLElement>(".builder-promote-party").forEach((row) => {
const idx = Number(row.getAttribute("data-idx"));
if (Number.isNaN(idx) || !parties[idx]) return;
parties[idx].name = (row.querySelector(".builder-promote-party-name") as HTMLInputElement).value;
parties[idx].role = (row.querySelector(".builder-promote-party-role") as HTMLInputElement).value;
parties[idx].representative = (row.querySelector(".builder-promote-party-rep") as HTMLInputElement).value;
});
}
function captureStep3(): void {
const get = (sel: string) => (modal.querySelector(sel) as HTMLInputElement | null)?.value ?? "";
meta.title = get(".builder-promote-title");
meta.reference = get(".builder-promote-reference");
meta.caseNumber = get(".builder-promote-casenumber");
meta.clientNumber = get(".builder-promote-clientnumber");
meta.ourSide = ((modal.querySelector(".builder-promote-ourside") as HTMLSelectElement)?.value || "") as typeof meta.ourSide;
meta.parentId = (modal.querySelector(".builder-promote-parent") as HTMLSelectElement)?.value || "";
meta.teamIds = new Set(
Array.from(modal.querySelectorAll<HTMLInputElement>(".builder-promote-team-cb:checked"))
.map((cb) => cb.getAttribute("data-user-id") || "")
.filter(Boolean),
);
}
function wire(): void {
modal.querySelector(".builder-modal-close")?.addEventListener("click", close);
modal.querySelector(".builder-promote-cancel")?.addEventListener("click", close);
modal.querySelector(".builder-promote-backbtn")?.addEventListener("click", () => {
if (step === 2) captureStep2();
if (step === 3) captureStep3();
step = Math.max(1, step - 1);
render();
});
modal.querySelector(".builder-promote-nextbtn")?.addEventListener("click", () => {
if (step === 2) captureStep2();
if (step < 3) {
step += 1;
render();
return;
}
captureStep3();
void commit();
});
if (step === 2) {
modal.querySelector(".builder-promote-party-add")?.addEventListener("click", () => {
captureStep2();
parties.push({ name: "", role: "", representative: "" });
render();
});
modal.querySelectorAll<HTMLElement>(".builder-promote-party-remove").forEach((btn) => {
btn.addEventListener("click", () => {
captureStep2();
const row = btn.closest(".builder-promote-party") as HTMLElement;
const idx = Number(row?.getAttribute("data-idx"));
if (!Number.isNaN(idx)) parties.splice(idx, 1);
render();
});
});
}
}
async function commit(): Promise<void> {
const errEl = modal.querySelector(".builder-promote-error") as HTMLElement | null;
const showErr = (msg: string) => {
if (errEl) {
errEl.textContent = msg;
errEl.hidden = false;
}
};
if (!meta.title.trim()) {
showErr(t("builder.promote.error.title_required"));
return;
}
const nextBtn = modal.querySelector(".builder-promote-nextbtn") as HTMLButtonElement | null;
if (nextBtn) nextBtn.disabled = true;
const payload: Record<string, unknown> = {
title: meta.title.trim(),
reference: meta.reference.trim() || undefined,
case_number: meta.caseNumber.trim() || undefined,
client_number: meta.clientNumber.trim() || undefined,
our_side: meta.ourSide || undefined,
parent_id: meta.parentId || undefined,
parties: parties
.filter((p) => p.name.trim())
.map((p) => ({
name: p.name.trim(),
role: p.role.trim() || undefined,
representative: p.representative.trim() || undefined,
})),
team_members: Array.from(meta.teamIds).map((id) => ({ user_id: id })),
};
try {
const resp = await fetch(
"/api/builder/scenarios/" + encodeURIComponent(ctx.scenarioId) + "/promote",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!resp.ok) {
if (nextBtn) nextBtn.disabled = false;
showErr(t("builder.promote.error.generic"));
return;
}
const out = (await resp.json()) as { project_id: string };
const body = modal.querySelector(".builder-promote-body") as HTMLElement;
if (body) body.innerHTML = `<p class="builder-promote-success">${escHtml(t("builder.promote.success"))}</p>`;
ctx.onSuccess(out.project_id);
} catch {
if (nextBtn) nextBtn.disabled = false;
showErr(t("builder.promote.error.generic"));
}
}
render();
document.body.appendChild(backdrop);
(modal.querySelector(".builder-promote-nextbtn") as HTMLElement | null)?.focus();
}
async function fetchProjects(type: string): Promise<ProjectOption[]> {
try {
const resp = await fetch("/api/projects?type=" + encodeURIComponent(type));
if (!resp.ok) return [];
const data = (await resp.json()) as ProjectOption[];
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
async function fetchUsers(): Promise<UserOption[]> {
try {
const resp = await fetch("/api/users");
if (!resp.ok) return [];
const data = (await resp.json()) as UserOption[];
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -0,0 +1,412 @@
// Universal search dropdown for the Litigation Builder (m/paliad#153 B3).
//
// PRD §2.2 + §3.1 + §6.3: the page-header search box ("Suche") drives
// a typed dropdown returning grouped event / scenario / project hits.
// Picking an event lands the user on a scratch scenario with one
// triplet anchored on that event's proceeding type. Picking a scenario
// loads it; picking a project (Akte) is deferred to B4 (the dropdown
// row renders but pick falls through to a console hint until B4 wires
// project-backed scenarios).
//
// The controller is owned by builder.ts; this module exports
// `mountBuilderSearch` which wires the input + dropdown lifecycle and
// invokes the supplied callbacks. No module-level state — re-mounting
// is safe.
import { t } from "./i18n";
export interface EventSearchHit {
id: string;
code: string;
name_de: string;
name_en: string;
event_kind?: string | null;
primary_party?: string | null;
anchor_rule_id: string;
follow_up_count: number;
proceeding_type: {
id: number;
code: string;
name_de: string;
name_en: string;
jurisdiction?: string | null;
};
}
export interface ScenarioSearchHit {
id: string;
name: string;
status: string;
updated_at: string;
}
export interface ProjectSearchHit {
id: string;
type: string;
title: string;
reference?: string | null;
case_number?: string | null;
matter_number?: string | null;
client_number?: string | null;
}
export interface UniversalSearchResponse {
query: string;
events: EventSearchHit[];
scenarios: ScenarioSearchHit[];
projects: ProjectSearchHit[];
counts: { events: number; scenarios: number; projects: number };
}
export interface BuilderSearchCallbacks {
onPickEvent: (hit: EventSearchHit) => void | Promise<void>;
onPickScenario: (hit: ScenarioSearchHit) => void | Promise<void>;
onPickProject?: (hit: ProjectSearchHit) => void | Promise<void>;
}
interface Controller {
input: HTMLInputElement;
dropdown: HTMLElement;
open: boolean;
abort: AbortController | null;
debounceTimer: number | null;
lang: "de" | "en";
}
let active: Controller | null = null;
// mountBuilderSearch wires the universal search behavior onto an
// existing <input>. Idempotent — re-calling tears down the previous
// dropdown and rebinds. Returns a controller exposing focus() so the
// entry-mode toggle in builder.ts can land on the search input.
export function mountBuilderSearch(
input: HTMLInputElement,
cb: BuilderSearchCallbacks,
): { focus: () => void; close: () => void } {
teardown();
const lang: "de" | "en" = document.documentElement.lang === "en" ? "en" : "de";
// Single dropdown container, anchored under the input. Positioned
// absolutely so it floats above the canvas without reflowing layout.
const dropdown = document.createElement("div");
dropdown.className = "builder-search-dropdown";
dropdown.setAttribute("role", "listbox");
dropdown.hidden = true;
document.body.appendChild(dropdown);
active = {
input,
dropdown,
open: false,
abort: null,
debounceTimer: null,
lang,
};
input.addEventListener("input", onInput);
input.addEventListener("focus", onFocus);
input.addEventListener("keydown", onKeydown);
document.addEventListener("click", onOutsideClick, true);
window.addEventListener("resize", reposition);
window.addEventListener("scroll", reposition, true);
// Click handler is wired once on the dropdown root via event
// delegation; per-row data attributes identify the hit type.
dropdown.addEventListener("click", (ev) => {
const row = (ev.target as HTMLElement).closest<HTMLElement>(".builder-search-row");
if (!row) return;
const kind = row.getAttribute("data-hit-kind");
const payload = row.getAttribute("data-hit-payload");
if (!kind || !payload) return;
try {
const hit = JSON.parse(payload);
ev.stopPropagation();
closeDropdown();
if (kind === "event") void cb.onPickEvent(hit);
else if (kind === "scenario") void cb.onPickScenario(hit);
else if (kind === "project" && cb.onPickProject) void cb.onPickProject(hit);
} catch (err) {
console.error("builder-search: bad payload", err);
}
});
return {
focus: () => {
input.focus();
// Open the dropdown on focus even when input is empty — show the
// "start typing" hint per PRD §2.2 (search box auto-focuses).
openDropdown();
renderHint(t("builder.search.hint.start"));
},
close: closeDropdown,
};
}
function teardown(): void {
if (!active) return;
if (active.abort) active.abort.abort();
if (active.debounceTimer !== null) window.clearTimeout(active.debounceTimer);
active.dropdown.remove();
active.input.removeEventListener("input", onInput);
active.input.removeEventListener("focus", onFocus);
active.input.removeEventListener("keydown", onKeydown);
document.removeEventListener("click", onOutsideClick, true);
window.removeEventListener("resize", reposition);
window.removeEventListener("scroll", reposition, true);
active = null;
}
function onInput(): void {
if (!active) return;
const q = active.input.value.trim();
if (active.debounceTimer !== null) window.clearTimeout(active.debounceTimer);
if (q.length === 0) {
openDropdown();
renderHint(t("builder.search.hint.start"));
return;
}
if (q.length < 2) {
openDropdown();
renderHint(t("builder.search.hint.short"));
return;
}
active.debounceTimer = window.setTimeout(() => {
void runSearch(q);
}, 180);
}
function onFocus(): void {
if (!active) return;
const q = active.input.value.trim();
if (q.length === 0) {
openDropdown();
renderHint(t("builder.search.hint.start"));
} else if (q.length >= 2) {
void runSearch(q);
}
}
function onKeydown(ev: KeyboardEvent): void {
if (!active) return;
if (ev.key === "Escape") {
closeDropdown();
return;
}
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
const rows = Array.from(active.dropdown.querySelectorAll<HTMLElement>(".builder-search-row"));
if (rows.length === 0) return;
ev.preventDefault();
const current = active.dropdown.querySelector<HTMLElement>(".builder-search-row.is-focus");
let idx = current ? rows.indexOf(current) : -1;
idx = ev.key === "ArrowDown"
? Math.min(rows.length - 1, idx + 1)
: Math.max(0, idx - 1);
rows.forEach((r) => r.classList.remove("is-focus"));
rows[idx].classList.add("is-focus");
rows[idx].scrollIntoView({ block: "nearest" });
return;
}
if (ev.key === "Enter") {
const focused = active.dropdown.querySelector<HTMLElement>(".builder-search-row.is-focus");
if (focused) {
ev.preventDefault();
focused.click();
}
}
}
function onOutsideClick(ev: Event): void {
if (!active) return;
const target = ev.target as Node;
if (active.input.contains(target)) return;
if (active.dropdown.contains(target)) return;
closeDropdown();
}
async function runSearch(q: string): Promise<void> {
if (!active) return;
// Cancel any in-flight request so a slow earlier query can't clobber
// a faster newer one.
if (active.abort) active.abort.abort();
const ctl = new AbortController();
active.abort = ctl;
openDropdown();
renderHint(t("builder.search.hint.loading"));
try {
const url = "/api/builder/search?q=" + encodeURIComponent(q);
const resp = await fetch(url, { signal: ctl.signal });
if (!resp.ok) {
renderHint(t("builder.search.hint.error"));
return;
}
const data = (await resp.json()) as UniversalSearchResponse;
if (active.abort !== ctl) return;
renderResults(data);
} catch (err) {
if ((err as { name?: string })?.name === "AbortError") return;
console.error("builder-search error:", err);
renderHint(t("builder.search.hint.error"));
}
}
function renderHint(message: string): void {
if (!active) return;
active.dropdown.innerHTML = `<div class="builder-search-hint">${escHtml(message)}</div>`;
reposition();
}
function renderResults(data: UniversalSearchResponse): void {
if (!active) return;
const lang = active.lang;
const total = data.events.length + data.scenarios.length + data.projects.length;
if (total === 0) {
renderHint(t("builder.search.hint.empty"));
return;
}
// Result-count summary per PRD §2.2: "N Ereignisse · M Szenarios · K Akten"
const counts = `<div class="builder-search-summary">` +
escHtml(tCount("builder.search.summary.events", data.events.length)) +
` · ` +
escHtml(tCount("builder.search.summary.scenarios", data.scenarios.length)) +
` · ` +
escHtml(tCount("builder.search.summary.projects", data.projects.length)) +
`</div>`;
const sections: string[] = [counts];
if (data.events.length > 0) {
sections.push(renderGroup(
t("builder.search.group.events"),
data.events.map((e) => renderEventRow(e, lang)).join(""),
));
}
if (data.scenarios.length > 0) {
sections.push(renderGroup(
t("builder.search.group.scenarios"),
data.scenarios.map((s) => renderScenarioRow(s)).join(""),
));
}
if (data.projects.length > 0) {
sections.push(renderGroup(
t("builder.search.group.projects"),
data.projects.map((p) => renderProjectRow(p, lang)).join(""),
));
}
active.dropdown.innerHTML = sections.join("");
reposition();
}
function renderGroup(label: string, rowsHtml: string): string {
return `<section class="builder-search-group">` +
`<header class="builder-search-group-label">${escHtml(label)}</header>` +
rowsHtml +
`</section>`;
}
function renderEventRow(hit: EventSearchHit, lang: "de" | "en"): string {
const name = lang === "en" ? (hit.name_en || hit.name_de) : (hit.name_de || hit.name_en);
const ptName = lang === "en"
? (hit.proceeding_type.name_en || hit.proceeding_type.name_de)
: (hit.proceeding_type.name_de || hit.proceeding_type.name_en);
const party = hit.primary_party ? `<span class="builder-search-party">${escHtml(hit.primary_party)}</span>` : "";
const kind = hit.event_kind ? `<span class="builder-search-kind">${escHtml(hit.event_kind)}</span>` : "";
// Payload for the click handler — we embed the full hit so builder.ts
// doesn't need a second lookup. JSON-encoded into a data attribute,
// attr-escaped on the way in.
const payload = escAttr(JSON.stringify(hit));
return `<div class="builder-search-row" data-hit-kind="event" data-hit-payload="${payload}" tabindex="-1" role="option">` +
`<div class="builder-search-row-main">` +
`<span class="builder-search-pt-code">${escHtml(hit.proceeding_type.code)}</span>` +
`<span class="builder-search-event-name">${escHtml(name)}</span>` +
`</div>` +
`<div class="builder-search-row-meta">` +
`<span class="builder-search-pt-name">${escHtml(ptName)}</span>` +
kind + party +
`</div>` +
`</div>`;
}
function renderScenarioRow(hit: ScenarioSearchHit): string {
const payload = escAttr(JSON.stringify(hit));
return `<div class="builder-search-row" data-hit-kind="scenario" data-hit-payload="${payload}" tabindex="-1" role="option">` +
`<div class="builder-search-row-main">` +
`<span class="builder-search-scenario-name">${escHtml(hit.name)}</span>` +
`</div>` +
`<div class="builder-search-row-meta">` +
`<span class="builder-search-status">${escHtml(hit.status)}</span>` +
`</div>` +
`</div>`;
}
function renderProjectRow(hit: ProjectSearchHit, _lang: "de" | "en"): string {
const meta: string[] = [];
if (hit.case_number) meta.push(hit.case_number);
if (hit.matter_number) meta.push(hit.matter_number);
if (hit.client_number) meta.push(hit.client_number);
if (hit.reference) meta.push(hit.reference);
const metaText = meta.length > 0 ? meta.join(" · ") : "";
const payload = escAttr(JSON.stringify(hit));
return `<div class="builder-search-row" data-hit-kind="project" data-hit-payload="${payload}" tabindex="-1" role="option">` +
`<div class="builder-search-row-main">` +
`<span class="builder-search-project-type">${escHtml(hit.type)}</span>` +
`<span class="builder-search-project-title">${escHtml(hit.title)}</span>` +
`</div>` +
`<div class="builder-search-row-meta">${escHtml(metaText)}</div>` +
`</div>`;
}
function openDropdown(): void {
if (!active) return;
active.dropdown.hidden = false;
active.open = true;
reposition();
}
function closeDropdown(): void {
if (!active) return;
active.dropdown.hidden = true;
active.open = false;
if (active.abort) {
active.abort.abort();
active.abort = null;
}
}
function reposition(): void {
if (!active || !active.open) return;
const rect = active.input.getBoundingClientRect();
const top = rect.bottom + window.scrollY + 4;
const left = rect.left + window.scrollX;
const width = Math.max(rect.width, 380);
active.dropdown.style.position = "absolute";
active.dropdown.style.top = `${top}px`;
active.dropdown.style.left = `${left}px`;
active.dropdown.style.width = `${width}px`;
active.dropdown.style.zIndex = "60";
}
// tCount applies a simple plural pick: keys ".one" / ".other" carry
// the singular/plural variants; the caller's key is the bare stem.
function tCount(key: string, n: number): string {
const variant = n === 1 ? `${key}.one` : `${key}.other`;
return t(variant).replace("{n}", String(n));
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -0,0 +1,229 @@
// Litigation Builder — share-with-team UI (m/paliad#153 PRD §2.5, B5).
//
// "Teilen" opens a modal with an HLC user picker. Picking a colleague +
// "Schreibgeschützt teilen" POSTs a paliad.scenario_shares row; the owner
// stays sole editor. Existing shares are listed with a revoke affordance.
// The sharee sees the scenario in their "Geteilt mit mir" bucket (read-
// only) — that side is handled by builder.ts.
import { t } from "./i18n";
export interface ShareUser {
id: string;
email: string;
display_name?: string;
office?: string;
}
export interface BuilderShareRow {
id: string;
scenario_id: string;
shared_with_user_id: string;
created_by: string;
created_at: string;
}
interface ShareModalOpts {
scenarioId: string;
ownerId?: string;
currentShares: BuilderShareRow[];
// Called after a successful add/revoke with the fresh share list so the
// caller can update state.active.shares + re-render side panel buckets.
onChanged: (shares: BuilderShareRow[]) => void;
}
let allUsers: ShareUser[] | null = null;
async function fetchUsers(): Promise<ShareUser[]> {
if (allUsers) return allUsers;
try {
const resp = await fetch("/api/users");
if (!resp.ok) return [];
const data = (await resp.json()) as ShareUser[];
allUsers = Array.isArray(data) ? data : [];
return allUsers;
} catch {
return [];
}
}
function userLabel(u: ShareUser): string {
const name = (u.display_name || "").trim();
if (name) return u.office ? `${name} · ${u.office}` : name;
return u.email;
}
export async function openShareModal(opts: ShareModalOpts): Promise<void> {
const users = await fetchUsers();
let shares = [...opts.currentShares];
const backdrop = document.createElement("div");
backdrop.className = "builder-modal-backdrop";
backdrop.innerHTML = `
<div class="builder-modal builder-share-modal" role="dialog" aria-modal="true"
aria-label="${escAttr(t("builder.share.title"))}">
<header class="builder-modal-header">
<h2 class="builder-modal-title">${escHtml(t("builder.share.title"))}</h2>
<button type="button" class="builder-modal-close" aria-label="${escAttr(t("builder.share.close"))}">×</button>
</header>
<p class="builder-modal-subtitle">${escHtml(t("builder.share.subtitle"))}</p>
<div class="builder-share-pickerbox">
<input type="search" class="builder-share-search" autocomplete="off" spellcheck="false"
placeholder="${escAttr(t("builder.share.search.placeholder"))}" />
<ul class="builder-share-results" aria-label="${escAttr(t("builder.share.title"))}"></ul>
</div>
<div class="builder-share-current">
<h3 class="builder-share-current-title">${escHtml(t("builder.share.current.title"))}</h3>
<ul class="builder-share-current-list"></ul>
</div>
</div>`;
const close = () => {
document.removeEventListener("keydown", onEsc, true);
backdrop.remove();
};
const onEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
backdrop.querySelector(".builder-modal-close")?.addEventListener("click", close);
document.addEventListener("keydown", onEsc, true);
const searchEl = backdrop.querySelector(".builder-share-search") as HTMLInputElement;
const resultsEl = backdrop.querySelector(".builder-share-results") as HTMLElement;
const currentEl = backdrop.querySelector(".builder-share-current-list") as HTMLElement;
function renderCurrent(): void {
if (shares.length === 0) {
currentEl.innerHTML = `<li class="builder-share-current-empty">${escHtml(t("builder.share.current.empty"))}</li>`;
return;
}
currentEl.innerHTML = shares.map((sh) => {
const u = users.find((x) => x.id === sh.shared_with_user_id);
const label = u ? userLabel(u) : sh.shared_with_user_id;
return (
`<li class="builder-share-current-item" data-share-id="${escAttr(sh.id)}">` +
`<span class="builder-share-current-name">${escHtml(label)}</span>` +
`<button type="button" class="builder-share-revoke">${escHtml(t("builder.share.revoke"))}</button>` +
`</li>`
);
}).join("");
currentEl.querySelectorAll<HTMLElement>(".builder-share-current-item").forEach((li) => {
const id = li.getAttribute("data-share-id");
if (!id) return;
li.querySelector(".builder-share-revoke")?.addEventListener("click", () => {
void revoke(id);
});
});
}
function renderResults(): void {
const q = searchEl.value.trim().toLowerCase();
const sharedIds = new Set(shares.map((s) => s.shared_with_user_id));
const matches = users
.filter((u) => u.id !== opts.ownerId && !sharedIds.has(u.id))
.filter((u) => {
if (!q) return true;
return (
(u.display_name || "").toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
(u.office || "").toLowerCase().includes(q)
);
})
.slice(0, 12);
if (matches.length === 0) {
resultsEl.innerHTML = `<li class="builder-share-result-empty">${escHtml(t("builder.share.no_results"))}</li>`;
return;
}
resultsEl.innerHTML = matches.map((u) => (
`<li class="builder-share-result" data-user-id="${escAttr(u.id)}">` +
`<span class="builder-share-result-name">${escHtml(userLabel(u))}</span>` +
`<button type="button" class="builder-share-add">${escHtml(t("builder.share.button"))}</button>` +
`</li>`
)).join("");
resultsEl.querySelectorAll<HTMLElement>(".builder-share-result").forEach((li) => {
const uid = li.getAttribute("data-user-id");
if (!uid) return;
li.querySelector(".builder-share-add")?.addEventListener("click", () => {
void add(uid);
});
});
}
async function add(userId: string): Promise<void> {
try {
const resp = await fetch(
"/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) + "/shares",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ shared_with_user_id: userId }),
},
);
if (!resp.ok) {
flashError();
return;
}
const row = (await resp.json()) as BuilderShareRow;
shares = [...shares.filter((s) => s.id !== row.id), row];
searchEl.value = "";
renderResults();
renderCurrent();
opts.onChanged(shares);
} catch {
flashError();
}
}
async function revoke(shareId: string): Promise<void> {
try {
const resp = await fetch(
"/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) +
"/shares/" + encodeURIComponent(shareId),
{ method: "DELETE" },
);
if (!resp.ok && resp.status !== 204) {
flashError();
return;
}
shares = shares.filter((s) => s.id !== shareId);
renderResults();
renderCurrent();
opts.onChanged(shares);
} catch {
flashError();
}
}
function flashError(): void {
const box = backdrop.querySelector(".builder-share-pickerbox") as HTMLElement;
let err = box.querySelector(".builder-share-error") as HTMLElement | null;
if (!err) {
err = document.createElement("p");
err.className = "builder-share-error";
box.appendChild(err);
}
err.textContent = t("builder.share.error");
}
searchEl.addEventListener("input", renderResults);
renderResults();
renderCurrent();
document.body.appendChild(backdrop);
searchEl.focus();
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -0,0 +1,271 @@
// ProceedingTriplet renderer for the Litigation Builder.
//
// PRD §3.3 + §3.4 + §6.1: one triplet = jurisdiction badge + name +
// perspective + Detailgrad + columnar `proaktiv | court | reaktiv`
// body.
//
// B2 wires the live controls — perspective radio, scenario-flag strip,
// remove button, collapse — and the per-event-card overlays (3-state
// machine, action buttons, optional-horizon chip). The 3-column body
// itself is still produced by verfahrensablauf-core.renderColumnsBody;
// per-card overlays are layered on top after innerHTML write via the
// data-rule-id hooks added in the same slice.
import { t, tDyn, getLang } from "./i18n";
import type { DeadlineResponse, Side } from "./views/verfahrensablauf-core";
import type { BuilderProceeding, BuilderEvent } from "./builder";
import type { ProceedingTypeMeta } from "./builder-picker";
export interface ScenarioFlagCatalogEntry {
flag_key: string;
label_de: string;
label_en: string;
description?: string;
hidden_unless_set: boolean;
}
export interface TripletViewInput {
proceeding: BuilderProceeding;
meta: ProceedingTypeMeta;
data: DeadlineResponse | null;
side: Side;
// Flag catalog filtered to the keys the active proceeding actually
// references via its rules' condition_expr. B2 passes the global
// catalog and lets the user toggle any — flags that don't gate any
// rule are simply no-ops on this triplet.
flagCatalog: ScenarioFlagCatalogEntry[];
// Map keyed by sequencing_rule_id (lowercased UUID) → BuilderEvent
// for the per-card state machine. Cards whose rule is absent default
// to "planned".
eventsByRule: Map<string, BuilderEvent>;
// Per-card optional-horizon registry. Each rule with optional
// children carries a `+N Optionen` chip; the chip's count comes from
// here (defaults to scenario_events.horizon_optional, falls back to
// proceeding-level when not stored per-card).
columnsHtml: string;
isChild: boolean;
}
// Triplet header + controls + columns body. Pure-string render; the
// caller (builder.ts) wires click handlers on top.
export function renderTriplet(input: TripletViewInput): string {
const lang = getLang();
const procLabel = lang === "en"
? (input.meta.nameEN || input.meta.name)
: (input.meta.name || input.meta.nameEN);
const flagsBadge = activeFlagsBadge(input.proceeding.scenario_flags);
const body = input.data
? input.columnsHtml
: `<div class="builder-triplet-loading">${escHtml(t("builder.triplet.loading"))}</div>`;
const controls = renderControls(input);
const flagStrip = renderFlagStrip(input);
return `
<header class="builder-triplet-header">
<span class="builder-triplet-jurisdiction">${escHtml(jurisdictionFor(input.meta))}</span>
<span class="builder-triplet-code">${escHtml(input.meta.code)}</span>
<span class="builder-triplet-name">${escHtml(procLabel)}</span>
${flagsBadge}
</header>
${controls}
${flagStrip}
<div class="builder-triplet-body">
${body}
</div>
`;
}
function renderControls(input: TripletViewInput): string {
const perspective = input.side ?? "";
const detailgrad = input.proceeding.detailgrad || "selected";
const radio = (value: string, key: string, current: string): string => {
const active = value === current ? " is-active" : "";
return `<button type="button" class="builder-triplet-perspective-btn${active}"
data-action="perspective" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
};
const detailBtn = (value: string, key: string, current: string): string => {
const active = value === current ? " is-active" : "";
return `<button type="button" class="builder-triplet-detailgrad-btn${active}"
data-action="detailgrad" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
};
return `<div class="builder-triplet-controls">
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.perspective.label"))}</span>
<div class="builder-triplet-perspective">
${radio("", "builder.triplet.perspective.none", perspective)}
${radio("claimant", "builder.triplet.perspective.claimant", perspective)}
${radio("defendant", "builder.triplet.perspective.defendant", perspective)}
</div>
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.detailgrad.label"))}</span>
<div class="builder-triplet-detailgrad">
${detailBtn("selected", "builder.triplet.detailgrad.selected", detailgrad)}
${detailBtn("all_options", "builder.triplet.detailgrad.all_options", detailgrad)}
</div>
<button type="button" class="builder-triplet-remove" data-action="remove">
${escHtml(t("builder.triplet.remove"))}
</button>
</div>`;
}
function renderFlagStrip(input: TripletViewInput): string {
// B2 ships the full global catalog. Flags that don't gate any of the
// active proceeding's rules are still toggle-able but have no effect
// on the calc result (the engine simply doesn't read them).
const lang = getLang();
const flags = input.proceeding.scenario_flags || {};
if (input.flagCatalog.length === 0) {
return `<div class="builder-triplet-flagstrip">
<span class="builder-triplet-flag-empty">${escHtml(t("builder.triplet.no_flags"))}</span>
</div>`;
}
const toggles = input.flagCatalog.map((entry) => {
const label = lang === "en" ? entry.label_en : entry.label_de;
const isOn = flags[entry.flag_key] === true;
return `<label class="builder-triplet-flag-toggle">
<input type="checkbox"
data-action="flag"
data-flag-key="${escAttr(entry.flag_key)}"
${isOn ? "checked" : ""} />
<span>${escHtml(label)}</span>
</label>`;
}).join("");
return `<div class="builder-triplet-flagstrip">${toggles}</div>`;
}
function jurisdictionFor(meta: ProceedingTypeMeta): string {
if (meta.jurisdiction) return meta.jurisdiction;
if (meta.group) return meta.group;
const dot = meta.code.indexOf(".");
if (dot > 0) return meta.code.slice(0, dot).toUpperCase();
return meta.code.toUpperCase();
}
function activeFlagsBadge(flags: Record<string, unknown>): string {
const active = Object.entries(flags).filter(([, v]) => v === true).map(([k]) => k);
if (active.length === 0) return "";
const label = t("builder.triplet.flags.label");
const chips = active.map((f) =>
`<span class="builder-triplet-flag-chip">${escHtml(f)}</span>`,
).join("");
return `<span class="builder-triplet-flags">${escHtml(label)} ${chips}</span>`;
}
// overlayEventStates walks the rendered .fr-col-item nodes and:
// - sets data-builder-state from eventsByRule lookup;
// - appends a per-card action row (file / skip / reset);
// - shows a +N Optionen chip when the rule has optional children
// (the chip placeholder; B2 ships the per-card horizon control —
// the actual horizon-count→render expansion lands when the calc
// engine surfaces "available optionals" for a parent rule, which
// pasteur's Options.IncludeOptional flag already exposes server-
// side; full wiring is a follow-up). Cards without optional
// children get no chip.
export function overlayEventStates(
root: HTMLElement,
eventsByRule: Map<string, BuilderEvent>,
on: {
onAction: (ruleId: string, action: "file" | "skip" | "reset", payload?: { date?: string; reason?: string }) => void;
onHorizon: (ruleId: string, delta: 1 | -1) => void;
},
): void {
const items = root.querySelectorAll<HTMLElement>(".fr-col-item[data-rule-id]");
items.forEach((item) => {
const ruleId = item.getAttribute("data-rule-id");
if (!ruleId) return;
const ev = eventsByRule.get(ruleId.toLowerCase());
const state = ev?.state || "planned";
item.setAttribute("data-builder-state", state);
// Append actions (idempotent: clear any prior overlay first).
item.querySelectorAll(".builder-event-actions, .builder-event-horizon-chip").forEach((n) => n.remove());
const actions = document.createElement("div");
actions.className = "builder-event-actions";
actions.innerHTML = actionButtonsHtml(state);
item.appendChild(actions);
actions.addEventListener("click", (ev) => {
const btn = (ev.target as HTMLElement).closest<HTMLElement>(".builder-event-action");
if (!btn) return;
const action = btn.getAttribute("data-action") as "file" | "skip" | "reset" | null;
if (!action) return;
ev.stopPropagation();
if (action === "file") {
const today = new Date().toISOString().slice(0, 10);
const v = window.prompt(t("builder.event.actual_date.prompt"), today);
if (v === null) return;
on.onAction(ruleId, "file", { date: v.trim() || today });
} else if (action === "skip") {
const reason = window.prompt(t("builder.event.skip_reason.prompt"), "");
if (reason === null) return;
on.onAction(ruleId, "skip", { reason: reason.trim() });
} else {
on.onAction(ruleId, "reset");
}
});
// Per-card optional horizon chip. The PRD §3.4 places the chip on
// every card with optional children; until the calc surface exposes
// an "optionals available count" on each parent rule, the chip is
// shown only when the card has a stored non-zero horizon (so the
// user can see and reduce a previously-set horizon). This is the
// graceful B2 baseline; the full surface lands once the engine
// emits an optionalsAvailable counter (PRD §3.4 follow-up).
const horizonCount = ev?.horizon_optional ?? 0;
if (horizonCount > 0) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "builder-event-horizon-chip";
chip.setAttribute("data-action", "horizon-toggle");
chip.textContent = t("builder.event.horizon.label").replace("{n}", String(horizonCount));
chip.addEventListener("click", (e) => {
e.stopPropagation();
on.onHorizon(ruleId, -1);
});
item.appendChild(chip);
} else {
// Inline "+ Optionen" affordance — adds a horizon entry when
// first clicked. Tagged as data-builder-feature so the cleanup
// sweep can rip it out if the calc surface lands a counter.
const chip = document.createElement("button");
chip.type = "button";
chip.className = "builder-event-horizon-chip";
chip.setAttribute("data-action", "horizon-add");
chip.setAttribute("data-builder-feature", "horizon-add");
chip.textContent = "+ " + t("builder.event.horizon.label").replace("+{n} ", "");
chip.addEventListener("click", (e) => {
e.stopPropagation();
on.onHorizon(ruleId, 1);
});
item.appendChild(chip);
}
});
}
function actionButtonsHtml(state: BuilderEvent["state"]): string {
// Re-render the action row per state. Cards in the planned state
// show "File / Skip"; filed/skipped cards show "Reset to planned".
if (state === "planned") {
return `
<button type="button" class="builder-event-action" data-action="file">${escHtml(t("builder.event.action.file"))}</button>
<button type="button" class="builder-event-action" data-action="skip">${escHtml(t("builder.event.action.skip"))}</button>
`;
}
return `<button type="button" class="builder-event-action" data-action="reset">${escHtml(t("builder.event.action.reset"))}</button>`;
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
import { describe, expect, test } from "bun:test";
import {
bucketByDate,
filterByDay,
isToday,
isoDate,
shift,
startOfDay,
startOfWeek,
type CalendarItem,
} from "./mount-calendar";
// Regression tests for t-paliad-224: the calendar bucket / week / shift
// helpers underpin both /events Kalender and the Custom Views shape=
// calendar. DOM-rendering is covered by manual smoke (frontend tests in
// this repo run in plain Node, no jsdom — see verfahrensablauf-core.test
// ts comment), so the pure date-math goes here.
const item = (overrides: Partial<CalendarItem> = {}): CalendarItem => ({
kind: "deadline",
id: "00000000-0000-0000-0000-000000000000",
title: "Klageerwiderung",
event_date: "2026-05-08T00:00:00Z",
...overrides,
});
describe("isoDate / startOfDay / startOfWeek", () => {
test("isoDate pads month + day", () => {
expect(isoDate(new Date(2026, 0, 3))).toBe("2026-01-03");
expect(isoDate(new Date(2026, 11, 31))).toBe("2026-12-31");
});
test("startOfDay strips time", () => {
const d = new Date(2026, 4, 8, 13, 47, 22);
const out = startOfDay(d);
expect(out.getHours()).toBe(0);
expect(out.getMinutes()).toBe(0);
expect(out.getSeconds()).toBe(0);
expect(isoDate(out)).toBe("2026-05-08");
});
test("startOfWeek snaps to Monday (Mon=0)", () => {
// 2026-05-08 was a Friday.
const fri = new Date(2026, 4, 8);
expect(isoDate(startOfWeek(fri))).toBe("2026-05-04");
// Sunday wraps backward to the same Monday, not forward to the next.
const sun = new Date(2026, 4, 10);
expect(isoDate(startOfWeek(sun))).toBe("2026-05-04");
// Monday is its own startOfWeek.
const mon = new Date(2026, 4, 4);
expect(isoDate(startOfWeek(mon))).toBe("2026-05-04");
});
});
describe("shift", () => {
test("month shift lands on day=1 of the target month", () => {
const out = shift(new Date(2026, 4, 15), "month", 1);
expect(out.getFullYear()).toBe(2026);
expect(out.getMonth()).toBe(5);
expect(out.getDate()).toBe(1);
});
test("month shift wraps year boundary", () => {
const out = shift(new Date(2026, 11, 15), "month", 1);
expect(out.getFullYear()).toBe(2027);
expect(out.getMonth()).toBe(0);
expect(out.getDate()).toBe(1);
});
test("week shift moves seven days", () => {
const out = shift(new Date(2026, 4, 8), "week", 1);
expect(isoDate(out)).toBe("2026-05-15");
});
test("day shift moves one day", () => {
const out = shift(new Date(2026, 4, 8), "day", -1);
expect(isoDate(out)).toBe("2026-05-07");
});
});
describe("bucketByDate", () => {
test("groups items by ISO date and skips items outside the filter", () => {
const rows = [
item({ id: "a", event_date: "2026-05-08T00:00:00Z" }),
item({ id: "b", event_date: "2026-05-08T15:30:00Z" }),
item({ id: "c", event_date: "2026-05-09T00:00:00Z" }),
// outside the May 2026 filter:
item({ id: "x", event_date: "2026-06-01T00:00:00Z" }),
// malformed:
item({ id: "bad", event_date: "not-a-date" }),
];
const out = bucketByDate(rows, (d) => d.getMonth() === 4 && d.getFullYear() === 2026);
expect(out.size).toBe(2);
expect(out.get("2026-05-08")?.map((r) => r.id)).toEqual(["a", "b"]);
expect(out.get("2026-05-09")?.map((r) => r.id)).toEqual(["c"]);
expect(out.has("2026-06-01")).toBe(false);
});
});
describe("filterByDay", () => {
test("returns only items whose calendar day equals the target", () => {
const rows = [
item({ id: "a", event_date: "2026-05-08T00:00:00Z" }),
item({ id: "b", event_date: "2026-05-08T23:59:00Z" }),
item({ id: "c", event_date: "2026-05-09T00:00:00Z" }),
];
expect(filterByDay(rows, new Date(2026, 4, 8)).map((r) => r.id)).toEqual(["a", "b"]);
expect(filterByDay(rows, new Date(2026, 4, 9)).map((r) => r.id)).toEqual(["c"]);
expect(filterByDay(rows, new Date(2026, 4, 10))).toEqual([]);
});
test("ignores malformed dates", () => {
const rows = [
item({ id: "ok", event_date: "2026-05-08T00:00:00Z" }),
item({ id: "bad", event_date: "not-a-date" }),
];
expect(filterByDay(rows, new Date(2026, 4, 8)).map((r) => r.id)).toEqual(["ok"]);
});
});
describe("isToday", () => {
test("matches today's calendar day", () => {
expect(isToday(new Date())).toBe(true);
});
test("rejects yesterday + tomorrow", () => {
const now = new Date();
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
expect(isToday(yesterday)).toBe(false);
expect(isToday(tomorrow)).toBe(false);
});
});

View File

@@ -0,0 +1,579 @@
import { t, tDyn, getLang, type I18nKey } from "../i18n";
// mount-calendar.ts — the canonical month/week/day calendar (t-paliad-224).
// Lifted from the original shape-calendar.ts so both Custom Views
// (shape=calendar) and /events Kalender tab render through the same DOM.
// See docs/design-calendar-view-align-2026-05-20.md for the audit + plan.
//
// Surfaces wire in via mountCalendar(host, items, opts). The returned
// handle exposes update(items) for re-render after a filter change and
// destroy() for teardown when the host swaps to a different view.
export type CalendarKind =
| "deadline" | "appointment" | "project_event" | "approval_request";
export interface CalendarItem {
kind: CalendarKind;
id: string;
title: string;
/** ISO-8601 timestamp or date string. First 10 chars are read as the
* calendar bucket (yyyy-mm-dd). */
event_date: string;
project_id?: string;
project_title?: string;
project_reference?: string;
}
export type CalendarView = "month" | "week" | "day";
export interface CalendarOpts {
/** Initial view if URL has no override (or urlState is disabled). */
defaultView?: CalendarView;
/** Read/write ?cal_view + ?cal_date so a refresh restores the calendar.
* Surfaces that own their own URL contract pass urlState=false. */
urlState?: boolean;
/** Optional URL param prefix (e.g. "events" → ?eventsCalView=…). Only
* meaningful when urlState=true. Leave empty for the default
* ?cal_view / ?cal_date contract. */
urlPrefix?: string;
/** Override how a row's href is built. Default routes by kind. */
hrefFor?: (item: CalendarItem) => string;
}
export interface CalendarHandle {
/** Replace the item set and re-paint at the current view+anchor. */
update(items: CalendarItem[]): void;
/** Clear host + drop the keep-alive state. After destroy(), the handle
* is dead; create a fresh one with mountCalendar(). */
destroy(): void;
}
const MAX_PILLS_PER_MONTH_CELL = 3;
export function mountCalendar(
host: HTMLElement,
initialItems: CalendarItem[],
opts: CalendarOpts = {},
): CalendarHandle {
let items = initialItems;
let view: CalendarView;
let anchor: Date;
let destroyed = false;
const urlEnabled = opts.urlState ?? false;
const viewParam = urlEnabled ? paramName(opts.urlPrefix, "cal_view") : "";
const dateParam = urlEnabled ? paramName(opts.urlPrefix, "cal_date") : "";
view = urlEnabled
? readView(viewParam, opts.defaultView ?? "month")
: (opts.defaultView ?? "month");
anchor = urlEnabled ? readAnchor(dateParam, items) : firstAnchor(items);
paint();
return {
update(nextItems) {
if (destroyed) return;
items = nextItems;
paint();
},
destroy() {
destroyed = true;
host.innerHTML = "";
},
};
// --- paint -----------------------------------------------------------
function paint(): void {
if (destroyed) return;
host.innerHTML = "";
// Mobile fallback notice (<600px). Documented in design-calendar-
// view-align-2026-05-20.md §6. CSS still lays out the grid; the
// notice just nudges users toward a friendlier view.
if (typeof window !== "undefined" && window.innerWidth < 600) {
const notice = document.createElement("p");
notice.className = "views-calendar-mobile-notice";
notice.textContent = t("views.calendar.mobile_fallback");
host.appendChild(notice);
}
const wrap = document.createElement("div");
wrap.className = `views-calendar views-calendar--${view}`;
wrap.appendChild(renderToolbar());
if (view === "month") {
wrap.appendChild(renderMonth());
} else if (view === "week") {
wrap.appendChild(renderWeek());
} else {
wrap.appendChild(renderDay());
}
host.appendChild(wrap);
}
function setView(nextView: CalendarView, nextAnchor: Date): void {
view = nextView;
anchor = nextAnchor;
if (urlEnabled) writeURL(viewParam, dateParam, nextView, nextAnchor);
paint();
}
// --- Toolbar ---------------------------------------------------------
function renderToolbar(): HTMLElement {
const bar = document.createElement("div");
bar.className = "views-calendar-toolbar";
const switcher = document.createElement("div");
switcher.className = "views-calendar-view-switcher agenda-chip-row";
switcher.setAttribute("role", "tablist");
for (const v of ["month", "week", "day"] as CalendarView[]) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "agenda-chip views-calendar-view-chip" + (v === view ? " agenda-chip-active" : "");
chip.dataset.calView = v;
chip.setAttribute("role", "tab");
chip.setAttribute("aria-selected", v === view ? "true" : "false");
chip.textContent = t(`cal.view.${v}` as I18nKey);
chip.addEventListener("click", () => {
if (v === view) return;
setView(v, anchor);
});
switcher.appendChild(chip);
}
bar.appendChild(switcher);
const nav = document.createElement("div");
nav.className = "views-calendar-nav";
const prev = document.createElement("button");
prev.type = "button";
prev.className = "btn-secondary btn-small views-calendar-nav-btn";
prev.setAttribute("aria-label", t(navLabelKey(view, "prev")));
prev.textContent = "";
prev.addEventListener("click", () => setView(view, shift(anchor, view, -1)));
nav.appendChild(prev);
const label = document.createElement("span");
label.className = "views-calendar-nav-label";
label.textContent = formatRangeLabel(view, anchor);
nav.appendChild(label);
const next = document.createElement("button");
next.type = "button";
next.className = "btn-secondary btn-small views-calendar-nav-btn";
next.setAttribute("aria-label", t(navLabelKey(view, "next")));
next.textContent = "";
next.addEventListener("click", () => setView(view, shift(anchor, view, 1)));
nav.appendChild(next);
// "Heute" button — jump back to today in the current view. Adds a
// recognisable affordance for the /events Kalender users who relied
// on the old toolbar's "Heute" button.
const today = document.createElement("button");
today.type = "button";
today.className = "btn-secondary btn-small views-calendar-nav-btn";
today.textContent = t("cal.today");
today.addEventListener("click", () => setView(view, startOfDay(new Date())));
nav.appendChild(today);
if (view !== "month") {
const backToMonth = document.createElement("button");
backToMonth.type = "button";
backToMonth.className = "btn-link views-calendar-back-to-month";
backToMonth.textContent = t("cal.day.back_to_month");
backToMonth.addEventListener("click", () => setView("month", anchor));
nav.appendChild(backToMonth);
}
bar.appendChild(nav);
return bar;
}
// --- Month -----------------------------------------------------------
function renderMonth(): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-month";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
wrap.appendChild(header);
const grid = document.createElement("div");
grid.className = "views-calendar-grid";
const weekdayKeys: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
for (const k of weekdayKeys) {
const cell = document.createElement("div");
cell.className = "views-calendar-weekday";
cell.textContent = t(k);
grid.appendChild(cell);
}
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
for (let i = 0; i < startWeekday; i++) {
const cell = document.createElement("div");
cell.className = "views-calendar-cell views-calendar-cell--out";
grid.appendChild(cell);
}
const byDate = bucketByDate(items, (d) =>
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
);
for (let day = 1; day <= daysInMonth; day++) {
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
const dateKey = isoDate(dayDate);
const dayRows = byDate.get(dateKey) ?? [];
grid.appendChild(renderMonthCell(dayDate, day, dayRows));
}
wrap.appendChild(grid);
return wrap;
}
function renderMonthCell(dayDate: Date, dayNum: number, dayRows: CalendarItem[]): HTMLElement {
const cell = document.createElement("div");
cell.className = "views-calendar-cell";
if (isToday(dayDate)) cell.classList.add("views-calendar-cell--today");
if (dayRows.length > 0) cell.classList.add("views-calendar-cell--has");
const dayLabel = document.createElement("button");
dayLabel.type = "button";
dayLabel.className = "views-calendar-cell-day";
dayLabel.textContent = String(dayNum);
dayLabel.setAttribute("aria-label", t("cal.day.open_day"));
dayLabel.addEventListener("click", (e) => {
e.stopPropagation();
setView("day", dayDate);
});
cell.appendChild(dayLabel);
if (dayRows.length > 0) {
const ul = document.createElement("ul");
ul.className = "views-calendar-pills";
const visible = dayRows.slice(0, MAX_PILLS_PER_MONTH_CELL);
for (const row of visible) ul.appendChild(renderPill(row));
if (dayRows.length > visible.length) {
const more = document.createElement("li");
const moreBtn = document.createElement("button");
moreBtn.type = "button";
moreBtn.className = "views-calendar-pill views-calendar-pill--more";
moreBtn.textContent = `+${dayRows.length - visible.length}`;
moreBtn.setAttribute("aria-label", t("cal.day.open_day"));
moreBtn.addEventListener("click", (e) => {
e.stopPropagation();
setView("day", dayDate);
});
more.appendChild(moreBtn);
ul.appendChild(more);
}
cell.appendChild(ul);
}
return cell;
}
// --- Week ------------------------------------------------------------
function renderWeek(): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-week";
const weekStart = startOfWeek(anchor);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = formatWeekHeader(weekStart, weekEnd, lang);
wrap.appendChild(header);
const grid = document.createElement("div");
grid.className = "views-calendar-week-grid";
for (let i = 0; i < 7; i++) {
const day = new Date(weekStart);
day.setDate(weekStart.getDate() + i);
grid.appendChild(renderWeekColumn(day));
}
wrap.appendChild(grid);
return wrap;
}
function renderWeekColumn(day: Date): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const col = document.createElement("div");
col.className = "views-calendar-week-column";
if (isToday(day)) col.classList.add("views-calendar-week-column--today");
const head = document.createElement("div");
head.className = "views-calendar-week-head";
const weekdayKey = WEEKDAY_KEYS[(day.getDay() + 6) % 7];
const dow = document.createElement("span");
dow.className = "views-calendar-week-dow";
dow.textContent = t(weekdayKey);
const dnum = document.createElement("span");
dnum.className = "views-calendar-week-dnum";
dnum.textContent = day.toLocaleDateString(lang, { day: "numeric", month: "short" });
head.appendChild(dow);
head.appendChild(dnum);
col.appendChild(head);
const dayRows = filterByDay(items, day);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-week-empty";
empty.textContent = t("cal.day.no_entries");
col.appendChild(empty);
return col;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-week-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "week"));
ul.appendChild(li);
}
col.appendChild(ul);
return col;
}
// --- Day -------------------------------------------------------------
function renderDay(): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-day-wrap";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, {
weekday: "long", year: "numeric", month: "long", day: "numeric",
});
wrap.appendChild(header);
const dayRows = filterByDay(items, anchor);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-day-empty";
empty.textContent = t("cal.day.no_entries");
wrap.appendChild(empty);
return wrap;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-day-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "day"));
ul.appendChild(li);
}
wrap.appendChild(ul);
return wrap;
}
// --- Row rendering ---------------------------------------------------
function renderPill(row: CalendarItem): HTMLElement {
const li = document.createElement("li");
const a = document.createElement("a");
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
a.href = hrefFor(row);
a.textContent = row.title;
a.title = row.title + (row.project_title ? `${row.project_title}` : "");
a.addEventListener("click", (e) => e.stopPropagation());
li.appendChild(a);
return li;
}
function renderRowAnchor(row: CalendarItem, density: "week" | "day"): HTMLElement {
const a = document.createElement("a");
a.className = `views-calendar-row views-calendar-row--${density} views-calendar-row--${row.kind}`;
a.href = hrefFor(row);
const dot = document.createElement("span");
dot.className = `views-calendar-row-dot views-calendar-row-dot--${row.kind}`;
a.appendChild(dot);
const body = document.createElement("span");
body.className = "views-calendar-row-body";
const title = document.createElement("span");
title.className = "views-calendar-row-title";
title.textContent = row.title;
body.appendChild(title);
const metaParts: string[] = [];
metaParts.push(tDyn("views.kind." + row.kind));
if (row.project_reference) metaParts.push(row.project_reference);
else if (row.project_title) metaParts.push(row.project_title);
if (metaParts.length > 0) {
const meta = document.createElement("span");
meta.className = "views-calendar-row-meta";
meta.textContent = metaParts.join(" · ");
body.appendChild(meta);
}
a.appendChild(body);
return a;
}
function hrefFor(row: CalendarItem): string {
if (opts.hrefFor) return opts.hrefFor(row);
return defaultHrefFor(row);
}
}
// --- Pure helpers (shared, not closure-bound) ----------------------------
const WEEKDAY_KEYS: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
function navLabelKey(view: CalendarView, dir: "prev" | "next"): I18nKey {
if (view === "month") return dir === "prev" ? "cal.month.prev" : "cal.month.next";
if (view === "week") return dir === "prev" ? "cal.week.prev" : "cal.week.next";
return dir === "prev" ? "cal.day.prev" : "cal.day.next";
}
function defaultHrefFor(row: CalendarItem): string {
switch (row.kind) {
case "deadline": return `/deadlines/${encodeURIComponent(row.id)}`;
case "appointment": return `/appointments/${encodeURIComponent(row.id)}`;
case "approval_request": return `/inbox`;
case "project_event": return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
}
}
export function bucketByDate(
rows: CalendarItem[], filter: (d: Date) => boolean,
): Map<string, CalendarItem[]> {
const out = new Map<string, CalendarItem[]>();
for (const row of rows) {
const d = new Date(row.event_date);
if (isNaN(d.getTime())) continue;
if (!filter(d)) continue;
const key = isoDate(d);
const arr = out.get(key);
if (arr) arr.push(row);
else out.set(key, [row]);
}
return out;
}
export function filterByDay(rows: CalendarItem[], day: Date): CalendarItem[] {
const key = isoDate(day);
return rows.filter((r) => {
const d = new Date(r.event_date);
if (isNaN(d.getTime())) return false;
return isoDate(d) === key;
});
}
export function startOfWeek(d: Date): Date {
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const offset = (out.getDay() + 6) % 7;
out.setDate(out.getDate() - offset);
return out;
}
export function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
export function shift(d: Date, view: CalendarView, dir: number): Date {
if (view === "month") return new Date(d.getFullYear(), d.getMonth() + dir, 1);
if (view === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir * 7);
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir);
}
export function isToday(d: Date): boolean {
const now = new Date();
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate();
}
export function isoDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function formatRangeLabel(view: CalendarView, anchor: Date): string {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
if (view === "month") {
return anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
}
if (view === "week") {
const start = startOfWeek(anchor);
const end = new Date(start);
end.setDate(start.getDate() + 6);
return formatWeekHeader(start, end, lang);
}
return anchor.toLocaleDateString(lang, {
weekday: "short", year: "numeric", month: "long", day: "numeric",
});
}
function formatWeekHeader(start: Date, end: Date, lang: string): string {
const startStr = start.toLocaleDateString(lang, { day: "numeric", month: "short" });
const endStr = end.toLocaleDateString(lang, { day: "numeric", month: "short", year: "numeric" });
return `${startStr} ${endStr}`;
}
function firstAnchor(rows: CalendarItem[]): Date {
for (const row of rows) {
const d = new Date(row.event_date);
if (!isNaN(d.getTime())) return startOfDay(d);
}
return startOfDay(new Date());
}
function paramName(prefix: string | undefined, base: string): string {
if (!prefix) return base;
return `${prefix}_${base}`;
}
function readView(viewParam: string, fallback: CalendarView): CalendarView {
if (typeof window === "undefined") return fallback;
const params = new URLSearchParams(window.location.search);
const raw = params.get(viewParam);
if (raw === "month" || raw === "week" || raw === "day") return raw;
return fallback;
}
function readAnchor(dateParam: string, rows: CalendarItem[]): Date {
if (typeof window === "undefined") return firstAnchor(rows);
const params = new URLSearchParams(window.location.search);
const raw = params.get(dateParam);
if (raw) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
if (m) {
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (!isNaN(d.getTime())) return d;
}
}
return firstAnchor(rows);
}
function writeURL(viewParam: string, dateParam: string, view: CalendarView, anchor: Date): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set(viewParam, view);
url.searchParams.set(dateParam, isoDate(anchor));
history.replaceState(null, "", url.toString());
}

View File

@@ -0,0 +1,365 @@
// Authoring wizard for paliad.checklists. Serves both /checklists/new
// (create) and /checklists/templates/{slug}/edit (edit). The HTML bundle is the
// same; this client reads location.pathname to decide which mode to
// boot into.
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
interface Item {
labelDE: string;
labelEN: string;
noteDE?: string;
noteEN?: string;
rule?: string;
}
interface Group {
titleDE: string;
titleEN: string;
items: Item[];
}
interface Checklist {
id: string;
slug: string;
title: string;
description: string;
regime: string;
court: string;
reference: string;
deadline: string;
lang: string;
visibility: string;
body: { groups: Group[] };
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function detectMode(): { mode: "create" | "edit"; slug?: string } {
const path = window.location.pathname;
if (path === "/checklists/new") {
return { mode: "create" };
}
const m = path.match(/^\/checklists\/templates\/([^/]+)\/edit$/);
if (m) {
return { mode: "edit", slug: m[1] };
}
return { mode: "create" };
}
let groups: Group[] = [];
function renderGroups() {
const container = document.getElementById("groups-container")!;
if (groups.length === 0) {
// Seed with a single empty group + item so the user has something
// to fill out rather than a blank canvas.
groups = [{ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] }];
}
container.innerHTML = groups.map((g, gi) => {
const itemsHTML = g.items.map((it, ii) => {
return `<div class="author-item" data-gi="${gi}" data-ii="${ii}">
<div class="form-row">
<label class="form-label">${esc(t("checklisten.author.item.label"))}</label>
<input class="form-input" data-field="label" value="${escAttr(it.labelDE || "")}" />
</div>
<div class="form-grid form-grid-2">
<div class="form-row">
<label class="form-label">${esc(t("checklisten.author.item.note"))}</label>
<input class="form-input" data-field="note" value="${escAttr(it.noteDE || "")}" />
</div>
<div class="form-row">
<label class="form-label">${esc(t("checklisten.author.item.rule"))}</label>
<input class="form-input" data-field="rule" value="${escAttr(it.rule || "")}" />
</div>
</div>
<button type="button" class="btn btn-small btn-danger" data-action="remove-item">${esc(t("checklisten.author.item.remove"))}</button>
</div>`;
}).join("");
return `<div class="author-group" data-gi="${gi}">
<div class="form-row">
<label class="form-label">${esc(t("checklisten.author.group.title"))}</label>
<input class="form-input" data-field="group-title" value="${escAttr(g.titleDE || "")}" />
</div>
<div class="author-items">${itemsHTML}</div>
<div class="author-group-actions">
<button type="button" class="btn btn-small" data-action="add-item">${esc(t("checklisten.author.item.add"))}</button>
<button type="button" class="btn btn-small btn-danger" data-action="remove-group">${esc(t("checklisten.author.group.remove"))}</button>
</div>
</div>`;
}).join("");
// Wire input changes back into the data array.
container.querySelectorAll<HTMLInputElement>(".author-group > .form-row input[data-field=group-title]").forEach((input) => {
const groupDiv = input.closest<HTMLElement>(".author-group")!;
const gi = parseInt(groupDiv.dataset.gi!, 10);
input.addEventListener("input", () => {
groups[gi].titleDE = input.value;
groups[gi].titleEN = input.value; // single-language for Slice A
});
});
container.querySelectorAll<HTMLDivElement>(".author-item").forEach((itemDiv) => {
const gi = parseInt(itemDiv.dataset.gi!, 10);
const ii = parseInt(itemDiv.dataset.ii!, 10);
itemDiv.querySelectorAll<HTMLInputElement>("input[data-field]").forEach((input) => {
input.addEventListener("input", () => {
const field = input.dataset.field!;
if (field === "label") {
groups[gi].items[ii].labelDE = input.value;
groups[gi].items[ii].labelEN = input.value;
} else if (field === "note") {
groups[gi].items[ii].noteDE = input.value || undefined;
groups[gi].items[ii].noteEN = input.value || undefined;
} else if (field === "rule") {
groups[gi].items[ii].rule = input.value || undefined;
}
});
});
itemDiv.querySelector<HTMLButtonElement>("button[data-action=remove-item]")!.addEventListener("click", () => {
groups[gi].items.splice(ii, 1);
if (groups[gi].items.length === 0) {
groups[gi].items.push({ labelDE: "", labelEN: "" });
}
renderGroups();
});
});
container.querySelectorAll<HTMLButtonElement>("button[data-action=add-item]").forEach((btn) => {
const groupDiv = btn.closest<HTMLElement>(".author-group")!;
const gi = parseInt(groupDiv.dataset.gi!, 10);
btn.addEventListener("click", () => {
groups[gi].items.push({ labelDE: "", labelEN: "" });
renderGroups();
});
});
container.querySelectorAll<HTMLButtonElement>("button[data-action=remove-group]").forEach((btn) => {
const groupDiv = btn.closest<HTMLElement>(".author-group")!;
const gi = parseInt(groupDiv.dataset.gi!, 10);
btn.addEventListener("click", () => {
groups.splice(gi, 1);
renderGroups();
});
});
}
function showError(msg: string) {
const err = document.getElementById("author-error")!;
err.textContent = msg;
err.style.display = "";
err.scrollIntoView({ behavior: "smooth", block: "center" });
}
function clearError() {
const err = document.getElementById("author-error")!;
err.textContent = "";
err.style.display = "none";
}
function collectInput() {
const title = (document.getElementById("title") as HTMLInputElement).value.trim();
const description = (document.getElementById("description") as HTMLTextAreaElement).value.trim();
const regime = (document.getElementById("regime") as HTMLSelectElement).value;
const court = (document.getElementById("court") as HTMLInputElement).value.trim();
const reference = (document.getElementById("reference") as HTMLInputElement).value.trim();
const deadline = (document.getElementById("deadline") as HTMLInputElement).value.trim();
const lang = (document.getElementById("lang") as HTMLSelectElement).value;
const visibilityInput = document.querySelector<HTMLInputElement>("input[name=visibility]:checked");
const visibility = visibilityInput?.value || "private";
return { title, description, regime, court, reference, deadline, lang, visibility };
}
function validateGroups(): boolean {
if (groups.length === 0) return false;
let totalItems = 0;
for (const g of groups) {
if (!g.titleDE.trim()) return false;
for (const it of g.items) {
if (it.labelDE.trim()) totalItems += 1;
}
}
return totalItems > 0;
}
function trimmedGroups(): Group[] {
return groups
.filter((g) => g.titleDE.trim() && g.items.some((it) => it.labelDE.trim()))
.map((g) => ({
titleDE: g.titleDE.trim(),
titleEN: g.titleEN.trim(),
items: g.items
.filter((it) => it.labelDE.trim())
.map((it) => ({
labelDE: it.labelDE.trim(),
labelEN: it.labelEN.trim(),
noteDE: it.noteDE?.trim() || undefined,
noteEN: it.noteEN?.trim() || undefined,
rule: it.rule?.trim() || undefined,
})),
}));
}
async function loadEditTemplate(slug: string) {
// Use /api/checklists/{slug} (catalog Find with visibility check) +
// the mine list to ensure we have the editable fields. Templates the
// caller doesn't own/admin will trip the PATCH gate later.
const resp = await fetch(`/api/checklists/templates/mine`);
if (!resp.ok) {
showError(t("checklisten.author.error.notfound"));
return;
}
const rows: Checklist[] = (await resp.json()) ?? [];
const tpl = rows.find((r) => r.slug === slug);
if (!tpl) {
showError(t("checklisten.author.error.notfound"));
return;
}
(document.getElementById("author-heading")!).textContent = t("checklisten.author.heading.edit");
document.title = t("checklisten.author.title.edit");
(document.getElementById("title") as HTMLInputElement).value = tpl.title;
(document.getElementById("description") as HTMLTextAreaElement).value = tpl.description;
(document.getElementById("regime") as HTMLSelectElement).value = tpl.regime;
(document.getElementById("court") as HTMLInputElement).value = tpl.court;
(document.getElementById("reference") as HTMLInputElement).value = tpl.reference;
(document.getElementById("deadline") as HTMLInputElement).value = tpl.deadline;
(document.getElementById("lang") as HTMLSelectElement).value = tpl.lang || "de";
const visIn = document.querySelector<HTMLInputElement>(`input[name=visibility][value=${tpl.visibility}]`);
if (visIn) visIn.checked = true;
groups = (tpl.body?.groups || []).map((g) => ({
titleDE: g.titleDE || "",
titleEN: g.titleEN || g.titleDE || "",
items: g.items.map((it) => ({
labelDE: it.labelDE || "",
labelEN: it.labelEN || it.labelDE || "",
noteDE: it.noteDE,
noteEN: it.noteEN,
rule: it.rule,
})),
}));
if (groups.length === 0) {
groups = [{ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] }];
}
renderGroups();
}
async function submitCreate() {
clearError();
const input = collectInput();
if (!input.title) {
showError(t("checklisten.author.error.title"));
return;
}
if (!validateGroups()) {
showError(t("checklisten.author.error.no_groups"));
return;
}
const saveBtn = document.getElementById("author-save") as HTMLButtonElement;
saveBtn.disabled = true;
saveBtn.textContent = t("checklisten.author.saving");
const body = JSON.stringify({ ...input, body: { groups: trimmedGroups() } });
const resp = await fetch("/api/checklists/templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
saveBtn.disabled = false;
saveBtn.textContent = t("checklisten.author.save");
if (!resp.ok) {
let msg = t("checklisten.author.error.generic");
try {
const j = await resp.json();
if (j?.error) msg = j.error;
} catch { /* keep generic */ }
showError(msg);
return;
}
const created: Checklist = await resp.json();
window.location.href = `/checklists/${encodeURIComponent(created.slug)}`;
}
async function submitEdit(slug: string) {
clearError();
const input = collectInput();
if (!input.title) {
showError(t("checklisten.author.error.title"));
return;
}
if (!validateGroups()) {
showError(t("checklisten.author.error.no_groups"));
return;
}
const saveBtn = document.getElementById("author-save") as HTMLButtonElement;
saveBtn.disabled = true;
saveBtn.textContent = t("checklisten.author.saving");
const patch = {
title: input.title,
description: input.description,
regime: input.regime,
court: input.court,
reference: input.reference,
deadline: input.deadline,
body: { groups: trimmedGroups() },
};
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
// Visibility lives on its own endpoint so the audit row reflects the
// distinct transition. Only call if it actually changed.
if (resp.ok && input.visibility) {
await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}/visibility`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ visibility: input.visibility }),
});
}
saveBtn.disabled = false;
saveBtn.textContent = t("checklisten.author.save");
if (!resp.ok) {
let msg = t("checklisten.author.error.generic");
try {
const j = await resp.json();
if (j?.error) msg = j.error;
} catch { /* keep generic */ }
showError(msg);
return;
}
window.location.href = `/checklists/${encodeURIComponent(slug)}`;
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
renderGroups();
document.getElementById("add-group")!.addEventListener("click", () => {
groups.push({ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] });
renderGroups();
});
const { mode, slug } = detectMode();
if (mode === "edit" && slug) {
void loadEditTemplate(slug);
}
document.getElementById("author-form")!.addEventListener("submit", (e) => {
e.preventDefault();
if (mode === "edit" && slug) {
void submitEdit(slug);
} else {
void submitCreate();
}
});
});

View File

@@ -30,6 +30,37 @@ interface Checklist {
referenceDE?: string;
referenceEN?: string;
groups: ChecklistGroup[];
// Slice B fields — present on authored entries via the merged
// catalog response. 'static' templates don't carry these.
origin?: "static" | "authored";
visibility?: string;
owner_email?: string;
owner_display_name?: string;
}
interface Me {
id: string;
email: string;
display_name: string;
global_role?: string;
}
interface UserSummary {
id: string;
email: string;
display_name: string;
}
interface PartnerUnit {
id: string;
name: string;
}
interface Share {
id: string;
checklist_id: string;
recipient_kind: "user" | "office" | "partner_unit" | "project";
recipient_label: string;
}
interface ChecklistInstance {
@@ -371,13 +402,320 @@ function rerenderAll() {
renderInstances();
}
// --- Slice B: owner actions + admin promote + share modal ----------------
let me: Me | null = null;
let isOwner = false;
let isAdmin = false;
let shareUsers: UserSummary[] = [];
let sharePartnerUnits: PartnerUnit[] = [];
let shareProjects: AkteSummary[] = [];
let activeShareKind: "user" | "office" | "partner_unit" | "project" = "user";
async function loadMe(): Promise<Me | null> {
try {
const resp = await fetch("/api/me");
if (!resp.ok) return null;
return await resp.json();
} catch {
return null;
}
}
function templateOriginInfo() {
return template as unknown as {
origin?: string;
visibility?: string;
owner_email?: string;
owner_display_name?: string;
} | null;
}
function applyOwnerControls() {
const info = templateOriginInfo();
const isAuthored = info?.origin === "authored";
const provenance = document.getElementById("checklist-provenance")!;
if (isAuthored && info?.owner_display_name) {
provenance.style.display = "";
provenance.textContent = t("checklisten.detail.authored.by").replace("{author}", info.owner_display_name);
} else {
provenance.style.display = "none";
}
isOwner = !!(isAuthored && me && info?.owner_email && me.email.toLowerCase() === info.owner_email.toLowerCase());
isAdmin = !!(me && me.global_role === "global_admin");
const ownerOnly = (id: string, show: boolean) => {
const el = document.getElementById(id);
if (el) (el as HTMLElement).style.display = show ? "" : "none";
};
if (template) {
(document.getElementById("btn-edit-template") as HTMLAnchorElement | null)?.setAttribute(
"href",
`/checklists/templates/${encodeURIComponent(template.slug)}/edit`,
);
}
ownerOnly("btn-edit-template", isOwner);
ownerOnly("btn-share-template", isOwner);
ownerOnly("btn-delete-template", isOwner);
// Admin promote/demote — only when an authored template is visible to
// an admin, and only the appropriate one for the current visibility.
if (isAuthored && isAdmin) {
const isGlobal = info?.visibility === "global";
ownerOnly("btn-promote-template", !isGlobal);
ownerOnly("btn-demote-template", isGlobal);
} else {
ownerOnly("btn-promote-template", false);
ownerOnly("btn-demote-template", false);
}
}
function initOwnerActions() {
document.getElementById("btn-delete-template")?.addEventListener("click", async () => {
if (!template) return;
const isEN = getLang() === "en";
const title = isEN ? template.titleEN : template.titleDE;
const msg = t("checklisten.detail.delete.confirm").replace("{title}", title);
if (!window.confirm(msg)) return;
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}`, { method: "DELETE" });
if (!resp.ok) {
window.alert(t("checklisten.detail.delete.error"));
return;
}
window.location.href = "/checklists?tab=mine";
});
document.getElementById("btn-promote-template")?.addEventListener("click", async () => {
if (!template) return;
if (!window.confirm(t("checklisten.detail.promote.confirm"))) return;
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/promote`, { method: "POST" });
if (!resp.ok) {
window.alert(t("checklisten.detail.promote.error"));
return;
}
window.location.reload();
});
document.getElementById("btn-demote-template")?.addEventListener("click", async () => {
if (!template) return;
if (!window.confirm(t("checklisten.detail.demote.confirm"))) return;
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/demote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: "firm" }),
});
if (!resp.ok) {
window.alert(t("checklisten.detail.promote.error"));
return;
}
window.location.reload();
});
}
async function loadSharePickerData() {
// Fire all three lookups in parallel — the share modal needs all of
// them but doesn't depend on their order.
try {
const [usersResp, unitsResp, projectsResp] = await Promise.all([
fetch("/api/users"),
fetch("/api/partner-units"),
fetch("/api/projects"),
]);
shareUsers = usersResp.ok ? await usersResp.json() : [];
sharePartnerUnits = unitsResp.ok ? await unitsResp.json() : [];
shareProjects = projectsResp.ok ? await projectsResp.json() : [];
} catch {
/* leave whatever loaded */
}
populateSharePickerOptions();
}
function populateSharePickerOptions() {
const userSel = document.getElementById("share-user") as HTMLSelectElement;
if (userSel) {
userSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
shareUsers
.slice()
.sort((a, b) => a.display_name.localeCompare(b.display_name))
.forEach((u) => {
if (me && u.id === me.id) return; // can't share with self
const opt = document.createElement("option");
opt.value = u.id;
opt.textContent = `${u.display_name} (${u.email})`;
userSel.appendChild(opt);
});
}
const officeSel = document.getElementById("share-office") as HTMLSelectElement;
if (officeSel) {
const officeKeys = ["munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan", "madrid"];
officeSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
officeKeys.forEach((k) => {
const opt = document.createElement("option");
opt.value = k;
opt.textContent = k.charAt(0).toUpperCase() + k.slice(1);
officeSel.appendChild(opt);
});
}
const puSel = document.getElementById("share-partner-unit") as HTMLSelectElement;
if (puSel) {
puSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
sharePartnerUnits
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((u) => {
const opt = document.createElement("option");
opt.value = u.id;
opt.textContent = u.name;
puSel.appendChild(opt);
});
}
const prSel = document.getElementById("share-project") as HTMLSelectElement;
if (prSel) {
prSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
shareProjects
.slice()
.sort((a, b) => (a.reference || a.title).localeCompare(b.reference || b.title))
.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.id;
opt.textContent = `${p.reference || ""}${p.title}`;
prSel.appendChild(opt);
});
}
}
function switchShareKind(kind: "user" | "office" | "partner_unit" | "project") {
activeShareKind = kind;
document.querySelectorAll<HTMLButtonElement>("#share-kind-pills .filter-pill").forEach((p) => {
p.classList.toggle("active", p.dataset.kind === kind);
});
document.querySelectorAll<HTMLElement>(".share-kind-section").forEach((s) => {
s.style.display = s.dataset.kind === kind ? "" : "none";
});
}
function initShareModal() {
const modal = document.getElementById("share-modal")!;
const msg = document.getElementById("share-msg")!;
const close = () => { modal.style.display = "none"; };
document.getElementById("btn-share-template")?.addEventListener("click", async () => {
if (!template) return;
msg.textContent = "";
msg.className = "form-msg";
switchShareKind("user");
modal.style.display = "flex";
await loadSharePickerData();
await renderGrants();
});
document.getElementById("share-close")?.addEventListener("click", close);
document.getElementById("share-cancel")?.addEventListener("click", close);
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
document.getElementById("share-kind-pills")?.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill[data-kind]");
if (!btn) return;
switchShareKind(btn.dataset.kind as typeof activeShareKind);
});
document.getElementById("share-submit")?.addEventListener("click", async () => {
if (!template) return;
const input: Record<string, unknown> = { recipient_kind: activeShareKind };
switch (activeShareKind) {
case "user": {
const v = (document.getElementById("share-user") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_user_id"] = v;
break;
}
case "office": {
const v = (document.getElementById("share-office") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_office"] = v;
break;
}
case "partner_unit": {
const v = (document.getElementById("share-partner-unit") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_partner_unit_id"] = v;
break;
}
case "project": {
const v = (document.getElementById("share-project") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_project_id"] = v;
break;
}
}
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!resp.ok) {
let errMsg = t("checklisten.share.error.generic");
try {
const j = await resp.json();
if (j?.error) errMsg = j.error;
} catch { /* keep generic */ }
msg.textContent = errMsg;
msg.className = "form-msg form-msg-error";
return;
}
msg.textContent = t("checklisten.share.success");
msg.className = "form-msg form-msg-success";
await renderGrants();
});
}
async function renderGrants() {
if (!template) return;
const list = document.getElementById("share-grants-list")!;
const empty = document.getElementById("share-grants-empty")!;
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`);
const rows: Share[] = resp.ok ? await resp.json() : [];
if (rows.length === 0) {
list.innerHTML = "";
list.appendChild(empty);
empty.style.display = "";
return;
}
empty.style.display = "none";
list.innerHTML = rows.map((s) => {
const kindLabel = esc(t(("checklisten.share.grants.recipient." + s.recipient_kind) as never) || s.recipient_kind);
return `<li class="share-grant-row" data-id="${esc(s.id)}">
<span class="share-grant-kind">${kindLabel}</span>
<span class="share-grant-label">${esc(s.recipient_label || "")}</span>
<button type="button" class="btn-small btn-ghost" data-action="revoke" data-id="${esc(s.id)}">${esc(t("checklisten.share.grants.revoke"))}</button>
</li>`;
}).join("");
list.querySelectorAll<HTMLButtonElement>("button[data-action=revoke]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!window.confirm(t("checklisten.share.grants.revoke.confirm"))) return;
const resp = await fetch(`/api/checklists/shares/${encodeURIComponent(btn.dataset.id!)}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) {
window.alert(t("checklisten.share.grants.revoke.error"));
return;
}
await renderGrants();
});
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initNewInstance();
initFeedback();
initOwnerActions();
initShareModal();
onLangChange(rerenderAll);
void loadTemplate();
void (async () => {
me = await loadMe();
await loadTemplate();
applyOwnerControls();
})();
void loadInstances();
void loadAkten();
});

View File

@@ -40,6 +40,16 @@ interface Instance {
created_by: string;
created_at: string;
updated_at: string;
// Slice C — snapshot of the template body + its version at create time.
template_snapshot?: { groups: ChecklistGroup[] } | null;
template_version?: number | null;
}
// Slice C — augmented Checklist with origin + version, returned by
// /api/checklists/{slug}.
interface ChecklistWithMeta extends Checklist {
origin?: "static" | "authored";
version?: number;
}
let template: Checklist | null = null;
@@ -155,6 +165,119 @@ function renderHeader() {
parts.push(`<div class="checklist-meta-item"><dt>${akteLabel}</dt><dd><a href="/projects/${esc(instance.project_id)}">${t("checklisten.instance.akte.open") || "Öffnen"}</a></dd></div>`);
}
document.getElementById("instance-meta")!.innerHTML = parts.join("");
renderOutdatedBadge();
}
// Slice C — show an "outdated" badge when the live template has a
// version > the instance's snapshot version. Both values must be
// non-null for the comparison to be meaningful (pre-Slice-C instances
// have NULL template_version; static templates always have version=1
// and never bump).
function renderOutdatedBadge() {
const slot = document.getElementById("instance-outdated-slot");
if (!slot || !instance || !template) return;
const tplMeta = template as ChecklistWithMeta;
const instVersion = instance.template_version;
const tplVersion = tplMeta.version;
if (
instVersion == null ||
tplVersion == null ||
tplMeta.origin !== "authored" ||
tplVersion <= instVersion
) {
slot.innerHTML = "";
return;
}
const badge = esc(t("checklisten.instance.outdated.badge"));
const note = esc(
t("checklisten.instance.outdated.note")
.replace("{from}", String(instVersion))
.replace("{to}", String(tplVersion)),
);
const action = esc(t("checklisten.instance.outdated.diff"));
slot.innerHTML = `<div class="instance-outdated-banner">
<span class="instance-outdated-badge">${badge}</span>
<span class="instance-outdated-note">${note}</span>
<button type="button" class="btn-small" id="btn-show-diff">${action}</button>
</div>`;
document.getElementById("btn-show-diff")!.addEventListener("click", openDiffModal);
}
// Shallow diff between two checklist bodies. Compares item label/note/
// rule pairs grouped by section title. Items with the same group title
// + same label are matched; differences in note/rule are flagged
// 'changed'. Items present only in snapshot are 'removed'; items only
// in current are 'added'.
function diffBodies(snapshot: { groups: ChecklistGroup[] } | null | undefined, current: ChecklistGroup[]):
{ added: string[]; removed: string[]; changed: string[] } {
const added: string[] = [];
const removed: string[] = [];
const changed: string[] = [];
const oldGroups = snapshot?.groups ?? [];
const oldMap: Record<string, ChecklistItem> = {};
for (const g of oldGroups) {
for (const it of g.items) {
const key = `${g.titleDE || g.titleEN}::${it.labelDE || it.labelEN}`;
oldMap[key] = it;
}
}
const newMap: Record<string, ChecklistItem> = {};
for (const g of current) {
for (const it of g.items) {
const key = `${g.titleDE || g.titleEN}::${it.labelDE || it.labelEN}`;
newMap[key] = it;
if (!(key in oldMap)) {
added.push(it.labelDE || it.labelEN);
} else {
const o = oldMap[key];
if ((o.noteDE || o.noteEN || "") !== (it.noteDE || it.noteEN || "") ||
(o.rule || "") !== (it.rule || "")) {
changed.push(it.labelDE || it.labelEN);
}
}
}
}
for (const key in oldMap) {
if (!(key in newMap)) {
const labelParts = key.split("::");
removed.push(labelParts[1] || key);
}
}
return { added, removed, changed };
}
function openDiffModal() {
if (!template || !instance) return;
const modal = document.getElementById("instance-diff-modal")!;
const body = document.getElementById("instance-diff-body")!;
const diff = diffBodies(instance.template_snapshot, template.groups);
const empty = diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0;
if (empty) {
body.innerHTML = `<p class="entity-events-empty">${esc(t("checklisten.instance.diff.empty"))}</p>`;
} else {
const section = (label: string, klass: string, items: string[]) => {
if (items.length === 0) return "";
return `<section class="instance-diff-section ${klass}">
<h3>${esc(label)}</h3>
<ul>${items.map((s) => `<li>${esc(s)}</li>`).join("")}</ul>
</section>`;
};
body.innerHTML = [
section(t("checklisten.instance.diff.added"), "instance-diff-added", diff.added),
section(t("checklisten.instance.diff.removed"), "instance-diff-removed", diff.removed),
section(t("checklisten.instance.diff.changed"), "instance-diff-changed", diff.changed),
].join("");
}
modal.style.display = "flex";
}
function initDiffModal() {
const modal = document.getElementById("instance-diff-modal");
if (!modal) return;
const close = () => { modal.style.display = "none"; };
document.getElementById("instance-diff-close")?.addEventListener("click", close);
document.getElementById("instance-diff-close-bottom")?.addEventListener("click", close);
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
}
function renderGroups() {
@@ -389,6 +512,7 @@ document.addEventListener("DOMContentLoaded", () => {
initPrint();
initRename();
initFeedback();
initDiffModal();
onLangChange(renderAll);
void bootstrap();
});

View File

@@ -11,6 +11,26 @@ interface ChecklistSummary {
courtDE: string;
courtEN: string;
itemCount: number;
origin?: "static" | "authored";
visibility?: string;
owner_email?: string;
owner_display_name?: string;
}
interface MyChecklist {
id: string;
slug: string;
owner_id: string;
title: string;
description: string;
regime: string;
court: string;
reference: string;
deadline: string;
lang: string;
visibility: string;
created_at: string;
updated_at: string;
}
interface ChecklistInstance {
@@ -26,15 +46,20 @@ interface ChecklistInstance {
project_title?: string | null;
}
type TabId = "templates" | "instances";
type TabId = "templates" | "mine" | "gallery" | "instances";
const VALID_TABS: TabId[] = ["templates", "instances"];
const VALID_TABS: TabId[] = ["templates", "mine", "gallery", "instances"];
let allChecklists: ChecklistSummary[] = [];
let activeRegime = "all";
let galleryRegime = "all";
let allInstances: ChecklistInstance[] = [];
let templatesBySlug: Record<string, ChecklistSummary> = {};
let instancesLoaded = false;
let myTemplates: MyChecklist[] = [];
let myTemplatesLoaded = false;
let galleryLoaded = false;
let me: { id: string; email: string } | null = null;
let activeTab: TabId = "templates";
function esc(s: string): string {
@@ -208,7 +233,10 @@ function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
el.style.display = el.id === `tab-${tab}` ? "" : "none";
});
if (opts.pushHistory ?? true) {
const newURL = tab === "instances" ? "/checklists?tab=instances" : "/checklists";
let newURL = "/checklists";
if (tab === "instances") newURL = "/checklists?tab=instances";
if (tab === "mine") newURL = "/checklists?tab=mine";
if (tab === "gallery") newURL = "/checklists?tab=gallery";
if (window.location.pathname + window.location.search !== newURL) {
window.history.replaceState({}, "", newURL);
}
@@ -216,6 +244,155 @@ function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
if (tab === "instances") {
void loadInstances();
}
if (tab === "mine") {
void loadMyTemplates();
}
if (tab === "gallery") {
void loadGallery();
}
}
async function loadGallery(force = false) {
if (galleryLoaded && !force) return;
galleryLoaded = true;
// /api/checklists already returns the merged catalog; the gallery
// filter just narrows to non-static + non-owned + non-private.
if (allChecklists.length === 0) {
await loadTemplates();
}
renderGallery();
}
function renderGallery() {
const loading = document.getElementById("checklists-gallery-loading")!;
const empty = document.getElementById("checklists-gallery-empty")!;
const grid = document.getElementById("checklists-gallery-grid") as HTMLElement;
loading.style.display = "none";
const visible = allChecklists.filter((c) => {
if (c.origin !== "authored") return false;
if (me && c.owner_email && me.email.toLowerCase() === c.owner_email.toLowerCase()) return false;
if (galleryRegime !== "all" && c.regime !== galleryRegime) return false;
return true;
});
if (visible.length === 0) {
empty.style.display = "";
grid.style.display = "none";
return;
}
empty.style.display = "none";
grid.style.display = "";
const isEN = getLang() === "en";
grid.innerHTML = visible.map((c) => {
const title = isEN ? c.titleEN : c.titleDE;
const desc = isEN ? c.descriptionEN : c.descriptionDE;
const court = isEN ? c.courtEN : c.courtDE;
const itemsLabel = isEN ? "items" : "Punkte";
const visKey = `checklisten.mine.visibility.${c.visibility || ""}`;
const visLabel = c.visibility ? esc(t(visKey as never) || c.visibility) : "";
const authorLine = c.owner_display_name
? `<p class="checklist-card-author">${esc(t("checklisten.detail.authored.by").replace("{author}", c.owner_display_name))}</p>`
: "";
return `<a href="/checklists/${esc(c.slug)}" class="checklist-card">
<div class="checklist-card-top">
<span class="checklist-regime checklist-regime-${esc(c.regime)}">${esc(c.regime)}</span>
<span class="checklist-card-count">${c.itemCount} ${itemsLabel}</span>
</div>
<h2 class="checklist-card-title">${esc(title)}</h2>
<p class="checklist-card-desc">${esc(desc)}</p>
<p class="checklist-card-court">${esc(court)}</p>
${authorLine}
${visLabel ? `<span class="visibility-chip visibility-chip-${esc(c.visibility || "")}">${visLabel}</span>` : ""}
</a>`;
}).join("");
}
function initGalleryFilters() {
const container = document.getElementById("checklist-gallery-filters");
if (!container) return;
container.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill");
if (!btn) return;
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
galleryRegime = btn.dataset.regime ?? "all";
renderGallery();
});
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (resp.ok) me = await resp.json();
} catch { /* leave me=null */ }
}
async function loadMyTemplates(force = false) {
if (myTemplatesLoaded && !force) return;
myTemplatesLoaded = true;
const resp = await fetch("/api/checklists/templates/mine");
if (!resp.ok) {
myTemplates = [];
} else {
myTemplates = (await resp.json()) ?? [];
}
renderMyTemplates();
}
function renderMyTemplates() {
const loading = document.getElementById("checklists-mine-loading")!;
const empty = document.getElementById("checklists-mine-empty")!;
const grid = document.getElementById("checklists-mine-grid") as HTMLElement;
loading.style.display = "none";
if (myTemplates.length === 0) {
empty.style.display = "";
grid.style.display = "none";
return;
}
empty.style.display = "none";
grid.style.display = "";
grid.innerHTML = myTemplates.map((tpl) => {
const visKey = `checklisten.mine.visibility.${tpl.visibility}`;
const visLabel = esc(t(visKey as never) || tpl.visibility);
const titleSafe = esc(tpl.title);
return `<article class="checklist-card checklist-card-mine" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}">
<div class="checklist-card-top">
<span class="checklist-regime checklist-regime-${esc(tpl.regime)}">${esc(tpl.regime)}</span>
<span class="checklist-card-count visibility-chip visibility-chip-${esc(tpl.visibility)}">${visLabel}</span>
</div>
<h2 class="checklist-card-title">
<a href="/checklists/${esc(tpl.slug)}">${titleSafe}</a>
</h2>
<p class="checklist-card-desc">${esc(tpl.description || "")}</p>
<p class="checklist-card-court">${esc(tpl.court || "")}</p>
<div class="checklist-card-actions">
<a class="btn btn-small" href="/checklists/templates/${esc(tpl.slug)}/edit" data-i18n="checklisten.mine.edit">Bearbeiten</a>
<button class="btn btn-small btn-danger" data-action="delete" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}" data-i18n="checklisten.mine.delete">L&ouml;schen</button>
</div>
</article>`;
}).join("");
grid.querySelectorAll<HTMLButtonElement>("button[data-action=delete]").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
const slug = btn.dataset.slug!;
const title = btn.dataset.title || slug;
const msg = t("checklisten.mine.delete.confirm").replace("{title}", title);
if (!window.confirm(msg)) return;
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}`, { method: "DELETE" });
if (!resp.ok) {
window.alert(t("checklisten.mine.delete.error"));
return;
}
await loadMyTemplates(true);
});
});
}
function initTabs() {
@@ -234,11 +411,15 @@ document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initFilters();
initGalleryFilters();
initTabs();
onLangChange(() => {
renderTemplates();
if (instancesLoaded) renderInstances();
if (myTemplatesLoaded) renderMyTemplates();
if (galleryLoaded) renderGallery();
});
void loadMe();
void loadTemplates();
showTab(parseTab(), { pushHistory: false });
});

View File

@@ -76,12 +76,15 @@ interface FieldSpec {
required?: boolean;
}
// Deadline-only fields rendered in the editable section. `rule_code` and
// `event_type_ids` are intentionally NOT here — they're bundled into the
// dedicated "Verfahrenshandlung" section below the base fields so the
// event-type (parent concept) reads before the rule (m/paliad#56).
const DEADLINE_FIELDS: ReadonlyArray<FieldSpec> = [
{ key: "title", labelKey: "deadlines.field.title", inputType: "text", required: true },
{ key: "due_date", labelKey: "deadlines.field.due", inputType: "date" },
{ key: "original_due_date", labelKey: "approvals.suggest.field.original_due_date", inputType: "date" },
{ key: "warning_date", labelKey: "approvals.suggest.field.warning_date", inputType: "date" },
{ key: "rule_code", labelKey: "approvals.suggest.field.rule_code", inputType: "text" },
{ key: "description", labelKey: "approvals.suggest.field.description", inputType: "textarea" },
{ key: "notes", labelKey: "deadlines.field.notes", inputType: "textarea" },
];
@@ -121,7 +124,7 @@ export async function openApprovalEditModal(
let eventTypePicker: PickerHandle | null = null;
let eventTypePickerLoaded = false;
if (args.entityType === "deadline") {
const pickerSection = renderEventTypePickerSection();
const pickerSection = renderEventTypePickerSection(original, preImage);
body.appendChild(pickerSection.section);
void (async () => {
try {
@@ -191,67 +194,94 @@ function renderFieldsSection(
section.appendChild(h);
for (const f of fields) {
const wrap = document.createElement("div");
wrap.className = "form-field approval-suggest-field";
const label = document.createElement("label");
label.textContent = t(f.labelKey as never);
wrap.appendChild(label);
const value = formatFieldForInput(original[f.key], f.inputType);
let input: HTMLInputElement | HTMLTextAreaElement;
if (f.inputType === "textarea") {
input = document.createElement("textarea");
input.rows = 3;
(input as HTMLTextAreaElement).value = value;
} else {
input = document.createElement("input");
(input as HTMLInputElement).type = f.inputType;
(input as HTMLInputElement).value = value;
}
input.dataset.suggestField = f.key;
input.dataset.suggestOriginal = value;
input.dataset.suggestInputType = f.inputType;
if (f.required) input.required = true;
// Wire the <label> to focus the <input> on click.
const inputID = `suggest-field-${f.key}`;
input.id = inputID;
label.setAttribute("for", inputID);
wrap.appendChild(input);
// "Vorher" hint when pre_image carries a distinct value for this field.
const preVal = formatFieldForInput(preImage[f.key], f.inputType);
if (preVal && preVal !== value) {
const hint = document.createElement("span");
hint.className = "approval-suggest-prehint";
hint.textContent = `${t("approvals.diff.before")}: ${preVal}`;
wrap.appendChild(hint);
}
section.appendChild(wrap);
section.appendChild(renderSingleField(f, original, preImage));
}
return section;
}
function renderEventTypePickerSection(): { section: HTMLElement; host: HTMLElement } {
// Verfahrenshandlung section — bundles the event-type picker and the
// rule_code input so the editor reads "what procedural step? which rule
// cites it?" instead of two disconnected fields with rule above type
// (m/paliad#56). The hint underneath spells out the parent/child
// relationship so first-time editors don't read them as peers.
function renderEventTypePickerSection(
original: Record<string, unknown>,
preImage: Record<string, unknown>,
): { section: HTMLElement; host: HTMLElement } {
const section = document.createElement("section");
section.className = "approval-suggest-section approval-suggest-section--editable";
const h = document.createElement("h3");
h.className = "approval-suggest-section-title";
h.textContent = t("deadlines.field.event_type");
h.textContent = t("approvals.suggest.section.event_type_rule");
section.appendChild(h);
const host = document.createElement("div");
host.className = "approval-suggest-event-type-picker";
section.appendChild(host);
// Rule citation — rendered as a sub-field directly beneath the picker so
// the visual hierarchy matches the conceptual one (rule is meta on the
// event type, not a peer).
const ruleField: FieldSpec = {
key: "rule_code",
labelKey: "approvals.suggest.field.rule_code",
inputType: "text",
};
section.appendChild(renderSingleField(ruleField, original, preImage));
return { section, host };
}
// renderSingleField builds one labelled input in the same shape as the
// fields-section loop. Extracted so the Verfahrenshandlung section can
// host the rule_code input next to the picker without duplicating the
// wiring (dirty-tracking, pre_image hint, label/for binding).
function renderSingleField(
f: FieldSpec,
original: Record<string, unknown>,
preImage: Record<string, unknown>,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "form-field approval-suggest-field";
const label = document.createElement("label");
label.textContent = t(f.labelKey as never);
wrap.appendChild(label);
const value = formatFieldForInput(original[f.key], f.inputType);
let input: HTMLInputElement | HTMLTextAreaElement;
if (f.inputType === "textarea") {
input = document.createElement("textarea");
input.rows = 3;
(input as HTMLTextAreaElement).value = value;
} else {
input = document.createElement("input");
(input as HTMLInputElement).type = f.inputType;
(input as HTMLInputElement).value = value;
}
input.dataset.suggestField = f.key;
input.dataset.suggestOriginal = value;
input.dataset.suggestInputType = f.inputType;
if (f.required) input.required = true;
const inputID = `suggest-field-${f.key}`;
input.id = inputID;
label.setAttribute("for", inputID);
wrap.appendChild(input);
const preVal = formatFieldForInput(preImage[f.key], f.inputType);
if (preVal && preVal !== value) {
const hint = document.createElement("span");
hint.className = "approval-suggest-prehint";
hint.textContent = `${t("approvals.diff.before")}: ${preVal}`;
wrap.appendChild(hint);
}
return wrap;
}
function renderContextSection(
args: ApprovalEditModalArgs,
original: Record<string, unknown>,

View File

@@ -0,0 +1,149 @@
// t-paliad-252 / m/paliad#83 — withdraw warning modal.
//
// Before t-paliad-252 the deadline + appointment detail pages did a
// confirm() dialog before POSTing to /api/approval-requests/{id}/revoke.
// For pending CREATE lifecycles that endpoint silently DELETES the
// underlying entity row — m's "withdrawing the approval deletes the event"
// surprise.
//
// This modal replaces the confirm() with three explicit paths:
//
// 1. Cancel — does nothing
// 2. Termin bearbeiten (primary) — opens the edit form; saving routes
// through POST /approval-requests/{id}/
// edit-entity which keeps the request
// pending and merges the new fields
// into approval_request.payload
// 3. Endgültig zurückziehen + — destructive; current /revoke
// löschen behaviour (delete for CREATE, revert
// for UPDATE/COMPLETE, cancel for
// DELETE-lifecycle requests)
//
// Built on the unified openModal() primitive (t-paliad-217 Slice A) so the
// three-button row sits cleanly inside the body — the primitive only
// supports one secondary action, but we paint the destructive button as a
// separate row above the footer.
import { t } from "../i18n";
import { openModal } from "./modal";
export type WithdrawAction = "edit" | "withdraw";
export interface WithdrawWarningArgs {
// entityType drives the copy ("event" vs "appointment" labels).
entityType: "deadline" | "appointment";
// lifecycleEvent of the pending request; copy adapts (CREATE warns about
// deletion; UPDATE/COMPLETE warn about revert; DELETE warns about
// cancelling the deletion request).
lifecycleEvent: "create" | "update" | "complete" | "delete" | string;
}
// openWithdrawWarningModal resolves with the chosen action, or null if the
// user dismissed via Cancel / Esc / backdrop / browser back-button.
export async function openWithdrawWarningModal(
args: WithdrawWarningArgs,
): Promise<WithdrawAction | null> {
const body = document.createElement("div");
body.className = "withdraw-warning-body";
// Lead paragraph + sub-paragraph adapt to lifecycle so the user always
// knows what the destructive button will actually do. The /revoke
// backend behaviour:
// - create → DELETE the entity (the "surprise" m flagged)
// - update → revert to pre_image
// - complete → revert to pre-complete state
// - delete → cancel the delete request (entity stays alive)
const intro = document.createElement("p");
intro.className = "withdraw-warning-intro";
intro.textContent = leadCopyFor(args);
body.appendChild(intro);
const sub = document.createElement("p");
sub.className = "withdraw-warning-sub muted";
sub.textContent = subCopyFor(args);
body.appendChild(sub);
// The destructive button lives inside the body — the openModal primitive
// only exposes one secondary button slot, and we want the safe "Edit"
// path to be the primary CTA. Painting it in red here, separated from
// the footer, signals "this is the dangerous option" without competing
// visually with the primary CTA.
const destructiveRow = document.createElement("div");
destructiveRow.className = "withdraw-warning-destructive-row";
const destructiveBtn = document.createElement("button");
destructiveBtn.type = "button";
destructiveBtn.className = "btn btn-danger withdraw-warning-destructive-btn";
destructiveBtn.textContent = t("approvals.withdraw.destructive.label");
destructiveRow.appendChild(destructiveBtn);
body.appendChild(destructiveRow);
return new Promise<WithdrawAction | null>((resolve) => {
let chosen: WithdrawAction | null = null;
// The destructive button has to close the modal and return "withdraw".
// We need access to the modal's internal close() — fortunately openModal
// exposes it via the primary handler's first arg. We pass through the
// outer resolve and let the primary handler (Edit) own the close-fn
// route. For the destructive button we resolve the outer promise
// directly and then synthesise an ESC keypress so the modal dismisses
// — or, simpler, set chosen and use the secondary "Cancel" path that
// the modal already supports. (openModal's onClose fires on every
// dismiss path including the primary handler resolution.)
destructiveBtn.addEventListener("click", () => {
chosen = "withdraw";
// The unified openModal primitive (modal.ts) wires its dismiss path
// through the native <dialog>'s `cancel` event. Dispatching it on
// the parent <dialog> runs the same finish() → onClose → resolve
// sequence as ESC / backdrop. We then map the resolved `null` back
// to "withdraw" via the captured `chosen` in onClose below.
const dialogEl = body.closest("dialog");
dialogEl?.dispatchEvent(new Event("cancel"));
});
void openModal<WithdrawAction>({
title: t("approvals.withdraw.modal.title"),
body,
size: "md",
classNames: "withdraw-warning-modal",
primary: {
label: t("approvals.withdraw.primary.label"),
handler: (close) => {
chosen = "edit";
close("edit");
},
},
secondary: { label: t("approvals.withdraw.cancel") },
onClose: () => {
// Resolves whatever was chosen via the destructive button OR the
// primary handler. ESC / backdrop / secondary clear `chosen` to
// null which is the right "cancel" semantics.
resolve(chosen);
},
});
});
}
function leadCopyFor(args: WithdrawWarningArgs): string {
switch (args.lifecycleEvent) {
case "create":
return args.entityType === "appointment"
? t("approvals.withdraw.lead.create.appointment")
: t("approvals.withdraw.lead.create.deadline");
case "delete":
return t("approvals.withdraw.lead.delete");
default:
// update / complete / unknown → revert semantics
return t("approvals.withdraw.lead.update");
}
}
function subCopyFor(args: WithdrawWarningArgs): string {
switch (args.lifecycleEvent) {
case "create":
return t("approvals.withdraw.sub.create");
case "delete":
return t("approvals.withdraw.sub.delete");
default:
return t("approvals.withdraw.sub.update");
}
}

View File

@@ -0,0 +1,285 @@
import { describe, expect, test } from "bun:test";
import {
GRID_COLUMNS,
clampH,
clampW,
placeWidgets,
type WidgetPlacementInput,
} from "./dashboard-grid";
// Regression suite for m/paliad#70 (t-paliad-228): the post-#69 edit
// mode produced overlapping widgets when a 2-col widget sat next to a
// 1-col widget on the same row, when a drag swapped widgets of
// different widths, and when a resize grew a widget into a sibling. The
// fix moved the placement math into ./dashboard-grid + made it
// collision-aware. These tests pin the no-overlap invariant.
function spec(
key: string,
x: number | undefined,
y: number | undefined,
w: number,
h = 1,
visible = true,
): WidgetPlacementInput {
return { key, visible, x, y, w, h };
}
// hasOverlap returns true if any placed pair shares a cell. O(n²) is
// fine — layouts cap at 32 widgets and the tests stay tiny.
function hasOverlap(rects: Map<string, { x: number; y: number; w: number; h: number }>): string | null {
const list = Array.from(rects.entries());
for (let i = 0; i < list.length; i++) {
const [ka, a] = list[i];
for (let j = i + 1; j < list.length; j++) {
const [kb, b] = list[j];
const xOverlap = a.x < b.x + b.w && b.x < a.x + a.w;
const yOverlap = a.y < b.y + b.h && b.y < a.y + a.h;
if (xOverlap && yOverlap) return `${ka}${kb} at (${a.x},${a.y},${a.w}x${a.h}) vs (${b.x},${b.y},${b.w}x${b.h})`;
}
}
return null;
}
describe("placeWidgets — basic auto-flow", () => {
test("places two 6-wide widgets side by side on row 0", () => {
const out = placeWidgets([
spec("a", undefined, undefined, 6),
spec("b", undefined, undefined, 6),
]);
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
expect(out.get("b")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
expect(hasOverlap(out)).toBeNull();
});
test("wraps when row doesn't fit", () => {
const out = placeWidgets([
spec("a", undefined, undefined, 8),
spec("b", undefined, undefined, 8),
]);
expect(out.get("a")!.y).toBe(0);
expect(out.get("b")!.y).toBeGreaterThan(0);
expect(hasOverlap(out)).toBeNull();
});
test("hidden widgets are skipped and reserve no cells", () => {
const out = placeWidgets([
spec("hidden", 0, 0, 12, 1, false),
spec("visible", undefined, undefined, 6),
]);
expect(out.has("hidden")).toBe(false);
expect(out.get("visible")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
});
});
describe("placeWidgets — explicit positions, no collision", () => {
test("trusts non-colliding explicit positions exactly", () => {
const out = placeWidgets([
spec("a", 0, 0, 6),
spec("b", 6, 0, 6),
spec("c", 0, 1, 12),
]);
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
expect(out.get("b")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
expect(out.get("c")).toEqual({ x: 0, y: 1, w: 12, h: 1 });
expect(hasOverlap(out)).toBeNull();
});
});
describe("placeWidgets — mixed-width collision (m/paliad#70 regression)", () => {
test("1-col + 2-col on same row do not overlap when both explicit", () => {
// Half-width left + half-width right is the canonical 'two widgets per
// row' layout; pre-fix this was fine but the next regression below
// exercises the actual bug.
const out = placeWidgets([
spec("left", 0, 0, 6),
spec("right", 6, 0, 6),
]);
expect(hasOverlap(out)).toBeNull();
});
test("4-col + 8-col both claiming (0,0) end up non-overlapping", () => {
// Simulates a post-#69 layout where a 4-wide widget sits at (0, 0)
// and an 8-wide widget got accidentally placed at (0, 0) too (e.g.
// a buggy reset path or a stale spec from before #70). Placer must
// honour the first one's position and fit the second somewhere
// free — landing it on the same row at x=4 is acceptable (better
// density) as long as nothing overlaps.
const out = placeWidgets([
spec("first", 0, 0, 4),
spec("colliding", 0, 0, 8),
]);
expect(out.get("first")).toEqual({ x: 0, y: 0, w: 4, h: 1 });
expect(out.get("colliding")!.w).toBe(8);
expect(hasOverlap(out)).toBeNull();
});
test("drag-drop swap of 12-wide onto 6-wide does not overlap", () => {
// Setup before swap:
// A at (0, 0, w=12) — full width row 0
// B at (0, 1, w=6) — half row 1 left
// C at (6, 1, w=6) — half row 1 right
// User drags A onto B. reorderViaDnd swaps (x, y):
// A.x=0, A.y=1
// B.x=0, B.y=0
// Result must not overlap C.
const out = placeWidgets([
spec("a", 0, 1, 12),
spec("b", 0, 0, 6),
spec("c", 6, 1, 6),
]);
expect(hasOverlap(out)).toBeNull();
});
test("auto-flow widget steps past explicit blocker on same row", () => {
// Explicit widget at (6, 0, w=6); auto-flow widget would pack into
// (0, 0, w=6) which is fine — but the next auto-flow widget at w=6
// would want (6, 0) which is taken. Placer must wrap it.
const out = placeWidgets([
spec("flow-a", undefined, undefined, 6),
spec("anchored", 6, 0, 6),
spec("flow-b", undefined, undefined, 6),
]);
expect(out.get("flow-a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
expect(out.get("anchored")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
expect(out.get("flow-b")!.y).toBeGreaterThan(0);
expect(hasOverlap(out)).toBeNull();
});
});
describe("placeWidgets — resize-grow shifts siblings", () => {
test("growing a 6-wide to 12-wide bumps the sibling on the same row", () => {
// Pre-resize state:
// A at (0, 0, w=6)
// B at (6, 0, w=6)
// User resizes A to w=12. resizeWidget() updates A.w but leaves B
// at (6, 0). Placer must shift B down.
const out = placeWidgets([
spec("a", 0, 0, 12),
spec("b", 6, 0, 6),
]);
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 12, h: 1 });
expect(out.get("b")!.y).toBeGreaterThan(0);
expect(hasOverlap(out)).toBeNull();
});
test("growing widget pushes only the first colliding sibling", () => {
// A grows to 12-wide; B and C on row 0 are both colliding. Both must
// move; their relative order on row 0 is preserved (B at x=0, C at
// x=6) on row 1.
const out = placeWidgets([
spec("a", 0, 0, 12),
spec("b", 0, 0, 4),
spec("c", 4, 0, 4),
]);
expect(hasOverlap(out)).toBeNull();
expect(out.get("a")!.y).toBe(0);
expect(out.get("b")!.y).toBeGreaterThan(0);
expect(out.get("c")!.y).toBeGreaterThan(0);
});
});
describe("placeWidgets — explicit position overflow clamp", () => {
test("x+w > GRID_COLUMNS is clamped not rejected", () => {
// A 12-wide widget with x=6 would extend past col 11. Placer must
// clamp x to 0 (or wherever fits) so the widget renders inside the
// grid.
const out = placeWidgets([
spec("wide", 6, 0, 12),
]);
const r = out.get("wide")!;
expect(r.x + r.w).toBeLessThanOrEqual(GRID_COLUMNS);
expect(r.w).toBe(12);
});
});
describe("placeWidgets — vertical (multi-row) widgets", () => {
test("a 2-row-tall widget reserves both rows", () => {
const out = placeWidgets([
spec("tall", 0, 0, 6, 2),
spec("collides-on-row-1", 0, 1, 6, 1),
]);
expect(out.get("tall")).toEqual({ x: 0, y: 0, w: 6, h: 2 });
// The colliding widget must move because tall covers cols 0..5
// on both row 0 and row 1. The placer may shift it to the right
// half of row 1 (cols 6..11) or to a later row — either is fine
// as long as nothing overlaps.
const other = out.get("collides-on-row-1")!;
expect(other.x >= 6 || other.y >= 2).toBe(true);
expect(hasOverlap(out)).toBeNull();
});
});
describe("placeWidgets — includeHidden (edit mode)", () => {
test("hidden widgets are skipped by default", () => {
const out = placeWidgets([
spec("visible", 0, 0, 6),
spec("hidden", 0, 0, 6, 1, false),
]);
expect(out.has("visible")).toBe(true);
expect(out.has("hidden")).toBe(false);
});
test("includeHidden:true places hidden widgets after visible ones", () => {
// Regression for m/paliad#73 / t-paliad-238: in edit mode hidden
// widgets MUST receive a placement, otherwise applyLayout leaves
// their inline grid-column empty and CSS Grid auto-flows them as
// 1×1 slivers ("super slim greyed-out column").
const out = placeWidgets([
spec("active", 0, 0, 12),
spec("hidden", 0, 0, 6, 1, false),
], { includeHidden: true });
expect(out.has("hidden")).toBe(true);
const h = out.get("hidden")!;
// Must keep its requested width (6), not collapse to 1.
expect(h.w).toBe(6);
// Must land below the visible widget — never overlap or steal cells.
expect(h.y).toBeGreaterThanOrEqual(1);
expect(hasOverlap(out)).toBeNull();
});
test("includeHidden two-pass: visible widgets keep priority over hidden", () => {
// Hidden widget stored at (0, 0) shouldn't displace a visible
// widget that wants (0, 0). The visible pass runs first, claims
// (0, 0); the hidden widget is then placed wherever free — the
// placer happily fits it next to the visible widget on the same
// row if there's room. The hard invariant is just no-overlap.
const out = placeWidgets([
spec("active", 0, 0, 6),
spec("hidden-at-origin", 0, 0, 6, 1, false),
], { includeHidden: true });
expect(out.get("active")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
expect(out.has("hidden-at-origin")).toBe(true);
expect(hasOverlap(out)).toBeNull();
});
test("multiple hidden widgets all receive valid placements", () => {
const out = placeWidgets([
spec("a", 0, 0, 12),
spec("h1", undefined, undefined, 6, 1, false),
spec("h2", undefined, undefined, 6, 1, false),
spec("h3", undefined, undefined, 12, 1, false),
], { includeHidden: true });
expect(out.size).toBe(4);
for (const r of out.values()) {
expect(r.w).toBeGreaterThanOrEqual(1);
expect(r.x + r.w).toBeLessThanOrEqual(GRID_COLUMNS);
}
expect(hasOverlap(out)).toBeNull();
});
});
describe("clamp helpers", () => {
test("clampW respects min/max bounds", () => {
expect(clampW(2, { min_w: 4, max_w: 12 })).toBe(4);
expect(clampW(20, { min_w: 4, max_w: 12 })).toBe(12);
expect(clampW(0, { default_w: 6 })).toBe(6);
expect(clampW(NaN, { default_w: 8 })).toBe(8);
});
test("clampH respects min/max bounds and MAX_ROW_SPAN", () => {
expect(clampH(0, { default_h: 2 })).toBe(2);
expect(clampH(99, undefined)).toBe(5); // MAX_ROW_SPAN
expect(clampH(1, { min_h: 3 })).toBe(3);
});
});

View File

@@ -0,0 +1,264 @@
// dashboard-grid — pure layout math for the dashboard widget grid.
//
// Lives outside dashboard.ts so the placement logic is importable from
// tests without dragging in the DOM-side rendering code. The grid is a
// 12-column CSS Grid matching internal/services/dashboard_layout_spec.go;
// rows grow vertically as widgets are placed.
//
// The core invariant is no-overlap: after placeWidgets() returns, every
// pair of widgets occupies disjoint cells. Pre-overhaul callers wrote
// computePlacements() to trust explicit (x, y) without checking — that
// produced visual overlap whenever a drag or resize landed a widget on
// cells another widget already covered (m/paliad#70). The collision-
// aware placer below shifts colliding widgets to the next free row so
// the rendered grid never overlaps regardless of the input spec.
export const GRID_COLUMNS = 12;
export const MAX_ROW_SPAN = 5;
// Hard cap on the row-scan depth in findFreeSlot. The widget cap on a
// single layout is 32 (LayoutWidgetCap on the Go side); each row holds
// at least one widget, so 256 rows is an order-of-magnitude buffer
// against runaway loops on pathological inputs.
const MAX_SCAN_ROWS = 256;
export interface PlacedRect {
x: number;
y: number;
w: number;
h: number;
}
// WidgetSizeBound captures the per-widget min/max/default clamps the
// catalog publishes. Optional fields keep callers from having to
// synthesize zeroes when the catalog entry is missing.
export interface WidgetSizeBound {
default_w?: number;
default_h?: number;
min_w?: number;
max_w?: number;
min_h?: number;
max_h?: number;
}
// WidgetPlacementInput is the per-widget data the placer consumes. The
// catalog bound is optional — when missing, defaults fall back to a
// full-width 1-row widget.
export interface WidgetPlacementInput {
key: string;
visible: boolean;
x?: number;
y?: number;
w?: number;
h?: number;
bound?: WidgetSizeBound;
}
export function clampW(w: number, bound: WidgetSizeBound | undefined): number {
let v = Math.round(w);
if (!Number.isFinite(v) || v <= 0) v = bound?.default_w ?? GRID_COLUMNS;
v = Math.max(1, Math.min(GRID_COLUMNS, v));
if (bound?.min_w && v < bound.min_w) v = bound.min_w;
if (bound?.max_w && v > bound.max_w) v = bound.max_w;
return v;
}
export function clampH(h: number, bound: WidgetSizeBound | undefined): number {
let v = Math.round(h);
if (!Number.isFinite(v) || v <= 0) v = bound?.default_h ?? 1;
v = Math.max(1, Math.min(MAX_ROW_SPAN, v));
if (bound?.min_h && v < bound.min_h) v = bound.min_h;
if (bound?.max_h && v > bound.max_h) v = bound.max_h;
return v;
}
// Occupancy bitmap: one row → Uint8Array of GRID_COLUMNS bits. Rows are
// created lazily so the map only stores rows the layout actually
// reaches. Cell value 1 = occupied.
class Occupancy {
private rows = new Map<number, Uint8Array>();
row(y: number): Uint8Array {
let r = this.rows.get(y);
if (!r) {
r = new Uint8Array(GRID_COLUMNS);
this.rows.set(y, r);
}
return r;
}
free(x: number, y: number, w: number, h: number): boolean {
if (x < 0 || y < 0 || x + w > GRID_COLUMNS) return false;
for (let yy = y; yy < y + h; yy++) {
const row = this.row(yy);
for (let xx = x; xx < x + w; xx++) {
if (row[xx]) return false;
}
}
return true;
}
mark(x: number, y: number, w: number, h: number): void {
for (let yy = y; yy < y + h; yy++) {
const row = this.row(yy);
for (let xx = x; xx < x + w; xx++) row[xx] = 1;
}
}
}
// findFreeSlot scans for the first (x, y) where a w×h block fits without
// collision, starting at row startY. At each row preferX is tried first
// — that keeps a widget close to its requested column when only the row
// is blocked. Falls back to left-to-right scan within the row, then to
// the next row. Caller guarantees w ≤ GRID_COLUMNS.
function findFreeSlot(
occ: Occupancy,
startY: number,
w: number,
h: number,
preferX: number,
): { x: number; y: number } {
for (let y = startY; y < startY + MAX_SCAN_ROWS; y++) {
if (preferX >= 0 && preferX + w <= GRID_COLUMNS && occ.free(preferX, y, w, h)) {
return { x: preferX, y };
}
for (let x = 0; x + w <= GRID_COLUMNS; x++) {
if (x === preferX) continue;
if (occ.free(x, y, w, h)) return { x, y };
}
}
// Pathological fallback — caller's widget cap (32) makes this
// unreachable in practice. Snap to the bottom-left so the widget at
// least renders somewhere visible instead of vanishing.
return { x: 0, y: startY + MAX_SCAN_ROWS };
}
// PlaceOptions tunes the placer for the caller's render-vs-persist
// needs.
export interface PlaceOptions {
// When true, hidden widgets are placed too — for edit-mode rendering
// where the user can see + un-hide them inline. The two-pass order
// (visible first, then hidden) guarantees hidden widgets never
// displace visible ones: they get whatever cells are left below the
// active layout. Default false matches view-mode behaviour and the
// persistence path (materializePositions) where hidden widgets
// retain their stored coordinates instead of being repacked.
//
// Without this option, hidden widgets in edit mode were left without
// an explicit grid-column inline style by applyLayout(), so CSS Grid
// auto-flowed them into the next free cell at 1×1 — the "super slim
// greyed-out column" symptom of m/paliad#73 / t-paliad-238.
includeHidden?: boolean;
}
// placeWidgets assigns no-overlap grid coordinates to widgets. By
// default only visible widgets receive placements; pass
// {includeHidden:true} to also place hidden widgets after the visible
// pass (used by applyLayout in edit mode).
//
// Algorithm — per pass:
// 1. Clamp w/h against catalog bounds.
// 2. If the spec carries explicit x and y, try that slot. On a
// collision, search downward starting at the requested y for the
// first free w×h block (preferring the requested x).
// 3. If only x is explicit, search from y=0 at that x.
// 4. Otherwise auto-flow: pack left-to-right under a running cursor;
// when the row doesn't fit or is blocked by an explicitly-placed
// widget, wrap to the next free row.
//
// The mixed-spec case (some widgets explicit, others auto-flow) is the
// real-world layout — placing the explicit widgets first would change
// the visual order, so we keep input order and let auto-flow widgets
// step around any explicit blockers via the same collision search.
//
// Two-pass behaviour for hidden widgets: the visible pass owns its
// own auto-flow cursor; the hidden pass continues from where the
// visible pass left off so the hidden widgets stack right under the
// active layout. The shared Occupancy bitmap guarantees the second
// pass can never overlap a placed visible widget.
export function placeWidgets(
widgets: WidgetPlacementInput[],
options: PlaceOptions = {},
): Map<string, PlacedRect> {
const out = new Map<string, PlacedRect>();
const occ = new Occupancy();
// Auto-flow cursor — advances as we place flowed widgets. cursorY
// tracks the row currently being filled; rowMaxH is the tallest
// widget in that row so wrapping advances past it (not just past the
// new widget's height — that would let taller previous neighbours
// overlap into the wrap row).
let cursorX = 0;
let cursorY = 0;
let rowMaxH = 0;
const placeOne = (w: WidgetPlacementInput): void => {
const dw = clampW(w.w ?? w.bound?.default_w ?? GRID_COLUMNS, w.bound);
const dh = clampH(w.h ?? w.bound?.default_h ?? 1, w.bound);
const hasX = typeof w.x === "number";
const hasY = typeof w.y === "number";
let placed: { x: number; y: number };
if (hasX && hasY) {
// Clamp x so the widget never overflows the right edge — drag/
// resize gestures can produce x+w > GRID_COLUMNS otherwise.
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
const prefY = Math.max(0, w.y as number);
if (occ.free(prefX, prefY, dw, dh)) {
placed = { x: prefX, y: prefY };
} else {
placed = findFreeSlot(occ, prefY, dw, dh, prefX);
}
} else if (hasX) {
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
placed = findFreeSlot(occ, 0, dw, dh, prefX);
} else {
// Auto-flow. Wrap the cursor when the widget wouldn't fit in the
// remaining columns of the current row, then ask findFreeSlot to
// honour the cursor's preferred (x, y) — that lets it step past
// any explicit widget that already claimed cells under the
// cursor.
if (cursorX + dw > GRID_COLUMNS) {
cursorY += rowMaxH || 1;
cursorX = 0;
rowMaxH = 0;
}
placed = findFreeSlot(occ, cursorY, dw, dh, cursorX);
if (placed.y > cursorY) {
// Wrap was forced by a collision deeper than the current row.
cursorY = placed.y;
rowMaxH = 0;
}
cursorX = placed.x + dw;
if (dh > rowMaxH) rowMaxH = dh;
}
occ.mark(placed.x, placed.y, dw, dh);
out.set(w.key, { x: placed.x, y: placed.y, w: dw, h: dh });
};
// Pass 1: visible widgets. They own the active layout.
for (const w of widgets) {
if (!w.visible) continue;
placeOne(w);
}
// Pass 2: hidden widgets (edit-mode only). Wrap the cursor to the
// start of the next row before the second pass so the hidden tray
// visually separates from the active layout — even if the last
// visible widget left half a row open.
if (options.includeHidden) {
if (cursorX > 0) {
cursorY += rowMaxH || 1;
cursorX = 0;
rowMaxH = 0;
}
for (const w of widgets) {
if (w.visible) continue;
placeOne(w);
}
}
return out;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,289 @@
// Unit tests for the date-range picker's pure helpers (t-paliad-248).
// Run with `bun test`.
import { test, expect, describe } from "bun:test";
import {
horizonBounds,
isValidHorizon,
isValidISODate,
validateCustomRange,
parseURL,
serializeURL,
isDefault,
ALL_HORIZONS,
PAST_HORIZONS,
NEXT_HORIZONS,
type TimeHorizon,
type TimeSpec,
} from "./date-range-picker-pure";
// Anchor the clock so day-arithmetic assertions don't drift with the
// wall clock. 2026-05-25 00:00 UTC matches the Go-side bounds test.
const NOW = new Date(Date.UTC(2026, 4, 25));
const DAY = (offsetDays: number): Date =>
new Date(NOW.getTime() + offsetDays * 86_400_000);
describe("ALL_HORIZONS / PAST / NEXT registries", () => {
test("registries sum to a known total without overlap", () => {
// 6 past + 6 next + any + custom = 14 fan chips (custom is the
// trailing entry in ALL_HORIZONS; `all` is intentionally absent —
// surfaces don't render the legacy bidirectional-unbounded chip).
expect(ALL_HORIZONS.length).toBe(14);
expect(PAST_HORIZONS.length).toBe(6);
expect(NEXT_HORIZONS.length).toBe(6);
expect(new Set(ALL_HORIZONS).size).toBe(ALL_HORIZONS.length);
});
test("PAST_HORIZONS are all past_*", () => {
for (const h of PAST_HORIZONS) {
expect(h.startsWith("past_")).toBe(true);
}
});
test("NEXT_HORIZONS are all next_*", () => {
for (const h of NEXT_HORIZONS) {
expect(h.startsWith("next_")).toBe(true);
}
});
test("ALL_HORIZONS ends with custom and contains any in the middle", () => {
expect(ALL_HORIZONS.at(-1)).toBe("custom");
expect(ALL_HORIZONS).toContain("any");
});
});
describe("horizonBounds", () => {
test("future fan: bounds anchor at today, extend forward", () => {
expect(horizonBounds("next_1d", NOW)).toEqual({ from: DAY(0), to: DAY(1) });
expect(horizonBounds("next_7d", NOW)).toEqual({ from: DAY(0), to: DAY(7) });
expect(horizonBounds("next_14d", NOW)).toEqual({ from: DAY(0), to: DAY(14) });
expect(horizonBounds("next_30d", NOW)).toEqual({ from: DAY(0), to: DAY(30) });
expect(horizonBounds("next_90d", NOW)).toEqual({ from: DAY(0), to: DAY(90) });
});
test("past fan: bounds extend back, upper bound is tomorrow (exclusive end-of-today)", () => {
expect(horizonBounds("past_1d", NOW)).toEqual({ from: DAY(-1), to: DAY(1) });
expect(horizonBounds("past_7d", NOW)).toEqual({ from: DAY(-7), to: DAY(1) });
expect(horizonBounds("past_14d", NOW)).toEqual({ from: DAY(-14), to: DAY(1) });
expect(horizonBounds("past_30d", NOW)).toEqual({ from: DAY(-30), to: DAY(1) });
expect(horizonBounds("past_90d", NOW)).toEqual({ from: DAY(-90), to: DAY(1) });
});
test("next_all is one-sided: from=today, to undefined", () => {
const b = horizonBounds("next_all", NOW);
expect(b.from).toEqual(DAY(0));
expect(b.to).toBeUndefined();
});
test("past_all is one-sided: from undefined, to=tomorrow", () => {
const b = horizonBounds("past_all", NOW);
expect(b.from).toBeUndefined();
expect(b.to).toEqual(DAY(1));
});
test("any / all / custom: both bounds undefined", () => {
expect(horizonBounds("any", NOW)).toEqual({});
expect(horizonBounds("all", NOW)).toEqual({});
expect(horizonBounds("custom", NOW)).toEqual({});
});
test("bounds anchor on UTC start-of-day regardless of input clock time", () => {
const nowAfternoon = new Date(Date.UTC(2026, 4, 25, 14, 37, 0));
const nowMidnight = new Date(Date.UTC(2026, 4, 25, 0, 0, 0));
expect(horizonBounds("past_7d", nowAfternoon)).toEqual(horizonBounds("past_7d", nowMidnight));
});
});
describe("isValidHorizon", () => {
test("accepts every entry in ALL_HORIZONS plus 'all' (legacy)", () => {
for (const h of ALL_HORIZONS) {
expect(isValidHorizon(h)).toBe(true);
}
expect(isValidHorizon("all")).toBe(true);
});
test("rejects unknown strings, numbers, undefined, null", () => {
expect(isValidHorizon("next_5d")).toBe(false);
expect(isValidHorizon("past_100d")).toBe(false);
expect(isValidHorizon("")).toBe(false);
expect(isValidHorizon(7)).toBe(false);
expect(isValidHorizon(undefined)).toBe(false);
expect(isValidHorizon(null)).toBe(false);
});
});
describe("isValidISODate", () => {
test("accepts valid YYYY-MM-DD", () => {
expect(isValidISODate("2026-05-25")).toBe(true);
expect(isValidISODate("2026-12-31")).toBe(true);
expect(isValidISODate("2024-02-29")).toBe(true);
});
test("rejects shape mismatches", () => {
expect(isValidISODate("2026/05/25")).toBe(false);
expect(isValidISODate("25.05.2026")).toBe(false);
expect(isValidISODate("2026-5-25")).toBe(false);
expect(isValidISODate("")).toBe(false);
expect(isValidISODate(undefined)).toBe(false);
});
test("rejects calendar-impossible dates (Date.parse silently rolls over)", () => {
expect(isValidISODate("2026-02-30")).toBe(false);
expect(isValidISODate("2026-13-01")).toBe(false);
expect(isValidISODate("2026-04-31")).toBe(false);
});
test("rejects 2025-02-29 (non-leap February)", () => {
expect(isValidISODate("2025-02-29")).toBe(false);
});
});
describe("validateCustomRange", () => {
test("requires both bounds present and valid", () => {
expect(validateCustomRange(undefined, undefined)).toBe("date_range.custom.invalid_missing");
expect(validateCustomRange("2026-05-25", undefined)).toBe("date_range.custom.invalid_missing");
expect(validateCustomRange(undefined, "2026-05-25")).toBe("date_range.custom.invalid_missing");
});
test("rejects malformed dates with format error", () => {
expect(validateCustomRange("bogus", "2026-05-25")).toBe("date_range.custom.invalid_format");
expect(validateCustomRange("2026-13-01", "2026-12-31")).toBe("date_range.custom.invalid_format");
});
test("rejects to <= from with invalid error", () => {
expect(validateCustomRange("2026-05-25", "2026-05-25")).toBe("date_range.custom.invalid");
expect(validateCustomRange("2026-05-25", "2026-05-24")).toBe("date_range.custom.invalid");
});
test("accepts strictly-ordered valid pair", () => {
expect(validateCustomRange("2026-05-25", "2026-05-26")).toBeNull();
expect(validateCustomRange("2026-01-01", "2026-12-31")).toBeNull();
});
});
describe("parseURL", () => {
test("missing horizon yields contract default", () => {
expect(parseURL(new URLSearchParams(""))).toEqual({ horizon: "any" });
expect(parseURL(new URLSearchParams(""), { default: "next_30d" })).toEqual({ horizon: "next_30d" });
});
test("unknown horizon falls back to default, doesn't throw", () => {
expect(parseURL(new URLSearchParams("horizon=mystery"), { default: "next_7d" }))
.toEqual({ horizon: "next_7d" });
});
test("every fan horizon round-trips on a fresh URLSearchParams", () => {
for (const h of ALL_HORIZONS) {
if (h === "custom") continue;
const params = new URLSearchParams(`horizon=${h}`);
expect(parseURL(params)).toEqual({ horizon: h });
}
});
test("custom horizon reads from+to", () => {
const params = new URLSearchParams("horizon=custom&horizon_from=2026-03-15&horizon_to=2026-04-30");
expect(parseURL(params)).toEqual({
horizon: "custom",
from: "2026-03-15",
to: "2026-04-30",
});
});
test("custom with malformed dates falls back to default rather than half-state", () => {
const params = new URLSearchParams("horizon=custom&horizon_from=2026-99-99&horizon_to=2026-04-30");
expect(parseURL(params, { default: "next_30d" })).toEqual({ horizon: "next_30d" });
});
test("custom with from>=to falls back", () => {
const params = new URLSearchParams("horizon=custom&horizon_from=2026-05-25&horizon_to=2026-05-25");
expect(parseURL(params)).toEqual({ horizon: "any" });
});
test("custom URL key override", () => {
const params = new URLSearchParams("range=past_30d");
expect(parseURL(params, { key: "range" })).toEqual({ horizon: "past_30d" });
expect(parseURL(params)).toEqual({ horizon: "any" }); // default `horizon` key absent
});
});
describe("serializeURL", () => {
test("default horizon is omitted (canonical URL stays short)", () => {
const params = new URLSearchParams();
serializeURL({ horizon: "any" }, params);
expect(params.toString()).toBe("");
});
test("explicit default param removed when value matches default", () => {
const params = new URLSearchParams("horizon=past_30d&other=keep");
serializeURL({ horizon: "past_30d" }, params, { default: "past_30d" });
expect(params.toString()).toBe("other=keep");
});
test("non-default horizon is written", () => {
const params = new URLSearchParams("other=keep");
serializeURL({ horizon: "next_7d" }, params);
expect(params.toString()).toBe("other=keep&horizon=next_7d");
});
test("custom writes horizon+from+to", () => {
const params = new URLSearchParams();
serializeURL({ horizon: "custom", from: "2026-03-15", to: "2026-04-30" }, params);
expect(params.toString()).toBe("horizon=custom&horizon_from=2026-03-15&horizon_to=2026-04-30");
});
test("custom partial bounds: from/to are written individually", () => {
const params = new URLSearchParams();
serializeURL({ horizon: "custom", from: "2026-03-15" }, params);
expect(params.toString()).toBe("horizon=custom&horizon_from=2026-03-15");
});
test("stale params cleared on re-serialize", () => {
const params = new URLSearchParams("horizon=custom&horizon_from=2026-03-15&horizon_to=2026-04-30&other=keep");
serializeURL({ horizon: "past_30d" }, params);
expect(params.toString()).toBe("other=keep&horizon=past_30d");
// Stale from/to must be gone.
expect(params.has("horizon_from")).toBe(false);
expect(params.has("horizon_to")).toBe(false);
});
test("key override propagates to from/to", () => {
const params = new URLSearchParams();
serializeURL({ horizon: "custom", from: "2026-03-15", to: "2026-04-30" }, params, { key: "range" });
expect(params.toString()).toBe("range=custom&range_from=2026-03-15&range_to=2026-04-30");
});
test("URL round-trips through parse → serialize → parse", () => {
const specs: TimeSpec[] = [
{ horizon: "any" },
{ horizon: "next_7d" },
{ horizon: "past_all" },
{ horizon: "next_all" },
{ horizon: "custom", from: "2026-03-15", to: "2026-04-30" },
];
for (const spec of specs) {
const params = new URLSearchParams();
serializeURL(spec, params);
expect(parseURL(params)).toEqual(spec);
}
});
});
describe("isDefault", () => {
test("true when horizon matches default exactly", () => {
expect(isDefault({ horizon: "any" }, "any")).toBe(true);
expect(isDefault({ horizon: "next_30d" }, "next_30d")).toBe(true);
});
test("false when horizon differs", () => {
expect(isDefault({ horizon: "past_7d" }, "any")).toBe(false);
expect(isDefault({ horizon: "next_30d" }, "next_7d")).toBe(false);
});
test("custom is never default — even when bounds match", () => {
// No surface treats "custom" as the natural default, so any custom
// selection IS user-driven and the closed button must surface
// the non-default indicator.
expect(isDefault({ horizon: "custom", from: "2026-01-01", to: "2026-12-31" }, "custom" as TimeHorizon))
.toBe(false);
});
});

View File

@@ -0,0 +1,292 @@
// date-range-picker-pure.ts — pure helpers for the symmetric date-range
// picker (t-paliad-248). No DOM access; runnable under `bun test`. The
// picker's boot client (date-range-picker.ts) drives the popover, but
// every interesting decision — what does "Letzte 7 Tage" mean today,
// what URL params should land, when is a custom range valid — lives
// here so it can be tested without a browser.
//
// The Go side (internal/services/view_service.go:computeViewSpecBounds)
// is the canonical materializer; horizonBounds() below MUST stay in
// step with it. The bounds test in pure-tests pins the shape so a
// divergent change to one side breaks the assertions on the other.
import type { I18nKey } from "../i18n-keys";
/**
* TimeHorizon — the full 14-value union the symmetric picker can emit.
* Mirrors `internal/services/filter_spec.go` TimeHorizon.
*
* The fan chips: 6 past + 6 next + the ALLES centre (`any`) + custom.
* `all` is the legacy bidirectional-unbounded value, gated to
* scope=explicit by the validator (Q26); the picker doesn't surface it
* but parseURL accepts it for back-compat with saved Custom Views.
*/
export type TimeHorizon =
| "next_1d" | "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all"
| "past_1d" | "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all"
| "any" | "all" | "custom";
/**
* TimeSpec — the wire shape mirrored from the Go FilterSpec.TimeSpec.
* `from`/`to` are ISO YYYY-MM-DD strings — UTC dates, not timestamps.
* Times-of-day intentionally absent from the picker's contract.
*/
export interface TimeSpec {
horizon: TimeHorizon;
from?: string;
to?: string;
}
/**
* The full list of horizon values the picker is willing to render
* as chips. Order is the picker's reading order — past edge → past
* → ALLES → next → next edge, with `custom` last because it lives
* below the chip rows in the popover, not in the row itself.
*/
export const ALL_HORIZONS: readonly TimeHorizon[] = [
"past_all",
"past_90d",
"past_30d",
"past_14d",
"past_7d",
"past_1d",
"any",
"next_1d",
"next_7d",
"next_14d",
"next_30d",
"next_90d",
"next_all",
"custom",
];
// Strict-validity set. Includes the legacy bidirectional-unbounded `all`
// horizon so a saved Custom View JSON ({"horizon":"all", …}) deserializes
// without falling back to the surface default. The picker UI itself
// doesn't surface a chip for `all` — it's read in, kept as state, but
// the chip the user sees light up is `any` (the centre ALLES button).
const ALL_HORIZONS_SET: ReadonlySet<string> = new Set([...ALL_HORIZONS, "all"]);
/**
* Past chips, in reading order (outermost → innermost). The picker
* renders this left-to-right in the popover's past fan.
*/
export const PAST_HORIZONS: readonly TimeHorizon[] = [
"past_all",
"past_90d",
"past_30d",
"past_14d",
"past_7d",
"past_1d",
];
/**
* Future chips, in reading order (innermost → outermost). The picker
* renders this left-to-right in the popover's future fan.
*/
export const NEXT_HORIZONS: readonly TimeHorizon[] = [
"next_1d",
"next_7d",
"next_14d",
"next_30d",
"next_90d",
"next_all",
];
/**
* The i18n key for the closed-button label and chip text of every
* horizon. Lives here (not in the TSX) so a single dictionary lookup
* sites can hand back a translated string at any point.
*/
export const HORIZON_LABEL_KEY: Record<TimeHorizon, I18nKey> = {
past_all: "date_range.horizon.past_all",
past_90d: "date_range.horizon.past_90d",
past_30d: "date_range.horizon.past_30d",
past_14d: "date_range.horizon.past_14d",
past_7d: "date_range.horizon.past_7d",
past_1d: "date_range.horizon.past_1d",
any: "date_range.horizon.any",
next_1d: "date_range.horizon.next_1d",
next_7d: "date_range.horizon.next_7d",
next_14d: "date_range.horizon.next_14d",
next_30d: "date_range.horizon.next_30d",
next_90d: "date_range.horizon.next_90d",
next_all: "date_range.horizon.next_all",
all: "date_range.horizon.any", // legacy alias — surfaces "Alles" in the closed label
custom: "date_range.horizon.custom",
};
/**
* Bounds for a given horizon, anchored at `now`. Pure function: the
* caller passes the clock so tests can pin a specific day without
* mocking Date. Bounds are UTC dates; the `to` bound is exclusive
* (start-of-day-after) so "past 7d" includes today.
*
* Returns `{}` for `any` / `all` / `custom` — the picker's surface
* lifts the from/to out of TimeSpec directly when horizon === custom,
* and treats unbounded values as "no narrowing in that direction".
*/
export function horizonBounds(
horizon: TimeHorizon,
now: Date,
): { from?: Date; to?: Date } {
const day = new Date(Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
));
const offset = (days: number): Date =>
new Date(day.getTime() + days * 86_400_000);
switch (horizon) {
case "past_1d": return { from: offset(-1), to: offset(1) };
case "past_7d": return { from: offset(-7), to: offset(1) };
case "past_14d": return { from: offset(-14), to: offset(1) };
case "past_30d": return { from: offset(-30), to: offset(1) };
case "past_90d": return { from: offset(-90), to: offset(1) };
case "past_all": return { to: offset(1) };
case "next_1d": return { from: day, to: offset(1) };
case "next_7d": return { from: day, to: offset(7) };
case "next_14d": return { from: day, to: offset(14) };
case "next_30d": return { from: day, to: offset(30) };
case "next_90d": return { from: day, to: offset(90) };
case "next_all": return { from: day };
case "any":
case "all":
case "custom":
return {};
}
}
/**
* isValidHorizon — narrows an unknown string to a TimeHorizon, used
* by parseURL and by surface-side URL alias adapters.
*/
export function isValidHorizon(s: unknown): s is TimeHorizon {
return typeof s === "string" && ALL_HORIZONS_SET.has(s);
}
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/**
* isValidISODate — `YYYY-MM-DD` shape check plus a real-date validity
* check (rejects 2026-02-30). Doesn't enforce timezone or floor at any
* particular date.
*/
export function isValidISODate(s: unknown): s is string {
if (typeof s !== "string" || !ISO_DATE_RE.test(s)) return false;
const ms = Date.parse(`${s}T00:00:00Z`);
if (Number.isNaN(ms)) return false;
// Reject 2026-02-30 etc. — Date.parse accepts those by rolling over.
return new Date(ms).toISOString().slice(0, 10) === s;
}
/**
* Validate a custom range. Returns null on success, an i18n key
* pointing at the error message on failure.
*
* Rules:
* - Both `from` and `to` must be valid ISO YYYY-MM-DD.
* - `to` must be strictly after `from` (single-day ranges use
* `from=2026-05-25&to=2026-05-26`, NOT `from=to=2026-05-25`).
*/
export function validateCustomRange(
from: string | undefined,
to: string | undefined,
): I18nKey | null {
if (!from || !to) return "date_range.custom.invalid_missing";
if (!isValidISODate(from) || !isValidISODate(to)) return "date_range.custom.invalid_format";
if (Date.parse(`${from}T00:00:00Z`) >= Date.parse(`${to}T00:00:00Z`)) {
return "date_range.custom.invalid";
}
return null;
}
/**
* URLContract — the picker's stable URL serialization. Surfaces can
* override the param name via `key` so two pickers on the same page
* (rare) don't collide.
*/
export interface URLContract {
/** Base param name, defaults to "horizon". */
key?: string;
/** Default value omitted from URL (matches surface's natural default). */
default?: TimeHorizon;
}
/**
* parseURL — reads a URL search-params object into a TimeSpec.
*
* ?horizon=past_30d → {horizon:"past_30d"}
* ?horizon=custom&from=2026-03-15&to=… → {horizon:"custom",from,to}
* (no params) → {horizon: contract.default ?? "any"}
*
* Unknown / malformed values fall back to the default. Out-of-shape
* custom dates clamp to {horizon: default} — the picker never lands
* in a half-custom state from a URL.
*/
export function parseURL(
params: URLSearchParams,
contract: URLContract = {},
): TimeSpec {
const key = contract.key ?? "horizon";
const fallback: TimeHorizon = contract.default ?? "any";
const raw = params.get(key);
if (raw === null) return { horizon: fallback };
if (!isValidHorizon(raw)) return { horizon: fallback };
if (raw !== "custom") return { horizon: raw };
const from = params.get(`${key}_from`) ?? undefined;
const to = params.get(`${key}_to`) ?? undefined;
if (validateCustomRange(from, to) !== null) {
return { horizon: fallback };
}
return { horizon: "custom", from, to };
}
/**
* serializeURL — writes a TimeSpec into the URL search-params object,
* mutating the passed-in instance. Values equal to the surface
* default are OMITTED — the canonical URL stays short.
*
* Always deletes `horizon`, `<key>_from`, `<key>_to` first so a
* re-serialise after the picker reverts to default cleans up rather
* than accumulating stale entries.
*/
export function serializeURL(
spec: TimeSpec,
params: URLSearchParams,
contract: URLContract = {},
): void {
const key = contract.key ?? "horizon";
const fromKey = `${key}_from`;
const toKey = `${key}_to`;
params.delete(key);
params.delete(fromKey);
params.delete(toKey);
if (spec.horizon === (contract.default ?? "any") && spec.horizon !== "custom") {
return;
}
if (spec.horizon === "custom") {
params.set(key, "custom");
if (spec.from) params.set(fromKey, spec.from);
if (spec.to) params.set(toKey, spec.to);
return;
}
params.set(key, spec.horizon);
}
/**
* isDefault — used by surfaces to decide whether to render the
* "value is non-default" dot on the closed button.
*/
export function isDefault(spec: TimeSpec, defaultHorizon: TimeHorizon): boolean {
if (spec.horizon !== defaultHorizon) return false;
if (spec.horizon === "custom") return false;
return true;
}

View File

@@ -0,0 +1,490 @@
// date-range-picker.ts — boot client + DOM mount for the symmetric
// date-range picker (t-paliad-248). The picker is a controlled
// component: callers pass `value` + `onChange`, the component renders
// the trigger button + popover scaffold, the popover materialises a
// chip row and (when "Anpassen" is picked) an inline date-pair editor.
//
// The picker reuses the existing `.agenda-chip` styling for chips and
// the `.multi-panel` popover pattern (auto-positioned under a
// `.multi-anchor` wrapper). Both patterns are battle-tested by the
// filter-bar + multi-select widgets — no new design tokens, no new
// dark-mode contrast risk.
import { t } from "./i18n";
import {
ALL_HORIZONS,
HORIZON_LABEL_KEY,
NEXT_HORIZONS,
PAST_HORIZONS,
isDefault,
isValidISODate,
validateCustomRange,
type TimeHorizon,
type TimeSpec,
} from "./date-range-picker-pure";
export interface MountOpts {
/** Current value. The picker is fully controlled. */
value: TimeSpec;
/** Fired on every committed change (chip click or Anwenden). */
onChange(next: TimeSpec): void;
/**
* Which horizon constitutes the "default" for this surface. Used
* for the non-default indicator dot. Defaults to `"any"`.
*/
defaultHorizon?: TimeHorizon;
/**
* Which chips to render. Order is preserved. Defaults to the full
* 14-chip fan from ALL_HORIZONS.
*/
presets?: readonly TimeHorizon[];
/**
* Stable surface tag — feeds into the `data-testid` on every DOM
* node the picker creates so tests can scope. Example: "agenda",
* "filter-bar.time", "audit-log".
*/
surface: string;
/**
* Optional prefix for the closed-button label. The label always
* starts with the resolved horizon name (e.g. "Letzte 30 Tage").
* Surfaces that want a heading prefix ("Zeitraum: Letzte 30 Tage")
* pass it here.
*/
labelPrefix?: string;
}
export interface PickerHandle {
/** Root element — append to the host container. */
element: HTMLElement;
/** Read the current value (may have been edited via Anpassen). */
getValue(): TimeSpec;
/** Update the value from the host (e.g. after URL change). */
setValue(next: TimeSpec): void;
/** Force-close the popover. Safe to call when already closed. */
close(): void;
/** Detach event listeners + remove from DOM. */
destroy(): void;
}
/**
* Mount a date-range picker. The returned `element` is a single
* inline node containing both the trigger button and the popover
* (absolutely positioned via `.multi-anchor` + `.multi-panel`).
*
* The popover stays in the DOM permanently; opening/closing toggles
* the `[hidden]` attribute. This keeps the chip's tab-order stable
* and matches the multi-select widget's behaviour.
*/
export function mountDateRangePicker(opts: MountOpts): PickerHandle {
const presets = opts.presets ?? ALL_HORIZONS;
const defaultHorizon = opts.defaultHorizon ?? "any";
let value: TimeSpec = normalize(opts.value);
// Cached drafts for the "Anpassen" editor — preserved across
// open/close so the user doesn't lose their typing if they peek
// away. Seeded from the live value when the editor opens.
let customFromDraft = value.horizon === "custom" ? (value.from ?? "") : "";
let customToDraft = value.horizon === "custom" ? (value.to ?? "") : "";
let customEditorOpen = value.horizon === "custom";
const root = document.createElement("div");
root.className = "date-range-anchor multi-anchor";
root.dataset.testid = `${opts.surface}.date-range-picker`;
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "date-range-trigger";
trigger.setAttribute("aria-haspopup", "dialog");
trigger.setAttribute("aria-expanded", "false");
trigger.dataset.testid = `${opts.surface}.date-range-trigger`;
const panel = document.createElement("div");
panel.className = "date-range-panel multi-panel";
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-label", t("date_range.dialog.label"));
panel.hidden = true;
panel.dataset.testid = `${opts.surface}.date-range-panel`;
root.appendChild(trigger);
root.appendChild(panel);
renderTrigger();
renderPanel();
// Open/close wiring. Click outside the root collapses the popover;
// Esc inside it bubbles up to the same handler via keydown delegate.
const onDocClick = (e: MouseEvent) => {
if (panel.hidden) return;
if (e.target instanceof Node && root.contains(e.target)) return;
closePopover();
};
const onKeydown = (e: KeyboardEvent) => {
if (panel.hidden) return;
if (e.key === "Escape") {
e.stopPropagation();
closePopover();
trigger.focus();
}
};
trigger.addEventListener("click", () => {
if (panel.hidden) openPopover();
else closePopover();
});
document.addEventListener("mousedown", onDocClick);
document.addEventListener("keydown", onKeydown);
function openPopover(): void {
panel.hidden = false;
trigger.setAttribute("aria-expanded", "true");
// Re-render to reflect the very latest value (host may have
// patched via setValue between open/close).
renderPanel();
// Move keyboard focus into the panel so Esc works without a
// prior click. The first chip is the natural landing spot.
const firstChip = panel.querySelector<HTMLButtonElement>(".date-range-chip");
firstChip?.focus({ preventScroll: true });
}
function closePopover(): void {
panel.hidden = true;
trigger.setAttribute("aria-expanded", "false");
}
function commit(next: TimeSpec, closeAfter: boolean): void {
value = normalize(next);
customEditorOpen = value.horizon === "custom";
if (value.horizon === "custom") {
customFromDraft = value.from ?? "";
customToDraft = value.to ?? "";
}
renderTrigger();
renderPanel();
opts.onChange(value);
if (closeAfter) {
closePopover();
trigger.focus({ preventScroll: true });
}
}
function renderTrigger(): void {
trigger.replaceChildren();
if (!isDefault(value, defaultHorizon)) {
const dot = document.createElement("span");
dot.className = "date-range-trigger-dot";
dot.setAttribute("aria-hidden", "true");
trigger.appendChild(dot);
}
const labelSpan = document.createElement("span");
labelSpan.className = "date-range-trigger-label";
labelSpan.textContent = labelFor(value, opts.labelPrefix);
trigger.appendChild(labelSpan);
const chev = document.createElement("span");
chev.className = "date-range-trigger-chev";
chev.setAttribute("aria-hidden", "true");
chev.textContent = "▾";
trigger.appendChild(chev);
}
function renderPanel(): void {
panel.replaceChildren();
// Three vertical columns: Past (closest→farthest top→bottom),
// NOW (Heute + Alles), Future (closest→farthest). The grid
// visualises time as space around NOW — each column's top is
// closest to the current moment, bottom is furthest away.
const grid = document.createElement("div");
grid.className = "date-range-grid";
// Past column: PAST_HORIZONS registry is outermost→innermost
// (past_all → past_1d); reverse for closeness-to-NOW ordering
// (past_1d at top, past_all at bottom).
const pastCol = renderColumn(
"past",
t("date_range.fan.past.label"),
[...PAST_HORIZONS].reverse().filter((h) => presets.includes(h)),
);
const nowCol = renderNowColumn();
// Future column: NEXT_HORIZONS registry is already in closeness
// order (next_1d → next_all). next_1d moves to the NOW column as
// "Heute" (semantically just-today, single-day window), so the
// future column skips it.
const futureCol = renderColumn(
"future",
t("date_range.fan.future.label"),
NEXT_HORIZONS.filter((h) => h !== "next_1d" && presets.includes(h)),
);
if (pastCol) grid.appendChild(pastCol);
if (nowCol) grid.appendChild(nowCol);
if (futureCol) grid.appendChild(futureCol);
panel.appendChild(grid);
// Custom-range section ("Anpassen"). Toggle button + collapsible
// date-pair editor below.
if (presets.includes("custom")) {
panel.appendChild(renderCustomSection());
}
}
function renderColumn(
side: "past" | "future",
heading: string,
horizons: readonly TimeHorizon[],
): HTMLElement | null {
if (horizons.length === 0) return null;
const col = document.createElement("div");
col.className = `date-range-col date-range-col--${side}`;
col.setAttribute("role", "group");
col.setAttribute("aria-label", heading);
const head = document.createElement("div");
head.className = "date-range-col-heading";
head.textContent = heading;
col.appendChild(head);
for (const h of horizons) {
col.appendChild(makeChip(h));
}
return col;
}
function renderNowColumn(): HTMLElement | null {
const showHeute = presets.includes("next_1d");
const showAlles = presets.includes("any");
if (!showHeute && !showAlles) return null;
const col = document.createElement("div");
col.className = "date-range-col date-range-col--now";
col.setAttribute("role", "group");
col.setAttribute("aria-label", t("date_range.center.label"));
const glyph = document.createElement("div");
glyph.className = "date-range-col-heading date-range-col-heading--glyph";
glyph.setAttribute("aria-hidden", "true");
glyph.textContent = "⌖"; // ⌖ POSITION INDICATOR
col.appendChild(glyph);
if (showHeute) col.appendChild(makeChip("next_1d"));
if (showAlles) {
const allesChip = makeChip("any");
// Legacy "all" horizon also lights up Alles for back-compat
// with saved Custom Views that store the bidirectional-unbounded
// value (Q26 — parser preserves it, picker surfaces it here).
if (value.horizon === "all") {
allesChip.classList.add("agenda-chip-active");
allesChip.setAttribute("aria-pressed", "true");
}
col.appendChild(allesChip);
}
return col;
}
function makeChip(h: TimeHorizon): HTMLButtonElement {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "agenda-chip date-range-chip";
if (value.horizon === h) chip.classList.add("agenda-chip-active");
chip.setAttribute("aria-pressed", String(value.horizon === h));
chip.textContent = t(HORIZON_LABEL_KEY[h]);
chip.dataset.testid = `${opts.surface}.date-range-chip.${h}`;
chip.addEventListener("click", () => {
commit({ horizon: h }, /*closeAfter*/ true);
});
return chip;
}
function renderCustomSection(): HTMLElement {
const section = document.createElement("div");
section.className = "date-range-custom";
const toggleBtn = document.createElement("button");
toggleBtn.type = "button";
toggleBtn.className = "agenda-chip date-range-chip date-range-chip--custom";
if (value.horizon === "custom") toggleBtn.classList.add("agenda-chip-active");
toggleBtn.setAttribute("aria-expanded", String(customEditorOpen));
toggleBtn.dataset.testid = `${opts.surface}.date-range-chip.custom`;
toggleBtn.textContent = t("date_range.horizon.custom");
toggleBtn.addEventListener("click", () => {
customEditorOpen = !customEditorOpen;
renderPanel();
if (customEditorOpen) {
// Focus the first input on expand.
panel.querySelector<HTMLInputElement>(".date-range-custom-from")?.focus();
}
});
section.appendChild(toggleBtn);
if (!customEditorOpen) return section;
const editor = document.createElement("div");
editor.className = "date-range-custom-editor";
const fromWrap = document.createElement("label");
fromWrap.className = "date-range-custom-field";
const fromLbl = document.createElement("span");
fromLbl.className = "date-range-custom-label";
fromLbl.textContent = t("date_range.custom.from");
const fromInput = document.createElement("input");
fromInput.type = "date";
fromInput.lang = "de";
fromInput.className = "date-range-custom-from";
fromInput.value = customFromDraft;
fromInput.dataset.testid = `${opts.surface}.date-range-custom-from`;
fromInput.addEventListener("input", () => {
customFromDraft = fromInput.value;
refreshValidity();
});
fromWrap.appendChild(fromLbl);
fromWrap.appendChild(fromInput);
const toWrap = document.createElement("label");
toWrap.className = "date-range-custom-field";
const toLbl = document.createElement("span");
toLbl.className = "date-range-custom-label";
toLbl.textContent = t("date_range.custom.to");
const toInput = document.createElement("input");
toInput.type = "date";
toInput.lang = "de";
toInput.className = "date-range-custom-to";
toInput.value = customToDraft;
toInput.dataset.testid = `${opts.surface}.date-range-custom-to`;
toInput.addEventListener("input", () => {
customToDraft = toInput.value;
refreshValidity();
});
toWrap.appendChild(toLbl);
toWrap.appendChild(toInput);
const applyBtn = document.createElement("button");
applyBtn.type = "button";
applyBtn.className = "date-range-custom-apply";
applyBtn.textContent = t("date_range.custom.apply");
applyBtn.dataset.testid = `${opts.surface}.date-range-custom-apply`;
applyBtn.addEventListener("click", () => {
const err = validateCustomRange(customFromDraft, customToDraft);
if (err !== null) {
showError(err);
return;
}
commit(
{ horizon: "custom", from: customFromDraft, to: customToDraft },
/*closeAfter*/ true,
);
});
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "date-range-custom-cancel";
cancelBtn.textContent = t("date_range.custom.cancel");
cancelBtn.addEventListener("click", () => {
customEditorOpen = false;
// Restore drafts from live value so a re-open shows the
// committed state rather than the abandoned typing.
customFromDraft = value.horizon === "custom" ? (value.from ?? "") : "";
customToDraft = value.horizon === "custom" ? (value.to ?? "") : "";
renderPanel();
});
const errEl = document.createElement("div");
errEl.className = "date-range-custom-error";
errEl.hidden = true;
errEl.dataset.testid = `${opts.surface}.date-range-custom-error`;
editor.appendChild(fromWrap);
editor.appendChild(toWrap);
editor.appendChild(applyBtn);
editor.appendChild(cancelBtn);
editor.appendChild(errEl);
section.appendChild(editor);
refreshValidity();
function refreshValidity(): void {
const err = validateCustomRange(customFromDraft, customToDraft);
if (err === null) {
applyBtn.disabled = false;
errEl.hidden = true;
errEl.textContent = "";
return;
}
applyBtn.disabled = true;
// Only surface the *content* error (`invalid` = inverted range)
// while the user is typing. Empty / format errors are visible
// through the disabled-Anwenden state alone — surfacing them on
// every keystroke would be noisy.
if (err === "date_range.custom.invalid") {
showError(err);
} else {
errEl.hidden = true;
}
}
function showError(key: Parameters<typeof t>[0]): void {
errEl.textContent = t(key);
errEl.hidden = false;
}
return section;
}
return {
element: root,
getValue: () => normalize(value),
setValue(next: TimeSpec) {
value = normalize(next);
customEditorOpen = value.horizon === "custom";
if (value.horizon === "custom") {
customFromDraft = value.from ?? "";
customToDraft = value.to ?? "";
}
renderTrigger();
renderPanel();
},
close: closePopover,
destroy() {
document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("keydown", onKeydown);
root.remove();
},
};
}
function normalize(spec: TimeSpec): TimeSpec {
if (spec.horizon === "custom") {
return {
horizon: "custom",
from: spec.from && isValidISODate(spec.from) ? spec.from : undefined,
to: spec.to && isValidISODate(spec.to) ? spec.to : undefined,
};
}
return { horizon: spec.horizon };
}
function labelFor(spec: TimeSpec, prefix?: string): string {
let body: string;
if (spec.horizon === "custom") {
if (spec.from && spec.to) {
body = t("date_range.button.label.custom_range")
.replace("{from}", formatISO(spec.from))
.replace("{to}", formatISO(spec.to));
} else {
body = t("date_range.horizon.custom");
}
} else {
body = t(HORIZON_LABEL_KEY[spec.horizon]);
}
return prefix ? `${prefix}: ${body}` : body;
}
function formatISO(iso: string): string {
if (!isValidISODate(iso)) return iso;
// DE locale: DD.MM.YYYY. The picker is German-first; surfaces in EN
// can override via labelPrefix or by formatting before commit if
// they want a different shape.
const [y, m, d] = iso.split("-");
return `${d}.${m}.${y}`;
}

View File

@@ -1,181 +0,0 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Deadline {
id: string;
project_id: string;
title: string;
due_date: string;
status: string;
project_reference: string;
project_title: string;
}
let allDeadlines: Deadline[] = [];
let viewYear = 0;
let viewMonth = 0; // 0-11
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtMonth(year: number, month: number): string {
return `${tDyn(`cal.month.${month}`)} ${year}`;
}
function urgencyClass(due: string, status: string): string {
if (status === "completed") return "frist-urgency-done";
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(due.slice(0, 10) + "T00:00:00");
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
if (diffDays < 0) return "frist-urgency-overdue";
if (diffDays <= 7) return "frist-urgency-soon";
return "frist-urgency-later";
}
async function loadDeadlines() {
try {
const resp = await fetch("/api/deadlines?status=all");
if (resp.ok) allDeadlines = await resp.json();
} catch {
/* non-fatal */
}
}
function deadlinesForDate(iso: string): Deadline[] {
return allDeadlines.filter((f) => f.due_date.slice(0, 10) === iso);
}
function isoDate(year: number, month: number, day: number): string {
const m = String(month + 1).padStart(2, "0");
const d = String(day).padStart(2, "0");
return `${year}-${m}-${d}`;
}
function render() {
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
const firstDay = new Date(viewYear, viewMonth, 1);
const jsWeekday = firstDay.getDay();
const offset = (jsWeekday + 6) % 7;
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const today = new Date();
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
const cells: string[] = [];
for (let i = 0; i < offset; i++) {
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
}
for (let day = 1; day <= daysInMonth; day++) {
const iso = isoDate(viewYear, viewMonth, day);
const items = deadlinesForDate(iso);
const isToday = iso === todayISO;
const dots = items
.slice(0, 4)
.map((f) => `<span class="frist-cal-dot ${urgencyClass(f.due_date, f.status)}"></span>`)
.join("");
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
cells.push(
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
<span class="frist-cal-day">${day}</span>
<div class="frist-cal-dots">${dots}${more}</div>
</div>`,
);
}
const grid = document.getElementById("deadline-cal-grid")!;
grid.innerHTML = cells.join("");
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
});
const monthStart = isoDate(viewYear, viewMonth, 1);
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
const hasInMonth = allDeadlines.some((f) => {
const iso = f.due_date.slice(0, 10);
return iso >= monthStart && iso <= monthEnd;
});
const empty = document.getElementById("deadline-cal-empty")!;
empty.style.display = hasInMonth ? "none" : "";
}
function openPopup(iso: string) {
const items = deadlinesForDate(iso);
if (items.length === 0) return;
const popup = document.getElementById("cal-popup")!;
const dateEl = document.getElementById("cal-popup-date")!;
const list = document.getElementById("cal-popup-list")!;
const d = new Date(iso + "T00:00:00");
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
list.innerHTML = items
.map((f) => {
const cls = urgencyClass(f.due_date, f.status);
return `<li class="frist-cal-popup-item">
<span class="frist-cal-dot ${cls}"></span>
<a href="/deadlines/${esc(f.id)}" class="frist-cal-popup-title">${esc(f.title)}</a>
<a href="/projects/${esc(f.project_id)}" class="frist-cal-popup-project">${esc(f.project_reference)}</a>
</li>`;
})
.join("");
popup.style.display = "flex";
}
function initPopup() {
const popup = document.getElementById("cal-popup")!;
const close = document.getElementById("cal-popup-close")!;
close.addEventListener("click", () => (popup.style.display = "none"));
popup.addEventListener("click", (e) => {
if (e.target === e.currentTarget) popup.style.display = "none";
});
}
function initNav() {
document.getElementById("cal-prev")!.addEventListener("click", () => {
viewMonth -= 1;
if (viewMonth < 0) {
viewMonth = 11;
viewYear -= 1;
}
render();
});
document.getElementById("cal-next")!.addEventListener("click", () => {
viewMonth += 1;
if (viewMonth > 11) {
viewMonth = 0;
viewYear += 1;
}
render();
});
document.getElementById("cal-today")!.addEventListener("click", () => {
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
render();
});
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
initNav();
initPopup();
onLangChange(render);
await loadDeadlines();
render();
});

View File

@@ -9,6 +9,8 @@ import {
type EventType,
type PickerHandle,
} from "./event-types";
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
import { formatRuleLabel, formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
interface Deadline {
id: string;
@@ -20,6 +22,9 @@ interface Deadline {
source: string;
rule_id?: string;
rule_code?: string;
// t-paliad-258 — lawyer's free-text rule label when the deadline was
// saved in Custom mode. Mutually exclusive with rule_id.
custom_rule_text?: string;
notes?: string;
created_at: string;
completed_at?: string;
@@ -38,6 +43,9 @@ interface PendingApprovalRequest {
requested_at: string;
required_role: string;
requester_name?: string;
// t-paliad-252 — used by the withdraw warning modal to pick the right
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
lifecycle_event?: string;
}
let eventTypePicker: PickerHandle | null = null;
@@ -54,7 +62,21 @@ interface DeadlineRule {
id: string;
code?: string;
name: string;
name_en?: string;
rule_code?: string;
legal_source?: string | null;
// t-paliad-258 — canonical event_type for Auto-mode rule resolution
// when the user flips to Auto on the edit form.
concept_default_event_type_id?: string | null;
proceeding_type_id?: number | null;
}
interface ProceedingType {
id: number;
jurisdiction: string;
name: string;
name_en?: string;
sort_order?: number;
}
interface Me {
@@ -70,6 +92,30 @@ let me: Me | null = null;
let allProjects: Project[] = [];
let pendingRequest: PendingApprovalRequest | null = null;
// t-paliad-258 — Auto/Custom rule editor state. Mirrors the create form.
// On enterEdit we initialise the mode from the persisted deadline:
// rule_id set → "auto"
// custom_rule_text set, no rule_id → "custom"
// neither set → "auto" (so the Type-driven
// resolver fills in immediately).
type RuleMode = "auto" | "custom";
let ruleMode: RuleMode = "auto";
let allRules: DeadlineRule[] = [];
let rulesByID = new Map<string, DeadlineRule>();
let proceedingTypesByID = new Map<number, ProceedingType>();
// t-paliad-252 — when the user chose "Edit event" in the withdraw warning
// modal, the entity is still in approval_status='pending'. Save must POST
// to /api/approval-requests/{id}/edit-entity (which keeps the request
// pending + merges the new fields into payload) instead of the regular
// PATCH /api/deadlines/{id} (which 409s during pending). Cleared on exit
// from edit mode + after a successful save.
let pendingEditMode = false;
// pendingEnterEdit — late-bound by initEdit() so the withdraw warning
// modal handler (initWithdraw) can route into pending-edit mode without
// duplicating the edit-mode toggle logic.
let pendingEnterEdit: (() => void) | null = null;
function parseDeadlineID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
if (parts[0] !== "deadlines" || !parts[1]) return null;
@@ -165,17 +211,66 @@ function populateProjectPicker() {
sel.value = deadline.project_id;
}
async function loadRule(ruleID: string) {
async function loadAllRules() {
try {
const resp = await fetch(`/api/deadline-rules`);
if (!resp.ok) return;
const all: DeadlineRule[] = await resp.json();
rule = all.find((r) => r.id === ruleID) || null;
allRules = (await resp.json()) as DeadlineRule[];
rulesByID = new Map(allRules.map((r) => [r.id, r]));
} catch {
/* non-fatal */
}
}
async function loadProceedingTypes() {
try {
const resp = await fetch("/api/proceeding-types-db");
if (!resp.ok) return;
const types: ProceedingType[] = await resp.json();
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
} catch {
/* non-fatal */
}
}
function lookupRule(ruleID: string): DeadlineRule | null {
return rulesByID.get(ruleID) || null;
}
// resolveAutoRuleForType mirrors the create-form resolver: pick the
// canonical rule for the chosen event_type, prioritising the project's
// proceeding then jurisdiction match.
function resolveAutoRuleForType(eventTypeID: string): DeadlineRule | null {
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
const projID = deadline?.project_id;
const proj = projID ? allProjects.find((p) => p.id === projID) as (Project & { proceeding_type_id?: number | null }) | undefined : undefined;
if (proj && proj.proceeding_type_id) {
const exact = candidates.find((r) => r.proceeding_type_id === proj.proceeding_type_id);
if (exact) return exact;
}
const et = eventTypeByID.get(eventTypeID);
if (et?.jurisdiction && et.jurisdiction !== "any") {
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
const jurMatch = candidates.find((r) => {
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
return pt?.jurisdiction === want;
});
if (jurMatch) return jurMatch;
}
return candidates[0];
}
function currentAutoRule(): DeadlineRule | null {
const picked = eventTypePicker?.getIDs() ?? [];
if (picked.length !== 1) return null;
return resolveAutoRuleForType(picked[0]);
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
@@ -227,9 +322,15 @@ function render() {
}
const ruleEl = document.getElementById("deadline-rule-display")!;
// t-paliad-258 — display priority:
// 1. catalog rule (canonical Name · Citation pattern)
// 2. custom_rule_text + Custom badge
// 3. legacy rule_code-only (Fristenrechner saves)
// 4. "—"
if (rule) {
const code = rule.rule_code || rule.code || "";
ruleEl.textContent = code ? `${code}${rule.name}` : rule.name;
ruleEl.innerHTML = formatRuleLabelHTML(rule, esc);
} else if (deadline.custom_rule_text && deadline.custom_rule_text.trim()) {
ruleEl.innerHTML = formatCustomRuleLabelHTML(deadline.custom_rule_text, esc);
} else if (deadline.rule_code) {
// Fristenrechner-saved deadlines carry rule_code directly without
// a rule_id (no rule UUID round-trips through the public API).
@@ -353,6 +454,49 @@ function render() {
}
}
function refreshRuleAutoDisplay(): void {
const panel = document.getElementById("deadline-rule-auto-display");
const text = document.getElementById("deadline-rule-auto-text");
if (!panel || !text) return;
if (ruleMode !== "auto") {
panel.style.display = "none";
return;
}
panel.style.display = "";
const r = currentAutoRule();
if (r) {
// Canonical "Name · Citation" with muted citation (t-paliad-258 addendum).
text.innerHTML = formatRuleLabelHTML(r, esc);
text.classList.remove("rule-auto-text--empty");
return;
}
const picked = eventTypePicker?.getIDs() ?? [];
const fallback = picked.length === 1
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
text.textContent = fallback;
text.classList.add("rule-auto-text--empty");
}
function applyRuleModeUI(): void {
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
const autoPanel = document.getElementById("deadline-rule-auto-display");
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
if (!toggleBtn || !autoPanel || !customInput) return;
if (ruleMode === "auto") {
autoPanel.style.display = "";
customInput.style.display = "none";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
} else {
autoPanel.style.display = "none";
customInput.style.display = "";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
}
refreshRuleAutoDisplay();
}
function initEdit() {
const titleDisplay = document.getElementById("deadline-title-display")!;
const titleEdit = document.getElementById("deadline-title-edit") as HTMLInputElement;
@@ -366,6 +510,11 @@ function initEdit() {
const etEdit = document.getElementById("deadline-event-types-edit");
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
const titleDefaultBtn = document.getElementById("deadline-title-default-btn") as HTMLButtonElement | null;
const ruleDisplay = document.getElementById("deadline-rule-display");
const ruleEdit = document.getElementById("deadline-rule-edit");
const ruleCustomInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
const ruleToggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
function enterEdit() {
titleDisplay.style.display = "none";
@@ -381,6 +530,20 @@ function initEdit() {
projectEdit.style.display = "";
projectEdit.value = deadline.project_id;
}
if (titleDefaultBtn) titleDefaultBtn.style.display = "";
// t-paliad-258 — show the Auto/Custom rule editor + initialise mode
// from the persisted deadline. Display element stays visible so the
// user keeps "before / after" context while editing.
if (ruleEdit) ruleEdit.style.display = "";
if (ruleDisplay) ruleDisplay.style.display = "none";
if (deadline?.custom_rule_text && !deadline.rule_id) {
ruleMode = "custom";
if (ruleCustomInput) ruleCustomInput.value = deadline.custom_rule_text;
} else {
ruleMode = "auto";
if (ruleCustomInput) ruleCustomInput.value = "";
}
applyRuleModeUI();
saveBtn.style.display = "";
editBtn.style.display = "none";
titleEdit.focus();
@@ -399,12 +562,71 @@ function initEdit() {
projectEdit.style.display = "none";
projectLink.style.display = "";
}
if (titleDefaultBtn) titleDefaultBtn.style.display = "none";
if (ruleEdit) ruleEdit.style.display = "none";
if (ruleDisplay) ruleDisplay.style.display = "";
saveBtn.style.display = "none";
editBtn.style.display = "";
pendingEditMode = false;
}
// Rule mode toggle (Auto ↔ Custom). The Auto resolver re-runs every
// time the Type picker changes, so just-toggling-to-Auto immediately
// surfaces a fresh resolution.
ruleToggleBtn?.addEventListener("click", () => {
ruleMode = ruleMode === "auto" ? "custom" : "auto";
applyRuleModeUI();
if (ruleMode === "custom") ruleCustomInput?.focus();
});
// t-paliad-252 — expose enterEdit so the withdraw warning modal can
// route into pending-edit mode without re-running the edit-button
// visibility gate (which hides the button during pending).
pendingEnterEdit = () => {
pendingEditMode = true;
enterEdit();
};
editBtn.addEventListener("click", enterEdit);
// t-paliad-251 Part 4 — Standardtitel button.
// Recipe (mirror of computeDefaultTitle in deadlines-new.ts):
// head = event_type label (if exactly one Typ chip in edit)
// || Auto-resolved rule's canonical label (Name · Citation)
// || saved rule's canonical label
// || custom_rule_text (when in Custom mode + non-empty)
// || rule_code-only legacy fallback
// || "Neue Frist" fallback
// suffix = " — <project.reference>" when not already in head
titleDefaultBtn?.addEventListener("click", () => {
if (!deadline) return;
let head = "";
const ids = eventTypePicker?.getIDs() ?? deadline.event_type_ids ?? [];
if (ids.length === 1) {
const et = eventTypeByID.get(ids[0]);
if (et) head = eventTypeLabel(et);
}
if (!head) {
const r = ruleMode === "auto" ? (currentAutoRule() ?? rule) : null;
if (r) head = formatRuleLabel(r);
}
if (!head && ruleMode === "custom") {
const txt = ruleCustomInput?.value.trim() || "";
if (txt) head = txt;
}
if (!head && rule) {
head = formatRuleLabel(rule);
}
if (!head && deadline.rule_code) {
head = deadline.rule_code;
}
if (!head) head = t("deadlines.field.title.default_fallback");
const ref = project?.reference?.trim() || "";
if (ref && !head.includes(ref)) head = `${head}${ref}`;
titleEdit.value = head;
titleEdit.focus();
});
saveBtn.addEventListener("click", async () => {
if (!deadline) return;
const newTitle = titleEdit.value.trim();
@@ -424,6 +646,48 @@ function initEdit() {
if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) {
payload.project_id = projectEdit.value;
}
// t-paliad-258 — rule_set discriminator tells the service this
// PATCH carries an Auto/Custom rule change. Both columns are
// mutually exclusive at the persistence boundary.
payload.rule_set = true;
if (ruleMode === "auto") {
const r = currentAutoRule();
payload.rule_id = r ? r.id : null;
payload.custom_rule_text = null;
} else {
const txt = ruleCustomInput?.value.trim() || "";
payload.rule_id = null;
payload.custom_rule_text = txt || null;
}
// t-paliad-252 — pending-edit mode routes through the new endpoint
// that updates the entity + merges payload into the still-pending
// approval_request. Outside pending-edit mode the regular PATCH
// path remains the authoritative one (with its existing 409-on-
// pending guard).
if (pendingEditMode && pendingRequest) {
const resp = await fetch(
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: payload }),
},
);
if (resp.ok) {
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
if (fresh.ok) deadline = await fresh.json();
await loadPendingRequest();
render();
} else {
const body = await resp.json().catch(() => null);
const msg = (body && (body.message || body.error))
|| (t("approvals.withdraw.error") || "Fehler");
window.alert(msg);
}
return;
}
const resp = await fetch(`/api/deadlines/${deadline.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -501,19 +765,39 @@ function initReopen() {
});
}
// initWithdraw — t-paliad-160 §C+E. Reuses the existing
// /api/approval-requests/{id}/revoke endpoint (no new server route
// needed). After the revoke lands, the entity goes back to
// approval_status='approved' and the page reloads to refresh the
// in-memory state cleanly.
// initWithdraw — t-paliad-160 §C+E + t-paliad-252.
//
// Click flow: open the withdraw warning modal (replaces the old
// confirm()). The modal returns one of:
//
// "edit" — open the edit form in pending-edit mode; Save calls
// /api/approval-requests/{id}/edit-entity which keeps the
// request pending + merges the new fields into payload
// "withdraw" — destructive: call the existing /revoke endpoint
// (DELETE entity for CREATE, revert for UPDATE/COMPLETE,
// cancel-delete for DELETE lifecycle)
// null — user cancelled; nothing happens
function initWithdraw() {
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", async () => {
if (!deadline || !pendingRequest) return;
if (!window.confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
btn.disabled = true;
try {
const action = await openWithdrawWarningModal({
entityType: "deadline",
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
});
if (action === null) {
btn.disabled = false;
return;
}
if (action === "edit") {
btn.disabled = false;
pendingEnterEdit?.();
return;
}
// action === "withdraw" → existing destructive path.
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -521,14 +805,16 @@ function initWithdraw() {
});
if (resp.ok) {
// Re-fetch the entity so approval_status flips back to 'approved'
// and the badge / buttons rerender accordingly.
// and the badge / buttons rerender accordingly. For CREATE
// lifecycle the entity is gone, so the 404 surfaces as a reload.
const r = await fetch(`/api/deadlines/${deadline.id}`);
if (r.ok) {
deadline = await r.json();
await loadPendingRequest();
render();
} else {
window.location.reload();
// CREATE lifecycle deleted the entity — bounce to the list.
window.location.href = "/events?type=deadline";
}
} else {
btn.disabled = false;
@@ -592,8 +878,14 @@ async function main() {
notfound.style.display = "block";
return;
}
await Promise.all([loadProject(deadline.project_id), loadAllProjects(), loadPendingRequest()]);
if (deadline.rule_id) await loadRule(deadline.rule_id);
await Promise.all([
loadProject(deadline.project_id),
loadAllProjects(),
loadPendingRequest(),
loadAllRules(),
loadProceedingTypes(),
]);
if (deadline.rule_id) rule = lookupRule(deadline.rule_id);
// Load event types in parallel; render once ready (the picker re-renders
// chips off the cached map, and the display element re-renders on the
@@ -614,6 +906,11 @@ async function main() {
eventTypePicker = attachEventTypePicker(pickerHost, {
initialIDs: deadline.event_type_ids ?? [],
currentUserAdmin: me?.global_role === "global_admin",
onChange: () => {
// Type change shifts the Auto-resolved rule. Refresh the
// read-only display panel (no-op outside edit mode / Custom).
refreshRuleAutoDisplay();
},
});
}

View File

@@ -1,4 +1,4 @@
import { initI18n, t, tDyn } from "./i18n";
import { initI18n, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import {
attachEventTypePicker,
@@ -8,22 +8,21 @@ import {
type PickerHandle,
} from "./event-types";
import { projectIndent } from "./project-indent";
import { formatRuleLabel, formatRuleLabelHTML } from "./rule-label";
let eventTypePicker: PickerHandle | null = null;
let currentUserAdmin = false;
let eventTypesByID = new Map<string, EventType>();
// expandedOverride flips to true when the user clicks "Anderen Typ
// wählen" on the collapsed inline summary. Sticky for the rest of the
// form session — cleared only when the user reverts the rule to "Keine
// Regel". When true, the picker stays visible regardless of whether
// the chip matches the rule's canonical default.
let expandedOverride = false;
interface Project {
id: string;
reference?: string | null;
title: string;
path: string;
// Used by the Type→Rule resolver to narrow rule candidates to the
// project's own proceeding when one applies. Optional because clients
// and matter-level projects don't carry a proceeding type.
proceeding_type_id?: number | null;
}
interface DeadlineRule {
@@ -32,23 +31,37 @@ interface DeadlineRule {
name: string;
name_en: string;
rule_code?: string;
// t-paliad-165 — canonical event_type for this rule's concept,
// hydrated server-side from paliad.deadline_concept_event_types.
// Drives auto-fill of the Typ chip when the user picks this rule.
legal_source?: string | null;
proceeding_type_id?: number | null;
sequence_order?: number;
// t-paliad-165 — canonical event_type for the rule's concept. The
// catalog is indexed by it so we can resolve Type → canonical Rule.
concept_default_event_type_id?: string | null;
}
// Rules indexed by id so the Regel-change handler can look up the
// concept's canonical event_type without re-fetching.
let rulesByID = new Map<string, DeadlineRule>();
interface ProceedingType {
id: number;
code: string;
name: string;
name_en?: string;
jurisdiction: string;
sort_order?: number;
}
// Last event_type the rule auto-filled. Tracked so we can tell whether
// the picker still reflects the rule's suggestion (replace silently on
// new rule pick) or whether the user has manually edited (leave alone,
// surface the mismatch warning instead).
let lastAutoFilledEventTypeID: string | null = null;
// Rule mode (t-paliad-258 / m/paliad#89). The form has two states:
// auto — rule_id resolved from the chosen event_type, rendered
// read-only as "Auto: Name · Citation".
// custom — free-text input; submits as custom_rule_text on the API.
type RuleMode = "auto" | "custom";
let ruleMode: RuleMode = "auto";
let rulesByID = new Map<string, DeadlineRule>();
let allRules: DeadlineRule[] = [];
let proceedingTypesByID = new Map<number, ProceedingType>();
let projectsByID = new Map<string, Project>();
let preselectedProjectID = "";
let preselectedProjectIDLocal = "";
function esc(s: string): string {
const d = document.createElement("div");
@@ -62,6 +75,13 @@ function showError(msg: string) {
el.className = "form-msg form-msg-error";
}
function proceedingLabel(pt: ProceedingType | undefined): string {
if (!pt) return "";
const lang = getLang();
const name = (lang === "en" && pt.name_en) ? pt.name_en : pt.name;
return `${pt.jurisdiction}${name}`;
}
async function loadProjects() {
const sel = document.getElementById("deadline-project") as HTMLSelectElement;
const hint = document.getElementById("deadline-project-empty-hint")!;
@@ -69,6 +89,7 @@ async function loadProjects() {
const resp = await fetch("/api/projects");
if (!resp.ok) return;
const projects: Project[] = await resp.json();
projectsByID = new Map(projects.map((p) => [p.id, p]));
if (projects.length === 0) {
hint.style.display = "";
hint.innerHTML = `${esc(t("deadlines.field.akte.empty"))} <a href="/projects/new">${esc(t("deadlines.field.akte.empty.link"))}</a>`;
@@ -82,7 +103,7 @@ async function loadProjects() {
const ref = p.reference || "";
const indent = projectIndent(p.path);
options.push(
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} \u2014 ${esc(p.title)}</option>`,
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} ${esc(p.title)}</option>`,
);
}
sel.innerHTML = options.join("");
@@ -91,122 +112,167 @@ async function loadProjects() {
}
}
async function loadProceedingTypes() {
try {
const resp = await fetch("/api/proceeding-types-db");
if (!resp.ok) return;
const types: ProceedingType[] = await resp.json();
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
} catch {
/* non-fatal */
}
}
async function loadRules() {
// Optional: load rules so user can attach. We pull all rules; small set.
const sel = document.getElementById("deadline-rule") as HTMLSelectElement;
try {
const resp = await fetch("/api/deadline-rules");
if (!resp.ok) return;
const rules: DeadlineRule[] = await resp.json();
rulesByID = new Map(rules.map((r) => [r.id, r]));
const opts: string[] = [
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
];
for (const r of rules) {
const code = r.rule_code || r.code || "";
const label = code ? `${code} \u2014 ${r.name}` : r.name;
opts.push(`<option value="${esc(r.id)}">${esc(label)}</option>`);
}
sel.innerHTML = opts.join("");
allRules = (await resp.json()) as DeadlineRule[];
rulesByID = new Map(allRules.map((r) => [r.id, r]));
} catch {
/* non-fatal — rule select stays at "no rule" */
/* non-fatal — rule display falls back to "—" */
}
}
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
// picker. The two modes are mutually exclusive:
// resolveAutoRuleForType picks the best-match catalog rule for the
// chosen event type, scoring by:
// 1. project's proceeding_type_id (if known) — exact match wins,
// 2. otherwise event_type.jurisdiction matches the rule's proceeding's
// jurisdiction (EPA→EPO canonicalised),
// 3. otherwise the first candidate in canonical sequence_order.
//
// collapsed: rule selected + canonical event_type known + picker
// contains exactly [default] + user hasn't clicked "Anderen Typ
// wählen". Hides the chip cluster, surfaces a single inline
// summary "Klageerwiderung (vorgegeben durch Regel)" + an
// override link.
//
// expanded: every other case — no rule, no default for the rule,
// picker has been edited, or expandedOverride is sticky after the
// user clicked the override link. Picker visible; mismatch warning
// surfaces yellow when the rule expected a different event_type.
function refreshRuleView(): void {
const collapsed = document.getElementById("deadline-event-type-collapsed");
const collapsedLabel = document.getElementById("deadline-event-type-collapsed-label");
const pickerHost = document.getElementById("deadline-event-types");
const warn = document.getElementById("deadline-event-type-rule-mismatch");
if (!collapsed || !collapsedLabel || !pickerHost || !warn) return;
// Returns null when no rule maps. Callers render that as "no Auto rule
// available" so the user can flip to Custom or pick a different Type.
function resolveAutoRuleForType(eventTypeID: string, projectID: string): DeadlineRule | null {
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
const expected = rule?.concept_default_event_type_id ?? null;
const project = projectID ? projectsByID.get(projectID) : undefined;
if (project?.proceeding_type_id) {
const exact = candidates.find((r) => r.proceeding_type_id === project.proceeding_type_id);
if (exact) return exact;
}
const et = eventTypesByID.get(eventTypeID);
if (et?.jurisdiction && et.jurisdiction !== "any") {
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
const jurMatch = candidates.find((r) => {
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
return pt?.jurisdiction === want;
});
if (jurMatch) return jurMatch;
}
return candidates[0];
}
// currentAutoRule returns the catalog rule the Auto mode would resolve
// to for the current form state, or null when no Type is picked or no
// rule maps. Centralised so the Auto display, submitForm, and the
// Standardtitel button all agree on the same resolution.
function currentAutoRule(): DeadlineRule | null {
const picked = eventTypePicker?.getIDs() ?? [];
if (picked.length !== 1) return null;
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
return resolveAutoRuleForType(picked[0], projectID);
}
const pickerMatchesDefault =
expected !== null && picked.length === 1 && picked[0] === expected;
const wantsCollapsed =
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault;
if (wantsCollapsed) {
const et = eventTypesByID.get(expected!);
collapsedLabel.textContent = et ? eventTypeLabel(et) : "";
collapsed.style.display = "";
pickerHost.style.display = "none";
warn.style.display = "none";
// refreshRuleAutoDisplay updates the read-only Auto display panel to
// reflect the rule that would be saved in Auto mode. Hides itself when
// the user is in Custom mode (the input takes its place).
function refreshRuleAutoDisplay(): void {
const panel = document.getElementById("deadline-rule-auto-display");
const text = document.getElementById("deadline-rule-auto-text");
if (!panel || !text) return;
if (ruleMode !== "auto") {
panel.style.display = "none";
return;
}
panel.style.display = "";
const rule = currentAutoRule();
if (rule) {
// Canonical "Name · Citation" with muted citation (t-paliad-258 addendum).
text.innerHTML = formatRuleLabelHTML(rule, esc);
text.classList.remove("rule-auto-text--empty");
return;
}
const picked = eventTypePicker?.getIDs() ?? [];
const fallback = picked.length === 1
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
text.textContent = fallback;
text.classList.add("rule-auto-text--empty");
}
collapsed.style.display = "none";
pickerHost.style.display = "";
// Mismatch warning: rule expected an event_type AND the picker
// doesn't contain it. (When the picker is empty + no override, no
// warning — user is free to leave it blank.)
if (expected && picked.length > 0 && !picked.includes(expected)) {
warn.style.display = "";
function applyRuleModeUI(): void {
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
const autoPanel = document.getElementById("deadline-rule-auto-display");
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
if (!toggleBtn || !autoPanel || !customInput) return;
if (ruleMode === "auto") {
autoPanel.style.display = "";
customInput.style.display = "none";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
} else {
warn.style.display = "none";
autoPanel.style.display = "none";
customInput.style.display = "";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
}
refreshRuleAutoDisplay();
}
function setRuleMode(mode: RuleMode): void {
ruleMode = mode;
applyRuleModeUI();
if (mode === "custom") {
const input = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
input?.focus();
}
}
// applyRuleAutoFill replaces the picker silently when it still reflects
// the previous rule's suggestion (or is empty); leaves a manually-edited
// picker alone. Called whenever the Regel select changes.
function applyRuleAutoFill(): void {
if (!eventTypePicker) return;
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
const expected = rule?.concept_default_event_type_id ?? null;
const current = eventTypePicker.getIDs();
// computeDefaultTitle — t-paliad-251 Part 4. Priority order picks the head:
// 1. event_type label (when exactly one Typ chip is set)
// 2. canonical rule name (when Auto resolves to a rule)
// 3. custom rule text (when in Custom mode)
// 4. proceeding type name (when project carries one)
// 5. fallback i18n key
// Suffix: " — <project-reference>" when not already in head.
function computeDefaultTitle(): string {
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
const project = projectID ? projectsByID.get(projectID) : undefined;
const picked = eventTypePicker?.getIDs() ?? [];
// Reset the override on transition to "Keine Regel" — fresh form
// session. Otherwise expandedOverride stays sticky.
if (ruleID === "") {
expandedOverride = false;
let head = "";
if (picked.length === 1) {
const et = eventTypesByID.get(picked[0]);
if (et) head = eventTypeLabel(et);
}
const pickerStillReflectsLastSuggestion =
lastAutoFilledEventTypeID !== null &&
current.length === 1 &&
current[0] === lastAutoFilledEventTypeID;
const pickerIsEmpty = current.length === 0;
if (expected) {
if (pickerIsEmpty || pickerStillReflectsLastSuggestion) {
eventTypePicker.setIDs([expected]);
lastAutoFilledEventTypeID = expected;
if (!head) {
if (ruleMode === "auto") {
const rule = currentAutoRule();
if (rule) head = formatRuleLabel(rule);
} else {
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
const txt = customInput?.value.trim() || "";
if (txt) head = txt;
}
} else if (pickerStillReflectsLastSuggestion) {
// New rule has no canonical event_type — clear the stale auto-fill
// so the picker doesn't carry a chip from the old rule.
eventTypePicker.setIDs([]);
lastAutoFilledEventTypeID = null;
}
refreshRuleView();
}
if (!head && project?.proceeding_type_id) {
const pt = proceedingTypesByID.get(project.proceeding_type_id);
if (pt) head = proceedingLabel(pt);
}
if (!head) {
head = t("deadlines.field.title.default_fallback");
}
function initBackLinks() {
if (preselectedProjectID) {
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
back.href = `/projects/${preselectedProjectID}/deadlines`;
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
const ref = project?.reference?.trim() || "";
if (ref && !head.includes(ref)) {
return `${head}${ref}`;
}
return head;
}
async function submitForm(e: Event) {
@@ -217,7 +283,6 @@ async function submitForm(e: Event) {
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement).value;
const title = (document.getElementById("deadline-title") as HTMLInputElement).value.trim();
const due = (document.getElementById("deadline-due") as HTMLInputElement).value;
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement).value;
const notes = (document.getElementById("deadline-notes") as HTMLTextAreaElement).value.trim();
if (!projectID || !title || !due) {
@@ -234,7 +299,15 @@ async function submitForm(e: Event) {
due_date: due,
source: "manual",
};
if (ruleID) payload.rule_id = ruleID;
// Rule field: Auto resolves to rule_id, Custom sends the free text.
if (ruleMode === "auto") {
const rule = currentAutoRule();
if (rule) payload.rule_id = rule.id;
} else {
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
const txt = customInput?.value.trim() || "";
if (txt) payload.custom_rule_text = txt;
}
if (notes) payload.notes = notes;
const eventTypeIDs = eventTypePicker?.getIDs() ?? [];
if (eventTypeIDs.length > 0) payload.event_type_ids = eventTypeIDs;
@@ -252,8 +325,8 @@ async function submitForm(e: Event) {
return;
}
const created = await resp.json();
if (preselectedProjectID) {
window.location.href = `/projects/${preselectedProjectID}/deadlines`;
if (preselectedProjectIDLocal) {
window.location.href = `/projects/${preselectedProjectIDLocal}/deadlines`;
} else {
window.location.href = `/deadlines/${created.id}`;
}
@@ -275,6 +348,16 @@ function detectPreselect() {
if (fromQuery) preselectedProjectID = fromQuery;
}
function initBackLinks() {
if (preselectedProjectID) {
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
back.href = `/projects/${preselectedProjectID}/deadlines`;
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
}
preselectedProjectIDLocal = preselectedProjectID;
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
@@ -288,8 +371,6 @@ async function loadMe() {
// t-paliad-154 — fetch the effective approval policy for (project,
// deadline, create) and reveal the form-time hint when it applies.
// Hidden when no policy applies. Re-runs on project change so the hint
// updates if the user picks a different project mid-form.
async function refreshApprovalHint(): Promise<void> {
const hint = document.getElementById("deadline-approval-hint");
const text = document.getElementById("deadline-approval-hint-text");
@@ -308,7 +389,6 @@ async function refreshApprovalHint(): Promise<void> {
hint.style.display = "none";
return;
}
// t-paliad-160 split-grammar (with M1 legacy fallback).
const eff = await resp.json() as {
requires_approval?: boolean;
min_role?: string | null;
@@ -343,44 +423,51 @@ document.addEventListener("DOMContentLoaded", async () => {
// Default due to today
const dueInput = document.getElementById("deadline-due") as HTMLInputElement;
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
await Promise.all([loadProjects(), loadRules(), loadMe()]);
await Promise.all([loadProjects(), loadProceedingTypes(), loadRules(), loadMe()]);
const pickerHost = document.getElementById("deadline-event-types");
if (pickerHost) {
eventTypePicker = attachEventTypePicker(pickerHost, {
currentUserAdmin,
onChange: () => refreshRuleView(),
onChange: () => {
// Type change shifts which Auto rule resolves; re-render the
// read-only Auto display panel.
refreshRuleAutoDisplay();
},
});
}
// t-paliad-165 follow-up — preload event_types so the collapsed
// summary can render the type's label inline without an extra round
// trip when the user picks a Regel.
// Preload event_types for the Auto display + Standardtitel resolver.
fetchEventTypes()
.then((types) => {
eventTypesByID = new Map(types.map((et) => [et.id, et]));
refreshRuleView();
refreshRuleAutoDisplay();
})
.catch(() => {/* non-fatal — collapsed view falls back to empty label */});
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
// concept's canonical event_type, when the picker hasn't been
// manually edited away from the previous rule's suggestion.
document.getElementById("deadline-rule")?.addEventListener("change", () => {
applyRuleAutoFill();
.catch(() => {/* non-fatal */});
// Rule mode toggle.
document.getElementById("deadline-rule-mode-toggle")?.addEventListener("click", () => {
setRuleMode(ruleMode === "auto" ? "custom" : "auto");
});
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
// visible even when the chip still matches the rule's default.
document.getElementById("deadline-event-type-override-btn")?.addEventListener("click", () => {
expandedOverride = true;
refreshRuleView();
// Move focus into the picker's search box so the user can type
// immediately without an extra click.
const search = document.querySelector<HTMLInputElement>(
"#deadline-event-types .event-type-search",
);
search?.focus();
});
// Wire approval-hint refresh: on first render + on project change.
applyRuleModeUI();
// Approval-hint refresh: on first render + on project change.
void refreshApprovalHint();
document.getElementById("deadline-project")?.addEventListener("change", () => {
void refreshApprovalHint();
// Project change can shift which Auto rule resolves (via the
// project's proceeding_type_id).
refreshRuleAutoDisplay();
});
// t-paliad-251 Part 4 — Standardtitel button.
document.getElementById("deadline-title-default-btn")?.addEventListener("click", () => {
const titleInput = document.getElementById("deadline-title") as HTMLInputElement | null;
if (!titleInput) return;
const derived = computeDefaultTitle();
if (derived) titleInput.value = derived;
titleInput.focus();
});
});

View File

@@ -686,6 +686,33 @@ export function openBrowseEventTypesModal(
return new Promise<string[] | null>((resolve) => {
let selected = new Set<string>(opts.initialIDs);
let searchQuery = "";
// t-paliad-251 — court-type filter chips. `null` = "Alle" (any
// jurisdiction). Any non-null value matches event_types.jurisdiction;
// "any" is mapped to NULL/missing rows via jurisdictionMatches().
let activeJurisdiction: string | null = null;
// Surface every jurisdiction present in the data — "any" stays bucketed
// separately so users still have a "show generic-only" chip. EPA is
// canonicalised to EPO in event_types (see mig 074); the chip label
// shows EPA to match the legal vocabulary the lawyers use.
const jurisdictionsPresent = new Set<string>();
for (const et of opts.types) {
const j = (et.jurisdiction ?? "").trim();
if (j) jurisdictionsPresent.add(j);
}
const JURISDICTION_ORDER = ["UPC", "EPO", "DPMA", "DE", "any"];
const chipJurisdictions = JURISDICTION_ORDER.filter((j) => jurisdictionsPresent.has(j));
// Any jurisdiction in the data that isn't in our ordered list lands at
// the end so the chip row never silently drops a court flavour.
for (const j of jurisdictionsPresent) {
if (!chipJurisdictions.includes(j)) chipJurisdictions.push(j);
}
function chipLabel(j: string): string {
if (j === "EPO") return "EPA";
if (j === "any") return t("event_types.browse.jurisdiction.none");
return j;
}
const overlay = document.createElement("div");
overlay.className = "modal-overlay event-type-browse-overlay";
@@ -694,6 +721,15 @@ export function openBrowseEventTypesModal(
<div class="event-type-browse-header">
<h2 id="event-type-browse-title">${esc(t("event_types.browse.title"))}</h2>
<input type="text" class="event-type-browse-search" data-role="search" placeholder="${esc(t("event_types.browse.search"))}" autocomplete="off" />
<div class="event-type-browse-chips" data-role="chips" role="group" aria-label="${esc(t("event_types.browse.jurisdiction.filter_label"))}">
<button type="button" class="event-type-browse-chip event-type-browse-chip--active" data-jurisdiction="" data-role="chip-all">${esc(t("event_types.browse.jurisdiction.all"))}</button>
${chipJurisdictions
.map(
(j) =>
`<button type="button" class="event-type-browse-chip" data-jurisdiction="${esc(j)}">${esc(chipLabel(j))}</button>`,
)
.join("")}
</div>
</div>
<div class="event-type-browse-list" data-role="list" tabindex="-1"></div>
<div class="event-type-browse-actions">
@@ -711,6 +747,7 @@ export function openBrowseEventTypesModal(
const countEl = overlay.querySelector<HTMLElement>("[data-role=count]")!;
const cancelBtn = overlay.querySelector<HTMLButtonElement>("[data-role=cancel]")!;
const applyBtn = overlay.querySelector<HTMLButtonElement>("[data-role=apply]")!;
const chipButtons = overlay.querySelectorAll<HTMLButtonElement>(".event-type-browse-chip");
const groups = groupByCategory(opts.types);
@@ -721,6 +758,12 @@ export function openBrowseEventTypesModal(
return j;
}
function jurisdictionMatches(et: EventType): boolean {
if (activeJurisdiction === null) return true;
const j = (et.jurisdiction ?? "").trim();
return j === activeJurisdiction;
}
function updateCount() {
countEl.textContent = t("event_types.browse.selected_count").replace(
"{n}",
@@ -731,6 +774,7 @@ export function openBrowseEventTypesModal(
function renderList() {
const q = searchQuery.trim().toLowerCase();
const matches = (et: EventType) => {
if (!jurisdictionMatches(et)) return false;
if (!q) return true;
return (
et.label_de.toLowerCase().includes(q) ||
@@ -783,6 +827,16 @@ export function openBrowseEventTypesModal(
renderList();
});
chipButtons.forEach((btn) => {
btn.addEventListener("click", () => {
const raw = btn.dataset.jurisdiction ?? "";
activeJurisdiction = raw === "" ? null : raw;
chipButtons.forEach((b) => b.classList.remove("event-type-browse-chip--active"));
btn.classList.add("event-type-browse-chip--active");
renderList();
});
});
function close(value: string[] | null) {
document.removeEventListener("keydown", onKey);
overlay.remove();

View File

@@ -8,6 +8,8 @@ import {
type FilterHandle,
} from "./event-types";
import { projectIndent } from "./project-indent";
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
@@ -65,6 +67,9 @@ interface EventListItem {
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
// t-paliad-258 — free-text rule label when the deadline was created
// via the Custom rule path. Mutually exclusive with rule_id.
custom_rule_text?: string;
event_type_ids?: string[];
// appointment-only
@@ -125,8 +130,11 @@ const STATUS_OPTIONS_DEADLINE: StatusOption[] = [
{ value: "completed", key: "deadlines.filter.completed" },
];
// Appointment status options — m/paliad#54: the legacy 'upcoming' /
// "Ab heute" option was a UI lie (backend never narrowed past events for
// appointments) and is removed. 'today' is the sane default — matches the
// dashboard tile. 'all' stays as the explicit opt-in for past events.
const STATUS_OPTIONS_APPOINTMENT: StatusOption[] = [
{ value: "upcoming", key: "events.filter.status.upcoming" },
{ value: "today", key: "deadlines.filter.today" },
{ value: "this_week", key: "deadlines.filter.thisweek" },
{ value: "next_week", key: "deadlines.filter.nextweek" },
@@ -140,7 +148,7 @@ function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
}
function defaultStatusFor(type: EventTypeChoice): string {
return type === "appointment" ? "upcoming" : "pending";
return type === "appointment" ? "today" : "pending";
}
let currentType: EventTypeChoice = "deadline";
@@ -154,8 +162,10 @@ let me: Me | null = null;
let eventTypeFilter: FilterHandle | null = null;
let eventTypeByID: Map<string, EventType> = new Map();
let loadedOK = false;
let calYear = 0;
let calMonth = 0;
// Calendar handle is created lazily when /events first switches into the
// Kalender view (t-paliad-224). The handle owns its own month/week/day
// state + ?cal_view / ?cal_date URL contract via mountCalendar.
let calendar: CalendarHandle | null = null;
function urlParams(): URLSearchParams {
return new URLSearchParams(window.location.search);
@@ -258,13 +268,26 @@ function urgencyClass(item: EventListItem): string {
function ruleDisplay(item: EventListItem): string {
if (item.type !== "deadline") return "";
// Prefer the saved citation (RoP.023, R.151) over the rule name —
// REGEL is meant for the legal reference, not the rule's display
// name (which is the title column's job).
if (item.rule_code && item.rule_code.trim()) return esc(item.rule_code);
const lang = getLang();
const localized = lang === "en" ? item.rule_name_en : item.rule_name;
if (localized && localized.trim()) return esc(localized);
// t-paliad-258 addendum — canonical display contract: Name primary,
// Citation muted secondary ("Notice of Appeal · UPC.RoP.220.1").
// Custom rules render the lawyer's free text + a "Custom" badge.
// Legacy rule-code-only saves (Fristenrechner, no rule_id) still
// show the bare citation as last-resort fallback.
const hasName = (item.rule_name && item.rule_name.trim()) ||
(item.rule_name_en && item.rule_name_en.trim());
if (hasName || (item.rule_code && item.rule_code.trim())) {
return formatRuleLabelHTML(
{
name: item.rule_name || "",
name_en: item.rule_name_en,
rule_code: item.rule_code,
},
esc,
);
}
if (item.custom_rule_text && item.custom_rule_text.trim()) {
return formatCustomRuleLabelHTML(item.custom_rule_text, esc);
}
return "&mdash;";
}
@@ -426,12 +449,13 @@ function hideTableAndCalendar() {
const calWrap = document.getElementById("events-calendar-wrap");
if (tableWrap) tableWrap.style.display = "none";
if (calWrap) calWrap.hidden = true;
teardownCalendar();
}
function render() {
if (!loadedOK) return;
if (currentView === "calendar") {
renderCalendar();
renderCalendarView();
} else {
renderTable();
}
@@ -554,135 +578,57 @@ function renderRow(item: EventListItem, showReopen: boolean): string {
</tr>`;
}
// itemDateISO returns the per-item bucketing date (YYYY-MM-DD) used when
// plotting an event onto the calendar. Deadlines bucket on due_date;
// appointments on start_at's local-date component.
function itemDateISO(item: EventListItem): string {
// toCalendarItem adapts an EventListItem to the canonical CalendarItem
// shape consumed by mountCalendar (t-paliad-224). Bucketing date matches
// the pre-refactor behaviour: deadlines bucket on due_date (fallback to
// event_date); appointments bucket on start_at (fallback to event_date).
function toCalendarItem(item: EventListItem): CalendarItem {
let bucketDate: string;
if (item.type === "deadline") {
const src = item.due_date ?? item.event_date;
return src.slice(0, 10);
bucketDate = item.due_date ?? item.event_date;
} else if (item.start_at) {
bucketDate = item.start_at;
} else {
bucketDate = item.event_date;
}
if (!item.start_at) return item.event_date.slice(0, 10);
const d = new Date(item.start_at);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
return {
kind: item.type,
id: item.id,
title: item.title,
event_date: bucketDate,
project_id: item.project_id,
project_title: item.project_title,
project_reference: item.project_reference,
};
}
function isoDate(year: number, month: number, day: number): string {
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function fmtMonthYear(year: number, month: number): string {
return `${tDyn(`cal.month.${month}`)} ${year}`;
}
function calDotClass(item: EventListItem): string {
// Per-item dot colour. Deadlines reuse the existing urgency palette;
// appointments get their own colour so they're visually distinct from
// deadlines on a mixed (Beides) calendar.
if (item.type === "appointment") return "events-cal-dot-appointment";
return urgencyClass(item).replace("frist-urgency-", "frist-urgency-");
}
function renderCalendar() {
const wrap = document.getElementById("events-calendar-wrap")!;
const grid = document.getElementById("events-cal-grid")!;
const empty = document.getElementById("events-cal-empty") as HTMLElement;
const monthLabel = document.getElementById("events-cal-month-label")!;
function renderCalendarView() {
const host = document.getElementById("events-calendar-wrap");
if (!host) return;
const tableEmpty = document.getElementById("events-empty")!;
const tableEmptyFiltered = document.getElementById("events-empty-filtered")!;
// Calendar always renders the visible month from allItems, regardless of
// pristine vs filtered state — empty calendar is allowed (the per-month
// empty hint communicates "no items in this month" without confusing it
// with the table-mode "no items at all" empty state).
tableEmpty.style.display = "none";
tableEmptyFiltered.style.display = "none";
wrap.hidden = false;
(host as HTMLElement).hidden = false;
monthLabel.textContent = fmtMonthYear(calYear, calMonth);
const firstDay = new Date(calYear, calMonth, 1);
const jsWeekday = firstDay.getDay();
const offset = (jsWeekday + 6) % 7;
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
const today = new Date();
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
// Bucket items by ISO date once, so day-cell rendering is O(d) not O(d*n).
const byDate = new Map<string, EventListItem[]>();
for (const item of allItems) {
const iso = itemDateISO(item);
const list = byDate.get(iso);
if (list) list.push(item);
else byDate.set(iso, [item]);
const items = allItems.map(toCalendarItem);
if (calendar) {
calendar.update(items);
return;
}
const cells: string[] = [];
for (let i = 0; i < offset; i++) {
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
}
for (let day = 1; day <= daysInMonth; day++) {
const iso = isoDate(calYear, calMonth, day);
const items = byDate.get(iso) ?? [];
const isToday = iso === todayISO;
const dots = items
.slice(0, 4)
.map((it) => `<span class="frist-cal-dot ${calDotClass(it)}"></span>`)
.join("");
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
cells.push(
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
<span class="frist-cal-day">${day}</span>
<div class="frist-cal-dots">${dots}${more}</div>
</div>`,
);
}
grid.innerHTML = cells.join("");
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
cell.addEventListener("click", () => openCalPopup(cell.dataset.iso!, byDate.get(cell.dataset.iso!) ?? []));
// urlState=true: the Kalender tab persists its month/week/day + anchor
// in ?cal_view + ?cal_date so a refresh / shared link lands on the same
// calendar state (per t-paliad-224 §11 Q3 head decision).
calendar = mountCalendar(host as HTMLElement, items, {
urlState: true,
defaultView: "month",
});
const monthStart = isoDate(calYear, calMonth, 1);
const monthEnd = isoDate(calYear, calMonth, daysInMonth);
const hasInMonth = allItems.some((it) => {
const iso = itemDateISO(it);
return iso >= monthStart && iso <= monthEnd;
});
empty.hidden = hasInMonth;
}
function openCalPopup(iso: string, items: EventListItem[]) {
if (items.length === 0) return;
const popup = document.getElementById("events-cal-popup") as HTMLElement;
const dateEl = document.getElementById("events-cal-popup-date")!;
const list = document.getElementById("events-cal-popup-list")!;
const d = new Date(iso + "T00:00:00");
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
list.innerHTML = items
.map((it) => {
const cls = calDotClass(it);
const href = it.type === "deadline" ? `/deadlines/${esc(it.id)}` : `/appointments/${esc(it.id)}`;
const projectHref = it.project_id ? `/projects/${esc(it.project_id)}` : "";
const projectLabel = it.project_reference ?? "";
const projectCell = projectHref
? `<a href="${projectHref}" class="frist-cal-popup-akte">${esc(projectLabel)}</a>`
: "";
return `<li class="frist-cal-popup-item">
<span class="frist-cal-dot ${cls}"></span>
<a href="${href}" class="frist-cal-popup-title">${rowTypeChip(it)} ${esc(it.title)}</a>
${projectCell}
</li>`;
})
.join("");
popup.style.display = "flex";
function teardownCalendar() {
if (!calendar) return;
calendar.destroy();
calendar = null;
}
function applyView() {
@@ -703,12 +649,18 @@ function applyView() {
// Cards view = the original layout (5-card summary + table).
// List view = no summary cards, table only — gives more vertical space
// and matches users' mental model of a flat list.
// Calendar view = month grid; cards + table both hidden.
// Calendar view = mountCalendar() canon (month/week/day); cards + table
// both hidden. The handle is torn down when the user leaves Kalender
// so its URL state isn't reapplied to other shapes.
summary.style.display = currentView === "cards" ? "" : "none";
tableWrap.style.display = currentView === "calendar" ? "none" : "";
calWrap.hidden = currentView !== "calendar";
if (currentView === "calendar" && loadedOK) renderCalendar();
if (currentView === "calendar") {
if (loadedOK) renderCalendarView();
} else {
teardownCalendar();
}
}
function wireRowHandlers(tbody: HTMLElement) {
@@ -1010,12 +962,10 @@ function initFilters() {
}
function initView() {
// Calendar always opens on the current month — month navigation is
// local to the view (cheap pagination, doesn't refetch).
const now = new Date();
calYear = now.getFullYear();
calMonth = now.getMonth();
// Kalender state (view + anchor) lives inside mountCalendar; no
// events-page-level wiring needed. The view chips below switch
// between Karten / Liste / Kalender; applyView() handles the
// mount + teardown.
document.querySelectorAll<HTMLButtonElement>("[data-event-view]").forEach((btn) => {
btn.addEventListener("click", () => {
const next = btn.dataset.eventView as EventView;
@@ -1025,31 +975,6 @@ function initView() {
syncURLParams();
});
});
document.getElementById("events-cal-prev")?.addEventListener("click", () => {
calMonth -= 1;
if (calMonth < 0) { calMonth = 11; calYear -= 1; }
renderCalendar();
});
document.getElementById("events-cal-next")?.addEventListener("click", () => {
calMonth += 1;
if (calMonth > 11) { calMonth = 0; calYear += 1; }
renderCalendar();
});
document.getElementById("events-cal-today")?.addEventListener("click", () => {
const t = new Date();
calYear = t.getFullYear();
calMonth = t.getMonth();
renderCalendar();
});
const popup = document.getElementById("events-cal-popup") as HTMLElement;
document.getElementById("events-cal-popup-close")?.addEventListener("click", () => {
popup.style.display = "none";
});
popup?.addEventListener("click", (e) => {
if (e.target === popup) popup.style.display = "none";
});
}
function initSummaryCards() {

View File

@@ -12,7 +12,13 @@
// New classes are scoped under .filter-bar-* so they don't bleed.
import { t, tDyn, type I18nKey } from "../i18n";
import type { BarState, AxisKey } from "./types";
import { mountDateRangePicker } from "../date-range-picker";
import {
ALL_HORIZONS as DRP_ALL_HORIZONS,
type TimeHorizon as DRPTimeHorizon,
type TimeSpec as DRPTimeSpec,
} from "../date-range-picker-pure";
import type { BarState, AxisKey, InboxFocus } from "./types";
export interface AxisCtx {
// Read the current value for this axis.
@@ -47,6 +53,8 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts):
case "shape": return renderShapeAxis(ctx);
case "density": return renderDensityAxis(ctx);
case "sort": return renderSortAxis(ctx);
case "unread_only": return renderUnreadOnlyAxis(ctx);
case "inbox_focus": return renderInboxFocusAxis(ctx);
// Per-source predicates that need their own widgets and a roundtrip
// through fetched option lists. Phase 2+ will fill these in by
@@ -57,60 +65,66 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts):
}
// ----------------------------------------------------------------------
// time — chip cluster (presets + Anpassen)
// time — symmetric date-range picker (t-paliad-248, replaces the t-163
// chip-cluster + disabled Anpassen stub). The picker emits a TimeSpec
// (horizon + optional custom from/to); the bar patches that onto
// BarState.time directly.
// ----------------------------------------------------------------------
type TimeHorizonValue = NonNullable<BarState["time"]>["horizon"];
const TIME_PRESET_LABELS: Record<TimeHorizonValue, I18nKey> = {
next_7d: "views.bar.time.next_7d",
next_30d: "views.bar.time.next_30d",
next_90d: "views.bar.time.next_90d",
past_7d: "views.bar.time.past_7d",
past_30d: "views.bar.time.past_30d",
past_90d: "views.bar.time.past_90d",
any: "views.bar.time.any",
all: "views.bar.time.all",
custom: "views.bar.time.custom",
};
// Default chip set when the surface doesn't override. Mirrors m's
// 3-column picker spec (t-paliad-278): symmetric 7d/30d/90d/all fan
// per side, plus Heute (next_1d) + Alles (any) in the centre column,
// plus Anpassen. Surfaces with a tighter scope (project history is
// past-only) keep overriding via `timePresets`.
const DEFAULT_TIME_PRESETS: TimeHorizonValue[] = [
"next_7d", "next_30d", "next_90d", "past_30d", "any",
"past_7d", "past_30d", "past_90d", "past_all",
"next_1d", "any",
"next_7d", "next_30d", "next_90d", "next_all",
"custom",
];
function renderTimeAxis(ctx: AxisCtx, presetOverride?: TimeHorizonValue[]): HTMLElement {
const wrap = group("views.bar.label.time");
const row = chipRow();
const presets = presetOverride && presetOverride.length ? presetOverride : DEFAULT_TIME_PRESETS;
// "any" / "all" are both unbounded — clearing state is the cleanest
// representation, so each maps to "no overlay" rather than a stored
// horizon. The chip's active state then keys off "no time set".
const current = ctx.get("time")?.horizon ?? "any";
for (const preset of presets) {
if (preset === "custom") continue; // custom rendered separately below
const isUnbounded = preset === "any" || preset === "all";
const isActive = isUnbounded
? !ctx.get("time")
: preset === current;
const chip = chipBtn(t(TIME_PRESET_LABELS[preset]), isActive);
chip.addEventListener("click", () => {
if (isUnbounded) {
const presetSource = presetOverride && presetOverride.length ? presetOverride : DEFAULT_TIME_PRESETS;
// The picker's pure module owns the complete chip set; we narrow it
// here to whatever this surface declares (preserving the surface's
// chip order so timePresets remains the override knob it always was).
const presets: DRPTimeHorizon[] = presetSource.flatMap((p) =>
DRP_ALL_HORIZONS.includes(p as DRPTimeHorizon) ? [p as DRPTimeHorizon] : [],
);
const current = ctx.get("time");
const initialValue: DRPTimeSpec = current
? { horizon: current.horizon as DRPTimeHorizon, from: current.from, to: current.to }
: { horizon: "any" };
const picker = mountDateRangePicker({
value: initialValue,
onChange(next) {
// The bar treats `any` as "no time overlay" (matches the legacy
// chip-cluster's behaviour) so the BarState stays minimal when
// the user lands on the centre ALLES button.
if (next.horizon === "any") {
ctx.patch({ time: undefined });
} else {
ctx.patch({ time: { horizon: preset } });
return;
}
});
row.appendChild(chip);
}
// Custom range — placeholder chip; opens a small popover with two
// <input type="date"> in Phase 2. For Phase 1 we render the chip
// disabled with a tooltip so the affordance is discoverable.
const customChip = chipBtn(t("views.bar.time.custom"), current === "custom");
customChip.classList.add("filter-bar-chip-pending");
customChip.title = t("views.bar.time.custom.coming_soon");
customChip.disabled = true;
row.appendChild(customChip);
wrap.appendChild(row);
ctx.patch({
time: {
horizon: next.horizon as TimeHorizonValue,
from: next.horizon === "custom" ? next.from : undefined,
to: next.horizon === "custom" ? next.to : undefined,
},
});
},
defaultHorizon: "any",
presets,
surface: "filter-bar.time",
labelPrefix: t("views.bar.label.time"),
});
wrap.appendChild(picker.element);
return wrap;
}
@@ -484,6 +498,56 @@ function renderSortAxis(ctx: AxisCtx): HTMLElement {
return wrap;
}
// ----------------------------------------------------------------------
// unread_only — single binary chip (t-paliad-249, inbox only)
// ----------------------------------------------------------------------
function renderUnreadOnlyAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.unread_only");
const row = chipRow();
const isUnread = ctx.get("unread_only") !== false; // default on
const unreadChip = chipBtn(t("views.bar.unread_only.on"), isUnread);
unreadChip.addEventListener("click", () => ctx.patch({ unread_only: true }));
const allChip = chipBtn(t("views.bar.unread_only.off"), !isUnread);
allChip.addEventListener("click", () => ctx.patch({ unread_only: false }));
row.appendChild(unreadChip);
row.appendChild(allChip);
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// inbox_focus — coarse 4-chip cluster (t-paliad-249, inbox only)
//
// Head's UX refinement #2 (2026-05-25): users pick "what to see" in
// human terms, not abstract event-kind names. The overlay translates
// the chip to a (Sources, ProjectEventPredicates.EventTypes,
// ApprovalRequestPredicates.EntityTypes) triple at spec-resolve time
// (see applyInboxFocusOverlay in url-codec.ts).
// ----------------------------------------------------------------------
const INBOX_FOCUS_CHIPS: Array<{ value: InboxFocus; key: I18nKey }> = [
{ value: "alles", key: "views.bar.inbox_focus.alles" },
{ value: "genehmigungen", key: "views.bar.inbox_focus.genehmigungen" },
{ value: "plus_termine", key: "views.bar.inbox_focus.plus_termine" },
{ value: "plus_fristen", key: "views.bar.inbox_focus.plus_fristen" },
];
function renderInboxFocusAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.inbox_focus");
const row = chipRow();
const current: InboxFocus = ctx.get("inbox_focus") ?? "alles";
for (const f of INBOX_FOCUS_CHIPS) {
const chip = chipBtn(t(f.key), f.value === current);
chip.addEventListener("click", () => {
ctx.patch({ inbox_focus: f.value === "alles" ? undefined : f.value });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// shared helpers — group + chip + row
// ----------------------------------------------------------------------

View File

@@ -0,0 +1,126 @@
// Unit tests for the FilterBar's computeEffective() overlay. These pin
// the contract that any chip the user clicks ends up as a predicate the
// server can see — the t-paliad-283 regression had four sources picking
// up zero narrowing for /views/any because the bar's chip click didn't
// produce a non-empty `filter.predicates` for that source.
//
// Run with `bun test`.
import { test, expect, describe } from "bun:test";
import { computeEffective } from "./index";
import type { FilterSpec, RenderSpec } from "../views/types";
import type { BarState } from "./types";
// Mirrors paliad.user_views row {slug: "any"} — the saved Custom View
// that triggered the t-paliad-283 regression report.
const ANY_VIEW_FILTER: FilterSpec = {
version: 1,
sources: ["deadline", "appointment", "project_event", "approval_request"],
scope: { projects: { mode: "all_visible" } },
time: { field: "auto", horizon: "past_30d" },
};
const ANY_VIEW_RENDER: RenderSpec = {
shape: "list",
list: { sort: "date_asc", density: "comfortable" },
};
describe("filter-bar/computeEffective — /views/any (all 4 sources)", () => {
test("empty state leaves base spec intact (no overlays)", () => {
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, {});
expect(eff.filter.sources).toEqual([
"deadline", "appointment", "project_event", "approval_request",
]);
expect(eff.filter.time).toEqual({ field: "auto", horizon: "past_30d" });
// predicates may be {} (the bar zero-fills it) but never carries a
// stray narrowing on any source — that would silently filter
// results the user never asked to filter.
for (const src of ANY_VIEW_FILTER.sources) {
expect(eff.filter.predicates?.[src]).toBeUndefined();
}
});
test("deadline_status chip narrows deadline predicate", () => {
const state: BarState = { deadline_status: ["pending"] };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.deadline?.status).toEqual(["pending"]);
});
test("appointment_type chip narrows appointment predicate", () => {
const state: BarState = { appointment_type: ["hearing"] };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.appointment?.appointment_types).toEqual(["hearing"]);
});
test("approval_viewer_role chip narrows approval predicate", () => {
const state: BarState = { approval_viewer_role: "any_visible" };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.approval_request?.viewer_role).toBe("any_visible");
});
test("approval_status chip narrows approval predicate", () => {
const state: BarState = { approval_status: ["pending", "approved"] };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.approval_request?.status).toEqual(["pending", "approved"]);
});
test("approval_entity_type chip narrows approval predicate", () => {
const state: BarState = { approval_entity_type: ["deadline"] };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.approval_request?.entity_types).toEqual(["deadline"]);
});
test("project_event_kind chip narrows project_event predicate", () => {
const state: BarState = { project_event_kind: ["deadline_created"] };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.project_event?.event_types).toEqual(["deadline_created"]);
});
test("time chip overrides base horizon", () => {
const state: BarState = { time: { horizon: "past_7d" } };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.time.horizon).toBe("past_7d");
expect(eff.filter.time.field).toBe("auto"); // preserved from base
});
test("personal_only chip flips scope flag", () => {
const state: BarState = { personal_only: true };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.scope.personal_only).toBe(true);
});
test("multiple chips combine into the same effective spec", () => {
const state: BarState = {
time: { horizon: "past_7d" },
deadline_status: ["pending"],
appointment_type: ["hearing"],
approval_status: ["pending"],
project_event_kind: ["deadline_created"],
};
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.time.horizon).toBe("past_7d");
expect(eff.filter.predicates?.deadline?.status).toEqual(["pending"]);
expect(eff.filter.predicates?.appointment?.appointment_types).toEqual(["hearing"]);
expect(eff.filter.predicates?.approval_request?.status).toEqual(["pending"]);
expect(eff.filter.predicates?.project_event?.event_types).toEqual(["deadline_created"]);
});
test("overlay does not mutate the caller's base filter", () => {
const base: FilterSpec = JSON.parse(JSON.stringify(ANY_VIEW_FILTER));
const state: BarState = { deadline_status: ["pending"], time: { horizon: "past_7d" } };
computeEffective(base, ANY_VIEW_RENDER, state);
// The bar deep-clones; the base must come back unchanged so a
// second click doesn't compound the previous click's overlay.
expect(base).toEqual(ANY_VIEW_FILTER);
});
test("inbox-only axes do not affect a /views/any spec (no inbox axis exposed)", () => {
// /views/any's axes don't include unread_only or inbox_focus, so
// those keys never appear in state. Verify that even if they did,
// the bar's overlay doesn't silently mutate sources or predicates
// in a way that would break a 4-source Custom View.
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, {});
expect(eff.filter.sources).toHaveLength(4);
expect(eff.filter.unread_only ?? false).toBe(false);
});
});

View File

@@ -333,9 +333,65 @@ export function computeEffective(
render.list = { ...(render.list ?? {}), density: state.density };
}
// Inbox overlays (t-paliad-249).
//
// unread_only is a top-level FilterSpec field; the server resolves
// the actual cursor at run-time. Default-on for the inbox surface is
// baked into the base spec — but we ALSO need to write `true` here
// when the user explicitly picks the chip so the server doesn't
// confuse "user wants unread" with "user wants no filter".
if (state.unread_only !== undefined) {
filter.unread_only = state.unread_only;
}
// inbox_focus is a coarse axis that overlays Sources + a few
// per-source predicates. Translate here so the server sees a clean
// spec; the validator + RunSpec don't need to know about the chip.
if (state.inbox_focus && state.inbox_focus !== "alles") {
applyInboxFocusOverlay(filter, state.inbox_focus);
}
return { filter, render };
}
// applyInboxFocusOverlay narrows the spec to the chip's intent.
// Mutates `filter` in place. Called only when state.inbox_focus is
// set to a non-default value.
//
// Contract:
// - "genehmigungen" → drop project_event from sources entirely.
// - "plus_termine" → keep both sources; narrow project_event to
// appointment_* kinds; narrow approval_request
// entity_types to ["appointment"].
// - "plus_fristen" → keep both sources; narrow project_event to
// deadline_* kinds; narrow approval_request
// entity_types to ["deadline"].
function applyInboxFocusOverlay(filter: FilterSpec, focus: Exclude<NonNullable<BarState["inbox_focus"]>, "alles">): void {
filter.predicates = filter.predicates ?? {};
if (focus === "genehmigungen") {
filter.sources = filter.sources.filter((s) => s !== "project_event");
delete filter.predicates.project_event;
return;
}
const kindPrefix = focus === "plus_fristen" ? "deadline_" : "appointment_";
const entity = focus === "plus_fristen" ? "deadline" : "appointment";
if (filter.sources.includes("project_event")) {
const baseKinds = filter.predicates.project_event?.event_types ?? [];
const narrowed = baseKinds.filter((k) => k.startsWith(kindPrefix));
filter.predicates.project_event = {
...(filter.predicates.project_event ?? {}),
event_types: narrowed,
};
}
if (filter.sources.includes("approval_request")) {
filter.predicates.approval_request = {
...(filter.predicates.approval_request ?? {}),
entity_types: [entity],
};
}
}
// isDirty — used to enable the Reset button only when there's something
// to reset to.
function isDirty(state: BarState): boolean {

View File

@@ -25,7 +25,17 @@ export type AxisKey =
| "timeline_track"
| "shape"
| "sort"
| "density";
| "density"
// Inbox-only (t-paliad-249): unread/all toggle + coarse focus chip
// (Alles / Genehmigungen / +Termine / +Fristen). The focus chip
// overlays Sources + per-source predicates at resolve-time.
| "unread_only"
| "inbox_focus";
// Inbox focus chip values. "alles" is the default — both sources, full
// curated kinds. Other values narrow at the bar's resolve step. See
// applyInboxFocusOverlay() in url-codec.ts for the spec rewrite.
export type InboxFocus = "alles" | "genehmigungen" | "plus_termine" | "plus_fristen";
// Effective spec — the result of overlaying URL + localStorage prefs
// on top of the base spec. Handed back to onResult so the surface can
@@ -62,10 +72,20 @@ export interface BarState {
shape?: RenderShape;
sort?: "date_asc" | "date_desc";
density?: "comfortable" | "compact";
// Inbox (t-paliad-249)
unread_only?: boolean;
inbox_focus?: InboxFocus;
}
export interface TimeOverlay {
horizon: "next_7d" | "next_30d" | "next_90d" | "past_7d" | "past_30d" | "past_90d" | "any" | "all" | "custom";
// Mirrors internal/services/filter_spec.go TimeHorizon. t-paliad-248
// added the symmetric 1d / 14d / all chips on each side; the union
// here is the wire-shape the URL codec parses and the picker emits.
horizon:
| "next_1d" | "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all"
| "past_1d" | "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all"
| "any" | "all" | "custom";
from?: string; // ISO 8601 — only when horizon === "custom"
to?: string;
}

View File

@@ -18,7 +18,12 @@ describe("filter-bar/url-codec", () => {
});
test("time horizon round-trips", () => {
for (const h of ["next_7d", "next_30d", "next_90d", "past_30d", "past_90d", "any", "all"] as const) {
// Includes the t-paliad-248 symmetric additions (1d / 14d / all on each side).
for (const h of [
"next_1d", "next_7d", "next_14d", "next_30d", "next_90d", "next_all",
"past_1d", "past_7d", "past_14d", "past_30d", "past_90d", "past_all",
"any", "all",
] as const) {
expect(roundTrip({ time: { horizon: h } })).toEqual({ time: { horizon: h } });
}
});
@@ -99,4 +104,28 @@ describe("filter-bar/url-codec", () => {
params.set("density", "huge");
expect(parseBar(params)).toEqual({});
});
// t-paliad-249 — inbox axes
test("unread_only round-trips both states", () => {
expect(roundTrip({ unread_only: true })).toEqual({ unread_only: true });
expect(roundTrip({ unread_only: false })).toEqual({ unread_only: false });
});
test("unread_only undefined stays out of the URL", () => {
const params = new URLSearchParams();
encodeBar({}, params);
expect(params.has("unread")).toBe(false);
});
test("inbox_focus round-trips for non-default values", () => {
for (const f of ["genehmigungen", "plus_termine", "plus_fristen"] as const) {
expect(roundTrip({ inbox_focus: f })).toEqual({ inbox_focus: f });
}
});
test("inbox_focus alles is omitted (it's the default)", () => {
const params = new URLSearchParams();
encodeBar({ inbox_focus: "alles" }, params);
expect(params.has("focus")).toBe(false);
});
});

View File

@@ -9,7 +9,7 @@
// Empty / default values are NOT written — the URL stays clean for
// users who don't tweak. The page's base spec is the implicit baseline.
import type { BarState, TimeOverlay, ProjectOverlay } from "./types";
import type { BarState, TimeOverlay, ProjectOverlay, InboxFocus } from "./types";
const PERSONAL_PROJECT_SENTINEL = "personal";
@@ -108,6 +108,16 @@ export function parseBar(params: URLSearchParams, ns?: string): BarState {
const density = params.get(k("density"));
if (density === "comfortable" || density === "compact") out.density = density;
// inbox (t-paliad-249)
const unread = params.get(k("unread"));
if (unread === "0") out.unread_only = false;
else if (unread === "1") out.unread_only = true;
const focus = params.get(k("focus"));
if (focus === "genehmigungen" || focus === "plus_termine" || focus === "plus_fristen" || focus === "alles") {
out.inbox_focus = focus as InboxFocus;
}
return out;
}
@@ -127,6 +137,7 @@ export function encodeBar(state: BarState, params: URLSearchParams, ns?: string)
"pe_kind",
"tl_status", "tl_track",
"shape", "sort", "density",
"unread", "focus",
]) {
params.delete(k(key));
}
@@ -168,16 +179,31 @@ export function encodeBar(state: BarState, params: URLSearchParams, ns?: string)
if (state.shape) params.set(k("shape"), state.shape);
if (state.sort) params.set(k("sort"), state.sort);
if (state.density) params.set(k("density"), state.density);
// inbox (t-paliad-249). unread_only is tri-state in BarState (undefined
// means "page default"); we only write a key when the user has flipped
// it explicitly so the URL stays clean for the default landing state.
if (state.unread_only === false) params.set(k("unread"), "0");
else if (state.unread_only === true) params.set(k("unread"), "1");
if (state.inbox_focus && state.inbox_focus !== "alles") {
params.set(k("focus"), state.inbox_focus);
}
}
function parseHorizon(s: string): TimeOverlay["horizon"] | null {
switch (s) {
case "next_1d":
case "next_7d":
case "next_14d":
case "next_30d":
case "next_90d":
case "next_all":
case "past_1d":
case "past_7d":
case "past_14d":
case "past_30d":
case "past_90d":
case "past_all":
case "any":
case "all":
case "custom":

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,37 +6,45 @@ import type { FilterSpec, RenderSpec, SystemView, ViewRunResult } from "./views/
import { renderListShape } from "./views/shape-list";
import { openApprovalEditModal } from "./components/approval-edit-modal";
// /inbox client — t-paliad-163 universal-filter migration.
// /inbox client — t-paliad-249 unified inbox feed.
//
// The bar owns every axis the old tab UI exposed plus more:
// - approval_viewer_role: "Zur Genehmigung" / "Eigene Anfragen" /
// "Alle sichtbaren" (collapses the legacy two-tab UI per Q4 lock-in)
// - approval_status: chip cluster (default: pending)
// - approval_entity_type: chip pair (Frist / Termin)
// - time: chip cluster (Any default)
// - density: comfortable / compact
// - sort: date asc / desc
// The bar exposes:
// - inbox_focus: coarse Alles / Genehmigungen / +Termine / +Fristen
// - unread_only: Nur ungelesen / Alle (default: ungelesen)
// - time: last 30 days default; chip cluster + custom range
// - project: single-select autocomplete from visible projects
// - approval_viewer_role: Zur Genehmigung / Eigene / Alle sichtbaren
// - approval_status / approval_entity_type / project_event_kind: power-user overrides
// - sort / density: newest first default
//
// Row rendering: shape-list.ts with row_action="approve" stamps the
// inbox markup (entity title, diff, approve/reject/revoke buttons).
// We wire action click handlers in onResult and refresh through the
// bar handle.
// Row rendering: shape-list.ts with row_action="inbox" dispatches per
// row.kind. Approval rows keep approve/reject/revoke; project_event
// rows render compact with an Öffnen link.
const INBOX_AXES: AxisKey[] = [
"inbox_focus",
"unread_only",
"time",
"project",
"approval_viewer_role",
"approval_status",
"approval_entity_type",
"density",
"project_event_kind",
"sort",
"density",
];
// Last paint's newest row timestamp — used to pin mark-all-seen so a
// second tab can't race the cursor past items the user hasn't seen.
let newestVisibleAt: string | null = null;
let bar: BarHandle | null = null;
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
applyLegacyTabRedirect();
wireMarkAllSeen();
void hydrate();
});
@@ -105,15 +113,25 @@ function paint(
if (!result.rows || result.rows.length === 0) {
results.innerHTML = "";
empty.style.display = "";
empty.textContent = t("approvals.empty.pending_mine");
empty.textContent = t("inbox.empty.feed");
newestVisibleAt = null;
void maybeShowAdminNudge();
return;
}
hideAdminNudge();
empty.style.display = "none";
// Remember the newest timestamp so mark-all-seen can pin the cursor
// to it (race-safety: a second tab adding a row between this paint
// and the click won't get wiped out).
newestVisibleAt = result.rows.reduce<string | null>((acc, r) => {
if (!acc) return r.event_date;
return r.event_date > acc ? r.event_date : acc;
}, null);
// shape-list.ts honours render.list.row_action — InboxSystemView's
// RenderSpec sets row_action="approve" so we get the inbox markup.
// RenderSpec sets row_action="inbox" so we get the unified dispatch
// (approval rows + project_event rows).
renderListShape(results, result.rows, render);
// Wire action handlers on the freshly stamped DOM. The action
@@ -122,6 +140,38 @@ function paint(
wireApprovalActions(results);
}
// wireMarkAllSeen wires the page-header "Alles als gelesen markieren"
// button. POSTs the newest visible row's timestamp as `up_to` so a
// stale second tab can't rewind anyone else's cursor; on success the
// bar refreshes (rows newer than now disappear under unread_only) and
// the sidebar badge re-counts.
function wireMarkAllSeen(): void {
const btn = document.getElementById("inbox-mark-all-seen") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", async () => {
btn.disabled = true;
try {
const body = newestVisibleAt ? JSON.stringify({ up_to: newestVisibleAt }) : "{}";
const r = await fetch("/api/inbox/mark-all-seen", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body,
});
if (!r.ok) {
alert(t("approvals.error.internal"));
return;
}
await bar?.refresh();
await refreshInboxBadge();
} catch (_e) {
alert("Network error");
} finally {
btn.disabled = false;
}
});
}
function wireApprovalActions(host: HTMLElement): void {
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
const action = btn.dataset.action as

View File

@@ -93,12 +93,13 @@ export function routeNameFor(pathname: string): string {
if (/^\/projects\/[^/]+\/settings/.test(pathname)) return "projects.settings";
if (/^\/deadlines\/[^/]+$/.test(pathname)) return "deadlines.detail";
if (pathname === "/deadlines/new") return "deadlines.new";
if (pathname === "/deadlines/calendar") return "deadlines.calendar";
if (pathname === "/deadlines") return "deadlines.list";
if (/^\/appointments\/[^/]+$/.test(pathname)) return "appointments.detail";
if (pathname === "/appointments/new") return "appointments.new";
if (pathname === "/appointments/calendar") return "appointments.calendar";
if (pathname === "/appointments") return "appointments.list";
// /deadlines/calendar + /appointments/calendar are 301 redirects to
// /events?type=…&view=calendar since t-paliad-224 — the client never
// sees those pathnames any more.
if (pathname === "/agenda") return "agenda";
if (pathname === "/inbox") return "inbox";
if (pathname === "/dashboard" || pathname === "/") return "dashboard";
@@ -108,7 +109,7 @@ export function routeNameFor(pathname: string): string {
if (pathname === "/links") return "links";
if (pathname === "/downloads") return "downloads";
if (pathname === "/checklists") return "checklists";
if (pathname.startsWith("/tools/fristenrechner")) return "tools.fristenrechner";
if (pathname.startsWith("/tools/procedures")) return "tools.procedures";
if (pathname.startsWith("/tools/kostenrechner")) return "tools.kostenrechner";
if (pathname.startsWith("/tools/gebuehrentabellen")) return "tools.gebuehrentabellen";
if (pathname === "/events") return "events";

Some files were not shown because too many files have changed in this diff Show More