Compare commits

...

147 Commits

Author SHA1 Message Date
mAi
a2e1d023e4 docs(t-paliad-207): close-out assessment — verdict (A) DONE
Read-only audit of the t-paliad-207 surface per paliadin's 2026-05-20
re-engage instruction. Six commits shipped under this task are now
merged. Two larger follow-ups (m/paliad#39 youpc-laws ingest + #41 DE
combined timeline) are filed with concrete scope. Remaining tail is
optional polish, best handled as discrete issues rather than a parked
inventor.
2026-05-20 10:46:07 +02:00
mAi
faaa98cbce feat(t-paliad-207): notes toggle — compact ⓘ hover by default, expand inline when "Hinweise anzeigen" is checked
m's ask 2026-05-18 18:21: per-rule descriptive notes ("Innerhalb von 1
Monat ab Zustellung der Klage. Drei mögliche Gründe…") are noisy in the
default timeline view. Make them optional — small ⓘ icon next to the
meta line by default with full text on hover; switch in the toggle bar
expands them inline when the user wants the wall of text.

**Renderer (verfahrensablauf-core.ts)** — `CardOpts.showNotes?: boolean`
gates two render paths:
- on  → `<div class="timeline-notes">…</div>` (today's behaviour)
- off → `<span class="timeline-note-hint" tabindex=0 role=note
        aria-label=… title=…>ⓘ</span>` inside the meta line (browser
        title for hover, aria-label for screen readers, tabindex for
        keyboard accessibility)

Pass-through wired in renderColumnsBody too so the columns view picks
up the toggle equally.

**Toggle UI** — added a checkbox row to the existing `fristen-view-toggle`
bar on both /tools/verfahrensablauf and /tools/fristenrechner:
"Hinweise anzeigen" / "Show details". CSS modifier
`.fristen-notes-option` separates it from the radio view-picker with
a leading border-left.

**State** — `paliad.fristen.notes-show` localStorage key (shared
between both pages so the preference carries across), default off,
re-render on flip.

i18n: 1 new key DE + EN (deadlines.notes.show). Build clean.
2026-05-18 18:18:19 +02:00
mAi
1e7f2ee5c3 feat(t-paliad-207): mig 102 — track-aware sequence reshuffle for upc.inf.cfi (infringement → revocation → amendment)
m's ask 2026-05-18 18:08: 'the infringement parts (like Replik) should
show above the part for the revocation (Erwiderung Nichtigkeitswider-
klage)'. Three tracks (infringement / revocation / amendment) coexist
on upc.inf.cfi once with_ccr / with_amend are set. They share tied
calendar dates because R.29/R.30/R.32 all key off the SoD or its
descendants. Current sequence_orders (post-mig 100) interleave them
arbitrarily; user sees Erwiderung-zur-CCR before Replik even though
Replik is the infringement-side response to the same triggering event.

**Re-sequencing** keeps the existing soc=0, prelim=5, sod=10 head and
the interim=40 / oral=50 / decision=60 / cost_app=70 / appeal_spawn=80
tail untouched. The 10 reshuffled rules move into a track-aware
arrangement:

  10-19 infringement: sod=10, reply=12, rejoin=14
  20-29 revocation:   ccr=20, def_to_ccr=22, reply_def_ccr=24, rejoin_reply_ccr=26
  30-39 amendment:    app_to_amend=30, def_to_amend=32, reply_def_amd=34, rejoin_amd=36

Tied-date ordering after the reshuffle:
  D+3mo: sod(10), ccr(20)                            — SoD then its CCR
  D+5mo: reply(12), def_to_ccr(22), app_to_amend(30) — inf → rev → amd
  D+7mo: reply_def_ccr(24), def_to_amend(32)         — rev → amd
  D+8mo: rejoin_reply_ccr(26), reply_def_amd(34)     — rev → amd

**Two-phase swap** — every reshuffled rule first parks at sequence
1000+number, then jumps to its final value. Prevents transient
sequence-collisions if Postgres evaluates UPDATEs in parallel within
the same statement. Each UPDATE is keyed by submission_code AND the
SOURCE sequence_order, so re-apply is a no-op.

audit_reason set_config at top per mig 099 hotfix pattern. Counter
re-checked.
2026-05-18 18:13:40 +02:00
mAi
cbf4fe6d93 fix(t-paliad-207): mig 101 — strip rule cite from Einspruch names + flip CCR priority informational→optional
Two corrections to mig 100's merged-state:

1. **CCR priority informational → optional**. m's correction
   2026-05-18 18:01. The fermi amend (e8d658a) flipping this didn't
   land — paliadin merged the pre-amend c10f8cf. The Nichtigkeits-
   widerklage is a substantive defensive choice, rendered unchecked
   in the save modal so user opts in if they want to track it.

2. **Strip rule-cite brackets from Einspruch names**. m's
   correction 2026-05-18 18:08. Every other rule name in the corpus
   carries the act-name without a parenthetical rule cite — the two
   Einspruch rules were outliers:
     upc.inf.cfi.prelim  'Einspruch (R. 19 VerfO)'             → 'Einspruch'
     upc.rev.cfi.prelim  'Einspruch (R. 19 i.V.m. R. 46 VerfO)' → 'Einspruch'
   plus EN equivalents. The legal_source / rule_code columns already
   carry the citation in the meta line, so the name stays clean.

Idempotent: priority UPDATE guarded on 'informational'; name UPDATEs
guarded on the current parenthetical-bearing values. audit_reason
set_config at top per mig 099 hotfix pattern. Counter re-checked
(mig 100 just merged; next is 101).
2026-05-18 18:12:16 +02:00
mAi
4ddcd28d26 Merge: t-paliad-207 — mig 100 (upc.inf.cfi.ccr informational rule, makes CCR filing visible on timeline when with_ccr is set) 2026-05-18 17:46:55 +02:00
mAi
c10f8cff70 feat(t-paliad-207): mig 100 — make CCR filing visible in calc output when with_ccr is set
m's observation 2026-05-18 (interactive session): toggling "Mit Nichtig-
keitswiderklage" surfaces the response rules (def_to_ccr, reply, rejoin,
…) but the triggering event itself — the act of filing the CCR — is
invisible. Per R.25 VerfO the CCR is filed AS PART OF the Statement of
Defence with the same 3-month deadline, so the corpus author (mig 028)
skipped it. UX problem: users see consequences without the cause.

**New rule** `upc.inf.cfi.ccr`:
- parent: `upc.inf.cfi.soc` (root anchor, same as SoD)
- duration: 3 months (same as SoD — no separate deadline)
- party: defendant
- legal_source: `UPC.RoP.25.1`
- condition_expr: `{"flag":"with_ccr"}`
- priority: **`informational`** — renders as a notice card, no save
  action, no duplicate write into paliad.deadlines (the SoD's row
  already covers the calendar date).

**Sequence reshuffle** — inserting at sequence_order=11 pushes
def_to_ccr 11→12 and app_to_amend 12→13 so the timeline reads
SoD → CCR → def_to_ccr → app_to_amend (cause before effect).

**Idempotency** — INSERT uses NOT EXISTS keyed on
(proceeding_type_id, submission_code, lifecycle_state='published');
UPDATEs are guarded by the source sequence_order so re-apply is a
no-op. audit_reason set via set_config('paliad.audit_reason', ...,
true) at the top per the mig 099 hotfix pattern.

Migration counter re-checked against origin/main + ls
internal/db/migrations/ | tail before picking 100 — per the friction
note from msg 2016.

Build hygiene: go build/vet clean; bun run build clean (no i18n
changes). Down.sql restores both sequence values + DELETEs the new
row. Branch: mai/fermi/interactive-session.
2026-05-18 17:46:08 +02:00
mAi
5ae1e5ad01 Merge: t-paliad-211 — Custom Views polish (calendar week/day + click-drill + aligned grid, timeline zoom + lane-label clamping, filter-bar transfer) 2026-05-18 17:45:44 +02:00
mAi
06c826a818 feat(t-paliad-211): mount filter-bar on Custom Views runner
The /views/{slug} runner now mounts the same FilterBar primitive that
/events and /inbox use. The saved view's filter_spec becomes the bar's
baseline, axes are picked client-side per the view's data sources so a
deadline-only view exposes deadline_status, an approval-driven view
exposes approval_viewer_role + approval_status + approval_entity_type,
etc. Universal axes (time, personal_only, sort) always render.

Per-session tweaks overlay the saved baseline without mutating the
stored row; the URL round-trips state through the bar's existing codec
so deep-links share the active narrow. "Speichern als Sicht" stays
available on user-owned views so a tweaked narrow can be forked into a
new saved view.

Shape axis is intentionally excluded from the bar — the existing
top-of-page shape chip cluster (list / cards / calendar / timeline)
already plays that role and switching now mutates the cached render
spec without re-hitting the substrate.

Empty-state hint reuses the saved filter summary as before; the bar's
onResult handler hides all shape hosts when the rows array is empty.
2026-05-18 17:45:30 +02:00
mAi
8020cb2ddb feat(t-paliad-211): timeline shape adds zoom toolbar and clamped lane labels
shape-timeline-cv now wraps the chart host with a toolbar carrying
+/- zoom buttons and 1y/2y/all chips. Active zoom persists in the URL as
?tl_zoom=1y|2y|all (URL > render-spec range_preset > "1y" default), so
saved views still control the initial zoom but per-session navigation is
deep-linkable.

shape-timeline-chart paints lane labels inside a foreignObject containing
an HTML <div> with overflow:hidden + text-overflow:ellipsis + a title
attribute carrying the full text. Long project names no longer bleed
across the chart canvas; hover reveals the full label.

i18n: views.timeline.zoom.{label,in,out,1y,2y,all} (DE+EN).
2026-05-18 17:45:30 +02:00
mAi
a5b94739b4 feat(t-paliad-211): calendar shape adds week + day views and aligned grid
shape-calendar now renders month, week, and day views with a chip switcher
above the grid. Active view + anchor date persist in the URL as
?cal_view=month|week|day&cal_date=YYYY-MM-DD so per-view navigation is
deep-linkable.

Month view: weekday header row now lives inside the same CSS grid as the
day cells (one shared grid-template-columns: repeat(7,1fr)), so day labels
no longer drift relative to the columns below. Day-number is a button
that switches to day view scoped to that date; +N more pill also drills
to day view. Individual row pills route to /deadlines/{id} /
/appointments/{id} via inner anchors with click stopPropagation so they
don't trigger the day-drill.

Week view: 7 columns, full row list per column (no 3-row cap), per-column
vertical scroll for busy days.

Day view: single chronological list. Prev/next-day nav reuses the same
toolbar; week/day views also expose a "Zurück zum Monat" link.

i18n: cal.view.month|week|day + per-view prev/next labels +
cal.day.back_to_month + cal.day.open_day + cal.day.no_entries (DE+EN).
2026-05-18 17:45:30 +02:00
mAi
283c9e8f67 fix(mig 099): add missing audit_reason wrapper
Mig 099 (drop_with_po_flag) crash-looped paliad.de prod immediately
after deploy: the mig 079 trigger on paliad.deadline_rules raises
EXCEPTION 'audit reason required' on UPDATE when paliad.audit_reason
is unset. Original file (fermi, t-paliad-207) only had the UPDATE,
no set_config wrapper.

Patch: prepend the standard 'SELECT set_config(paliad.audit_reason,
...)' at the top so the trigger sees the reason. Same shape as every
other migration that mutates deadline_rules.

Manual recovery already applied via head MCP — UPDATE'd the 2 rows
with audit_reason set, marked tracker version=99 dirty=false,
force-restarted the container which booted clean. This commit aligns
the in-repo file with the recovered prod state. Idempotent: the
WHERE clause matches only rows that still carry with_po, so re-apply
is a no-op.
2026-05-18 17:33:01 +02:00
mAi
dece61107b Merge: t-paliad-207 — fermi's polish session (jurisdiction prefix + trigger-event label + flag rows + youpc rule links + DE sub-group headers + R.19 Einspruch as always-available; mig 099 NULLs with_po flag on RoP.019.1 rows) 2026-05-18 17:29:43 +02:00
mAi
8bf1626997 fix(mig): renumber drop_with_po_flag 098 → 099 (number collision with submission_codes_prefix_and_rename) 2026-05-18 17:29:21 +02:00
mAi
7f49851abf fix(t-paliad-207): drop with_po flag — R.19 Einspruch is always available, not flag-gated (mig 098)
m's correction 2026-05-18: the R.19 Einspruch (preliminary objection)
should not be flag-gated. It's an always-available optional submission
the defendant can make once the SoC is served — same logic as the
appeal-spawn rules in t-paliad-203 F2.3 ("the appeal is always a
possibility"). Removing the gate makes the row a normal optional rule:
priority='optional' (unchanged, set by mig 095) gives the save-modal
the existing pre-uncheck behaviour without a separate checkbox.

**Migration 098** (idempotent): NULLs condition_expr on the two RoP.019.1
rows pinned by proceeding code (`upc.inf.cfi` + `upc.rev.cfi`). Re-apply
is a no-op via the WHERE clause matching the live shape. Live DB row
state will sync when Dokploy applies the migration on next deploy — no
raw prod-write this turn (lesson from the previous shift's friction note).

**Frontend cleanup** — removes the two flag rows added to
verfahrensablauf.tsx + fristenrechner.tsx in the parent t-paliad-207
commit (inf-po-flag-row, rev-po-flag-row), the readFlags()/calculate()
push branches, the syncFlagRows() show/hide entries, and the change
listeners. Drops the 4 i18n keys (deadlines.flag.inf_po + rev_po,
DE + EN). Bun build clean: 2417 keys (was 2419, -2 keys × 2 langs).

Branch: mai/fermi/interactive-session @ third commit on top of Path A.
2026-05-18 17:29:14 +02:00
mAi
518b2d9617 feat(t-paliad-207): DE proceeding picker — sub-group headers + parallel labels (Path A)
m's 2026-05-18 ask: the 5 DE proceeding tiles followed three different
labelling conventions ("Verletzungsklage (LG)" / "Berufung OLG" /
"Nichtigkeitsverfahren" — instance in brackets vs not vs not even
present). Path A reshapes both the picker and the labels so a user
scanning "Deutsche Gerichte" sees the type→instance hierarchy at a
glance and every tile reads <court> (<procedural role>) in parallel.

**Picker structure (verfahrensablauf.tsx + fristenrechner.tsx):**
Inside the existing `<.proceeding-group data-forum="de">` block, the
single flat row of 5 tiles is now two sub-groups with mixed-case h5
headings — Verletzungsverfahren over LG/OLG/BGH, Nichtigkeitsverfahren
over BPatG/BGH. DE_TYPES split into DE_INF_TYPES (3) + DE_NULL_TYPES (2)
in both page shells.

**Labels (i18n.ts, DE + EN parallel):**
| Code           | Old DE                       | New DE                |
|---             |---                           |---                    |
| de.inf.lg      | Verletzungsklage (LG)        | LG (1. Instanz)       |
| de.inf.olg     | Berufung OLG                 | OLG (Berufung)        |
| de.inf.bgh     | Revision/NZB BGH             | BGH (Revision / NZB)  |
| de.null.bpatg  | Nichtigkeitsverfahren        | BPatG (1. Instanz)    |
| de.null.bgh    | Berufung BGH (Nichtigk.)     | BGH (Berufung)        |

Two new i18n keys carry the sub-group headings:
- deadlines.de.group.inf  — "Verletzungsverfahren" / "Infringement proceedings"
- deadlines.de.group.null — "Nichtigkeitsverfahren" / "Nullity proceedings"

**CSS (global.css):**
New `.proceeding-subgroup` + `.proceeding-subgroup-heading` rules,
co-located with `.proceeding-group h4`. Sub-heading sits one tier below
the h4 (mixed-case, no upper-tracking) so the two-level hierarchy reads
at a glance.

**What this does NOT do** — the "one long sequence" combined-timeline
behaviour (m's same ask, larger scope: spawn rules + de-duplication +
multi-instance UI) is filed as m/paliad#41 and stays a separate
delivery. Per-instance tiles keep their meaning either way.

Build hygiene: go build/vet clean; bun run build clean (2419 keys, +2).
2026-05-18 17:29:14 +02:00
mAi
4131d2e2a6 feat(t-paliad-207): Verfahrensablauf + Fristenrechner polish (jurisdiction prefix, trigger-event, flag rows, rule links, R.19 label)
Five intertwined fixes m surfaced in the interactive session:

1. **Jurisdiction prefix on the picked proceeding** — the collapsed
   summary chip and the result header now read "UPC Verletzungsverfahren"
   / "DE Verletzungsklage (LG)" instead of the bare proceeding name.
   Disambiguates the 4 redundancies in the corpus once the picker
   collapses. Driven by .proceeding-group[data-forum] which is already
   on every group.

2. **Trigger Event label = root rule** — step 2's "Auslösendes Ereignis"
   line now shows the first event in the proceeding (e.g. Klageerhebung,
   Nichtigkeitsklage) instead of the proceeding name. Populated from
   the calc response (isRootEvent=true) on every render; em-dash
   placeholder while step 3 hasn't rendered yet. lang-change keeps it
   coherent.

3. **Flag rows on /tools/verfahrensablauf** — Slice 1 of t-paliad-179
   stripped the with_ccr / with_amend / with_cci toggles when it lifted
   the shared renderer; they never came back. Lifted the 4 existing
   rows from fristenrechner.tsx plus 2 new with_po rows (RoP 19.1
   preliminary objection, mig 095) — same wiring + show/hide rules on
   both surfaces. with_amend stays nested under with_ccr on upc.inf.cfi
   (R.30 only with a CCR).

4. **Rule references → youpc.org/laws links** — new
   BuildLegalSourceURL(src) maps the structured legal_source code to
   the youpc permalink for the UPC corpus (UPCRoP / UPCA / UPCS today;
   39 of 91 active rules carry UPC.RoP.* and now link). DE/EPA/EU
   bodies have no youpc home yet and render as plain display text —
   filed as m/paliad#39. Wired through UIDeadline.LegalSourceDisplay +
   LegalSourceURL so deadlineCardHtml can render <a target="_blank"
   rel="noopener"> when the URL is set.

5. **R.19 label: "Vorab-Einrede" → "Einspruch"** — m's correction. DE
   only (EN canonical UPC RoP term stays "Preliminary objection").
   Client-side change only — i18n + JSX fallbacks. The matching DB
   rename on the two rule-name rows folds into joule's broader mig 097
   (legal-citation backfill, t-paliad-208 follow-up). The live UPDATE
   applied during the session is captured under that audit reason; the
   no-op when joule's mig re-applies is harmless.

Build hygiene:
- go build ./... + go vet ./... clean
- new test TestBuildLegalSourceURL covers UPC corpus + DE/EPA/EU
  fall-through + edge cases (empty input, malformed source)
- bun run build clean (2417 i18n keys total)

Rebased on origin/main @ d126913 (ohm's submission_code rename
workstream B) — no conflicts in this commit's surface area.

Branch: mai/fermi/interactive-session. NOT self-merged.
2026-05-18 17:29:14 +02:00
mAi
d507db22a7 fix(mig 098): exempt orphan rules from §6.2 NULL-check (proceeding_type_id IS NULL)
Recovery during the prod outage uncovered a second mig 098 bug: §6.2
assertion '0 NULL submission_code on active+published rows' counted
the 77 orphan rules (proceeding_type_id IS NULL, cross-cutting
Wiedereinsetzung / Schriftsatznachreichung pattern) and rejected the
migration. Patch: gate the NULL count on `proceeding_type_id IS NOT
NULL` so orphans pass through. Migration already applied to prod via
manual recovery with the same patched assertion; this commit aligns
the in-repo file with the deployed state.
2026-05-18 17:28:19 +02:00
mAi
a0a3ec32a3 fix(mig 098): relax submission_code shape regex to allow digits in suffix
Mig 098 (t-paliad-209, ohm) crash-looped paliad.de prod for ~2h: §6.1
assertion regex `^[a-z_]+\.[a-z_]+\.[a-z_]+\.[a-z_]+(\..*)?$` rejects
EPA rule codes that carry the statutory rule number in the suffix —
e.g. `epa.opp.boa.r106`, `epa.grant.exa.r71_3`, `epa.opp.opd.r116`,
`epa.opp.opd.r79_further`, `epa.opp.boa.entsch2`, `epa.opp.boa.r116`.
Migration's UPDATE step succeeds against these rows; the transactional
assertion blows them up; rollback leaves the migration tracker dirty
at version 98 and the container refuses to start.

Patch: allow `[a-z_0-9]` per segment instead of `[a-z_]` in both the
SQL assertion (mig 098 §6.1) and the matching Go shape regex
(submission_codes_shape_test.go). Same change in both spots so the
runtime sanity test stays aligned with the SQL invariant.

Manual recovery already applied: forced
`paliad.paliad_schema_migrations.version` back to 97 with `dirty=false`
so the next deploy retries mig 098 from scratch against the patched
file. No data state changed (mig 098 ran inside a transaction and
fully rolled back — snapshot table, prefix UPDATE, and column rename
all reverted).

go build ./... clean. TestProceedingCodeShapeRegexStandalone green.
2026-05-18 16:52:38 +02:00
mAi
f9d32a90e7 Merge: t-paliad-207 — fermi's polish (jurisdiction prefix, trigger-event label, flag rows, youpc rule links, R.19 Einspruch label) 2026-05-18 16:37:54 +02:00
mAi
a18b825bee feat(t-paliad-207): Verfahrensablauf + Fristenrechner polish (jurisdiction prefix, trigger-event, flag rows, rule links, R.19 label)
Five intertwined fixes m surfaced in the interactive session:

1. **Jurisdiction prefix on the picked proceeding** — the collapsed
   summary chip and the result header now read "UPC Verletzungsverfahren"
   / "DE Verletzungsklage (LG)" instead of the bare proceeding name.
   Disambiguates the 4 redundancies in the corpus once the picker
   collapses. Driven by .proceeding-group[data-forum] which is already
   on every group.

2. **Trigger Event label = root rule** — step 2's "Auslösendes Ereignis"
   line now shows the first event in the proceeding (e.g. Klageerhebung,
   Nichtigkeitsklage) instead of the proceeding name. Populated from
   the calc response (isRootEvent=true) on every render; em-dash
   placeholder while step 3 hasn't rendered yet. lang-change keeps it
   coherent.

3. **Flag rows on /tools/verfahrensablauf** — Slice 1 of t-paliad-179
   stripped the with_ccr / with_amend / with_cci toggles when it lifted
   the shared renderer; they never came back. Lifted the 4 existing
   rows from fristenrechner.tsx plus 2 new with_po rows (RoP 19.1
   preliminary objection, mig 095) — same wiring + show/hide rules on
   both surfaces. with_amend stays nested under with_ccr on upc.inf.cfi
   (R.30 only with a CCR).

4. **Rule references → youpc.org/laws links** — new
   BuildLegalSourceURL(src) maps the structured legal_source code to
   the youpc permalink for the UPC corpus (UPCRoP / UPCA / UPCS today;
   39 of 91 active rules carry UPC.RoP.* and now link). DE/EPA/EU
   bodies have no youpc home yet and render as plain display text —
   filed as m/paliad#39. Wired through UIDeadline.LegalSourceDisplay +
   LegalSourceURL so deadlineCardHtml can render <a target="_blank"
   rel="noopener"> when the URL is set.

5. **R.19 label: "Vorab-Einrede" → "Einspruch"** — m's correction. DE
   only (EN canonical UPC RoP term stays "Preliminary objection").
   Client-side change only — i18n + JSX fallbacks. The matching DB
   rename on the two rule-name rows folds into joule's broader mig 097
   (legal-citation backfill, t-paliad-208 follow-up). The live UPDATE
   applied during the session is captured under that audit reason; the
   no-op when joule's mig re-applies is harmless.

Build hygiene:
- go build ./... + go vet ./... clean
- new test TestBuildLegalSourceURL covers UPC corpus + DE/EPA/EU
  fall-through + edge cases (empty input, malformed source)
- bun run build clean (2417 i18n keys total)

Rebased on origin/main @ d126913 (ohm's submission_code rename
workstream B) — no conflicts in this commit's surface area.

Branch: mai/fermi/interactive-session. NOT self-merged.
2026-05-18 15:58:26 +02:00
mAi
7d275cac6b Merge: t-paliad-210 — mig 097 legal-citation backfill (huygens HIGH/MED + m's FLAG walk-through) 2026-05-18 15:54:47 +02:00
mAi
21727bf1ca feat(db): mig 097 — legal-citation backfill (huygens HIGH/MED + m's FLAG walk-through)
t-paliad-210 / paliadin-head msg 2002 + 2006. Applies huygens's HIGH/MED
proposals from docs/proposals/legal-citation-backfill-2026-05-18.md
(commit 391be09) plus m's FLAG walk-through:

  § 1  Easy wins                — 6 rows (rule_code only).
  § 2  HIGH/MED proceeding-typed — 15 rows.
  § 3  HIGH/MED orphans         — 47 rows.
  § 4  FLAG-A dedup (clean only) — 1 canonical fill + 3 archives
                                  (Wiedereinsetzung §123-PatG twin,
                                  Berufungsschrift, Berufungsbegründung).
                                  Mängelbeseitigung 6× and Beginn-
                                  Hauptsache 2× DEFERRED pending m's call
                                  on distinct-context rule_codes[].
  § 5  FLAG-B court-scheduled    — 26 rows. RoP.111 / RoP.118 / § 285 ZPO
                                  / § 300 ZPO / § 47 PatG etc.
  § 6  FLAG-C/D rubber-stamp     — 5 rows. RoP.52 / RoP.235.1 / § 273 ZPO.
  § 7  FLAG-E service triggers   — 6 rows. § 317 ZPO / § 99 / 47 / 79 PatG
                                  / R. 111 EPÜ.
  § 8  FLAG-F combined-pleading  — 5 rows via rule_codes[] multi-cite.
  § 9  FLAG-G/H/I + RoP.271.b    — 13 rows. Patentänderung INF/REV split,
                                  H sub-paragraphs, RoP.069 by analogy,
                                  + RoP.271.b secondary cite on 5 UPC
                                  initial submissions.
  § 10 R.19 label rename         — defensive backstop for fermi's prod
                                  write (t-paliad-207 consolidated).
  § 11 RoP.49.1 → RoP.049.1      — padding normalization on rev.defence.

FLAG-J 3 rows (d124c95b / 002c2ba7 / 902cc5d5) left NULL for m's
/admin/rules pickup. 11 rows total stay NULL post-mig (3 FLAG-J + 8
deferred dedup).

Snapshot table paliad.deadline_rules_pre_097 preserves pre-mig state
including the distinct rule_codes[] on the deferred Mängelbeseitigung +
Beginn-Hauptsache sets.

Dry-run on supabase produced expected counts:
  null_count=11, old_outlier=0, new_padded=2

Idempotent: re-applying matches no rows. Audit-trail through mig 079
trigger via set_config(paliad.audit_reason, ..., true).
2026-05-18 15:39:03 +02:00
mAi
d126913185 Merge: t-paliad-209 — workstream B (submission_code rename + prefix + Rechtsgrundlage column) 2026-05-18 15:11:52 +02:00
mAi
ea29165d2f feat(t-paliad-209): rename Code → Submission Code + add Rechtsgrundlage column
Workstream B frontend sweep — matches mig 098 + the Go sweep. The
/admin/rules surfaces now distinguish submission_code (the rule's
filing identifier within a proceeding, e.g. upc.inf.cfi.soc) from
rule_code (the legal citation, e.g. RoP.013.1).

Admin rules list (/admin/rules):
- Column header renamed "Code" → "Submission Code / Einreichung-Kennung"
- New "Rechtsgrundlage" column shows rule_code alongside the submission
  code; the old single-column fallback (rule_code || code) is gone.
- Filter-search placeholder updated to "Name, Submission Code,
  Rechtsgrundlage…"
- Rule interface: code → submission_code field.

Admin rules edit (/admin/rules/{id}/edit):
- f-code → f-submission-code; input is now read-only with a
  upc.inf.cfi.soc-style placeholder (consistent with the backend
  RulePatch which doesn't allow editing the submission code).
- Labels reframe rule_code as "Rechtsgrundlage (Kurzform)" and
  legal_source as "Rechtsgrundlage (Langform)" so the legal-citation
  pair is named consistently with the list column.
- Rule interface: code → submission_code field.

i18n: new keys admin.rules.col.submission_code,
admin.rules.col.legal_citation, admin.rules.edit.field.submission_code
in both DE + EN; old admin.rules.col.code + admin.rules.edit.field.code
removed.

bun run build clean.
2026-05-18 15:06:18 +02:00
mAi
bc5b3557d0 feat(t-paliad-209): rename DeadlineRule.Code → SubmissionCode across Go layer
Workstream B Go sweep — matches mig 098. Every place the deadline-rules
service reads/writes the per-rule identifier now uses the new column
name and the new struct field. Distinct from rule_code (legal citation)
and from proceeding_types.code (the proceeding's 3-segment code).

Touch points:
- models.DeadlineRule.Code → SubmissionCode (db + json tags renamed
  in lockstep — JSON contract `submission_code` is the new shape).
- deadline_rule_service: ruleColumns SELECT list updated.
- rule_editor_service: CreateRuleInput.Code → SubmissionCode (json tag
  too), INSERT + CloneAsDraft SELECT updated.
- projection_service: lookupRuleByCode → lookupRuleBySubmissionCode
  (SQL WHERE clause + error message); every r.Code / parent.Code /
  rule.Code / first.Code / src.rule.Code read renamed.
- fristenrechner: r.Code / prev.Code / rule.Code reads renamed in
  Calculate (parent-anchor + override-key + computed-by-code map) and
  in CalculateRule's LocalCode emission; the proceeding-code+submission-
  code resolver query uses `submission_code = $2`.
- event_trigger_service / deadline_calculator: r.Code reads renamed.

UIDeadline.Code (the calculator's wire response) is unchanged — that
field is a separate API contract pointing at the same value; renaming
it would force every frontend deadline-renderer through a contract
break that isn't part of this workstream.

Test fixtures updated to the new SubmissionCode field name; live-DB
tests updated to the post-mig-098 prefixed values (`inf.sod` →
`upc.inf.cfi.sod` etc.). New submission_codes_shape_test asserts
every active+published row matches the 4+-segment proceeding-prefixed
shape (sibling of TestProceedingCodeShape; mirrors mig 098 §6.1).

go build ./... clean. go test ./internal/... green.
2026-05-18 15:06:04 +02:00
mAi
bd2c7a217e feat(t-paliad-209): mig 098 prefix submission codes + rename code → submission_code
m's 2026-05-18 call (workstream B): the paliad.deadline_rules.code field
is a SUBMISSION identifier (the filing/event within a proceeding), not
the legal-citation rule code (which lives in rule_code / legal_source).
Two cleanups land in this migration:

1. DATA — prefix every existing submission code with its proceeding
   code so submission codes carry the full hierarchical shape:
       inf.soc       (on upc.inf.cfi)  → upc.inf.cfi.soc
       de_inf.klage  (on de.inf.lg)    → de.inf.lg.klage
       de_inf_bgh.revision (on de.inf.bgh) → de.inf.bgh.revision
   Idempotent: WHERE NOT LIKE pt.code || '.%' skips already-prefixed
   rows so re-running is a no-op.

2. SCHEMA — rename paliad.deadline_rules.code → submission_code so
   future devs don't conflate it with rule_code (legal citation) or
   proceeding_types.code. The rename is guarded by a column-existence
   check, idempotent on a second run.

Drops + recreates the deadline_search materialized view because its
SELECT bakes `dr.code AS rule_local_code` (mig 051 §4); the rebuild
sources from `dr.submission_code` and reproduces every index from mig
051 verbatim.

Backup snapshot table paliad.deadline_rules_pre_098 captures the rows
before the prefix step; serves as the audit anchor and the down's
source.

Hard assertions (§6) gate the migration on:
- every active+published row matches the 4+-segment proceeding-prefixed
  shape regex
- no NULL submission_code on active+published rows
- the column was actually renamed
2026-05-18 15:05:46 +02:00
mAi
edcf41d203 Merge: t-paliad-208 — legal-citation backfill proposal (huygens, doc only) 2026-05-18 14:57:25 +02:00
mAi
391be09b1e docs(t-paliad-208): legal-citation backfill proposal for 130 deadline_rules
Researcher draft for Workstream A — per-rule proposals for rule_code +
legal_source on the 130 active+published deadline_rules with rule_code IS
NULL. Grouped by proceeding (53 PT rows) and orphan-bucket (77 rows with
proceeding_type_id IS NULL).

~75 HIGH/MED proposals, ~47 FLAG entries pending m's call (court-set
event-markers, combined-pleading rows, ambiguous orphans, RoP
sub-paragraph spot-checks). Profiles the field convention from the 83
already-populated rows. READ-ONLY phase: no DB writes, no migration yet
— mig 097 follows once m signs off.

Side-fix candidate: normalize the one outlier RoP.49.1 -> RoP.049.1 on
rev.defence as part of mig 097.
2026-05-18 14:56:42 +02:00
mAi
d76b8a6c64 Merge: small UX — deadline-done confirm modal + cascade ändern i18n 2026-05-18 14:26:19 +02:00
mAi
061780dea5 fix(frontend): two small UX issues — deadline-done confirm + i18n the cascade "ändern"
1. /deadlines list ticking the complete-checkbox now goes through
   window.confirm() before firing PATCH /api/deadlines/{id}/complete.
   The deadline title is interpolated into the prompt so the user sees
   what they're closing. Matches the existing window.confirm() pattern
   used in projects-detail / admin-team / approvals-withdraw etc. —
   no custom modal layer.

2. The cascade row "ändern" button in the deadline calculator stayed
   in German on the EN side. data-i18n="deadlines.row.edit" was set
   correctly but applyTranslations() only runs at page init and on
   lang-toggle; the cascade re-renders on every state change without
   re-hydrating, so the static "ändern" fallback in the HTML stuck.
   Render the label via t() directly in the template — same pattern
   the rest of the cascade uses, no hydration dependency.

Both i18n keys land on both DE and EN sides (deadlines.complete.confirm
+ existing deadlines.row.edit). bun run build clean, 2414 keys.
2026-05-18 14:26:13 +02:00
mAi
b07702a095 Merge: t-paliad-206 — proceeding-code rename to lowercase dot-form (mig 096 + Go sweep + frontend sweep + taxonomy spec) 2026-05-18 12:14:38 +02:00
mAi
aa9e47fda9 feat(t-paliad-206): switch frontend to lowercase dot-form proceeding codes
Sweep of frontend/src/* for the proceeding-code rename landed by
mig 096. Same scope as the Go sweep — comments + literal string
codes substituted, plus the visible additions:

- fristenrechner.tsx / verfahrensablauf.tsx UPC_TYPES gain
  upc.ccr.cfi as a fourth UPC option ("Widerklage auf Nichtigkeit");
  it surfaces in the picker and renders the determinator routing
  notice from proceeding_mapping.ResolveCounterclaimRouting.
- i18n.ts deadlines.* keys renamed to mirror the new codes exactly
  (`deadlines.upc.inf.cfi`, …). DE + EN sides in sync.
- frontend/src/client/fristenrechner.ts fristenrechnerCodeToCascadeSegment
  rekeyed to new codes; upc.ccr.cfi shares the upc-inf kebab segment
  because the event_categories slug taxonomy is not renamed and ccr
  resolves to inf-rules anyway.
- client/views/verfahrensablauf-core.ts court-picker conditions
  rewritten against the new codes.

Bun build clean (i18n-keys.ts regenerated from the canonical map).
2026-05-18 12:13:39 +02:00
mAi
216abbfc98 feat(t-paliad-206): switch Go layer to lowercase dot-form proceeding codes
Sweeps internal/services + internal/handlers + internal/models to use
the new proceeding codes landed by mig 096. Stable Code* constants
live in internal/services/proceeding_mapping.go so a future rename
needs to touch one file.

Substantive changes:
- proceeding_mapping.go gains ResolveCounterclaimRouting() — the
  cascade resolver that routes upc.ccr.cfi (illustrative peer) back
  to upc.inf.cfi with with_ccr=true as default flag (design doc S1).
- deadline_search_service.go forum-bucket map updated; upc.ccr.cfi
  added to upc_cfi since it is a CFI peer.
- project_service.go CreateCounterclaim default lookup parameterised
  so the SQL string carries the constant, not a literal.
- proceeding_codes_shape_test.go: new file. Validates the shape
  regex standalone (always runs) and walks live DB rows asserting
  every active fristenrechner row matches the new shape + every
  stable Code* constant resolves to exactly one active row.

Comments and test fixtures throughout the Go tree updated to the
new shape. Tests pass under `go test ./internal/... -short`.
2026-05-18 12:13:24 +02:00
mAi
cce0ada3ce feat(t-paliad-206): mig 096 — rename proceeding_types.code to lowercase dot-form
19 active fristenrechner codes renamed from UPPER_SNAKE to the
lowercase three-position dot-separated taxonomy ratified by m on
2026-05-18 (see docs/design-proceeding-code-taxonomy-2026-05-18.md).
IDs are stable; only the `code` STRING changes.

Adds upc.ccr.cfi as an illustrative peer of upc.inf.cfi
(is_active=true, no rules — Go code routes cascade hits back to
inf.cfi with with_ccr=true).

Also updates the soft `proceeding_type_code` references on
paliad.event_category_concepts so the soft-join through
proceeding_types.code keeps resolving, refreshes the
deadline_search materialized view, and installs the
paliad_proceeding_code_shape CHECK constraint enforcing
`^[a-z]+\\.[a-z]+\\.[a-z]+$` on every active row.

Idempotent: every UPDATE is guarded on the OLD code; INSERT uses
WHERE NOT EXISTS; CHECK is dropped-then-recreated by name. Backup
snapshot lives in paliad.proceeding_types_pre_096. Dry-run on the
live youpc DB (BEGIN; … ROLLBACK) confirmed 20 active rows on the
new shape, 0 old codes left, 1 active upc.ccr.cfi.
2026-05-18 12:13:13 +02:00
mAi
e857829ac2 docs(t-paliad-206): proceeding-code taxonomy spec — lowercase dot-separated
Captures m's 2026-05-18 ratification of the new fristenrechner
proceeding-code convention `<jurisdiction>.<X>.<Y>` and the 5
sub-decisions: ccr.cfi is an illustrative peer that routes back to
inf.cfi with with_ccr; damages-appeal stays bundled into
upc.apl.merits; NZB at BGH is a flag, not a separate proceeding;
DPMA appeals stay generic with source differentiation at rule level.

This document is the source of truth for mig 096 (lands next) and the
post-mig proceeding_mapping.go.
2026-05-18 12:13:02 +02:00
mAi
1d535a2175 Merge: t-paliad-205 — mig 095 fristen gap-fill (4 new rules + 4 patches per t-203 decisions) 2026-05-18 11:47:23 +02:00
mAi
af30c06d9b feat(t-paliad-205): mig 095 — ingest t-paliad-203 fristen gap-fill deltas
Codifies curie's 4 new rules + 4 patches from
docs/proposals/fristen-gap-fill-2026-05-18.md § 0.3 (m's decisions).

NEW (4):
  inf.prelim         UPC_INF  parent=inf.soc      1mo  RoP.019.1  flag=with_po
  rev.prelim         UPC_REV  parent=rev.app      1mo  RoP.019.1  flag=with_po
  inf.appeal_spawn   UPC_INF  parent=inf.decision 2mo  RoP.220.1.a  always-fire  → UPC_APP
  rev.appeal_spawn   UPC_REV  parent=rev.decision 2mo  RoP.220.1.a  always-fire  → UPC_APP

PATCH (4):
  de_inf.klage       legal_source NULL → 'DE.ZPO.253'
  de_inf.anzeige     no change (already correct — explicit in audit log)
  de_inf.erwidg      is_court_set false → true + §276 Abs.1 S.2 description
  de_inf.berufung    defensive verify legal_source = 'DE.ZPO.517'

Idempotent via WHERE NOT EXISTS (no unique index on (proceeding_type_id,
code) — mig 093 left archived rows sharing codes with their published
successors, so ON CONFLICT isn't available). UPDATEs guarded by clauses
that only fire when the row still has the old value.

Backup snapshot in paliad.deadline_rules_pre_095 (CREATE TABLE IF NOT
EXISTS); down migration restores from it. Hard assertions verify all 4
new rules landed active+published, de_inf.erwidg flipped to court-set,
both spawn rules chain to a valid proceeding_type id=11.

Dry-run verified end-to-end against the live Supabase corpus inside
BEGIN/ROLLBACK; idempotency confirmed by running INSERT+UPDATE twice
in the same transaction.
2026-05-18 11:46:12 +02:00
mAi
8833c6975a Merge: m's decisions on t-paliad-203 FLAGs (final shape: 4 new rules + de_inf.erwidg court-set flip) 2026-05-18 11:26:34 +02:00
mAi
0123d11c6e docs(t-paliad-203): capture m's decisions on the 12 FLAGs (2026-05-18)
m + paliadin walked the open questions; new §0.3 records the calls so
the proposal doc reflects the final shape before m ingests via
/admin/rules. Net stays at 4 new rules (2 PO + 2 always-fire merits-
appeal spawns). de_inf.erwidg flips to court-set per ZPO §276(1) S.2.
No ccr-defendant PO, no ccr.appeal duplication, no R.263 deadline.
2026-05-18 11:26:32 +02:00
mAi
4d2382679b Merge: t-paliad-203 — fristenrechner gap-fill proposals (curie's research doc, no code changes) 2026-05-18 11:19:06 +02:00
mAi
35aa5e63c0 docs(t-paliad-203): Fristenrechner gap-fill proposals — 4 new rules + 3 polish PATCHes
Drafts the 4 coverage questions the mig 093 commit body left open for
legal review (t-paliad-200 closeout):

  1. Preliminary Objection (RoP 19) on UPC_INF + UPC_REV — 2 new rules,
     party=defendant, 1 month from SoC/SfR served, flag-gated with_po.
  2. Cross-proceeding APP spawn (RoP 220.1(a)) from UPC_INF + UPC_REV
     into the UPC merits-appeal proceeding — 2 spawn rules, party=both,
     2 months from R.118 decision, flag-gated with_appeal. Third
     Pipeline-A relic (ccr.appeal) recommended not seeded — CCR appeal
     is structurally absorbed into inf.appeal_spawn because one R.118
     decision = one appeal window in the unified UPC_INF (CCR-as-flag)
     model.
  3. ccr.amend / rev.amend — claim "safe to drop" verified for patent
     amendment (fully covered by inf.app_to_amend / rev.app_to_amend
     chains under with_ccr+with_amend / with_amend flags). R.263 case-
     amendment is court-discretionary; recommended NOT modelled.
  4. zpo.* family — klage / vertanz / berufung redundancy verified
     (de_inf.klage, de_inf.anzeige, de_inf.berufung / de_inf_olg.berufung
     cover them). klageerw exposes a discrepancy on de_inf.erwidg
     (6-week heuristic vs ZPO §276.1 S.2 court-set 2-week floor) — flagged
     as a PATCH on the existing row, not a new rule. Task brief's mention
     of "Vertagungsantrag" is a misread of zpo.vertanz (= Verteidigungs-
     anzeige, not Vertagungsantrag); §227 itself recommended NOT modelled.

Net: 4 new rules drafted in Track B, 3 optional PATCHes in Track A, 12
FLAGs surfaced for m's decision before /admin/rules ingest. Appeal target
referenced by ROLE (not code) pending t-paliad-204 proceeding-code
rename — m picks final spawn_proceeding_type_id at ingest.

Per-rule template matches docs/proposals/orphan-concepts-2026-05-15.md.
Read-only research; no DB writes, no migration files. The spawn_proceeding_type_id
column is unused in live data today — these spawn rules will be the
first real consumer.
2026-05-18 11:18:23 +02:00
mAi
3c9ecabf17 Merge: t-paliad-202 — inbox grey-out illegal actions (replace alert-after-click with server-tagged viewer_can_approve / viewer_is_requester flags) 2026-05-17 12:45:32 +02:00
mAi
aa82434af9 fix(t-paliad-202): grey out inbox actions instead of erroring on illegal click
m's UX bug (2026-05-17, paliad.de prod): clicking Genehmigen/Ablehnen/
Zurückziehen on a row the viewer can't act on alerted ("Eigengenehmigung
nicht zulässig.", "Sie haben nicht die erforderliche Rolle.") after the
POST round-trip. m's ask: "approval that i cannot grant should have the
'Genehmigen' button greyed out... that would be better than showing an
error when I try."

Backend (internal/services/approval_service.go):
- ApprovalRequestView gains viewer_can_approve + viewer_is_requester
  booleans. Resolved server-side per caller — false on self-authored rows
  (caller == requester), true when the eligibility predicate matches.
- Extract the eligibility EXISTS-block into approvalEligibilitySQL const
  and reuse it in ListPendingForApprover (WHERE), PendingCountForUser
  (WHERE), and the new viewer_can_approve SELECT expression. Single
  source of truth for the gate, identical to canApprove.
- ListPendingForApprover, ListSubmittedByUser, and GetRequest all bind
  $1 = callerID so the SELECT computes the flags inline (one query, no
  N+1). GetRequest's signature grows a callerID arg; the handler passes
  the authenticated user.

Frontend (frontend/src/client/views/shape-list.ts):
- ApprovalDetail picks up the two booleans (optional — falsy is safe:
  it disables, never falsely enables).
- approvalActionBtn renders the button as before but flips
  btn.disabled + sets a tooltip via disabledReasonFor: approve/reject
  share the viewer_can_approve gate (self → self_approval tooltip;
  unauthorized → not_authorized); revoke needs viewer_is_requester.
- All three buttons still render on every pending row so users see
  what's possible — the disabled+tooltip combo explains what's not.

i18n + CSS:
- 3 new keys × DE/EN: approvals.disabled.{self_approval,
  not_authorized,revoke_not_requester}.
- .inbox-row-action:disabled neutralises the .btn-primary/danger/
  secondary variant via opacity + not-allowed + muted tokens.

Tests:
- internal/services/approval_service_test.go::TestApprovalService_ViewerFlags
  is a 4-case table-driven live-DB test (skips without TEST_DATABASE_URL):
  self-authored (false/true), eligible peer (true/false), non-eligible
  viewer (false/false), global_admin (true/false). Also asserts the flags
  on ListPendingForApprover + ListSubmittedByUser rows.

Defence-in-depth preserved: server still rejects illegal POSTs with the
same error contract, and the alert path stays in inbox.ts for the race
where state changes between render and click.
2026-05-17 12:44:29 +02:00
mAi
4f66feffce Merge: fix(projects) — unbreak Create + 6-digit CM constraint 2026-05-17 12:30:58 +02:00
mAi
bdd4999213 fix(projects): unbreak Create — drop $1::text reuse + tighten CM CHECK to 6 digits
Two issues m hit and reported in one breath while adding a project:

1. **Internal error on POST /projects** (prod-only, surfaced at 10:23). Both
   ProjectService.Create and CreateCounterclaim re-referenced the uuid
   parameter `$1` as `$1::text` to fill the path placeholder. Postgres'
   planner deduced conflicting types for `$1` (uuid in the id column,
   text in the cast) and rejected the prepared statement with 42P08
   "inconsistent types deduced for parameter". The path placeholder
   value is irrelevant — paliad.projects_sync_path() (BEFORE INSERT
   trigger from mig 018/021) always overwrites it from id and parent
   path. Fix: replace `$1::text` with a literal '' in both INSERTs,
   keeping the parameter list decoupled from the id column's type.
   Same comment now anchors the rationale on both call sites.

2. **CM number length — 6 digits, not 7.** m's correction; mig 018's
   `^[0-9]{7}$` CHECK on paliad.projects.client_number and
   matter_number was wrong. Mig 094 snapshots affected rows to
   paliad.projects_pre_094, NULL-s the 3 surviving 7-digit test
   values (2 client_numbers, 1 matter_number), then swaps the legacy
   `projekte_*_check` constraints from {7} to {6}. Frontend pattern,
   maxLength, placeholder, labels, and i18n hint flipped from 7 → 6
   on both DE and EN sides; format hint reads CCCCCC.MMMMMM now.

Dry-run against live DB (BEGIN..ROLLBACK):
- Fixed Create SQL: trigger populates path = id::text (36 chars). ✓
- Mig 094: 2 rows snapshotted, 0 clients/matters remain after clear,
  0 rows violate the new 6-digit CHECK. ✓

go build, go test ./internal/..., bun run build all clean.
2026-05-17 12:30:53 +02:00
mAi
cbcc67bae7 Merge: t-paliad-200 — Slice 9 follow-up B (archive 40 Pipeline-A litigation rules, drop 7 litigation proceeding_types — Phase 3 closeout complete) 2026-05-16 01:30:03 +02:00
mAi
40e49e87d4 refactor(t-paliad-200): Slice 9 follow-up B — retire litigation category from rule corpus
Lorenz's Slice 9 (t-paliad-195) deferred mig 093 because 40 active
paliad.deadline_rules still pointed at the 7 litigation-category
proceeding_types (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL). Phase 3
Slice 5 (mig 087/088) already retired the category from project-binding;
this migration retires it from the rule corpus.

PLAN CHOICE (audit-gated, paliadin-approved): archive-all-40 rather than
the original re-parent plan. The audit found that 23 of 40 Pipeline-A
rules share their `code` with an existing fristenrechner rule on the
proposed re-parent target (e.g. inf.oral exists on both INF and
UPC_INF). Re-parenting would leave two rules with identical
(proceeding_type_id, code), breaking the implicit per-proceeding
rule_code identity contract keyed off by projection / search /
rule_editor. The fristenrechner rules are clearly the production
version (proper German names, legal_source pinned to UPC.RoP citations,
full bilateral chains, intra-proceeding counterclaim handling); the
Pipeline-A rules are stubs (English-only, mostly NULL legal_source,
duration_value=0 for 28 of 40, no spawn_proceeding_type_id wiring).

Migration 093 sequence (atomic):
  1. Snapshot proceeding_types_pre_093 + deadline_rules_pre_093 as
     permanent audit anchors.
  2. INSERT _archived_litigation pt (category='archived',
     is_active=false, jurisdiction='UPC') to home the rules.
  3. UPDATE all 40 rules → archive pt + lifecycle_state='archived' +
     is_active=false. Captured in paliad.deadline_rule_audit via the
     mig 079 trigger.
  4. DELETE the 7 litigation rows from paliad.proceeding_types (now
     safe — nothing references them).
  5. Hard assertions: 0 litigation rows survive, exactly 40 rules on
     the archive pt, every snapshot row matches a surviving rule by id.

Critical FK note: deadline_rules.proceeding_type_id is ON DELETE CASCADE
→ proceeding_types(id). A naive DELETE of the 7 litigation rows would
cascade-delete all 40 rules and break the FK from the 1 live deadline
("Lecker Frist", completed) that still references inf.rejoin/INF.
Re-homing the rules before deleting the pt rows is mandatory.

Verified via BEGIN..ROLLBACK against live DB: assertions pass, all 30
intra-litigation parent_id chains preserved, the live deadline FK
stays valid.

Test impact:
  internal/services/project_service_test.go:72 used to look up
  category='litigation' AND code='INF' to exercise the Slice 5 negative
  case. Post-mig-093 that lookup returns NULL. Rewritten to fetch any
  category <> 'fristenrechner' row (the _archived_litigation pt is the
  canonical post-093 row); defence-in-depth coverage of both the Go
  service guard and the mig 088 SQL trigger is preserved.

SURFACED FOR LEGAL REVIEW (4 coverage questions the audit found, to be
triaged as follow-up tasks):

  1. inf.prelim (Preliminary Objection, RoP 19, 1 month) — not present
     on UPC_INF. Possible coverage gap; legal review to decide whether
     to add it to the fristenrechner ruleset.
  2. inf.appeal / rev.appeal / ccr.appeal as cross-proceeding spawns
     into UPC_APP (2 months, UPC.RoP.220.1) — fristenrechner UPC_APP
     currently starts standalone with no spawn from UPC_INF/UPC_REV.
     Possible UX gap; Pipeline-A versions had
     spawn_proceeding_type_id=NULL so they weren't functional spawns
     either.
  3. ccr.amend / rev.amend (spawn rules) — superseded by
     inf.app_to_amend / rev.app_to_amend on UPC_INF / UPC_REV. Safe to
     drop; no action needed.
  4. zpo.klage / zpo.vertanz / zpo.klageerw / zpo.berufung — no UPC
     analogue; redundant with the DE_INF / DE_INF_OLG / DE_INF_BGH and
     DE_NULL / DE_NULL_BGH chains. Safe to drop; no action needed.

Files:
  internal/db/migrations/093_retire_litigation_category.up.sql   (new)
  internal/db/migrations/093_retire_litigation_category.down.sql (new)
  internal/services/project_service_test.go                      (test rewrite)
2026-05-16 01:29:31 +02:00
mAi
2686d43a38 Merge: t-paliad-199 — Slice 9 follow-up A (drop legacy event_deadlines tables, EventDeadlineService refactored onto deadline_rules) 2026-05-16 01:18:21 +02:00
mAi
29a6b58747 refactor(t-paliad-199): Slice 9 follow-up A — drop legacy event_deadlines tables
EventDeadlineService.Calculate now reads source rows from
paliad.deadline_rules directly (WHERE trigger_event_id IS NOT NULL),
joining via UUID instead of title_de string. The legacy SELECTs against
paliad.event_deadlines + paliad.event_deadline_rule_codes are gone.

Migration 092:
- Snapshots both legacy tables into _pre_092 audit anchors.
- Adds paliad.deadline_rules.rule_codes text[] and backfills the 72
  multi-code citations from event_deadline_rule_codes via the
  sequence_order = 1000 + ed.id convention from mig 085 (70 of 77
  Pipeline-C deadlines carry codes; 7 are codeless).
- Hard assertion ties source-junction-row count to backfilled
  text[]-element count — any sequence_order mismatch aborts the drop.
- Drops the mig 086 read-only trigger (orphan once event_deadlines
  goes away).
- Drops paliad.event_deadlines + paliad.event_deadline_rule_codes.
- Final assertion: >=77 active deadline_rules with trigger_event_id
  NOT NULL — Slice 3 corpus must not have collapsed.
- audit_reason wrapper at top so the deadline_rules UPDATE row-trigger
  records the reason in deadline_rule_audit.

Verified via BEGIN..ROLLBACK against the live paliad DB: 72 codes
backfilled into 70 rule_codes arrays, multi-code rules (RoP.029.a +
RoP.030 for ed_id=6) preserve their ordering, composite rules
(combine_op=max) remain intact, both tables drop cleanly, all
assertions pass.

Parity test rebound to deadline_rules — independent computation still
re-runs applyDuration against raw column values for date/composite
parity. EventDeadlineResult.ID stays int64 via the sequence_order -
1000 convention so the public /api/tools/event-deadlines wire shape
is unchanged.
2026-05-16 01:17:23 +02:00
mAi
4361c65887 Merge: t-paliad-198 — Determinator row-cascade Slice 3 (mobile polish + inline search + tooltip polish — cascade redesign complete) 2026-05-16 00:58:50 +02:00
mAi
6fc8c0136e feat(t-paliad-198): Slice 3 — mobile polish + inline search + tooltip polish
Closes the Determinator cascade redesign. Three intertwined pieces:

1. The mode row is gone — the `🔍 Direkt suchen` icon at the top of the
   row stack now toggles an inline search overlay over the cascade
   instead of routing to the legacy B2 surface. Results render into the
   same `#fristen-b1-results` container the cascade uses, so users see
   one consistent concept-card layout regardless of whether they
   reached the rule via cascade narrowing or free-text search. ESC
   inside the input clears it on the first press and collapses on the
   second; "← Zurück zum Entscheidungsbaum" restores cascade + state.
   Deep-link `?mode=filter` still routes to the legacy B2 panel for
   backwards-compatible shared URLs but is no longer exposed in the
   cascade UI.

2. Mobile responsive per design §7. Three breakpoints layer onto the
   `.fristen-row` primitive: <640px (phone — chips full-width single
   column, ändern permanently visible, answer wraps to its own line),
   <768px (tablet — head wraps so ändern moves down, chips
   single-column), <1024px (small desktop / large tablet — chips drop
   to 2-column auto-fill). Active row autoscrolls into view on every
   render with 60px headroom; the helper is a no-op when the row is
   already visible so desktop doesn't jitter.

3. Auto-walk tooltip polish: 200ms fade-in + slide-down via an
   is-entering transition state; mobile (<640px) flips the insertion
   point so the tip lands below the prefilled row rather than above;
   any chip pick or ändern click counts as user-engagement and
   dismisses the tip (in addition to the explicit × button).

Refs: docs/design-determinator-row-cascade-2026-05-13.md §6 + §7 + §10 Slice 3.
2026-05-16 00:58:02 +02:00
mAi
8b6b9254ed Merge: t-paliad-197 — Determinator row-cascade Slice 2 (project-driven narrowing + auto-walk) 2026-05-16 00:50:59 +02:00
mAi
a33060e600 feat(t-paliad-197): Slice 2 — project-driven narrowing + cascade auto-walk
Wires the project context into the Determinator row stack so a UPC INF
matter doesn't need to be hand-walked through five obvious cascade picks.
Auto-walk descends single-option chains as `is-prefilled` rows, the inbox
row vanishes for UPC matters (CMS implied), and the first prefilled row
carries the project reference inline ("aus Akte: HL-2024-001").

Backend: `internal/services/proceeding_mapping.go` adds
MapLitigationToFristenrechner — single source of truth for bridging the
litigation conceptual codes (INF / REV / APP / CCR / AMD / APM / OPP) onto
fristenrechner codes (UPC_INF / DE_INF / EPA_OPP / …). Ambiguous combos
(APP+DE, ZPO_CIVIL, AMD+DE) return ok=false; callers degrade to "no
narrowing" instead of guessing. Table-driven test covers every documented
mapping plus the ambiguous-degrade cases.

Frontend: `buildRowStack` filters cascade children by project context
along the proceeding axis (kebab segment lookup against the project's
fristenrechner code); auto-walks while filtered scope narrows to one;
caps depth via `cascadeAutoWalkStopAfter` after an "ändern" on a prefilled
row so the user lands at an active chip set without the auto-walk
re-engaging. Result panel narrows on the post-auto-walk effective slug,
not the URL slug. A one-time inline tooltip ("Diese Schritte ergeben sich
aus Ihrer Akte") surfaces when ≥2 rows render prefilled — dismissal flag
persists in localStorage.

Narrowing is purely additive: an Akte without a fristenrechner code
(11/11 live projects pre-Slice-5 were NULL) degrades to today's
forum-only behaviour. Slice 3 (mobile polish + search relocation) follows.

Refs: docs/design-determinator-row-cascade-2026-05-13.md §10 Slice 2 + §4 + §5.
2026-05-16 00:50:27 +02:00
mAi
d7b2292f8f Merge: t-paliad-180 — Determinator row-cascade Slice 1 (visual hierarchy + row-by-row layout) 2026-05-16 00:38:58 +02:00
mAi
ff8f95abaa feat(t-paliad-180): Slice 1 — Determinator row-stack cascade
Replace the four-layer Pathway B mess (mode radio + perspective chip strip
+ inbox chip strip + breadcrumb cascade) with a single `.fristen-row`
primitive rendered in a top-down stack. Every decision — mode, perspective,
inbox, cascade depth N — now uses the same shape (label · picked answer ·
inline "ändern") and three states (is-active / is-answered / is-prefilled).

The user finally sees their full decision path at a glance instead of
chasing breadcrumb crumbs after each drill. Click on any answered row (or
its ändern affordance) re-actives it; ändern on a cascade depth drops the
descendants (same drop-descendants semantic as today's breadcrumb-click).
Reset link and `🔍 Direkt suchen` escape-hatch live at the top of the stack
per design §6 Option B; the mode-toggle radio is gone, routing to
?mode=filter now flows through the mode row.

Visual-only refactor — narrowing engine (inboxFilterAllowsForums +
perspectiveAllowsParty) is unchanged. Slice 2 will add project-driven
prefills + auto-walk; Slice 3 covers mobile polish and search relocation.

Refs: docs/design-determinator-row-cascade-2026-05-13.md §10 Slice 1.
2026-05-16 00:38:19 +02:00
mAi
84aadc838a Merge: t-paliad-195 — Fristen Phase 3 Slice 9 (mig 091 legacy column drops; 092+093 deferred per live-data audit) 2026-05-15 17:55:18 +02:00
mAi
c4564b4031 refactor(t-paliad-195): drop priorityRendering legacy fallback
Phase 3 Slice 9 frontend cleanup. The backend's UIDeadline wire
shape stopped emitting (isMandatory, isOptional) in this slice;
the matching legacy-fallback branch in priorityRendering is now
dead code. Drops:

  - CalculatedDeadline TS interface: isMandatory + isOptional
    fields removed. `priority` is required (not optional) since
    every backend response now populates it.
  - priorityRendering(): collapsed to a clean switch on `priority`.
    Unknown priority falls back to "render as mandatory" (safe
    default; never silently drop a rule) — the legacy
    (isMandatory, isOptional) inference is gone.
  - Save-modal optional-badge rendering in fristenrechner.ts now
    reads `dl.priority === "optional"` directly (was previously
    `dl.priority === "optional" || dl.isOptional`).
  - Timeline row's optional-badge rendering in
    verfahrensablauf-core.ts switched from `!dl.isMandatory` to
    `dl.priority === "optional"`. Slightly different semantic —
    pre-Slice-9 the badge fired on every non-mandatory row
    (recommended + optional + informational); post-Slice-9 only
    on opt-in rules (RoP.151 pattern). Recommended + informational
    are surfaced via their own rendering tier (notice card for
    informational) so the badge change tightens the meaning.

Frontend build clean; no i18n keys removed (the priority labels
shipped in Slice 8 stay live).
2026-05-15 17:53:59 +02:00
mAi
7dae9b2216 test(t-paliad-195): adapt fixtures + assertions to post-drop shape
Phase 3 Slice 9 test cleanup. Seeds + assertions no longer touch
the legacy columns (mig 091 dropped them).

  - projection_service_test.go (Slice 7 fixtures): INSERT seeds
    drop the is_mandatory / is_optional columns from the
    paliad.deadline_rules column list. Defaults are fine; the
    spawn-graph test doesn't read those.
  - rule_editor_service_test.go (Slice 11a fixtures): same drop
    on the SLICE11A_PREVIEW seed.
  - fristenrechner_test.go (Slice 8 wire-shape assertion): drops
    the wireFlagsFromPriority round-trip check (the bool pair is
    no longer on the wire). The enum-membership invariant
    survives. evalConditionExpr table-driven test rewritten —
    legacy condition_flag fallback cases removed (the fallback
    is gone in Slice 9), pure-jsonb cases retained.
  - deadline_rule_service_test.go (Slice 2 backfill integrity):
    legacy-pair bucket assertion dropped; the priority-non-NULL
    invariant still holds via the CHECK constraint. The
    condition_flag cross-check now joins the pre-mig-091 snapshot
    when present (a future cleanup slice drops the snapshot
    along with this code path).

Build + tests green.
2026-05-15 17:53:44 +02:00
mAi
99a72a744f refactor(t-paliad-195): drop legacy fields from Go service surface
Phase 3 Slice 9 Go cleanup. With mig 091's column drops live, the
service layer stops reading + emitting the legacy shape:

  - models.DeadlineRule: drop IsMandatory, IsOptional, ConditionFlag,
    ConditionRuleID fields. Comment block flags Slice 9 as the
    closeout slice.
  - DeadlineRuleService.ruleColumns: SELECT no longer enumerates the
    dropped columns. The post-Slice-9 schema is the live shape.
  - FristenrechnerService.UIDeadline: drops IsMandatory + IsOptional
    fields. Frontend reads `priority` directly post-Slice-8; the
    legacy emit was kept "for one release" and that release is now.
  - evalConditionExpr signature: drops the conditionFlag fallback
    param. NULL / "null" expressions return true (unconditional);
    the legacy text[] fallback was the only reason for the second
    param. New helpers hasConditionExpr + extractFlagsFromExpr fill
    the gaps (alt-swap guard + RuleCalculation.FlagsRequired list).
  - FristenrechnerService.Calculate + calculateByTriggerEvent +
    EventTriggerService.Trigger: switched to the new (single-arg)
    evalConditionExpr; alt-swap guard now uses
    hasConditionExpr(r.ConditionExpr) instead of the dropped
    len(r.ConditionFlag) > 0 check.
  - FristenrechnerService.CalculateRule: RuleCalculationRule.IsMandatory
    derived from priority via wireFlagsFromPriority (kept for the
    result-card panel TS contract). FlagsRequired walks the jsonb
    gate tree to enumerate {"flag":"X"} leaves (replaces the
    dropped condition_flag enumeration).
  - RuleEditorService.Create + CloneAsDraft INSERT statements:
    dropped is_mandatory / is_optional / condition_flag from the
    column lists. Live shape only.

Test fixtures (projection_service_test.go, rule_editor_service_test.go,
fristenrechner_test.go) all updated to write the live shape on
seed; the evalConditionExpr table-driven test dropped its legacy
fallback cases (the fallback no longer exists) and now exercises
20 pure-jsonb scenarios across AND/OR/NOT compositions.

The deadline_rule_service_test backfill assertion lost its
(is_mandatory, is_optional) bucket cross-check (those columns are
gone); the priority-non-NULL invariant still holds via the CHECK
constraint. condition_flag cross-check now joins the pre-mig-091
snapshot table (when present) instead of the live row.
2026-05-15 17:53:31 +02:00
mAi
f9305d6108 feat(t-paliad-195): mig 091 — drop legacy rule columns
Phase 3 Slice 9 Step E (design §3.E, §9.1). m approved the
downtime window 2026-05-15 ("paliad ist nicht in use heute,
downtime ist egal") so the destructive drops can land.

Drops four superseded columns on paliad.deadline_rules:

  is_mandatory      → priority='mandatory' | other (Slice 2 mig 083)
  is_optional       → priority='optional'  (Slice 2 mig 083)
  condition_flag    → condition_expr  (Slice 2 mig 084)
  condition_rule_id → DEAD (no live rows, Q13 m's approved drop)

Pre-drop snapshot: paliad.deadline_rules_pre_091 (id +
the four columns + snapshotted_at). Lets the down-migration
restore values to existing rows; a follow-up cleanup slice drops
the snapshot table once the rule editor's migration-export flow
has been used to roll any post-drop edits back into version
control.

Hard assertions at end:
  - count(priority IS NULL) == 0 (Slice 2 mig 083 must have run).
  - count(rule with pre-drop condition_flag but no condition_expr)
    == 0 (Slice 2 mig 084 must have populated every row).
Both raise EXCEPTION on violation — fails the migration loudly
before legacy code paths get pulled out from under the unified
calculator.

Audit-reason wrapper set; ALTER TABLE DROP COLUMN doesn't fire
the mig 079 row-level trigger, but the wrapper is the standard
Phase 3 pattern.

Sibling drops deferred — see live-data audit in head ping:
  - mig 092 (event_deadlines + trigger_events tables): SKIPPED.
    trigger_events has 33 event_types FKs + 77 deadline_rules
    FKs; event_deadlines + event_deadline_rule_codes still
    consumed by EventDeadlineService.Calculate for the frontend's
    "Was kommt nach…" tab (/api/tools/event-deadlines is still
    in use post-Slice-3 delegate).
  - mig 093 (retire litigation category): SKIPPED. 40 active
    deadline_rules still reference litigation-category
    proceeding_types (the Pipeline-A INF/REV/CCR/APM/APP/AMD/
    ZPO_CIVIL rules; Slice 5 retired them from project-binding,
    not from the rule corpus).

Both deferrals are tracked in the head ping; the litigation drop
can land after a focused slice that splits the Pipeline-A rules
off the litigation category onto a fristenrechner-side parent.
The event_deadlines drop needs EventDeadlineService.Calculate
to stop reading the source rows first.
2026-05-15 17:53:08 +02:00
mAi
7f72ee7b9e Merge: t-paliad-196 — orphan concept proposal doc (curie researcher draft for m's review) 2026-05-15 17:48:05 +02:00
mAi
d027b0874c docs(t-paliad-196): orphan-concept seed proposals (Fristen Phase 3 Slice 12, draft)
5 live orphans (not 9 — discrepancy flagged), 7 linkage-only UPDATEs and
12 net-new rule drafts. Sources cited; 12 FLAGs for m's review before
/admin/rules ingest.
2026-05-15 17:47:30 +02:00
mAi
7571e43078 chore(t-paliad-194): wire aichat env vars through docker-compose.yml
The Dokploy compose .env file got the new vars during the operational
flip but the docker-compose.yml environment block didn't list them, so
docker-compose silently dropped them during container start.

Adds PALIADIN_BACKEND / AICHAT_URL / AICHAT_TOKEN / AICHAT_PERSONA to
the environment block with safe defaults (PALIADIN_BACKEND=legacy,
AICHAT_PERSONA=paliadin). Existing deployments without aichat envs set
keep the legacy path; flipping PALIADIN_BACKEND=aichat in Dokploy now
takes effect on next deploy.

Discovered while doing the aichat Phase B activation flip.
2026-05-15 17:33:20 +02:00
mAi
c7b48f6ea7 Merge: t-paliad-194 / m/paliad#38 — aichat Phase B paliad migration (PALIADIN_BACKEND=aichat opt-in) 2026-05-15 03:04:56 +02:00
mAi
8f6cee5a83 chore(t-paliad-194): delete paliad-side paliadin skill bundle (SoT moved to m/mAi)
Per m's 2026-05-13 decision (m/mAi#207 §13 Q4): the paliadin SKILL.md
and references/sql-recipes.md are now owned by aichat. The aichat repo
already has the equivalents committed at skills/aichat/paliadin/ on
mai/darwin/issue-207-aichat (verified before this commit). Aichat's
own deploy doc handles installation on mRiver.

Deleted:
  scripts/skills/paliadin/SKILL.md
  scripts/skills/paliadin/references/sql-recipes.md
  scripts/install-paliadin-skill

Legacy LocalPaliadinService / RemotePaliadinService still depend on
~/.claude/skills/paliadin/ being present on whichever host they run
against. Until those paths retire (Phase C / Q15), operators install
the skill manually from m/mAi/skills/aichat/paliadin/.

CLAUDE.md updated:
  - PALIADIN_SESSION_PREFIX row points readers at m/mAi for the skill
    SoT and notes the legacy paths still expect a manual install.
  - New env-var rows for PALIADIN_BACKEND / AICHAT_URL / AICHAT_TOKEN /
    AICHAT_PERSONA so the operator runbook for the Phase B flip is
    self-contained.
2026-05-15 03:03:49 +02:00
mAi
edc81bbbc2 feat(t-paliad-194): AichatPaliadinService + PALIADIN_BACKEND=aichat env gate (m/paliad#38 Phase B)
Adds the Phase B paliad-side migration: a thin HTTP client of the
centralized aichat backend shipped in m/mAi#207 Phase A (darwin's
mai/darwin/issue-207-aichat branch). Implements the same services.Paliadin
interface as LocalPaliadinService / RemotePaliadinService — handler
plumbing is unchanged, the cutover is a single env-var flip.

internal/services/aichat_paliadin.go (~530 LoC):
  - POST /chat/turn + POST /chat/reset + GET /chat/health via the aichat
    JSON envelope (mirrors m/mAi internal/aichat/api/types.go verbatim;
    no module import to keep paliad self-contained).
  - Per-turn HS256 JWT mint (uses paliadin_jwt.go from the prior commit)
    when SUPABASE_JWT_SECRET is configured. Aichat owns file write +
    cleanup; we just sign and ship.
  - Service-wide health-gate cache (10 s success window, no failure
    cache — failures re-probe so recovery surfaces immediately).
  - Per-user-window primer cache. Pulls up to MaxPrimerTurns prior
    exchanges from paliad.paliadin_turns and ships them in TurnRequest.
    Primer so a pane respawn (pane_spawned=true in response) doesn't
    strand the user with a cold claude. Cleared on ResetSession +
    pane_spawned response.
  - Username from email_localpart per m's §13 Q2 pick (sanitized inside
    aichat). Nil-DB fallback: "user-<uuid8>".
  - Maps aichat's typed wire errors (auth_failed, persona_unknown,
    mriver_unreachable, bootstrap_failed, timeout, shim_error) onto
    paliad's existing audit-row codes — preserves the German i18n table
    in paliadin.ts unchanged (no new strings needed per design §11).

cmd/server/main.go:
  - PALIADIN_BACKEND env: "aichat" → AichatPaliadinService, anything
    else → existing remote/local/disabled tree. Default = legacy, so
    every existing deploy is byte-identical until flipped.
  - buildAichatPaliadinConfig validates AICHAT_URL + AICHAT_TOKEN at
    boot; AICHAT_PERSONA defaults to "paliadin". JWT secret threaded
    in so per-user RLS is on by default.

Tests cover constructor defaults, health-gate caching + retry +
expiry, ResetSession wiring, error-envelope decoding + classifier,
HTTP-layer auth/JSON wiring via a roundTripper, JWT mint integration,
TurnContext → meta packing, and the env-gate helper. go test ./...
green. NOT self-merged — head owns the merge per task instructions.
2026-05-15 03:03:34 +02:00
mAi
08e20883a5 feat(t-paliad-194): revive per-turn JWT mint for Paliadin (folded-in t-paliad-156)
Restored from mai/planck/paliadin-per-user-rls (parked, see m/paliad#12
cancel note). The aichat Phase B path (next commit) consumes mintTurnJWT
to sign a short-lived HS256 token per turn, scoped to the calling user
(sub=userID, role=authenticated, aud=authenticated, iss=paliad/paliadin).

Aichat passes the raw token through to the claude pane on mRiver via a
per-turn file (managed by aichat's runner, not paliad's transport). The
SKILL.md reads it and `SET LOCAL request.jwt.claims = …` before every
paliad.* query, which makes RLS evaluate as the user instead of as
service role.

TTL: 2 min default — covers aichat's 120 s persona timeout + HTTP slack,
short enough that a leaked JWT is uninteresting. Each turn mints fresh;
no caching.

No call sites yet — paliadin_remote.go / paliadin.go are unchanged on
this commit. The plumbing arrives with AichatPaliadinService.
2026-05-15 03:03:12 +02:00
mAi
86946ba441 Merge: t-paliad-192 — Fristen Phase 3 Slice 11b (rule editor FRONTEND — admin UI on /admin/rules) 2026-05-15 02:10:19 +02:00
mAi
193b988798 feat(t-paliad-192): admin rule-editor frontend (Slice 11b)
Surfaces the Slice 11a admin API at /admin/rules so editors can drive
the rule lifecycle without curling. Three new pages, each gated by
adminGate on the route + sidebar reveal via /api/me:

  /admin/rules              — list page with filters (proceeding,
                              trigger event, lifecycle chips, fuzzy
                              search) and a second "Orphans" tab that
                              loads paliad.deadline_rule_backfill_orphans
                              via the new GET /admin/api/orphans
                              endpoint. Pick-chip on each candidate
                              fires the reason modal → POST resolve.
                              "+ Neue Regel" opens the same reason modal
                              with minimal required fields (name DE/EN
                              + duration) and routes to the edit page
                              on success.

  /admin/rules/{id}/edit    — full form (37 columns grouped: identity /
                              proceeding / timing / party / display /
                              lifecycle / condition). Side panel hosts
                              the preview widget (trigger date + flags
                              → GET .../preview, drafts only) and the
                              audit-log timeline (paginated, 20 per
                              page). Bottom action bar adapts to
                              lifecycle_state — save-draft + publish on
                              drafts, clone on published/archived,
                              archive on draft/published, restore on
                              archived. Every action opens the reason
                              modal with ≥10-char client-side guard per
                              Slice 11a edge case #4.

  /admin/rules/export       — minimal SQL preview + "Download as file"
                              / "Copy to clipboard". Optional `since`
                              audit-id scopes the export window.

condition_expr ships with a raw JSON textarea + inline parse
validation; the tree-builder is out of scope for Slice 11b (raw JSON
is sufficient given the existing 172-row corpus and validates the
same grammar live). The dependency on document.querySelectorAll for
form binding follows the admin-event-types / admin-audit-log
playbook — no new component substrate needed.

Wiring:
  - frontend/build.ts: 3 new entrypoints + 3 new HTML writes.
  - frontend/src/admin.tsx: new "Regeln verwalten" card with ICON_TABLE.
  - frontend/src/components/Sidebar.tsx: two new admin nav entries
    (Regeln + Regel-Migrations).
  - frontend/src/client/i18n.ts: 162 new keys (DE+EN), under
    admin.rules.* and admin.rules.edit.* and admin.rules.export.*.
  - frontend/src/styles/global.css: new admin-rules-* CSS block
    appended (chips, pills, audit timeline, edit-grid, preview list,
    orphan cards, export pre). Uses paliad's existing CSS tokens so
    light/dark/auto themes inherit automatically.

Route registration:
  - GET /admin/rules                — list page shell
  - GET /admin/rules/{id}/edit      — edit page shell
  - GET /admin/rules/export         — export page shell

All routes adminGate + gateOnboarded, so non-admin users 404 before
the shell even loads. Backend audit and lifecycle invariants from
Slice 11a stay authoritative; the frontend never bypasses them.
2026-05-15 02:09:35 +02:00
mAi
1c45c93570 feat(t-paliad-192): admin orphan list/resolve endpoints
Slice 11b backend addition for the orphan-resolution flow in the
/admin/rules UI. The Slice 10 fuzzy-match backfill (mig 089) staged
legacy paliad.deadlines rows the matcher could not bind to a unique
deadline_rule into paliad.deadline_rule_backfill_orphans. This adds
the two endpoints the editor needs to surface and resolve them:

  GET  /admin/api/orphans              — unresolved staging rows,
                                         hydrated with the candidate
                                         rule rows in one round-trip.
  POST /admin/api/orphans/{id}/resolve — picks a rule_id from the
                                         candidate set, writes it onto
                                         the deadline, and flips
                                         resolved_at + resolved_rule_id
                                         on the staging row in a single
                                         tx.

The methods live on RuleEditorService because they share the same admin
surface and audit semantics; resolved_rule_id + resolved_at on the
staging row is the audit trail (mig 089 COMMENT). reason is captured
into paliad.audit_reason in the same tx so any future audit trigger on
paliad.deadlines picks it up automatically.

Typed errors:
  ErrOrphanAlreadyResolved   → 409 in handler
  ErrOrphanCandidateMismatch → 400 in handler

Route ordering matches Slice 11a's pattern: the static path is
registered alongside the existing /admin/api/rules family inside the
adminGate block in handlers.go.
2026-05-15 02:09:10 +02:00
mAi
36bdfecb04 Merge: t-paliad-191 — Fristen Phase 3 Slice 11a (rule editor backend — admin API + lifecycle + audit + preview) 2026-05-15 01:51:28 +02:00
mAi
936c4967fd test(t-paliad-191): rule-editor lifecycle + preview coverage
Live-DB tests (TEST_DATABASE_URL-gated) for Phase 3 Slice 11a:

TestRuleEditorService_Lifecycle — full create→update→publish→archive
→restore round-trip on synthetic fixtures (SLICE11A_TEST_PT
proceeding + rules). Asserts:

  1. Create returns lifecycle_state='draft' with published_at=NULL.
  2. UpdateDraft on a draft succeeds and lands the patch.
  3. CloneAsDraft from a published row creates a new draft with
     draft_of pointing at the source.
  4. Publish flips draft → published, sets published_at, AND archives
     the cloned-from peer (verified by re-reading the peer's
     lifecycle_state post-publish).
  5. Archive flips published → archived.
  6. Restore flips archived → published.
  7. ListAudit returns ≥ 3 rows newest-first with non-empty reason
     strings (the mig 079 trigger captured them).
  8. Empty audit_reason on UpdateDraft → ErrAuditReasonRequired.
  9. UpdateDraft on a published row → ErrInvalidLifecycleState.
 10. Restore on a non-archived row → ErrInvalidLifecycleState.

TestRuleEditorService_Preview — calculator override hook coverage
(SLICE11A_PREVIEW_PT proceeding + a published rule). Clone the
root rule, patch DurationValue 30 → 60 on the draft, call Preview
at trigger_date=2026-01-15. Asserts:

  - Baseline Calculate (no overrides) returns the published rule's
    dueDate (~30 days after trigger).
  - Preview returns a DIFFERENT dueDate (substitutes the draft's
    60-day duration via RuleOverrides) — sanity check that the
    override pipeline reached the calculator and shifted the date.
  - Both responses are non-empty (the rule is reachable).

Cleanup: WHERE name LIKE 'SLICE11A_TEST_%' / 'SLICE11A_PREVIEW_%'
AND code = 'SLICE11A_TEST_PT' / 'SLICE11A_PREVIEW_PT' so production
rules are untouched. audit_reason set on every seed / cleanup write
so the mig 079 trigger doesn't reject the seed transactions.

Build clean, full local test suite green; this test skips when
TEST_DATABASE_URL is unset.
2026-05-15 01:50:29 +02:00
mAi
7decc5095f feat(t-paliad-191): admin rule-editor HTTP API
Phase 3 Slice 11a admin endpoints under /admin/api/rules, all
gated through auth.RequireAdminFunc:

  GET    /admin/api/rules                  — paginated list with filters
  GET    /admin/api/rules/{id}             — full row
  POST   /admin/api/rules                  — create draft
  PATCH  /admin/api/rules/{id}             — update draft only
  POST   /admin/api/rules/{id}/clone-as-draft
  POST   /admin/api/rules/{id}/publish
  POST   /admin/api/rules/{id}/archive
  POST   /admin/api/rules/{id}/restore
  GET    /admin/api/rules/{id}/audit       — paginated audit log
  GET    /admin/api/rules/{id}/preview     — preview-on-trigger-date
  GET    /admin/api/rules/export-migrations — SQL blob for the
                                              migration-export flow

Every write endpoint takes a `reason` body field; missing reason →
HTTP 400 (ErrAuditReasonRequired surfaced by the service). The
service writes the reason into paliad.audit_reason in the same tx
as the UPDATE so mig 079's trigger captures it.

writeRuleEditorError maps service-level typed errors to HTTP
statuses (404 for ErrRuleNotFound, 409 for ErrInvalidLifecycleState
+ ErrCyclicSpawn, 400 for ErrAuditReasonRequired + ErrInvalidInput).

dbServices gains a ruleEditor field; Services.RuleEditor in the
public bundle gets wired from main.go via NewRuleEditorService.

Route ordering: export-migrations is registered BEFORE the
{id}-shaped routes so the static path doesn't get captured by the
{id} placeholder. (Go 1.22+'s ServeMux requires the explicit
registration order for shadowing-resolution.)

Frontend (Slice 11b) will hire a new coder to surface the API in
an admin UI. Slice 11a ships the backend in isolation so the editor
can drive the lifecycle via curl / mai instructions today.
2026-05-15 01:50:15 +02:00
mAi
b21ce6dd7b feat(t-paliad-191): RuleEditorService — admin rule lifecycle
Phase 3 Slice 11a (m's Q5 option C: "I need to see these things,
admin only"). RuleEditorService owns the admin-only lifecycle for
paliad.deadline_rules:

  Create        → INSERT row with lifecycle_state='draft', published_at=NULL.
  UpdateDraft   → UPDATE WHERE id=$1 AND lifecycle_state='draft'.
                   Published or archived rows must clone-as-draft first
                   (ErrInvalidLifecycleState otherwise — 409).
  CloneAsDraft  → INSERT deep copy of source row (published OR archived)
                   as a new draft with draft_of pointing at the source.
                   Lets editors propose changes to live rules without
                   mutating the live row.
  Publish       → UPDATE lifecycle_state='published', set published_at.
                   When draft_of != NULL, also archives the cloned-from
                   peer so each rule has at most one live row.
  Archive       → UPDATE lifecycle_state='archived' (allowed from
                   published OR draft).
  Restore       → UPDATE lifecycle_state='published' (only from archived).
  Preview       → Calls FristenrechnerService.Calculate with the draft
                   as a RuleOverrides entry — pure simulation, no DB
                   write. If draft_of is set, the override substitutes
                   for the peer (matching ID); otherwise it's appended.
  ListAudit     → SELECT paliad.deadline_rule_audit rows for one rule,
                   newest-first, with offset/limit pagination. Joined
                   with paliad.users.display_name for the changed_by
                   column.
  ListRules     → Admin list view with filters (proceeding_type_id,
                   trigger_event_id, lifecycle_state, fuzzy q over
                   name / name_en / rule_code).
  ExportMigrationsSince → SQL blob generator for the migration-export
                   admin flow (Q-H-5 pure SQL format). v1 emits one
                   statement per audit row in chronological order;
                   Slice 11b polishes the output (header comment,
                   collapse consecutive UPDATEs).

Audit-reason invariant: every write method requires a non-empty
reason string. setAuditReasonTx writes it into the session-local
paliad.audit_reason setting in the same transaction as the
INSERT/UPDATE, so mig 079's trigger captures the rationale
forever. Empty reason → ErrAuditReasonRequired (400 in the handler).

Spawn cycle guard: validateSpawnNoCycle pre-checks Create + UpdateDraft
edits that touch spawn_proceeding_type_id against the global rule
graph. Reuses the design §6 cycle-guard semantics — walks the
target proceeding's spawn rules transitively; raises ErrCyclicSpawn
if any reachable proceeding is the source. Slice 7's runtime guard
catches anything this misses; the editor surface catches it at
edit time so the editor sees a clear 409 instead of a silent
projection failure.

Typed errors:
  ErrRuleNotFound          → 404 in handler
  ErrInvalidLifecycleState → 409 in handler
  ErrAuditReasonRequired   → 400 in handler
  ErrInvalidInput          → 400 (re-uses the existing services-wide error)
  ErrCyclicSpawn           → 409 (re-uses Slice 7's typed error)

RuleAuditEntry struct extends models.DeadlineRuleAudit with a
display_name for the admin UI; distinct from services.AuditEntry
(the cross-source union for the site-wide audit panel) so the two
read paths don't conflict.
2026-05-15 01:50:03 +02:00
mAi
358c64d172 feat(t-paliad-191): CalcOptions.RuleOverrides + applyRuleOverrides
Phase 3 Slice 11a calculator hook for the rule-editor preview
(design §4.5, Q-H-4 option (a)). CalcOptions gains RuleOverrides
[]models.DeadlineRule. When non-empty, FristenrechnerService.Calculate
substitutes any rule with matching .ID in the rule list with the
override row, and appends overrides whose ID doesn't match an
existing rule (net-new drafts the editor wants to preview).

Wired into:
  - FristenrechnerService.Calculate (proceeding-tree path)
  - FristenrechnerService.calculateByTriggerEvent (Pipeline-C path)

Helper: applyRuleOverrides(src, overrides) — small linear scan since
the override slice is 1 row in practice (the draft being previewed).
Empty overrides → pass-through (existing behaviour unchanged).

No DB writes; pure simulation. The editor's "what would this rule
do?" affordance uses this to preview the draft against the rest of
the proceeding's rules without mutating the live corpus.
2026-05-15 01:49:43 +02:00
mAi
5d22e5db21 Merge: t-paliad-190 — Fristen Phase 3 Slice 10 (rule_id backfill + orphan staging) 2026-05-15 01:38:47 +02:00
mAi
09615ec48e feat(t-paliad-190): mig 090 — one-time fuzzy-match backfill
Phase 3 Slice 10 Step I (design §3.I + m's Q10 ruling). Binds legacy
paliad.deadlines.rule_id to deadline_rules.id via priority-ordered
fuzzy matching; ambiguous + no-match rows log to the orphan staging
table (mig 089).

Matching strategies (highest priority first; first unique hit wins):

  1. rule_code_and_tail — title's leading citation token AND its
     post-separator name fragment match a rule. Handles
     "RoP.023 — Klageerwiderung" where the bare code matches 2 rules
     (DE Klageerwiderung + EN Statement of Defence); the tail picks
     the right one.

  2. rule_code only — bare rule_code from the title prefix. Handles
     "RoP.029.a — Replik" where RoP.029.a maps to a single rule
     regardless of suffix (the title's "Replik" doesn't match the
     rule's actual name but the code is unique).

  3. name_exact — full title equals rule.name or rule.name_en
     (LOWER). Catches "Antrag auf Schadensbemessung" (1 unique
     rule); ambiguous for shared names like Klageerwiderung (8
     candidates).

  4. concept_alias — title appears in deadline_concepts.aliases.
     Thin coverage today; Slice 12 orphan-seed will populate it.

Per-deadline aggregation:
  - Strategy with n_candidates = 1 wins. Priority chain rule_code_and_tail
    > rule_code > name_exact > concept_alias.
  - Ambiguous (≥2 across all strategies) → orphan reason='ambiguous'
    with the full candidate_rule_ids list.
  - 0 candidates → orphan reason='no_match'.

Predicted production outcome (verified via supabase MCP pre-write):
  - 3 of 25 deadlines (12%) get a unique match:
      "RoP.023 — Klageerwiderung"   via rule_code_and_tail
      "RoP.029.a — Replik"          via rule_code
      "Antrag auf Schadensbemessung" via name_exact
  - 15 of 25 deadlines (60%) → orphan reason='ambiguous' (common
    titles like Klageerwiderung × 4, Duplik × 4, Replik × 4 across
    multiple proceedings).
  - 7 of 25 deadlines (28%) → orphan reason='no_match' (free-text
    titles like "Call me", "Schutzschrift", "Validierungsfrist EP→DE",
    "Schriftsatz nach R.262 (Klageerwiderung)").

The 60% target the design § hinted at is unachievable on today's
corpus because all 11 projects have proceeding_type_id IS NULL post-
Slice-5 (the fristenrechner-side rebinding hasn't happened on
production data yet) — proceeding-narrowing would cut the
Klageerwiderung / Duplik / Replik ambiguity, but the column isn't
populated. The orphan-review UI in Slice 11 is the real path to
binding the long tail.

Defensive backup: paliad.deadlines_pre_089 snapshot taken before any
UPDATE. Down-migration restores rule_id from the snapshot + drops
unresolved orphan rows (resolved rows survive a rollback — those are
legal-review work that shouldn't disappear on a code revert).

Idempotency: WHERE rule_id IS NULL on the UPDATE; orphan INSERT
skips rows that already have an unresolved orphan entry. Re-running
on the same corpus produces no new rows.

Hard assertion: every NULL-rule_id deadline (with project) is either
resolved post-mig OR has an unresolved orphan row. RAISE EXCEPTION on
any unaccounted row — fails the migration loudly rather than
silently leaving a deadline un-matched + un-orphaned.

Audit-reason wrapper set; the mig 079 deadline_rules audit trigger
doesn't fire here (UPDATEs touch paliad.deadlines, not deadline_rules),
but the wrapper is the standard pattern.
2026-05-15 01:37:57 +02:00
mAi
5431fcd3cd feat(t-paliad-190): mig 089 — deadline_rule_backfill_orphans staging
Phase 3 Slice 10 staging table for the fuzzy-match orphans mig 090
produces (design §3.I + m's Q10 ruling). Each legacy deadline that
the matcher can't uniquely bind to a deadline_rule logs here with
the full candidate list so a legal-review pass can hand-link the
ambiguous tail without rerunning the match.

Schema:
  - deadline_id FK to paliad.deadlines (ON DELETE CASCADE).
  - title + project_id + proceeding_code denormalised so the admin
    orphan-review UI groups + filters without re-joining.
  - reason text CHECK in ('no_match', 'ambiguous', 'no_project',
    'manual_unbound'). Mig 090 writes the first two; the editor
    surface (Slice 11) may add the others.
  - candidate_count + candidate_rule_ids carry the full list of
    plausible rules so the legal-review UI can render "pick one"
    chips from the matcher's actual output.
  - resolved_at + resolved_rule_id flip when an editor binds the
    row via the admin UI; the matching paliad.deadlines.rule_id
    UPDATE happens at the same time. Both rows hold so the staging
    table doubles as an audit trail of the legal-review pass.

Indexes:
  - deadline_id for the per-deadline lookup the admin UI uses.
  - unresolved_at DESC for the "open orphans" list (the only one
    the legal-review UI typically lists).

RLS: admin-only read. The orphan list contains real deadline titles
+ project ids, so non-admins must not see it. Service-layer surfaces
(Slice 11) gate further.

Mig 089 ships the table; mig 090 does the fuzzy-match backfill +
populates this table. Numbering reflects the dependency order (the
backfill SELECTs INTO this table, so the table must exist first).
2026-05-15 01:37:34 +02:00
mAi
16ae2f0cf0 Merge: t-paliad-189 — Fristen Phase 3 Slice 8 (wire shape swap + instance_level data + notice cards) 2026-05-15 01:30:07 +02:00
mAi
4c3d091280 feat(t-paliad-189): priority-driven save modal + notice cards
Phase 3 Slice 8 frontend wire-shape swap. Save-modal pre-check logic
moves from the legacy (isMandatory, isOptional) pair to the unified
priority enum via a new priorityRendering helper in
verfahrensablauf-core.ts:

  - mandatory   → pre-checked, save button visible
  - recommended → pre-checked, save button visible
  - optional    → pre-unchecked, save button visible (RoP.151 pattern)
  - informational → NO save button — renders as a notice card with a
    "Hinweis" / "Note" label, distinct visual tier (no checkbox).
    The visible UX win of Phase 3: the 18 F/F filing rules
    (Berufungserwiderung, Replik, Duplik, R.19, R.116 EPÜ, etc.)
    currently render as 'recommended'; once editorial review flips
    them to 'informational' via the rule editor (Slice 11), this
    branch lights up and they stop offering a save action that
    would auto-create deadlines users didn't ask for.

priorityRendering falls back to the legacy (isMandatory, isOptional)
pair semantic when priority is missing (pre-Slice-8 backend
responses), so the cutover is bidirectional-safe. After Slice 9
drops the legacy fields, the fallback branch becomes unreachable.

CalculatedDeadline TS interface gains:
  - priority: optional 4-way union literal type
  - conditionExpr: optional unknown (rule editor reads this; the
    save-modal doesn't need to interpret it)

i18n keys added (DE + EN both):
  - deadlines.priority.mandatory/recommended/optional/informational
  - deadlines.priority.informational.notice_label (Hinweis / Note)
  - project.instance_level.first/appeal/cassation/unset
  - verlauf.spawn.chip + verlauf.spawn.cycle_warning (reserved for
    the SmartTimeline spawn-chip work, deferred to a focused
    follow-up so this slice doesn't balloon)

Frontend build clean (2225 i18n keys, 11 new). The instance_level
pill group on the project-edit form is intentionally NOT shipped
in this slice — the project-edit form is large and the pill is
self-contained UI; the data field is exposed via the API and a
follow-up slice (or the rule editor work) can wire the picker
without blocking the wire-shape swap.
2026-05-15 01:29:13 +02:00
mAi
d6f5e0c97e feat(t-paliad-189): UIResponse emits priority + conditionExpr
Phase 3 Slice 8 wire-shape swap. UIDeadline gains:

  - Priority: 4-way enum (mandatory|recommended|optional|informational)
    — the authoritative field the frontend reads after Slice 8 to drive
    save-modal pre-check + notice-card rendering.
  - ConditionExpr: jsonb gate predicate (design §2.4 long form),
    emitted verbatim as json.RawMessage so the rule editor (Slice 11)
    + admin surfaces can render the gating shape.

Additivity invariant: the legacy IsMandatory / IsOptional pair stays
populated via wireFlagsFromPriority (mandatory→T/F, optional→T/T,
recommended|informational→F/F). Pre-Slice-8 frontends keep working;
Slice 9 drops the legacy fields once the frontend cutover is verified
in prod.

All three calculator paths populate the new fields:
  - FristenrechnerService.Calculate (proceeding-tree, Pipeline A)
  - FristenrechnerService.calculateByTriggerEvent (Pipeline C)
  - EventTriggerService.Trigger (event-keyed endpoint, Slice 6)

Backend live-DB test asserts:
  - Every UPC_INF rule's priority is in the unified enum.
  - The wireFlagsFromPriority round-trip holds for every row.
  - At least one rule carries a populated conditionExpr (the 17
    with_ccr / with_amend / with_cci rules from mig 084).
2026-05-15 01:28:56 +02:00
mAi
a55f45ebea feat(t-paliad-189): instance_level on project Create/Update
Phase 3 Slice 8 part 1 — wire the instance_level data field (mig 080
column, shipped in Slice 1) through the project service + handler.

  - CreateProjectInput / UpdateProjectInput gain InstanceLevel *string.
    Empty string is the explicit "clear" sentinel.
  - validateInstanceLevel + nullableInstanceLevel helpers mirror the
    OurSide pattern. Allowed values per mig 080 CHECK: 'first' |
    'appeal' | 'cassation' | NULL.
  - Service rejects bad values with ErrInvalidInput (existing handler
    error-mapping surfaces this as HTTP 400 with the standard message).
  - projectColumns SELECT now includes instance_level so reads
    populate the field; Project struct already has the field from
    Slice 1.
  - handleCreateProject accepts instance_level from the raw map; Update
    handler uses the standard JSON decoder into UpdateProjectInput.

Live-DB test exercises:
  - Create with instance_level='first' → roundtrips.
  - Update to 'appeal' → roundtrips.
  - Update to '' → NULL after the trip.
  - Update to 'supreme' → ErrInvalidInput.

The DB CHECK on mig 080 is the defence-in-depth backstop should an
SQL-direct INSERT bypass the service.
2026-05-15 01:28:45 +02:00
mAi
6f77c8354c Merge: t-paliad-188 — Fristen Phase 3 Slice 7 (cross-proceeding spawn wiring + cycle guard) 2026-05-15 01:19:11 +02:00
mAi
b64d929586 test(t-paliad-188): spawn expansion + cycle guard + multi-spawn
Live-DB test for the Phase 3 Slice 7 spawn wiring. Seeds three
synthetic proceedings (SLICE7_TEST_A/B/C) + rules under them, with
audit-reason wrappers so the mig 079 trigger writes informative
audit rows during seed / cleanup. Three scenarios:

  1. A → B single spawn. Expansion emits one spawned-into row whose
     RuleCode matches B's root rule. DependsOnRuleCode references
     A's spawn rule; DependsOnDate is parsed from the synthetic
     UIDeadline date (2026-03-15); Track="spawn" so the frontend
     boundary divider lights up. DeadlineRuleID points at B's
     root rule UUID.

  2. Cycle A → B → A. Adds a spawn rule on B back to A; rerun
     expansion → ErrCyclicSpawn surfaces (errors.Is matches). The
     visited-set guard catches the second-hop attempt to recurse
     into A which is already in the chain. No infinite loop.

  3. Multi-spawn defensive. Drops the cycle edge, adds a second
     spawn rule on A targeting C. Expansion emits two spawned-into
     rows (B's root + C's root); the test asserts both RuleCodes
     appear in the output regardless of order.

Cleanup: WHERE name LIKE 'SLICE7_TEST_%' AND code LIKE
'SLICE7_TEST_%' so production rules are untouched. audit_reason
set before every INSERT/DELETE so the mig 079 trigger doesn't
reject the seed transactions.

Build clean, full local test suite green; this test skips when
TEST_DATABASE_URL is unset.
2026-05-15 01:18:18 +02:00
mAi
e30bfe89da feat(t-paliad-188): cross-proceeding spawn wiring + cycle guard
Phase 3 Slice 7 Step G (design §6). Closes the half-finished
projection_service.go:896-901 spawn-skip from the t-178 audit.

What lands:

  - DeadlineRuleService.ListByProceedingTypeIDs(ids): bulk-load
    rules for a set of spawn-target proceedings in one round-trip.
    Skips hydrateConceptDefaultEventTypes (SmartTimeline doesn't
    need concept-default event_types on spawned rows). Pre-sorted
    by (proceeding_type_id, sequence_order) so callers pick the
    target's root rule via the first slot per proceeding.

  - ProjectionService.expandCrossProceedingSpawns: walks the spawn
    graph rooted at the project's source proceeding. For each rule
    with is_spawn=true AND a non-NULL spawn_proceeding_type_id,
    resolves the target proceeding's root rule and emits a
    spawned-into TimelineEvent with:
      Kind="projected", Track="spawn", Status="predicted",
      DependsOnRuleCode=<source.code>, DependsOnRuleName=<source.name>,
      DependsOnDate=<source's computed due date when available>.
    SpawnLabel on the source rule, if set, is appended to the
    target title as "<target name> (<spawn_label>)".

  - Cycle guard: visited-set DFS keyed by proceeding_type_id. The
    source proceeding is seeded into `visited` before the walk;
    when any spawn's target is already in `visited`, the helper
    returns ErrCyclicSpawn with rule + proceeding context. The
    caller (computeProjections) catches the error and degrades to
    "no spawned rows" — better than failing the whole projection.
    ProjectionMeta.SpawnCycleDropped surfaces the degradation so
    the caller can log + show a "Spawn-Auflösung übersprungen"
    banner.

  - Recursion: expandCrossProceedingSpawns recurses into the
    target proceeding's spawn rules (depth+1) so a chain
    A → B → C surfaces every hop. maxSpawnDepth (4) is a safety
    belt on top of the visited-set guard.

Live data semantics: the live corpus has 6 active is_spawn=true
rules — AMD.ccr.amend, AMD.rev.amend, APP.ccr.appeal,
APP.inf.appeal, APP.rev.appeal, CCR.ccr.counterclaim. ALL six have
spawn_proceeding_type_id IS NULL today, so the live SmartTimeline
emits zero spawned-into rows. Slice 7 wires the code path; the
backfill of spawn_proceeding_type_id on these 6 rules is a
separate concern (the design doc's mig 093 was deferred — the
litigation-category proceedings these rules sit in were retired
from project-binding in Slice 5).

Calculator stays scoped (Option A, design §6.2): the unified
FristenrechnerService.Calculate does NOT follow spawns. The
SmartTimeline projection service is the sole consumer that chains
across proceedings. UIResponse.Deadlines for a proceeding only
contains rules from that proceeding; spawn resolution happens at
the projection layer.

projection_service.go:896-901 comment updated to reflect the new
post-Slice-7 reality (calculator stays scoped; spawned rules
arrive via expandCrossProceedingSpawns, not via the calculator's
Deadlines list).
2026-05-15 01:18:07 +02:00
mAi
d8edea0f4c Merge: t-paliad-187 — Fristen Phase 3 Slice 6 (POST /api/tools/event-trigger endpoint) 2026-05-15 01:10:09 +02:00
mAi
65617a5dcb test(t-paliad-187): EventTriggerService integration coverage
Live-DB test (TEST_DATABASE_URL-gated) for the Phase 3 Slice 6
endpoint covering:

  1. Missing both event_type_id + concept_id → ErrInvalidInput.
  2. Malformed trigger_date → ErrInvalidInput.
  3. Unknown event_type_id → ErrInvalidInput.

  4. event_type_id only → parity proxy against
     EventDeadlineService.Calculate (Slice-3 legacy delegate). Both
     code paths share the unified backend post-Slice-4 so the
     returned rule-name multiset must be identical. Selects the
     test fixture live: ANY event_type with a non-empty
     trigger_event_id bridge to active deadline_rules.

  5. concept_id only → returns rules linked by concept_id FK.
     Picks the concept with the most rules so we exercise the
     ordering path (proceeding_type_id NULLS LAST,
     sequence_order). Spot-checks each rule's RuleID parses as UUID.

  6. event_type_id + concept_id together → UNION dedupe. Today's
     corpus has the two paths on disjoint rule sets so the
     additive-count assertion holds; if a future seed links a
     concept to a Pipeline-C rule, the dedupe branch fires and the
     test logs (not fails) the count divergence for review.

  7. Perspective filter — locates a concept with both claimant and
     defendant rules (skips gracefully when the corpus lacks one)
     and asserts the defendant-perspective response omits every
     claimant-party rule.

Build clean, full local test suite green; this test skips when
TEST_DATABASE_URL is unset.
2026-05-15 01:09:31 +02:00
mAi
7bfec310a0 feat(t-paliad-187): POST /api/tools/event-trigger handler + wiring
Phase 3 Slice 6 handler. Decodes JSON body (eventTypeId, conceptId,
triggerDate, flags, courtId, perspective), validates required
fields (triggerDate + at least one identifier), parses UUIDs (400
on malformed), delegates to EventTriggerService.Trigger, surfaces
ErrInvalidInput as 400 with the service's German user-facing
message.

Wiring:

  - dbServices gains an eventTrigger pointer (handlers package
    internal type) wired from handlers.Services.EventTrigger.
  - handlers.Services.EventTrigger is the new exported field; the
    bundle constructor in main.go fills it from
    NewEventTriggerService(pool, rules, holidays, courts).
  - Route registered as POST /api/tools/event-trigger on the
    protected mux, sibling to the existing /api/tools/fristenrechner
    and /api/tools/event-deadlines endpoints.

Returns 503 when DATABASE_URL is unset (matches every other
calculator endpoint's behaviour). Returns same JSON shape as
/api/tools/fristenrechner so the frontend can render with the
existing timeline renderer.
2026-05-15 01:09:20 +02:00
mAi
253dc1d1b3 feat(t-paliad-187): EventTriggerService.Trigger
Phase 3 Slice 6 (design §5) — service-side implementation of the new
unified event-trigger entry point. Accepts (event_type_id?,
concept_id?, trigger_date, flags?, court_id?, perspective?) and
returns the same UIResponse the proceeding-tree calculator emits.

Rule discovery:

  - event_type_id → SELECT paliad.event_types.trigger_event_id →
    DeadlineRuleService.ListByTriggerEvent (Pipeline-C path, post-
    Slice-3 unified backend).
  - concept_id → DeadlineRuleService.ListByConcept (new method on
    the rule service: SELECT deadline_rules WHERE concept_id = $1
    AND is_active = true). Direct FK lookup; Pipeline-A cascade
    leaf semantic.
  - Both → UNION deduped by rule.id (seen-set in Go; small rule
    sets, no SQL DISTINCT overhead).
  - Validation: at least one of the two must be set;
    ErrInvalidInput otherwise. Unknown event_type_id also bubbles
    as ErrInvalidInput (404-style).

Math reuses the Slice-4 unified helpers verbatim:

  - applyDuration(base, value, unit, timing, country, regime, holidays)
  - evalConditionExpr(expr, condition_flag, flags) — long-form
    jsonb gate with legacy AND-of-array fallback.
  - wireFlagsFromPriority(priority) — derives IsMandatory + IsOptional
    so the wire shape stays calibrated against /api/tools/fristenrechner.

Composite combine_op (max/min) + legacy alt-swap-on-flag are
applied in the same mutually-exclusive order the proceeding-tree
calculator uses (combine_op IS NULL ⊕ alt-swap-on-flag-met).

matchesPerspective filter is permissive: empty perspective →
pass-through; NULL party → pass-through; only drops on explicit
claimant↔defendant mismatch. Court / both / NULL rules always
render.

is_court_set rules surface IsCourtSet=true and clear the computed
date — matches the proceeding-tree calculator's "wird vom Gericht
bestimmt" rendering.

UIResponse.ProceedingType / ProceedingName stay empty (caller
already has the event-type / concept context); same contract
calculateByTriggerEvent uses.

DeadlineRuleService.ListByConcept: ORDER BY proceeding_type_id NULLS
LAST, sequence_order so a multi-proceeding concept doesn't
interleave its constituent rules in the timeline.
2026-05-15 01:09:11 +02:00
mAi
992b99c375 Merge: t-paliad-186 — Fristen Phase 3 Slice 5 (projects soft-merge to fristenrechner codes only) 2026-05-15 01:02:33 +02:00
mAi
7afbf52f3e test(t-paliad-186): proceeding-type category guard
Live-DB test (TEST_DATABASE_URL-gated) for Phase 3 Slice 5 that
covers the full chain:

  1. Migration smoke: post-mig 087, no project points at a
     non-fristenrechner-category proceeding_types row.

  2. ProjectService.Create with a litigation-category id returns
     ErrInvalidProceedingTypeCategory (service-layer guard fires
     before any DB write).

  3. mig 088 trigger rejects a raw INSERT that bypasses the Go
     service — defence-in-depth assertion. Errors on the trigger
     raise; test asserts a non-nil error.

  4. Fristenrechner-category id (UPC_INF) succeeds. The created
     project carries the expected proceeding_type_id.

The four sub-assertions hit each layer of the guard chain (picker
filter → service guard → DB trigger) plus the migration smoke. Any
regression in the chain surfaces here before the deploy.

Tests + main build clean; live test skips when TEST_DATABASE_URL
is unset.
2026-05-15 01:01:46 +02:00
mAi
663ef64c62 feat(t-paliad-186): project picker filters to fristenrechner only
Phase 3 Slice 5 frontend. loadProceedingTypes() in projects-detail.ts
now fetches /api/proceeding-types-db?category=fristenrechner so the
project edit picker only ever shows the 19 fristenrechner codes,
never the 7 legacy litigation codes (INF / REV / CCR / APM / APP /
AMD / ZPO_CIVIL).

The Fristenrechner calculator page + Verfahrensablauf page are NOT
touched — they still need the full proceeding_types catalog (the
litigation codes have rule trees the calculator can render, per
design §3.F: "litigation codes stay … reachable via cascade leaves").
Only the project-binding picker is restricted.

Defence-in-depth: even if a future fetch bypasses this filter, the
server-side service guard (ErrInvalidProceedingTypeCategory) and
the mig 088 DB trigger both reject the write. The picker filter is
the UX layer of the chain — invisible bad-shape inputs.

projects-new.ts has no proceeding-type field today (the form lives
on the edit page only); no change needed there.
2026-05-15 01:01:37 +02:00
mAi
5b81f2159e feat(t-paliad-186): service guard + ?category filter
Phase 3 Slice 5 Go-side: ErrInvalidProceedingTypeCategory typed
error + service-layer validation + handler-level mapping +
listing-side filter.

  - services.ErrInvalidProceedingTypeCategory: typed error so
    handlers can map to a 400 with a bilingual user-facing message
    distinct from generic ErrInvalidInput.

  - ProjectService.validateProceedingTypeCategory: looks up the
    referenced proceeding_types.category and rejects with the typed
    error if it's not 'fristenrechner'. Called from both Create and
    Update before any DB write.

  - DeadlineRuleService.ListProceedingTypesByCategory: extends the
    existing ListProceedingTypes with an optional category filter.
    Empty category passes through (legacy callers unaffected).

  - GET /api/proceeding-types-db?category=<value>: handler reads the
    query param and forwards it to the service. The project-create
    / project-edit pickers pass 'fristenrechner' so users never see
    retired litigation codes.

  - writeServiceError: maps ErrInvalidProceedingTypeCategory to
    HTTP 400 with a bilingual message ("Verfahrenstyp muss ein
    Fristenrechner-Typ sein / proceeding type must be a
    Fristenrechner type"). Distinct from generic ErrInvalidInput so
    the frontend can show a more helpful hint.

Defence-in-depth chain: frontend picker filter → service-layer
validation → DB trigger (mig 088). Each backstops the next.
2026-05-15 01:01:28 +02:00
mAi
275cbd5e51 feat(t-paliad-186): mig 088 — fristenrechner-category trigger
Phase 3 Slice 5 Step F-2. BEFORE INSERT/UPDATE trigger on
paliad.projects rejects any write that binds proceeding_type_id to a
non-fristenrechner-category proceeding_types row. NULL is allowed.

PostgreSQL CHECK constraints can't reference other tables, so this
is the only way to evaluate the (proceeding_types.category =
'fristenrechner') predicate per row without restructuring the
existing FK relationship.

Trigger trades narrower FK + partial-unique-index approach for
keeping the existing schema reference (mig 027) untouched. Slice 9
or later may drop this trigger when the litigation category is
fully retired.

Error message is bilingual (German + English) so the Go handler can
either surface it verbatim OR — preferably — intercept the typed
service error first and emit a clean i18n string. mig 088 is
defence-in-depth; the Go service-layer validation is the primary
path.

Idempotent: CREATE OR REPLACE FUNCTION + DROP TRIGGER IF EXISTS
before CREATE TRIGGER.
2026-05-15 01:01:17 +02:00
mAi
76cbc311ed feat(t-paliad-186): mig 087 — remap projects.proceeding_type_id
Phase 3 Slice 5 Step F-1 (design §3.F, m's Q2 ruling). UPDATE any
paliad.projects row still pointing at a litigation-category code
to the fristenrechner-category equivalent:

  INF       → UPC_INF       (UPC infringement, canonical reading)
  REV       → UPC_REV
  APP       → UPC_APP
  CCR       → NULL          (no UPC_CCR — flag for legal review)
  APM       → NULL          (no UPC_APM)
  AMD       → NULL          (no UPC_AMD)
  ZPO_CIVIL → NULL          (no fristenrechner analogue)

Live-data reality: 11/11 projects carry proceeding_type_id IS NULL
today, so this migration touches zero production rows. Ships
defensively for any future test / staging / imported data.

NULL-remaps write a paliad.project_events row
('proceeding_type_remap_null') with the old code in metadata so a
legal-review pass can spot the project + pick a hand-mapped code.

Idempotency: WHERE pt_old.category = 'litigation' AND pt_old.code IN
(...). Re-running on a clean target is a no-op.

Hard assertion at end: zero non-fristenrechner-category references
remain post-mig. RAISE EXCEPTION on violation — fails the migration
loudly rather than relying on mig 088's runtime trigger to catch
the next write.

Audit-reason wrapper cites design §3.F so the rationale persists
forever (mig 079 trigger doesn't fire here directly — no
deadline_rules rows are touched — but set_config is harmless and
keeps the wrapper pattern uniform across all Phase 3 migrations).
2026-05-15 01:01:08 +02:00
mAi
0f142e07af Merge: t-paliad-185 — Fristen Phase 3 Slice 4 (calculator unification — foundation chain complete) 2026-05-15 00:54:01 +02:00
mAi
d7bb238e46 test(t-paliad-185): table-driven unit tests for new helpers
Phase 3 Slice 4 test coverage. Adds:

  - TestEvalConditionExpr (20 sub-cases): AND/OR/NOT compositions,
    single-flag leaf, nested AND-of-OR-and-NOT, empty-args
    vacuous-truth semantics, NULL-expr → legacy condition_flag
    fallback (preserves the AND-of-flags behaviour for any
    pre-Slice-2-style row), malformed JSON / unknown op / malformed
    NOT all defensive-true (rule still renders).

  - TestWireFlagsFromPriority (6 sub-cases): exhaustive enum +
    safe-default for unknown values. Matches the reverse of the
    Slice 2 mig 083 backfill mapping.

  - TestApplyDuration_Matrix (7 sub-cases): 4 units × multiple
    timings × calendar/holiday rollover. Includes the
    Thu+1d-over-Tag-der-Arbeit edge that exercises the
    weekend+holiday cascade.

Test file housekeeping:

  - Drops TestIsCourtDeterminedRule (the function it tested no
    longer exists; equivalence is preserved by mig 082's WHERE
    predicate and verified by the Slice 2 backfill integrity test).
  - Drops the unused models import that becomes orphaned.
  - Renames the EventDeadlineService.applyDuration / addWorkingDays
    method-receiver tests to call the package-level functions
    directly. Same test names + expected dates; only the helper
    signature shifted.
  - Parity test still calls the same applyDuration body, now via
    the unified helper.

Full test suite green locally (live DB tests skip when
TEST_DATABASE_URL is unset, as ever).
2026-05-15 00:53:01 +02:00
mAi
990cc2b797 refactor(t-paliad-185): unified calculator (Slice 4 Step D)
Phase 3 Slice 4 Step D (design §3.D, the last foundation slice).
Pure Go — no migrations. Collapses the proceeding-tree + Pipeline-C
calculators onto a single set of unified helpers + reads, all
without changing wire output.

Helpers (package-level in services/fristenrechner.go):

  applyDuration(base, value, unit, timing, country, regime, holidays)
      → (raw, adjusted, didAdjust, reason)
    Single source-of-truth for date arithmetic. Replaces:
      - addDuration (proceeding-tree, no timing / working_days)
      - applyDurationOnCalendar (Slice 3 Pipeline-C-only)
      - EventDeadlineService.applyDuration / addWorkingDays methods
    Handles: timing=before/after, units days/weeks/months/working_days,
    weekend + holiday rollover for calendar units. working_days lands
    on a working day by construction (no post-rollover).

  evalConditionExpr(expr jsonb, conditionFlag []string, flags) bool
    Long-form jsonb gate evaluator (design §2.4). Grammar:
      leaf:  {"flag":"X"}
      AND:   {"op":"and","args":[<n>...]}
      OR:    {"op":"or","args":[<n>...]}
      NOT:   {"op":"not","args":[<one>]}
    NULL / empty / "null" → unconditional. Defensive fall-through
    on malformed JSON / unknown ops (rule still renders — never
    silently drop a deadline). Fallback to condition_flag
    AND-semantics when expr is NULL but the legacy column is set
    (defensive cover for any row Slice 2 missed).

  wireFlagsFromPriority(priority) → (isMandatory, isOptional)
    Derives the legacy wire pair from the unified priority enum:
      mandatory     → (T, F)     — statutory must
      optional      → (T, T)     — RoP.151 (opt-in, ☐ pre-unchecked)
      recommended   → (F, F)     — situational filing
      informational → (F, F)     — never saves today
      unknown       → (T, F)     — safe default
    Slice 8 will swap the wire to emit priority directly.

Calculate (proceeding-tree) refactor:

  - r.IsCourtSet column read direct, isCourtDeterminedRule() heuristic
    function deleted. Slice 2 backfill (mig 082) wrote the column
    using the exact heuristic predicate; column-read saves the
    per-rule branch test at runtime.
  - r.Priority drives the wire IsMandatory / IsOptional pair via
    wireFlagsFromPriority. Read of r.IsMandatory / r.IsOptional
    columns retained (compat-mode) but never decision-shaping.
  - r.ConditionExpr drives the gate; condition_flag is the fallback.
  - Added combine_op composite (max/min) branch for proceeding-tree
    rules. No live Pipeline-A rules carry combine_op today (it's a
    future-friendly column the rule editor will surface); the
    branch is reachable but produces zero diffs on the current
    corpus.
  - timing=before + working_days now usable on proceeding-tree rules
    via the unified applyDuration. No live Pipeline-A rules use them.

CalculateRule (single-rule card-click) refactor: same column reads
(IsCourtSet, ConditionExpr, Priority), unified applyDuration.

calculateByTriggerEvent (Pipeline C) refactor: switched to the
unified applyDuration; loses the redundant post-pick reason
recompute (applyDuration now returns reason directly).

EventDeadlineService.Calculate composite-note recompute now calls
the package-level applyDuration instead of the deleted method.

Frontend wire shape stays pixel-identical pre/post-Slice-4. The 17
condition_flag rules in the live corpus continue to gate via the
same (a) leaf or (b) AND-of-args evaluator branches mig 084
produced; jsonb path is exercised first, the array fallback
remains as defensive cover.
2026-05-15 00:52:49 +02:00
mAi
650d30f99f Merge: t-paliad-184 — Fristen Phase 3 Slice 3 (Pipeline C migration + EventDeadlineService delegate) 2026-05-15 00:42:55 +02:00
mAi
6cddb2e587 test(t-paliad-184): 77-row Pipeline-C parity assertion
LOAD-BEARING regression guard for Phase 3 Slice 3. For every distinct
trigger_event_id in paliad.event_deadlines, calls Calculate (now
delegating through FristenrechnerService) AND independently re-runs
the legacy applyDuration math against the source row, asserting:

  - count(returned deadlines) == count(active source rows for trigger)
  - id, title, titleDE, durationValue, durationUnit, timing all match
  - dueDate matches the independently-computed expected date (even
    a 1-day diff fails the test — that's the entire point of the
    read-only cutover window)
  - isComposite matches (CombineOp != nil && alt_* set)

Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.

Sweep guard: at least 77 rows must have been checked across all
triggers — if the test only walks 0 triggers (e.g. due to a SELECT
glitch), the final tally raises.

Trigger date is an arbitrary working day (2026-01-15) so weekend
rollover noise is minimal; the parity comparison is against an
inline expected value, not a fixed snapshot, so any date that
exercises the calculator works.
2026-05-15 00:41:29 +02:00
mAi
8a814e3442 refactor(t-paliad-184): EventDeadlineService.Calculate delegates
Phase 3 Slice 3 service-side rewire. EventDeadlineService.Calculate
now:

  1. Looks up trigger event metadata (unchanged — the legacy response
     shape still carries TriggerEvent + TriggerDate at the top level).
  2. SELECTs source event_deadlines rows for the trigger to recover
     (id, duration, alt_*, combine_op, notes_en) — the unified
     UIResponse drops those fields. SELECT is still allowed by the
     mig 086 read-only trigger; only writes are blocked.
  3. Delegates the rule SELECT + math to FristenrechnerService.Calculate
     with TriggerEventIDFilter set.
  4. Merges the unified result with the source rows (join by Name =
     title_de) to produce the legacy EventDeadlineResult shape with
     ID, ruleCodes, isComposite, compositeNote intact.
  5. Loads rule_codes from event_deadline_rule_codes (also still
     readable) by source.id.

Public signature unchanged — /api/tools/event-deadlines callers see
no diff. The legacy applyDuration / addWorkingDays helpers stay on
EventDeadlineService for the pure-Go unit tests + the composite-note
leg-pick that the unified UIDeadline doesn't expose.

main.go wiring: NewEventDeadlineService gains the FristenrechnerService
dependency.
2026-05-15 00:41:20 +02:00
mAi
5f9a8b2ef4 feat(t-paliad-184): FristenrechnerService.calculateByTriggerEvent
Phase 3 Slice 3 calculator-side rewire. Adds the Pipeline-C branch
to FristenrechnerService so the unified backend can serve
event-driven deadlines:

  - CalcOptions.TriggerEventIDFilter *int64 — when non-nil, Calculate
    dispatches to calculateByTriggerEvent (proceedingCode ignored).
  - calculateByTriggerEvent — flat-rule calculator: SELECT rules
    WHERE trigger_event_id = X, compute each via the new
    applyDurationOnCalendar helper (handles timing='before',
    working_days, combine_op alt-leg max/min). No parent_id chains,
    no flag gating, no IsRootEvent / IsCourtSet semantics — those
    are Pipeline-A concerns.
  - applyDurationOnCalendar + addWorkingDays — package-level helpers
    that the proceeding-tree calculator's existing addDuration
    doesn't cover. Slice 4 will fold them into a single unified
    helper when the proceeding-tree side also reads timing +
    working_days from the unified rule shape.
  - DeadlineRuleService.ListByTriggerEvent — SELECT rules scoped to
    a single trigger_event_id, ORDER BY sequence_order (preserves
    the 1000 + ed.id ordering mig 085 wrote). Skips
    hydrateConceptDefaultEventTypes since Pipeline-C rules don't
    carry concept_id today.

UIResponse for trigger-event calls returns empty ProceedingType /
ProceedingName — EventDeadlineService owns the trigger metadata in
the legacy CalculateResponse shape. That's a stable contract for
the caller and avoids polluting UIResponse with trigger-event-only
fields.
2026-05-15 00:41:10 +02:00
mAi
ee2caf9d79 feat(t-paliad-184): mig 086 — event_deadlines read-only trigger
Phase 3 Slice 3 cutover-window guard. BEFORE INSERT/UPDATE/DELETE
trigger on paliad.event_deadlines raises EXCEPTION with a message
pointing the writer at paliad.deadline_rules. SELECT remains
unaffected.

Why: Slice 3 just moved 77 rows into the unified backend (mig 085).
Until Slice 4 cuts every reader over and Slice 9 drops the legacy
table, the two sides must not diverge. Letting any write through
event_deadlines would silently regress "Was kommt nach…" parity.

Supabase service_role bypasses RLS but NOT triggers — direct DB
maintenance (psql, migration scripts, MCP) is also blocked. That's
intentional: every further edit to event_deadlines pre-Slice-9 is a
mistake. Slice 9's mig ~090 will drop the table + this trigger
together as part of the legacy cleanup.

Function is plain (not SECURITY DEFINER): the trigger function only
RAISE EXCEPTIONs, no INSERTs anywhere, so it doesn't need elevated
privileges. Caller's RLS / role context doesn't matter — the raise
fires unconditionally before any tuple lock is taken.
2026-05-15 00:40:59 +02:00
mAi
88d5656a35 feat(t-paliad-184): mig 085 — Pipeline C data-move (77 rows)
Phase 3 Slice 3 Step C (design §3.C). INSERT 77 active rows from
paliad.event_deadlines into paliad.deadline_rules so the unified
backend can serve both pipelines. Source rows preserved (mig 086
wraps the source table in a read-only trigger; Slice 9 drops it).

Mapping:
  trigger_event_id              ← event_deadlines.trigger_event_id (bigint, mig 028)
  name (DE, NOT NULL)           ← event_deadlines.title_de         (NOT NULL DEFAULT '')
  name_en (NOT NULL)            ← event_deadlines.title            (EN, NOT NULL)
  duration_value / unit         ← event_deadlines.duration_value / unit
  timing                        ← event_deadlines.timing           (before / after)
  alt_duration_value / unit     ← event_deadlines.alt_duration_*
  combine_op                    ← event_deadlines.combine_op       (mig 078 column)
  deadline_notes (DE)           ← event_deadlines.notes  (DE; NULLIF '' so empty
                                                          stays NULL on dr side)
  deadline_notes_en             ← event_deadlines.notes_en (mig 036)
  legal_source                  ← event_deadlines.legal_source
  published_at                  ← event_deadlines.created_at        (chronological audit)
  sequence_order = 1000 + ed.id (large offset so Pipeline-C rules
                                  sort after any hand-authored
                                  Pipeline-A sequence_orders; preserves
                                  source ordering within Pipeline C)
  lifecycle_state = 'published' / priority = 'mandatory' / is_active = ed.is_active

Pipeline-A-only fields stay NULL on the new rows: proceeding_type_id,
parent_id, spawn_proceeding_type_id, code, primary_party, event_type,
condition_expr, condition_flag. is_court_set = false (no court-set
rules in the Pipeline-C corpus today; legal-review pass can flip
Zustellung-* later via a separate slice).

Idempotency: WHERE NOT EXISTS guard on (trigger_event_id, name).
Re-running the migration is a no-op.

Hard assertion at end: COUNT(deadline_rules WHERE trigger_event_id
IS NOT NULL) must equal COUNT(event_deadlines WHERE is_active=true)
post-mig. RAISE EXCEPTION on mismatch — better to fail the migration
loudly than to ship a partial Pipeline-C corpus and poison Slice 4.

Audit-reason set via set_config so the mig 079 trigger writes 77
paliad.deadline_rule_audit rows with the design §3.C citation
preserved as the rationale. That's the persistent compliance trail
for the data-move.

No mandatory bool on event_deadlines (the head instruction sketch
suggested mapping it; the schema doesn't have one) — Pipeline-C
rules default priority='mandatory', consistent with the statutory
nature of the corpus.
2026-05-15 00:40:50 +02:00
mAi
238c4d7cf0 Merge: t-paliad-183 — Fristen Phase 3 Slice 2 (backfill is_court_set / priority / condition_expr) 2026-05-15 00:29:56 +02:00
mAi
32a620b788 test(t-paliad-183): assert backfill integrity for Slice 2
Live-DB test (TEST_DATABASE_URL-gated, mirrors Slice 1 pattern)
validating mig 082/083/084 landed correctly:

  1. is_court_set matches isCourtDeterminedRule() exactly. Counts
     rows where is_court_set != (primary_party='court' OR
     event_type IN ('hearing','decision','order')); must be zero.

  2. priority is non-NULL everywhere (CHECK guards the schema —
     this is belt-and-braces). Buckets by (is_mandatory,
     is_optional) and asserts the design §2.3 mapping:
       T/F → mandatory; T/T → optional; F/* → recommended.

  3. condition_expr translation is complete + non-spurious:
       - every non-empty condition_flag has non-NULL condition_expr
       - every NULL/empty condition_flag has NULL condition_expr
       - single-flag rows: condition_expr ->> 'flag' = condition_flag[1]
       - multi-flag rows: condition_expr ->> 'op' = 'and' AND
         jsonb_array_length(args) = array_length(condition_flag, 1)

The Slice 1 test's "every row priority='mandatory' && !is_court_set"
assertion is loosened to "priority in enum" + "lifecycle_state='published'"
since Slice 2 backfills now mutate those defaults.

Build clean, full test suite green (live DB tests skip locally).
2026-05-15 00:29:10 +02:00
mAi
9d73b91e05 feat(t-paliad-183): mig 084 — backfill condition_expr per design §2.4
Phase 3 Slice 2 Step B-3. Convert condition_flag text[] →
condition_expr jsonb per DESIGN §2.4 long form (NOT msg 1746's
short {"and":[...]} form — head clarified in msg 1750 that
design §2.4 wins because long form parses uniformly across
and/or/not, matching what the Slice-4 calculator + Slice-11 rule
editor will emit).

Mapping:
  ['with_ccr']                  →  {"flag":"with_ccr"}              (5 rows)
  ['with_amend']                →  {"flag":"with_amend"}            (4 rows)
  ['with_cci']                  →  {"flag":"with_cci"}              (4 rows)
  ['with_ccr', 'with_amend']    →  {"op":"and","args":[
                                       {"flag":"with_ccr"},
                                       {"flag":"with_amend"}
                                   ]}                                (4 rows)
  NULL or {}                    →  NULL                             (155 rows)

Total translated: 17 rows.

Single-flag is unwrapped (no AND wrapper) per design §2.4 — a
shortcut equivalent to a 1-arg AND that saves a layer of nesting
without losing semantics. The calculator's parser treats
{"flag":"<name>"} as the leaf and {"op":"<and|or|not>","args":[…]}
as the canonical boolean node.

jsonb construction uses jsonb_build_object + a LATERAL unnest…WITH
ORDINALITY over the flag array so args[] order matches the source
array exactly (load-bearing if a future migration adds order-
sensitive ops).

Idempotent via WHERE condition_expr IS NULL — re-running doesn't
double-write audit rows for already-translated rules. Migration
ends with a DO block that RAISE EXCEPTION if any non-empty
condition_flag row still has NULL condition_expr (catches a
broken translation path before it reaches Slice 4).
2026-05-15 00:29:00 +02:00
mAi
b966d7c8cd feat(t-paliad-183): mig 083 — backfill priority per design §2.3
Phase 3 Slice 2 Step B-2. UPDATE paliad.deadline_rules.priority
from the legacy (is_mandatory, is_optional) pair per DESIGN §2.3
(NOT msg 1746's inverted mapping — head clarified in msg 1750
that design §2.3 is the load-bearing spec).

Mapping:
  T/F (153 rows) → 'mandatory'   (statutory must, ☑ pre-checked)
  T/T (  1 row)  → 'optional'    (RoP.151 — opt-in deadline,
                                  ☐ pre-unchecked per mig 068)
  F/T (  0 rows) → 'recommended' (defensive; no live data)
  F/F ( 18 rows) → 'recommended' (situational filings —
                                  Berufungserwiderung, Replik,
                                  Duplik, R.19 Preliminary
                                  Objection, R.116 EPÜ, etc.)

Why NOT msg 1746's mapping:
  - T/T → 'recommended' would PRE-CHECK RoP.151 in the save modal
    and auto-create a Kostenentscheidung deadline the user didn't
    ask for. That's the regression we'd ship.
  - F/F → 'informational' would render 18 real filing deadlines
    NEVER-SAVEABLE per design §2.3 ("informational … NEVER saves
    as a deadline"). They'd disappear from save flows entirely.

T/F branch is intentionally skipped — mig 078 already defaults
priority='mandatory', so all 153 T/F rows are already correct.
Writing 153 needless audit rows would dilute the backfill trail.

Audit-reason cites design §2.3 — that's the persistent rationale
captured in paliad.deadline_rule_audit. Migration enforces NOT NULL
post-run via a DO block that RAISE EXCEPTION on stragglers.
2026-05-15 00:28:49 +02:00
mAi
755a1042ff feat(t-paliad-183): mig 082 — backfill is_court_set from heuristic
Phase 3 Slice 2 Step B-1 (design §3.B). UPDATE paliad.deadline_rules
to set is_court_set=true where the live isCourtDeterminedRule()
heuristic returns true:

  primary_party = 'court'
    OR event_type IN ('hearing', 'decision', 'order')

Expected delta on the production corpus: 47 rows flipped false→true
(every primary_party='court' rule overlaps with a court event_type
in the current data, so the two predicates fully overlap at 47).

Replicates the live fristenrechner.go body EXACTLY, not the
ILIKE-padded sketch in msg 1746. Per head's ruling msg 1750:
padding with '%entscheidung%' / '%urteil%' would mis-flag party
filings like RoP.151 (Antrag auf Kostenentscheidung) and § 83 PatG
(Stellungnahme zum Hinweisbeschluss) as court-set. They aren't —
only their anchors are.

Audit footnote: ~8 'Zustellung…' rules (LG-Urteil, OLG-Urteil,
BPatG-Entscheidung, Beschwerdeentscheidung, DPMA-Entscheidung)
carry primary_party='both' + event_type='filing'. Semantically the
Zustellung date IS court-set; flagging them is left to the legal-
review pass mentioned in design §2.3, not this slice.

Idempotent via WHERE is_court_set = false. Audit-reason is set via
set_config('paliad.audit_reason', …, true) so the mig 079 trigger
captures one paliad.deadline_rule_audit row per flipped rule —
the persistent backfill trail.

Mig 081 was reserved for proceeding_types display_order verification
in design §3.1; it was a no-op and was not authored. Tracker
skips 081, advances 80 → 82. golang-migrate handles non-contiguous
numbers fine as long as the order ascends.
2026-05-15 00:28:38 +02:00
mAi
c7fa0d6542 Merge: t-paliad-182 — Fristen Phase 3 Slice 1 (unified rule columns + audit table + instance_level) 2026-05-15 00:20:52 +02:00
mAi
1f8230b264 feat(t-paliad-182): models + service compat-read for unified rules
Phase 3 Slice 1 Go-side of mig 078–080. Compat-mode reads: the
service selects BOTH the legacy shape (is_mandatory, is_optional,
condition_flag, condition_rule_id) and the new shape (priority,
condition_expr, is_court_set, trigger_event_id,
spawn_proceeding_type_id, combine_op, lifecycle_state, draft_of,
published_at). Existing callers stay on the legacy fields until
Slice 4 cuts the calculator over.

Adds:
  - DeadlineRule field block for the nine Phase 3 columns. NULLable
    jsonb (condition_expr) uses NullableJSON to dodge the
    json.RawMessage NULL-scan trap (see Project.Metadata note from
    t-paliad-138 dogfood).
  - Project.InstanceLevel *string.
  - DeadlineRuleAudit row struct (id, rule_id, changed_by,
    changed_at, action, before_json, after_json, reason,
    migration_exported).
  - ruleColumns const extended to project every new column.

Test (TEST_DATABASE_URL-gated, mirrors audit_service_test.go):
  1. ruleColumns SELECT scans cleanly — every new column populates
     its Go field.
  2. Migration defaults land: priority='mandatory',
     is_court_set=false, lifecycle_state='published' on every
     pre-Slice-1 row.
  3. Audit trigger writes one row on UPDATE WITH paliad.audit_reason
     set, captures before+after JSON + reason.
  4. Audit trigger RAISES on UPDATE WITHOUT paliad.audit_reason —
     Slice 2 backfills fail loudly if they forget to set it.
  5. paliad.projects.instance_level accepts NULL + first/appeal/
     cassation, rejects 'final'.

Build clean, full test suite green (live DB test skipped locally).
2026-05-15 00:19:49 +02:00
mAi
bd8ec42b80 feat(t-paliad-182): mig 080 — projects.instance_level
Phase 3 Slice 1, design §2.7 + §7. Adds a nullable text column
gated by a CHECK to 'first' | 'appeal' | 'cassation'. Combined
with proceeding_code + jurisdiction, the FristenrechnerService
(Slice 8) will derive the effective proceeding code — e.g.
DE_INF + appeal → DE_INF_OLG.

No backfill in this slice. The project-detail picker UI (Slice 8)
writes the column; pre-Slice-1 rows stay NULL and behave as
implicit 'first' in the calculator's fallback.
2026-05-15 00:19:37 +02:00
mAi
ec0ec32271 feat(t-paliad-182): mig 079 — deadline_rule_audit table + trigger
Phase 3 Slice 1 audit-log foundation (design §2.8). The audit log
lands BEFORE the rule editor (Slice 11) so every future write to
paliad.deadline_rules is captured — including the Slice 2
backfill UPDATEs.

paliad.deadline_rule_audit columns mirror design §2.8 (changed_by,
changed_at, before_json / after_json, reason, migration_exported).
Two intentional deviations, documented inline:

  1. changed_by is nullable, not NOT NULL. Trigger reads auth.uid()
     which is NULL under service_role (migrations, server-side Go
     using the service key). NOT NULL would block Slice 2 backfills
     and every seed insert.

  2. action values written by the trigger are 'create'|'update'|
     'delete' (raw TG_OP). Go-authored audit rows additionally
     write 'publish'|'archive'|'restore' (lifecycle_state flips
     that the trigger sees as plain UPDATEs). The audit UI in
     Slice 11 collapses the paired rows.

Trigger is SECURITY DEFINER so its INSERT into the audit table
bypasses the audit table's RLS — otherwise an authenticated
user's UPDATE on a rule would fail when the trigger tried to write
under their RLS context.

Audit-reason enforcement: trigger reads paliad.audit_reason via
current_setting(..., true) and raises EXCEPTION on UPDATE/DELETE
when unset. INSERT defaults to 'create' so seed migrations stay
ergonomic.

RLS: SELECT for global_admin only (mirrors mig 057 pattern). No
INSERT policy — the SECURITY DEFINER trigger and service_role are
the only writers.
2026-05-15 00:19:31 +02:00
mAi
251f5a250f feat(t-paliad-182): mig 078 — unified rule columns
Phase 3 Slice 1 Step A (design §3.1). Additive only; no drops, no
data change. Adds nine columns to paliad.deadline_rules so the
calculator + rule editor can converge on a single rule shape over
the following slices:

  trigger_event_id          (bigint, FK trigger_events.id)
  spawn_proceeding_type_id  (int,    FK proceeding_types.id)
  combine_op                (text, CHECK 'max'|'min')
  condition_expr            (jsonb)
  priority                  (text, DEFAULT 'mandatory', 4-way CHECK)
  is_court_set              (bool, DEFAULT false)
  lifecycle_state           (text, DEFAULT 'published', 3-way CHECK)
  draft_of                  (uuid, self-FK)
  published_at              (timestamptz)

FK types follow the actual referenced columns (bigint on
trigger_events, int4 serial on proceeding_types) — the design doc's
"int FK" shorthand is widened to the precise widths.

FKs are DEFERRABLE INITIALLY IMMEDIATE so Slice 3's data-move can
defer FK checks within a single transaction without disturbing
normal-statement semantics.

Indexes: partial WHERE NOT NULL on the two FK columns (sparse;
most rules have neither); plain btree on lifecycle_state so the
admin filter on 'published' is O(log n).
2026-05-15 00:19:19 +02:00
mAi
58a1abc6d8 Merge: t-paliad-181 — Fristen Phase 2 design (unified rule model + 12 slices, DESIGN READY FOR REVIEW) 2026-05-15 00:11:28 +02:00
mAi
7159443dcb Merge: t-paliad-177 Slice 4 (FINAL) — Custom Views shape=timeline + cross-project lane aggregation 2026-05-15 00:10:43 +02:00
mAi
119b06dcff design(t-paliad-181): Fristen Phase 2 — unified rule model + 12-slice plan
Phase 2 design pass operationalising all 7 m-locked + 8 head-default
picks from audit §9.

Headline architecture:
- ONE unified deadline_rules table (evolved, not replaced) absorbing
  Pipeline A + Pipeline C. Adds trigger_event_id, spawn_proceeding_type_id,
  combine_op, condition_expr (jsonb AND/OR/NOT), priority (4-way enum),
  is_court_set (real column, drops heuristic), lifecycle_state +
  draft_of + published_at (rule-editor draft → published lifecycle).
  Drops condition_flag, condition_rule_id, is_mandatory, is_optional.
  Net +5 columns, 32 → 37.
- paliad.deadline_rule_audit table + DB trigger + RLS for admin-only
  rule editing (Q5C). Mandatory reason field. Migration-export
  endpoint keeps rules in version control after-the-fact.
- paliad.projects.instance_level column (first/appeal/cassation)
  enables DE_INF → DE_INF_OLG → DE_INF_BGH ladder without proceeding_type
  re-pick.
- Cross-proceeding spawn wired via spawn_proceeding_type_id FK +
  global rule index in the calculator + cycle guard.
- POST /api/tools/event-trigger preserves Pipeline C contract on
  unified backend.

Migration path (Steps A-I, ~17 migrations 078-094):
- Step A additive schema → Step B backfill → Step C Pipeline C
  data-move → Step D calculator unification (service refactor) →
  Step E destructive drops (gated) → Step F project soft-merge
  (Q2) → Step G spawn → Step H instance-level → Step I rule_id
  backfill on legacy deadlines.
- Read-only trigger on paliad.event_deadlines during the cutover
  window prevents drift.
- Backup snapshots before destructive drops.

12 prioritized slices (§10) for Phase 3:
- Slices 1-4 sequential: schema, backfill, Pipeline C migration,
  calculator unification.
- Slices 5-8 parallel: project soft-merge, event-trigger endpoint,
  spawn wiring, instance level.
- Slices 9-10 cleanup: destructive drops, rule_id fuzzy-match
  backfill.
- Slices 11a + 11b: rule-editor backend + frontend (HEAVIEST,
  lands last on stable schema).
- Slice 12: orphan concept seed (wiedereinsetzung first), through
  the editor as its real-world workout.

§9 risk surface: destructive migrations, audit-log compliance gap
during cutover (mitigated by SET LOCAL audit_reason in migration
tooling), cross-corpus drift window (mitigated by read-only
trigger), condition_expr jsonb perf (trivial at 172-row scale),
migration-export manual step.

§12 has 12 open questions for HEAD (not m) — sub-decisions head
resolves at slice-start: migration window, draft lifecycle for
v1, audit retention, preview implementation, export format, slice
ordering, cycle-guard strictness, picker placement, testing scope,
ambiguity-tail handling, seed-vs-editor ordering, telemetry.

§0 drift since 2026-05-13 audit: 1 fristenrechner code deactivated
(20→19 active); mig 075-077 are SmartTimeline, NOT Fristen-logic;
new concept (56→57); new event_types (40→45). All audit findings
hold.

NOT self-merged. Head gates Phase 3 transition (no m-gate).
NOT cronus per memory directive 2026-05-06.
2026-05-15 00:10:07 +02:00
mAi
1c915639b9 feat(t-paliad-177): Custom Views timeline-shape host (frontend)
Slice 4 step 2 (faraday-Q7). Wires shape="timeline" into the /views
shape switcher and the dispatch in client/views.ts.

New file shape-timeline-cv.ts holds the adapter:
- ViewRow.kind="deadline" → TimelineEvent kind="deadline" + deadline_id
- ViewRow.kind="appointment" → kind="appointment" + appointment_id
- ViewRow.kind="project_event" → kind="milestone" + project_event_id
- ViewRow.kind="approval_request" → SKIPPED (no chart-meaningful date)
- Lane axis = project_id (design §10 cross-project chart use case);
  first-seen order keeps lanes deterministic across re-renders.
- Rows without project_id collapse to a synthetic "self" lane.
- Status comes from row.detail.status for deadlines (done/overdue),
  defaults to "open" everywhere else.

shape-timeline-chart.ts gets a new ChartMountOpts.staticData escape
hatch: when supplied, mount() skips the /api/projects/{id}/timeline
fetch and paints from the supplied events + lanes directly. This is
what lets the CV adapter feed pre-loaded ViewRows into the same
renderer that powers /projects/{id}/chart — Slice 1-3 features
(palette, density, range chips, lane filter, permalink) all carry
over for free.

views.ts switches the active shape host and disposes the chart handle
on shape flips so resize listeners don't leak between mounts.

Tests (13 new): pin the kind mapping, lane bucketing by project_id,
status extraction precedence, date passthrough, empty-input safety.

Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5.
2026-05-15 00:09:23 +02:00
mAi
83a3d27fe0 feat(t-paliad-177): ShapeTimeline enum + render_spec wiring
Slice 4 step 1 (faraday-Q7). RenderShape gets a fourth member
ShapeTimeline, AllShapes extends, Validate accepts it. The
companion TimelineConfig struct stores the saved palette / density /
range-preset for a CV-timeline view so re-opening the view restores
the same visual settings — same vocabulary as the standalone
/projects/{id}/chart URL state, just persisted in user_views.render_spec
instead of the URL.

Validator mirrors the frontend's enum guards:
- known palettes (default | kind-coded | track-coded | high-contrast | print)
- known densities (compact | standard | spacious)
- known range presets (1y | 2y | all | custom)
- ISO-date strings length-bounded to 32 chars so a hostile editor
  can't bloat the jsonb column.

Tests pin every accept/reject path in TestRenderSpec_TimelineConfigValidates.

Design ref: docs/design-project-chart-2026-05-09.md §11.5 + §14 Q7.
2026-05-15 00:06:37 +02:00
mAi
79f6be3fc9 Merge: t-paliad-157 — Fristen-Logik-Audit (AUDIT READY FOR REVIEW) 2026-05-15 00:02:31 +02:00
mAi
b455df265e audit(t-paliad-157): Fristen logic — rules, triggers, conditionals
Phase 1 audit (AUDIT ONLY, no implementation). 799 lines, mai/pauli/fristen-logic-audit.

Headline findings:

- THREE parallel deadline-generation systems coexist with overlapping
  intent:
  - Pipeline A (proceeding-driven) — paliad.deadline_rules (172 rows),
    FristenrechnerService.Calculate, drives /tools/fristenrechner +
    SmartTimeline.
  - Pipeline B (single-rule subset of A) — Pathway B cascade click.
  - Pipeline C (event-driven, youpc legacy) — paliad.trigger_events
    (110) + paliad.event_deadlines (77), EventDeadlineService.Calculate,
    drives "Was kommt nach…" tab. Disjoint corpus from A.

- Rule corpus is RICHER than the brief implied: 32 columns, 172 rules
  across 27 proceeding_types (132 fristenrechner + 40 litigation). The
  dual-corpus is a latent footgun: paliad.projects.proceeding_type_id
  accepts both categories with no CHECK constraint, so a project's
  SmartTimeline depends on which code lands first.

- Data model already encodes most of m's mental model:
  multi-deadline triggers via parent_id chains (deepest live: 3
  levels in UPC_INF), conditional via condition_flag (AND-only),
  flag-swap via alt_duration_value / alt_rule_code, court-set via
  heuristic + 4-bucket classification, holiday adjustment via
  HolidayService+CourtService.

- Real gaps (§6, 13 of them):
  - Pipeline A/C redundancy (different capabilities, disjoint data).
  - Litigation vs fristenrechner corpus drift (no contract).
  - is_mandatory + is_optional overlap.
  - deadline_concept_event_types is config layer, NOT trigger model.
  - No real event-driven trigger endpoint.
  - AND-only condition_flag (no OR/NOT/compound).
  - Cross-proceeding spawn half-wired.
  - 9 orphan concepts with rule_count=0 (incl wiedereinsetzung,
    schriftsatznachreichung, weiterbehandlung).
  - condition_rule_id dead column.
  - Instance dimension (LG/OLG/BGH) not on paliad.projects.
  - 1/26 deadlines linked to rule_id (anchor-from-actuals barely
    used).
  - Court-set is heuristic, not first-class column.
  - Pipeline A lacks before / working_days / combine_op.

- The big m's-question: "all in the Rules so we should be able to
  manage" is FALSE today. Rules edits = SQL migrations only. §8
  proposes a 3-step ladder: status-quo / read-only admin / full
  editor with audit log.

- §7 has concrete extension proposal for each §6 gap (migration size
  costed).

- §9 has 15 open questions for m to call before Phase 2 starts.

- Live data sparse: 11/11 projects NULL proceeding_type_id, 1/26
  deadlines with rule_id — demand-side mostly empty even though
  supply-side (rules) is rich.

NOT cronus per memory directive 2026-05-06. NOT self-merged. Awaiting
m's go/no-go.
2026-05-13 21:33:38 +02:00
mAi
7d9935de60 Merge: t-paliad-177 Slice 3 — chart range chips + lane filter + permalink + sidebar entry 2026-05-13 11:54:29 +02:00
mAi
e9bcf3a7b6 feat(t-paliad-177): chart reciprocal "Zurück zum Verlauf" link
Slice 3 step 5 (optional). The back-link on the chart page now points
explicitly at /projects/{id}/history (Verlauf sub-path) instead of
the bare /projects/{id}. Today's projects-detail.ts treats both the
same — bare and /history land on the Verlauf tab — but /history is
the explicit form, so the link keeps working if Verlauf ever stops
being the default tab.

Label flips from "Zurück zum Projekt" → "Zurück zum Verlauf" so
users see exactly where they're heading. Pairs naturally with the
Slice 1 "Als Chart anzeigen ↗" affordance: the trip is round.

Design ref: docs/design-project-chart-2026-05-09.md §8.1.
2026-05-13 11:53:46 +02:00
mAi
1ad78918bc feat(t-paliad-177): chart sidebar contextual entry (option a)
Slice 3 step 4 (head Slice-2 deferral). Implements head's option (a):
sidebar.ts walks the URL pathname on init and reveals a contextual
"Als Chart anzeigen" entry when it sits on a /projects/{uuid}/* page
that ISN'T already the chart itself.

Sidebar TSX gets a new hidden slot id="sidebar-project-chart-link"
right under the Übersicht group. The page never has to touch the
sidebar — initProjectContextChartLink owns the path-match and the
href population. Clean separation: pages don't know about the slot;
sidebar.ts doesn't know about pages.

UUID-shape regex prevents the chip from appearing on /projects (list)
or /projects/new. Rest-path check excludes /chart and /chart/ — the
chart page already has its own "Zurück zum Verlauf" path (Slice 1
link goes the other direction, a reciprocal can land in the next
commit).

i18n: 1 new key DE+EN under nav.context.project_chart.

Design ref: docs/design-project-chart-2026-05-09.md §8.1 +
Slice-2 head deferral resolution.
2026-05-13 11:53:13 +02:00
mAi
5e1f1fecf6 feat(t-paliad-177): chart permalink copy-link + URL params consolidation
Slice 3 step 3 (faraday-Q10). The URL already aggregates every chip's
state via the individual writeParamToURL writers we built in Slice 2
and Slice 3 C1-C2 — palette + density + range + lanes. The copy
button just reads window.location.href and writes it to the clipboard.

Two-tier clipboard strategy:
1. navigator.clipboard.writeText in secure contexts (modern browsers,
   localhost, paliad.de over TLS).
2. document.execCommand("copy") fallback for older / non-secure
   contexts (file://, some iframes).

Visual feedback flashes green/amber on the button for 1.8s after the
click — no toast component needed, the button IS the affordance.

Permalink contract: reload an identical URL → visually identical
chart. Tested by hand on every chip combination; URL stays canonical
(default values omit their param) so shared links don't accumulate
defaults that drift if defaults change.

Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §14 Q10.
2026-05-13 11:51:47 +02:00
mAi
731e762919 feat(t-paliad-177): chart lane visibility filter + URL state
Slice 3 step 2. The chip group is rendered dynamically by the boot
client after refresh() reports lanes via the new onDataLoaded
callback — the lane labels and ids only exist after the server
responds, so static TSX can't render the chips. Hidden when the
projection has 0-1 lanes (filter has no value on a single-track
render).

setVisibleLanes(allowlist | null) on the chart handle filters BOTH
lanes and events in repaint() before passing to layout() — drops
unselected entirely (doesn't fall back to first-lane the way an
unknown stale id does). null = show all.

Stale lane ids are dropped from the URL-restored allowlist after
every refresh: deleted CCRs / child cases can't keep their lane id
alive across re-fetches.

URL state in ?lanes=id1,id2; absent / empty = show all. Hostile or
oversized ids are filtered (length cap 200) at parse time; the
allowlist intersection in repaint() defends again. Toggling every
chip back on collapses to null so the URL stays canonical.

Design ref: docs/design-project-chart-2026-05-09.md §3.2 + §8.2.
2026-05-13 11:51:08 +02:00
mAi
581fbe7d92 feat(t-paliad-177): chart range chips + custom-range URL state
Slice 3 step 1. Four range presets per design §10 + faraday-Q8 default:
1y (today-1y..today+1y, default), 2y, all (derives bounds from loaded
events with a +30d right pad), and custom (date-pair inputs).

mount() grows currentRangePreset + customRangeFrom + customRangeTo so
the layout-time viewport is computed from the live preset, not the
constructor-time opts. resolveRange() handles the four cases; "all"
calls rangeFromEvents() over the last fetched timeline so completing
or adding a row reflows on next repaint.

URL state in ?range=1y|2y|all|custom (omit when 1y); custom adds
?from=&to=. ISO_DATE_RE guards malformed input. Custom date-pair
shows / hides based on the preset.

i18n: 7 new keys DE+EN under projects.chart.range.*.

Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §10 + §14 Q8.
2026-05-13 11:49:24 +02:00
mAi
8f5b83ec93 Merge: t-paliad-166 — Determinator row-by-row cascade design doc (DESIGN READY FOR REVIEW) 2026-05-13 11:43:31 +02:00
mAi
7c4bc39115 design(t-paliad-166): Determinator B1 row-by-row cascade
- §0 premises verified live: 4-layer Pathway B mess (radio + 2 chip-strips
  + breadcrumb-cascade), 91/103 leaves carry forum tag, 16 leaves carry
  party tag, 11/11 live projects have NULL proceeding_type_id (graceful
  degrade), 4 distinct condition_flag value-sets on UPC_INF + UPC_REV
  only, project.court is free-text not FK, verfahrensablauf-core.ts
  carries zero cascade leakage post-t-paliad-179 Slice 1.
- §1 three intertwined pillars: project-driven narrowing / visual
  hierarchy overhaul / row-by-row persistent cascade.
- §2-3 single .fristen-row primitive (active / answered / prefilled /
  hidden) replaces radio + chip-strips + breadcrumb-cards.
- §4 data mapping: forum derivation already shipped; new
  litigation_code x jurisdiction -> fristenrechner_code helper
  (shared with t-paliad-178 Slice 2).
- §5 per-row pre-fill / hide / skipped-but-shown matrix across UPC /
  DE / EPA / DPMA / ad-hoc / zero-context flows with two compact
  ASCII diagrams.
- §6 Filter / Suche mode = escape-hatch icon-button (inventor's pick).
- §7-9 mobile breakpoints, three reset flavours, search affordance
  placement.
- §10 three slices: visual-only (Slice 1), narrowing depth +
  proceeding_mapping.go helper (Slice 2), mobile + search polish
  (Slice 3).
- §11 seven trade-offs flagged (row-stack height, aus-Akte noise,
  auto-walk magic, radio removal, NULL proceeding_type_id reality,
  mapping ambiguities, ändern descendant invalidation).
- §12 file-touch map for Slice 1 only.
- §13 fifteen open questions for m to call before coder shift.

NOT self-merged. Awaiting m's go/no-go.
2026-05-13 11:27:06 +02:00
mAi
adf377c2ca Merge: t-paliad-179 Slice 1 — Tools surface split (route + shell + code-lift) 2026-05-13 00:20:44 +02:00
mAi
f5eb84718a chore(t-paliad-179): sidebar maps Verfahrensablauf 1:1 to its own URL
Sidebar.tsx href flips from /tools/fristenrechner?path=a to
/tools/verfahrensablauf. The two Werkzeuge entries now resolve to
distinct pathnames, so the SSR navItem helper picks the right active
class on its own — fixVerfahrensablaufActive (which compared search
params client-side to disambiguate) is deleted along with its call
in initSidebar.
2026-05-13 00:19:16 +02:00
mAi
1255ee049f feat(t-paliad-179): /tools/verfahrensablauf page (TSX + client + build)
The new abstract-browse surface. TSX shell hosts:

  - header (h1 + subtitle)
  - jurisdiction-tabbed proceeding-tile picker (UPC / DE / EPA / DPMA)
  - trigger date input
  - court picker (visible only for proceedings with multiple
    compatible courts — UPC_REV across CD + LD seats etc.)
  - view toggle (Spalten / Zeitstrahl)
  - result container

client/verfahrensablauf.ts wires picker click → calculateDeadlines →
renderColumnsBody/renderTimelineBody via the shared core. Pre-selects
the first proceeding tile on load so users see a timeline immediately,
matching /tools/fristenrechner's auto-render behaviour. No Akte
picker, no Pathway B cascade, no save modal, no anchor-override edit
— Slice 1 is the structural foundation; variant chips + lane view
(Slice 3) and compare (Slice 4) layer on top in later commits.

build.ts wires the new entrypoint + write step. i18n adds
tools.verfahrensablauf.title / .heading / .subtitle in DE + EN; the
existing nav.verfahrensablauf reused.
2026-05-13 00:19:10 +02:00
mAi
0105d35f0c refactor(t-paliad-179): fristenrechner consumes shared renderer module
client/fristenrechner.ts imports renderTimelineBody / renderColumnsBody
/ deadlineCardHtml / formatDate / partyBadge / escAttr / escHtml /
calculateDeadlines / populateCourtPicker from views/verfahrensablauf-
core, deleting the local copies (~480 lines out). The click-to-edit
anchor-override path stays wired by passing { editable: true } to the
shared renderers; the local anchor-override Map / openInlineDateEditor
/ render-on-override path are unchanged.

The "Verfahrensablauf einsehen" Step 2 card (t-paliad-168) is retired
— TSX markup gone, click handler gone. The abstract-browse intent
lives at /tools/verfahrensablauf now (Slice 1 design §9, §10).
2026-05-13 00:19:00 +02:00
mAi
0531e5dbf6 feat(t-paliad-179): lift Fristenrechner renderers into shared core module
frontend/src/client/views/verfahrensablauf-core.ts — pure-functional
module with the proceeding-timeline rendering surface:

  - DeadlineResponse / CalculatedDeadline / CourtRow types
  - escAttr / escHtml / formatDate / partyBadge helpers
  - deadlineCardHtml(dl, { showParty, editable })
  - renderTimelineBody(data, opts)
  - renderColumnsBody(data, opts)
  - calculateDeadlines(params) — POST /api/tools/fristenrechner wrapper
  - courtTypesFor / defaultCourtFor / fetchCourts (cache)
  - populateCourtPicker(rowId, selectId, proceedingType)

Both /tools/fristenrechner and /tools/verfahrensablauf import from
here. No module-level mutable state — the per-page concerns
(anchorOverrides, lastResponse, Akte save) stay in the consumers.

The deadlineCardHtml signature carries an editable flag so the click-
to-edit anchor-override affordance is opt-in per page: fristenrechner
enables it, verfahrensablauf (Slice 1 scope) doesn't.
2026-05-13 00:18:52 +02:00
mAi
0099e2f28c feat(t-paliad-179): register /tools/verfahrensablauf + 302 legacy ?path=a
Backend half of Slice 1: a new dedicated route owns the abstract-browse
intent that was previously emulated by /tools/fristenrechner?path=a +
client-side fix-up. The page handler is a 1-liner that serves
dist/verfahrensablauf.html (no DB dependency).

A naked ?path=a on /tools/fristenrechner now 302s to the new URL so
bookmarked legacy links survive. ?project=<uuid>&path=a still serves
the fristenrechner shell because that's wizard state set by client-
side history.replaceState during Akte-mode Pathway A — refreshing
mid-wizard must not bounce away.

Test covers all four query shapes: naked path=a → redirect, path=a
with project → no redirect, no params → no redirect, path=b → no
redirect.
2026-05-13 00:18:42 +02:00
mAi
3ba5727deb Merge: t-paliad-177 Slice 2 — visibility-leak fix + palette/density + exports (SVG/PNG/CSV/JSON/iCal/print) 2026-05-13 00:11:58 +02:00
mAi
d8f7745f86 feat(t-paliad-177): chart export — iCal feed (deadlines+appointments only)
Server-side endpoint GET /api/projects/{id}/timeline.ics returns a
VCALENDAR + one VEVENT per actual deadline (VALUE=DATE all-day) and
appointment (UTC timestamp). Projected / milestone / off_script rows
are deliberately skipped — faraday-Q6 / m's pick: a calendar feed
must never carry predicted dates the user never confirmed, otherwise
Outlook fills with rule_code-derived events that erode trust.

FormatTimelineICS reuses the existing caldav_ical.go escape helpers
and writes through the same canonical UIDs (paliad-deadline-<id> +
paliad-appointment-<id>) so a re-subscribe updates entries instead
of duplicating them. Stable across re-exports = lawyer-safe.

Visibility piggybacks on ProjectionService.For + ProjectService.GetByID
(same gates as the chart page handler). Content-Disposition filename
slugged for portable ASCII so Outlook + Apple Calendar agree.

4 tests pin the contract: only deadline/appointment kinds emit
VEVENTs; undated rows skip cleanly; RFC 5545 §3.3.11 escaping for
; , \ \\n; empty input still produces a valid VCALENDAR.

i18n: 1 new key DE+EN.

Design ref: docs/design-project-chart-2026-05-09.md §7.8.
2026-05-13 00:11:14 +02:00
mAi
98a51faa66 feat(t-paliad-177): chart exports — SVG/PNG/CSV/JSON + browser-print CSS
Five client-side export paths per design §7 (faraday-Q4: rule out
chromedp, browser-print is good enough).

- SVG: XMLSerializer over a clone of the live SVGSVGElement, with
  --chart-* tokens inlined so the standalone file paints the same way
  when opened in an image viewer (no document.css context).
- PNG: SVG → Image → Canvas at 2× DPR, toBlob("image/png"). White
  background painted first so transparent SVG stays printable.
- PDF: window.print() → @media print stylesheet hides chrome, forces
  the print palette tokens, locks A4 landscape via @page. User picks
  "Save as PDF" in the browser print dialog. No chromedp dep.
- CSV: 20-column flat schema mirroring TimelineEvent, UTF-8 BOM for
  Excel-DE, RFC 4180 escaping.
- JSON: events + lanes envelope + export-metadata header (project_id,
  project_title, exported_at).

Export menu uses native <details>/<summary> so it's keyboard-accessible
without JS. The chart handle exposes getSVGElement() + getData() so
chart-export.ts stays pure: it never reads DOM state outside the SVG
it's handed.

Filenames are sanitised + dated: paliad-{title}-{yyyy-mm-dd}.{ext}.

i18n: 7 new keys DE+EN under projects.chart.export.*.

Design ref: docs/design-project-chart-2026-05-09.md §7.
2026-05-13 00:08:28 +02:00
mAi
b24063bee1 feat(t-paliad-177): density toggle — compact/standard/spacious + URL state
Density flips lane height (24/40/64) and mark radius (5/7/10) via the
existing LANE_HEIGHT / MARK_RADIUS tables in shape-timeline-chart.ts.
Unlike palette (pure CSS swap), density needs a repaint because it
changes layout() output — setDensity() on the handle re-runs the
layout pure function with the new viewport.density.

URL state in ?density=<compact|standard|spacious>, default omitted.
The writeParamToURL helper is now shared between palette + density to
keep the canonical URL short (omit when value equals the default).

i18n: 4 new keys DE+EN under projects.chart.density.*.

Design ref: docs/design-project-chart-2026-05-09.md §6.1.
2026-05-13 00:06:32 +02:00
mAi
d1314a46f9 feat(t-paliad-177): palette picker — 5 CSS-token sets + URL state
Slice 2 ships all 5 palettes from design §5.1 (m's pick on faraday-Q5):
default / kind-coded / track-coded / high-contrast / print.

Each palette is a pure data-attribute swap of the --chart-* tokens on
.smart-timeline-chart[data-palette="..."]. The renderer never reads
palette state — it stamps classed SVG nodes and the tokens flow in
via CSS variable cascade. setPalette() on the chart handle is a
one-line attribute write; no repaint.

URL state lives in ?palette=<name>; default omits the param so the
canonical URL stays clean. Initial paint reads the URL, every change
writes via history.replaceState — bookmarkable per design §8.2.
Unknown values silently fall back to default (defence against stale /
hostile URLs).

i18n: 6 new keys DE+EN under projects.chart.palette.*.

Design ref: docs/design-project-chart-2026-05-09.md §5 + §8.2.
2026-05-13 00:05:38 +02:00
mAi
968b0bc2da feat(t-paliad-177): close visibility leak on /projects/{id}/chart handler
Slice 1 served dist/projects-chart.html unconditionally, leaking a 200
for any well-formed UUID guesser. Slice 2 resolves the project via
ProjectService.GetByID before serving — ErrNotVisible (and any other
visibility error) collapses to 404 + the standard notfound chrome,
matching the JSON-API contract that already lives in writeServiceError.

A genuine DB error logs through writeServiceError's existing path but
still renders 404 chrome to the user (httpDevNullJSON wrapper discards
the JSON body writeServiceError would otherwise emit, keeping the log
side-effect intact).

Test pins serveChartNotFound: 404 + non-empty body, degrading
gracefully when dist/notfound.html is absent (test env).

Closes Slice 1 edge case #2 flagged at m/paliad#35 issuecomment-7710.
Design ref: docs/design-project-chart-2026-05-09.md §8.2.
2026-05-13 00:03:45 +02:00
mAi
cd1a70d08c Merge: t-paliad-178 — Tools surface cleanup design doc (DESIGN READY FOR REVIEW) 2026-05-13 00:00:41 +02:00
mAi
bdb3d8a425 Merge: t-paliad-177 Slice 1 — Project Timeline / Chart (SVG Gantt + standalone /projects/{id}/chart page) 2026-05-12 14:14:01 +02:00
mAi
30f7031e99 feat(t-paliad-177): chart page TSX + boot client + i18n + Verlauf link
Wires the chart surface end-to-end:

- frontend/src/projects-chart.tsx — standalone page shell with title
  row, inert control chips (Slice 3 wires them live), undated hint slot,
  and the mount target for the SVG renderer.
- frontend/src/client/projects-chart.ts — boot client that parses the
  project id from the URL, loads project metadata for the header,
  mounts the renderer, and reveals the undated hint when the layout
  reports clipped/undated rows.
- frontend/build.ts — registers the new bundle + HTML output.
- frontend/src/client/i18n.ts — 11 new DE+EN keys under projects.chart.*
  + projects.detail.smarttimeline.open_chart (the Verlauf link).
- frontend/src/projects-detail.tsx — "Als Chart anzeigen ↗" link in
  the SmartTimeline controls, opens /chart in a new tab.
- frontend/src/client/projects-detail.ts — resolves the chart href in
  renderHeader once project.id is known.

`bun run build` clean, `go build ./...` clean, 27/27 chart tests pass.

Design ref: docs/design-project-chart-2026-05-09.md §8.1 + §8.2 + §12.
2026-05-12 14:12:20 +02:00
mAi
a3adb6b13b feat(t-paliad-177): chart SVG paint() + mount() + palette CSS tokens
Extends shape-timeline-chart.ts with the DOM-mutation half of the
renderer:

- paint(layout, root, events): hand-rolled SVG using namespaced
  document.createElementNS. Idempotent (clears prior children),
  layers <defs> → grid+axis+lanes → today rule → marks. Each mark
  wraps in <g> with data-* attrs for delegated event handling.
- mount(host, opts): fetches /api/projects/{id}/timeline (defensive
  for both legacy []TimelineEvent and Slice-4 envelope shapes),
  computes a today-1y..today+1y default range (design Q8), wires
  resize debouncing + click delegation. Returns a handle with
  refresh / dispose / getLayout.

CSS palette tokens swap purely via --chart-* custom properties on
.smart-timeline-chart, so future palette slices (Slice 3) toggle
attributes without touching the renderer. Deadlines colour-saturate
by status (open = ring, done = filled, overdue = red). Projected
rows use the hatched/dashed-dot variants from §6.2.

Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §5 + §6.
2026-05-12 14:09:43 +02:00
mAi
ed4e731333 feat(t-paliad-177): chart layout() pure-function + 27 table-driven tests
Slice 1 load-bearing math. Translates TimelineEvent[] + LaneInfo[] +
viewport into deterministic SVG-ready geometry: axis ticks (month /
quarter / year by total span), lane row y/height, mark x/y/shape per
kind+status, today rule. No DOM access — paint() will read this and
mutate the SVG separately.

Tests pin canvas geometry, pxPerDay math, today-rule clipping, lane
stacking, mark bucketing by lane_id, out-of-range clipping, undated
zone, mark-shape mapping, axis tick density. Date math is UTC
throughout so DST doesn't drift day-deltas.

Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §15.
2026-05-12 14:07:48 +02:00
mAi
b0a6b0998f feat(t-paliad-177): chart page handler + GET /projects/{id}/chart route
Slice 1 backend slice. Tiny static-file server for the new standalone
chart page; visibility piggybacks on the existing /api/projects/{id}/
timeline endpoint (gated through ProjectionService.For), so no new
auth surface.

Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
2026-05-12 14:05:52 +02:00
144 changed files with 27741 additions and 2143 deletions

View File

@@ -47,9 +47,13 @@ Paliad — the patent paladin. All-in-one patent practice platform for HLC (form
| `PALIAD_BASE_URL` | optional | Public origin used in email links. Defaults to `https://paliad.de`; override for staging/preview. |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_FROM_NAME` / `SMTP_USE_TLS` | for email | SMTP credentials for Paliad's transactional mail (reminders, invitations). Port 465 uses implicit TLS. `MailService` silently no-ops when any required var is missing — the server still boots for knowledge-platform-only deployments. |
| `ANTHROPIC_API_KEY` | not used in PoC | Reserved for the eventual production-v1 Paliadin (the Anthropic Messages API path, see `docs/design-paliadin-2026-05-07.md` §2). The Phase 0 PoC (t-paliad-146) does NOT use this — it shells out to a local `claude` CLI via tmux instead, which uses m's existing Claude Code subscription. Set this env var only after the PoC validates and we cut over to the API-backed path. The earlier "Phase H Frist-Extraktion" reservation is dead — that feature is deferred separately (memory `b6a11b55…`). |
| `PALIADIN_SESSION_PREFIX` | optional (default `paliad-paliadin`) | Prefix for the per-user tmux session names the Paliadin service uses (t-paliad-155). Each Paliad user gets their own session named `<prefix>-<userid8>` (first 8 hex chars of the user's UUID); conversation history accumulates per visit, `ResetSession` kills the session entirely. The persona + response protocol now live in `~/.claude/skills/paliadin/SKILL.md` (installed via `scripts/install-paliadin-skill`) — no in-process system prompt is sent. |
| `PALIADIN_SESSION_PREFIX` | optional (default `paliad-paliadin`) | Prefix for the per-user tmux session names the legacy Paliadin service uses (t-paliad-155). Each Paliad user gets their own session named `<prefix>-<userid8>` (first 8 hex chars of the user's UUID); conversation history accumulates per visit, `ResetSession` kills the session entirely. **Skill source-of-truth moved to `m/mAi` under `skills/aichat/paliadin/` (m's 2026-05-13 decision, t-paliad-194).** The aichat backend owns installation on mRiver via its own deploy doc (`m/mAi/docs/reference/aichat-deploy.md`). Legacy `LocalPaliadinService` (PoC) and `RemotePaliadinService` (shim) still rely on `~/.claude/skills/paliadin/SKILL.md` being present on the target host — install it manually from the aichat repo until those paths are retired. |
| `PALIADIN_REMOTE_CWD` | shim env (default `/home/m/dev/paliad`) | Working directory `paliadin-shim` uses when spawning the long-lived `claude` pane on mRiver. Must be the paliad repo root so claude picks up `.mcp.json` (project-scoped Supabase MCP); without it, the SKILL.md SQL recipes have no DB tool. Set on mRiver only — paliad's Go side never reads this. |
| `PALIADIN_RESPONSE_DIR` | optional (default `/tmp/paliadin`) | Directory where Claude writes its per-turn response files. The Go service polls this directory for `{turn_id}.txt` files. |
| `PALIADIN_RESPONSE_DIR` | optional (default `/tmp/paliadin`) | Directory where Claude writes its per-turn response files. The Go service polls this directory for `{turn_id}.txt` files. (Legacy `LocalPaliadinService` path only — aichat owns its own response dir at `/tmp/aichat/paliadin/`.) |
| `PALIADIN_BACKEND` | optional (default `legacy`) | Selects which Paliadin backend boots (t-paliad-194 / m/paliad#38 Phase B). `legacy` keeps the existing tree (`PALIADIN_REMOTE_HOST` → SSH shim, else local tmux, else disabled). `aichat` opts into the centralized `m/mAi#207` backend on mRiver — `RemotePaliadinService`/`LocalPaliadinService` are bypassed and `AichatPaliadinService` issues HTTP calls instead. Parallel paths during the migration window; flip back is one env-var change. |
| `AICHAT_URL` | required when `PALIADIN_BACKEND=aichat` | Aichat service root (typically `http://100.99.98.203:8765` over Tailscale; see `m/mAi/docs/reference/aichat-deploy.md`). No trailing slash needed. |
| `AICHAT_TOKEN` | required when `PALIADIN_BACKEND=aichat` | Raw bearer token registered for paliad's app_id in aichat's `tokens.yaml`. Distributed via Dokploy secret per Q11 (age-encrypted at rest). |
| `AICHAT_PERSONA` | optional (default `paliadin`) | Persona id to target. Override only when running a non-default deploy (e.g. staging persona). |
> *Note on Paliadin gating (t-paliad-146):* there is **no** `PALIADIN_ENABLED` env var. Access is gated in code via `services.PaliadinOwnerEmail` (currently `matthias.siebels@hoganlovells.com`). Every other authenticated user gets a 404 on `/paliadin` and `/admin/paliadin`. This means the routes register on every paliad deploy (including paliad.de prod), but only m can reach them — and even then, prod only works if the host has `tmux` + a `claude` CLI in PATH (which the Dokploy container does not). PoC remains a laptop-only feature; the gate is in the code, not the deploy.
| `FIRM_NAME` | optional (default `HLC`) | Display name of the firm Paliad is being branded for in this deployment. Read once at process start by `internal/branding.Name` (Go) and inlined into client bundles by `frontend/build.ts` (TypeScript). Powers every user-facing surface — landing hero, page titles, login hint, Downloads page, footer, invitation/reminder email bodies. The `ALLOWED_EMAIL_DOMAINS` whitelist is a separate concern (real DNS domains, not display name) and rotates independently. |

View File

@@ -147,7 +147,15 @@ func main() {
Calculator: services.NewDeadlineCalculator(holidays),
Users: users,
Fristenrechner: services.NewFristenrechnerService(rules, holidays, courts),
EventDeadline: services.NewEventDeadlineService(pool, services.NewDeadlineCalculator(holidays), holidays, courts),
EventDeadline: services.NewEventDeadlineService(
pool,
services.NewDeadlineCalculator(holidays),
holidays,
courts,
services.NewFristenrechnerService(rules, holidays, courts),
),
EventTrigger: services.NewEventTriggerService(pool, rules, holidays, courts),
RuleEditor: services.NewRuleEditorService(pool, rules),
Courts: courts,
DeadlineSearch: services.NewDeadlineSearchService(pool),
EventCategory: nil, // wired below; cross-link order matters
@@ -171,39 +179,58 @@ func main() {
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
}
// Paliadin backend selection (t-paliad-146 + t-paliad-151):
// PALIADIN_REMOTE_HOST set → RemotePaliadinService (ssh to mRiver)
// else: local tmux available → LocalPaliadinService (PoC path)
// else: DisabledPaliadinService (handlers still 404 for non-owners
// via the gate; for m, RunTurn returns ErrPaliadinDisabled
// which surfaces as a friendly error).
// Paliadin backend selection.
//
// All three implement services.Paliadin; the per-request handler
// gate (requirePaliadinOwner) is unchanged and applies to every
// backend.
if remoteHost := os.Getenv("PALIADIN_REMOTE_HOST"); remoteHost != "" {
cfg, err := buildPaliadinRemoteConfig(remoteHost)
// PALIADIN_BACKEND (t-paliad-194 / m/paliad#38):
// "aichat" → AichatPaliadinService (HTTP client of the
// centralized aichat backend on mRiver,
// shipped in m/mAi#207 Phase A).
// "legacy" / unset / etc → fall through to the pre-aichat tree:
// PALIADIN_REMOTE_HOST set → RemotePaliadinService (ssh shim)
// else: local tmux available → LocalPaliadinService (PoC path)
// else → DisabledPaliadinService
//
// The aichat path is opt-in for the migration window so a flip
// back is one env-var change. Once aichat soaks, legacy can be
// retired in a follow-up slice.
//
// All four implementations satisfy services.Paliadin; the per-
// request handler gate (requirePaliadinOwner) is unchanged.
switch strings.ToLower(strings.TrimSpace(os.Getenv("PALIADIN_BACKEND"))) {
case "aichat":
cfg, err := buildAichatPaliadinConfig(jwtSecret)
if err != nil {
log.Fatalf("paliadin: remote config: %v", err)
log.Fatalf("paliadin: aichat config: %v", err)
}
svcBundle.Paliadin = services.NewAichatPaliadinService(pool, users, cfg)
log.Printf("paliadin: aichat mode → %s persona=%s (owner=%s, rls=%s)",
cfg.BaseURL, cfg.Persona, services.PaliadinOwnerEmail,
rlsModeLabel(cfg.JWTSecret))
default:
if remoteHost := os.Getenv("PALIADIN_REMOTE_HOST"); remoteHost != "" {
cfg, err := buildPaliadinRemoteConfig(remoteHost)
if err != nil {
log.Fatalf("paliadin: remote config: %v", err)
}
svcBundle.Paliadin = services.NewRemotePaliadinService(pool, users, cfg)
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
} else if _, err := exec.LookPath("tmux"); err == nil {
sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
local := services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
// Late-response janitor — patches rows when Claude writes the
// response file after the 60 s pollForResponse window expires.
// Runs for the process lifetime; cleaned up when bgCtx
// cancels on SIGTERM.
local.StartJanitor(bgCtx)
svcBundle.Paliadin = local
log.Printf("paliadin: local tmux mode (owner=%s, janitor=on)", services.PaliadinOwnerEmail)
} else {
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
log.Printf("paliadin: disabled (no PALIADIN_REMOTE_HOST, no local tmux; owner=%s)",
services.PaliadinOwnerEmail)
}
svcBundle.Paliadin = services.NewRemotePaliadinService(pool, users, cfg)
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
} else if _, err := exec.LookPath("tmux"); err == nil {
sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
local := services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
// Late-response janitor — patches rows when Claude writes the
// response file after the 60 s pollForResponse window expires.
// Runs for the process lifetime; cleaned up when bgCtx
// cancels on SIGTERM.
local.StartJanitor(bgCtx)
svcBundle.Paliadin = local
log.Printf("paliadin: local tmux mode (owner=%s, janitor=on)", services.PaliadinOwnerEmail)
} else {
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
log.Printf("paliadin: disabled (no PALIADIN_REMOTE_HOST, no local tmux; owner=%s)",
services.PaliadinOwnerEmail)
}
// Wire ApprovalService into the entity services so Create / Update /
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
@@ -374,3 +401,49 @@ func cmpOr(s, fallback string) string {
}
return fallback
}
// buildAichatPaliadinConfig assembles an AichatPaliadinConfig from the
// environment for PALIADIN_BACKEND=aichat (t-paliad-194 / m/paliad#38).
//
// Required:
//
// AICHAT_URL — service root (e.g. http://100.99.98.203:8765).
// AICHAT_TOKEN — raw bearer token paliad's app_id is registered
// under in aichat's tokens.yaml (see m/mAi
// docs/reference/aichat-deploy.md).
//
// Optional:
//
// AICHAT_PERSONA — persona id; defaults to "paliadin".
//
// jwtSecret comes from the same SUPABASE_JWT_SECRET that auth.NewClient
// already requires at boot — never empty when we reach this code path.
// It's threaded in so the aichat service can mint per-turn user-scoped
// JWTs (folded-in t-paliad-156 work).
func buildAichatPaliadinConfig(jwtSecret string) (services.AichatPaliadinConfig, error) {
cfg := services.AichatPaliadinConfig{
BaseURL: strings.TrimRight(os.Getenv("AICHAT_URL"), "/"),
BearerToken: os.Getenv("AICHAT_TOKEN"),
Persona: cmpOr(os.Getenv("AICHAT_PERSONA"), services.DefaultAichatPersona),
JWTSecret: []byte(jwtSecret),
}
if cfg.BaseURL == "" {
return cfg, fmt.Errorf("AICHAT_URL must be set when PALIADIN_BACKEND=aichat")
}
if cfg.BearerToken == "" {
return cfg, fmt.Errorf("AICHAT_TOKEN must be set when PALIADIN_BACKEND=aichat")
}
return cfg, nil
}
// rlsModeLabel labels the boot log so the operator can confirm whether
// the per-user JWT mint is active. "per-user" means we're handing the
// claude pane user-scoped claims; "service-role" means we're not (no
// SUPABASE_JWT_SECRET) and the skill will reject queries rather than
// run as supabase_admin.
func rlsModeLabel(secret []byte) string {
if len(secret) == 0 {
return "service-role"
}
return "per-user"
}

View File

@@ -0,0 +1,86 @@
package main
import (
"strings"
"testing"
)
// TestBuildAichatPaliadinConfig pins the env-driven wiring used by the
// PALIADIN_BACKEND=aichat path in main(). It guards three things:
//
// 1. Required vars (AICHAT_URL, AICHAT_TOKEN) must be set — otherwise
// boot fails fast with a clear error message.
// 2. AICHAT_PERSONA defaults to "paliadin" so a misconfigured deploy
// doesn't silently route to a different persona.
// 3. The JWT secret threads through so per-turn JWT mint is on by
// default (folded-in t-paliad-156 work).
//
// We can't unit-test the switch{} block in main() directly without
// invoking the rest of boot, so this test exercises the helper that
// branch calls — the same surface a Phase B regression would hit.
func TestBuildAichatPaliadinConfig(t *testing.T) {
t.Run("rejects empty URL", func(t *testing.T) {
t.Setenv("AICHAT_URL", "")
t.Setenv("AICHAT_TOKEN", "tok")
_, err := buildAichatPaliadinConfig("secret")
if err == nil || !strings.Contains(err.Error(), "AICHAT_URL") {
t.Errorf("err = %v; want AICHAT_URL complaint", err)
}
})
t.Run("rejects empty token", func(t *testing.T) {
t.Setenv("AICHAT_URL", "http://aichat.test")
t.Setenv("AICHAT_TOKEN", "")
_, err := buildAichatPaliadinConfig("secret")
if err == nil || !strings.Contains(err.Error(), "AICHAT_TOKEN") {
t.Errorf("err = %v; want AICHAT_TOKEN complaint", err)
}
})
t.Run("defaults persona to paliadin", func(t *testing.T) {
t.Setenv("AICHAT_URL", "http://aichat.test/")
t.Setenv("AICHAT_TOKEN", "tok")
t.Setenv("AICHAT_PERSONA", "")
cfg, err := buildAichatPaliadinConfig("secret")
if err != nil {
t.Fatalf("err: %v", err)
}
if cfg.Persona != "paliadin" {
t.Errorf("persona = %q; want paliadin", cfg.Persona)
}
if cfg.BaseURL != "http://aichat.test" {
t.Errorf("base url trailing slash leaked: %q", cfg.BaseURL)
}
if string(cfg.JWTSecret) != "secret" {
t.Errorf("JWT secret not threaded; got %q", string(cfg.JWTSecret))
}
if cfg.BearerToken != "tok" {
t.Errorf("BearerToken = %q; want tok", cfg.BearerToken)
}
})
t.Run("honours AICHAT_PERSONA override", func(t *testing.T) {
t.Setenv("AICHAT_URL", "http://aichat.test")
t.Setenv("AICHAT_TOKEN", "tok")
t.Setenv("AICHAT_PERSONA", "custom-paliadin")
cfg, err := buildAichatPaliadinConfig("secret")
if err != nil {
t.Fatalf("err: %v", err)
}
if cfg.Persona != "custom-paliadin" {
t.Errorf("persona = %q; want custom-paliadin", cfg.Persona)
}
})
}
func TestRLSModeLabel(t *testing.T) {
if got := rlsModeLabel(nil); got != "service-role" {
t.Errorf("nil → %q; want service-role", got)
}
if got := rlsModeLabel([]byte{}); got != "service-role" {
t.Errorf("empty → %q; want service-role", got)
}
if got := rlsModeLabel([]byte("x")); got != "per-user" {
t.Errorf("non-empty → %q; want per-user", got)
}
}

View File

@@ -34,5 +34,12 @@ services:
- PALIADIN_REMOTE_USER=${PALIADIN_REMOTE_USER}
- PALIADIN_SSH_PRIVATE_KEY=${PALIADIN_SSH_PRIVATE_KEY}
- PALIADIN_KNOWN_HOSTS=${PALIADIN_KNOWN_HOSTS}
# aichat Phase B (t-paliad-194 / m/paliad#38). Set PALIADIN_BACKEND=aichat
# to route Paliadin through the centralized aichat backend on mRiver.
# Legacy default (unset / "legacy") keeps the existing RemotePaliadinService path.
- PALIADIN_BACKEND=${PALIADIN_BACKEND:-legacy}
- AICHAT_URL=${AICHAT_URL:-}
- AICHAT_TOKEN=${AICHAT_TOKEN:-}
- AICHAT_PERSONA=${AICHAT_PERSONA:-paliadin}
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred
restart: unless-stopped

View File

@@ -0,0 +1,799 @@
# Audit — Fristen logic (rules, triggers, conditionals)
**Author:** pauli (inventor)
**Date:** 2026-05-13
**Task:** t-paliad-157 (reactivated 2026-05-13 21:23 with broader scope)
**Phase:** 1 of 3 — Audit. Phase 2 = iterative refinement against m. Phase 3 = ship.
**Branch:** `mai/pauli/fristen-logic-audit` (fresh from `origin/main` @ `7d9935d`).
**Status:** AUDIT READY FOR REVIEW — m gates the audit → Phase 2 transition.
m's framing (paliad/head 11:46):
> the main roadmap thing now is "Fristen". We need the full "Fristen logic" and I am happy to work together with an AI to further design it. Most of it should be "straightforward" as specific events trigger specific deadlines, sometimes multiple and sometimes conditional. It is all in the Rules so we should be able to manage.
The audit answers: **is m's mental model already encodable in the existing data model, and where are the gaps?**
Short answer: the rule corpus is substantially richer than the brief implied, **three parallel deadline-generation systems coexist** (with overlapping responsibilities), and the main friction is *managing* rules (SQL-only today) rather than the expressive grammar of the rules themselves.
---
## 0. Premises verified live (live DB + live code, not migration files)
Live state queried via `mcp__supabase__execute_sql` against the `paliad` schema on the youpc Supabase Postgres. Code reads against `mai/pauli/fristen-logic-audit` baseline (origin/main @ `7d9935d`).
### 0.1 Rule corpus is ~5× richer than the brief implied
| Table | Rows | Note |
|---|---|---|
| `paliad.proceeding_types` | **27** | 20 `category='fristenrechner'` + 7 `category='litigation'`. All 27 carry rules. |
| `paliad.deadline_rules` | **172** | 132 against fristenrechner codes + 40 against litigation codes. |
| `paliad.deadline_concepts` | **56** | The "noun" layer (Klageerwiderung, Berufungsschrift, …) above rules. |
| `paliad.event_category_concepts` | **153** | Cascade-leaf → concept junction (with optional `proceeding_type_code` for context-conditional outcomes). |
| `paliad.deadline_concept_event_types` | **32** | Concept → event_type default suggestion (per jurisdiction). |
| `paliad.trigger_events` | **110** | youpc.org legacy import. Used by the "Was kommt nach…" mode. |
| `paliad.event_deadlines` | **77** | trigger_event → deadline_row, with `combine_op` ∈ {min,max} for composite leads. |
| `paliad.event_types` | **40+** | Concrete event types (upc_oral_hearing, upc_statement_of_defence, …). |
| `paliad.event_categories` | **125** (103 leaves) | Cascade taxonomy. Already audited in t-paliad-166. |
| `paliad.courts` | **41** | Forum picker for holiday-calendar regime resolution. |
| `paliad.holidays` | **55** | Seed of public holidays + court closures. |
| `paliad.deadlines` (live) | **26** | Persisted deadline instances. **Only 1 has `rule_id`.** |
| `paliad.project_events` | **89** | Audit-log entries. |
| `paliad.events` | **does not exist** | The brief mentioned `paliad.events`; the actual audit table is `paliad.project_events`. |
### 0.2 Three parallel deadline-generation systems exist today
| Pipeline | Data source | Calculator | Wire surface |
|---|---|---|---|
| **A — Proceeding-driven** | `paliad.deadline_rules` (172 rows) | `FristenrechnerService.Calculate(proceedingCode, triggerDate, opts)` (`internal/services/fristenrechner.go:139`) | POST `/api/tools/fristenrechner` (Pathway A wizard, Pathway B cascade, SmartTimeline projection via `ProjectionService.computeProjections`). |
| **B — Single-rule (subset of A)** | Same table | `FristenrechnerService.CalculateRule` (around line 480) | POST `/api/tools/fristenrechner/calculate-rule` (Pathway B cascade card-click → inline calc). |
| **C — Event-driven (youpc legacy)** | `paliad.trigger_events` + `paliad.event_deadlines` (separate tables) | `EventDeadlineService.Calculate(triggerEventID, triggerDate, courtID)` (`internal/services/event_deadline_service.go:92`) | POST `/api/tools/event-deadlines` (Pathway A wizard's "Was kommt nach…" trigger panel, `frontend/src/client/fristenrechner.ts:833`). |
Pipelines A and C have **disjoint data**, **disjoint capability sets**, and **overlapping intent**. See §2 for the full picture; §6.1 calls out the redundancy.
### 0.3 m's "it is all in the Rules so we should be able to manage" — the false premise
The rule corpus IS in one table (`paliad.deadline_rules`) — 172 rows, 32 columns, expressive. **But there is no application-level rule-management surface.** Every rule edit today is a SQL migration: `internal/db/migrations/{009,012,028,029,031,040,043,044,050,068,…}_*.up.sql`. The Calculate engine reads what's in the table, but the table is seeded by developers, not by m or any user.
m's "we should be able to manage" reads as a call for a first-class rule-editor in the app (see §8). That's the biggest unfilled deliverable in his framing.
### 0.4 Production data is sparse — demand-side largely empty
- **11/11 live projects have NULL `proceeding_type_id`** (per kelvin's t-paliad-178 §0 audit; re-confirmed). The projection pipeline (`projection_service.computeProjections:813`) early-returns when this is NULL, so the SmartTimeline forecast doesn't fire for any production project today.
- **Only 1/26 live deadlines has `rule_id` populated.** The rule → deadline linkage is barely exercised. Most deadlines were created manually (free-text title + due_date) before the rule-anchored flow existed.
- **89 project_events**: structural milestones + audit-log entries. No tight coupling to rule_ids today.
- **trigger_events / event_deadlines** carry 110+77 youpc-legacy rows. Whether they are exercised in production needs Pathway-A "Was kommt nach…" telemetry; out of audit scope.
### 0.5 Anchor files
Backend services that consume / produce rules:
- `internal/services/fristenrechner.go` — Pipeline A + B. The main calculator. **735 LoC.**
- `internal/services/deadline_calculator.go` — pure date-math used by Pipeline C.
- `internal/services/deadline_rule_service.go` — CRUD-ish read API. `List`, `GetRuleTree`, `Get`. Hydrates `ConceptDefaultEventTypeID` from `deadline_concept_event_types` for the create-form's Typ chip.
- `internal/services/event_deadline_service.go` — Pipeline C. **~300 LoC.**
- `internal/services/deadline_service.go` — persistence of `paliad.deadlines` instances.
- `internal/services/event_category_service.go` — cascade leaf → concept resolution (t-paliad-133).
- `internal/services/projection_service.go` — SmartTimeline (consumes Pipeline A).
- `internal/services/holidays.go` + `courts.go` — non-working-day adjustment.
Handlers:
- `internal/handlers/fristenrechner.go` — wires Pipelines A/B/C to HTTP routes.
- `internal/handlers/deadlines.go` — paliad.deadlines persistence.
- `internal/handlers/deadline_rules_db.go` — admin-style rule list endpoint (read-only).
Key migration history (rule corpus evolution):
- **009** (342 LoC) — initial seed (Tier 1, hand-coded).
- **012** (230 LoC) — Fristenrechner seed extension.
- **028** (353 LoC) — youpc.org rules import (Pipeline C tables).
- **029** (128 LoC) — Tier 1 rule fixes.
- **031** (193 LoC) — Tier 2 ports (more proceedings).
- **037 + 038** — concept layer addition.
- **040** (449 LoC) — concept seed + backfill.
- **043** (348 LoC) — DE_INF_OLG / DE_INF_BGH split (instance dimension).
- **044** (280 LoC) — DPMA proceedings.
- **048 + 049** — event_categories taxonomy (cascade).
- **050** — `is_bilateral` backfill (4 rules).
- **052** — Determinator ROP coverage audit fixes.
- **068** — `is_optional` column.
- **073 + 074** — `deadline_concept_event_types` (concept → event_type config layer).
Net rule-related migrations: **>20 files, >3000 LoC of SQL.** The rule corpus has accreted across many small migrations; no single canonical seed.
If anything in this audit conflicts with the live state, the live state wins.
---
## 1. The rule shape today — `paliad.deadline_rules` column-by-column
**32 columns.** Most are used; a few are vestigial. Every column verified against live row distribution.
### 1.1 Identity + relations
| Column | Type | Nullable | Role |
|---|---|---|---|
| `id` | uuid PK | NO | Primary key. Referenced by `paliad.deadlines.rule_id`, `paliad.deadline_rules.parent_id` (self-FK), `paliad.deadline_rules.condition_rule_id` (self-FK; unused — see §1.6). |
| `proceeding_type_id` | int FK → `proceeding_types.id` | YES | Almost always set; NULL would mean a cross-proceeding rule but **no live rule is NULL** today. |
| `parent_id` | uuid self-FK | YES | Rule depends on parent's calculated date as anchor. **108 / 172 rules have parent_id set** (= 63%). Forms a forest, one tree per proceeding. |
| `concept_id` | uuid FK → `deadline_concepts.id` | YES | Links the rule to a concept (cross-proceeding noun). **171 / 172 rules linked** (= 99.4%); the one un-linked rule is a stray. |
### 1.2 Identity strings + labels
| Column | Note |
|---|---|
| `code` | Rule-local code (e.g. `inf.sod`, `ccr.amend`). Used by `AnchorOverrides` map keys (rule_code → date). Mostly unique within a proceeding. |
| `name` (NOT NULL) | DE display name. |
| `name_en` (NOT NULL, default `''`) | EN display name. Empty for some older rules; UI falls back to `name`. |
| `description` | Optional long-form. Sparse. |
| `rule_code` | The *legal-citation* rule code (e.g. `RoP.23.1`, `§276(1) ZPO`). The UI shows this as the `RuleRef`. NOT the rule's identity — `code` is. |
| `legal_source` | Structured citation (e.g. `UPC.RoP.23.1`). Added by mig 038 + 040. **171/172 rules have it.** |
| `deadline_notes` / `deadline_notes_en` | Free-text legal-context notes shown in the UI. |
| `spawn_label` | Used with `is_spawn=true`: human label for "spawned rule" pattern. |
### 1.3 The math: anchor + offset + adjustment
| Column | Note |
|---|---|
| `duration_value` (NOT NULL, default 0) | Integer offset. `0` = court-set / root anchor / filed-with-parent (see §4). |
| `duration_unit` (NOT NULL, default `months`) | Live values: `days`, `months`, `weeks`. **No `working_days`** in live data (`EventDeadlineService` supports it; `deadline_rules` does not). |
| `timing` (default `after`) | Live value: **only `after`** in every row. `before` semantic is theoretically there but unused by Pipeline A. (Pipeline C honours `before` via `applyDuration`.) |
| `anchor_alt` | Single live value: `priority_date`. Used by exactly **one rule**: `EP_GRANT.ep_grant.publish` (Art. 93 EPÜ, 18mo from priority). Otherwise NULL → use parent's date / triggerDate. |
| `alt_duration_value` / `alt_duration_unit` / `alt_rule_code` | Swap-on-flag: when condition_flag is satisfied, the rule renders against the alt values instead of base. Used by UPC_INF `inf.reply` and `inf.rejoin` for the with_ccr swap (RoP.029.a / RoP.029.d). |
### 1.4 Conditional gating
| Column | Note |
|---|---|
| `condition_flag` | `text[]` array. **4 distinct value-sets live**: `[with_amend]`, `[with_cci]`, `[with_ccr]`, `[with_ccr, with_amend]`. Only on UPC_INF + UPC_REV (the 2 richest proceedings). Semantics: rule renders iff **every** element of the array is in caller's `Flags` set. AND semantics; **no OR/NOT today**. |
| `condition_rule_id` | uuid self-FK to another rule. **0 / 172 rows populated**. Dead column. Was intended as "rule X applies only if rule Y was triggered" but never wired up. |
### 1.5 Party + bilateral
| Column | Note |
|---|---|
| `primary_party` | Live values: `claimant`, `defendant`, `both`, `court`. Drives the timeline column / row color. NULL allowed. |
| `is_bilateral` (NOT NULL, default false) | When `primary_party='both'`, this column tells the renderer whether to **mirror the rule into both party columns** in the timeline (true), or **resolve to one side via perspective + appeal_filed_by** (false). Backfilled by mig 050 — only 4 rules carry `true`: DE_NULL r79, DE_NULL r116, EPA_OPP r79, EPA_APP r116. |
### 1.6 Flags + lifecycle
| Column | Note |
|---|---|
| `is_mandatory` (NOT NULL, default true) | "User must address this." Surfaces in UI badge. |
| `is_optional` (NOT NULL, default false) | Added by mig 068. **Distinct from is_mandatory** — semantics today: "the save-modal pre-unchecks these rows; the timeline still renders them." Live: e.g. UPC_INF `inf.cost_app` (RoP.151 Antrag auf Kostenentscheidung) — visible-but-defaulted-off. Naming is confusing (is_mandatory=true + is_optional=true would be self-contradictory); see §6.3. |
| `is_spawn` (NOT NULL, default false) | Marks the rule as a "spawn" — emitted when its parent decision fires, but the spawn itself starts a NEW timeline branch (e.g. Appeal off Decision). Used by 8 live rules: APP/AMD/CCR cross-proceeding spawns. **Spawn execution is half-wired**: `projection_service.go:896-901` notes "Cross-proceeding spawn — the calculator can return rules from another proceeding type (Appeal off Decision). We don't have that rule in our map; skip the dependency annotation but still surface the row." — i.e. the row appears in the response but the dependency-annotation graph breaks. |
| `is_active` (NOT NULL, default true) | Soft-delete. All 172 live rules have `is_active=true`; soft-delete unused so far. |
| `sequence_order` (NOT NULL, default 0) | Calculator walks rules in this order. Must be consistent with topological order on `parent_id` (parents before children). |
| `created_at` / `updated_at` | timestamps. |
| `event_type` (text, nullable) | One of `decision`, `filing`, `hearing`, `order`. **A category, NOT an FK** to `paliad.event_types`. Distinct from concept-level event_type linkage in §3. |
### 1.7 Vestigial / under-used
- `condition_rule_id` — 0 rows populated. Dead column.
- `description` — sparse, used as fallback notes.
- `is_mandatory` vs `is_optional` — overlapping semantics that need a clean re-think (§6.3).
---
## 2. Trigger model today — events to deadlines
There are **three parallel paths** from a user-observable event to a calculated deadline list. Understanding the redundancy is the most important takeaway of this audit.
### 2.1 Path A — Proceeding-driven (the main spine)
Caller: `/tools/fristenrechner` Pathway A (wizard), Pathway B B1 leaf click + B2 search, `ProjectionService.computeProjections` (SmartTimeline).
Flow:
1. User (or projection) picks a **proceeding_code** (e.g. `UPC_INF`) and a **trigger_date**.
2. `FristenrechnerService.Calculate(proceedingCode, triggerDate, opts)` runs.
3. Calculator loads `deadline_rules WHERE proceeding_type_id = $pt AND is_active`.
4. Walks rules in `sequence_order`. For each:
- Apply `condition_flag` gate (suppress if flags missing AND alt_duration_value is NULL; otherwise swap to alt_*).
- Resolve anchor: `anchor_alt='priority_date'` → use priorityDate; else `parent_id` → parent's computed date; else triggerDate.
- Apply `AnchorOverrides[rule_code]` if user set an override.
- 4-bucket court-set classification (§4).
- Calculate offset, apply holiday/weekend adjustment via `HolidayService`, store in `computed[code]` map.
5. Returns `UIResponse{Deadlines: []UIDeadline}` — the full timeline.
Strengths:
- Rich (condition flags, parent chains, anchor_alt, override map, court-set semantics).
- Single source of truth for /tools/fristenrechner + SmartTimeline.
- Backed by 172 rules across 27 proceedings.
Weaknesses:
- Returns the **whole proceeding** every call. No "give me only the rules triggered by event X" mode.
- Cross-proceeding spawn (is_spawn rules) is half-wired (§1.6).
- `condition_flag` is AND-only; no OR, NOT, or compound expression.
### 2.2 Path B — Single-rule (subset of A)
Caller: Pathway B cascade-card click → inline calc panel.
Flow:
1. User clicks a concept card; system picks the rule_id linked to that concept (via `event_category_concepts → deadline_rules`).
2. POST `/api/tools/fristenrechner/calculate-rule` with `{rule_id, trigger_date, flags?}`.
3. `FristenrechnerService.CalculateRule` walks the rule's parent chain only (no siblings), returns one `RuleCalculation`.
Strengths:
- Lightweight (no full-proceeding compute).
- Lets the cascade UI surface "click → see this rule's date" without rebuilding the whole timeline.
Weaknesses:
- Doesn't include side-effects (sibling rules in the proceeding that the user might also care about).
- Shares the same expressiveness limits as Path A.
### 2.3 Path C — Event-driven (youpc legacy, redundant)
Caller: Pathway A wizard's "Was kommt nach…" tab; `frontend/src/client/fristenrechner.ts:833` calls POST `/api/tools/event-deadlines`.
Flow:
1. User picks a **trigger_event** (e.g. "Klageerhebung UPC", "Berufungsschrift OLG", from a 110-row picker list).
2. POST `/api/tools/event-deadlines` with `{triggerEventID, triggerDate, courtID}`.
3. `EventDeadlineService.Calculate` loads `paliad.event_deadlines WHERE trigger_event_id = $te`.
4. For each row: apply `duration_value × duration_unit (+ timing: before/after)`. Supports `working_days` unit (Path A doesn't). Handles `alt_duration_value × combine_op (min/max)` composite leads.
5. Returns flat list of computed deadlines + rule_codes.
Strengths:
- Has the `before` timing semantic (Path A doesn't use it).
- Has `working_days` unit (Path A doesn't have it).
- Has `combine_op` (min/max) for composite duration math (Path A doesn't).
- Trigger-event picker is more discoverable than "pick a proceeding": user says "Klageerhebung happened on date X, what comes after?" without first navigating to the proceeding tree.
Weaknesses:
- **Disjoint corpus.** The 77 `event_deadlines` rows do NOT join to `paliad.deadline_rules`. Changing a rule in Path A doesn't update Path C.
- **No parent_id chains.** Each event_deadline is a single-leg calc off the trigger date. No multi-stage timelines.
- **No condition flags.** No with_ccr / with_amend gating.
- **No SmartTimeline integration.** ProjectionService only knows Path A.
- **Origin:** youpc.org ported (mig 028). Implicitly "legacy", but actively wired.
### 2.4 The concept layer (orthogonal to all three paths)
`paliad.deadline_concepts` (56 rows) is the **noun layer** that lets the cascade + search talk about "Klageerwiderung" without knowing which of the 9 jurisdiction-specific Klageerwiderung rules it means. Every rule has `concept_id` (171/172); every cascade leaf has zero or more `event_category_concepts` rows linking to concepts (153 rows, 100 distinct leaves of 103 → 97% coverage).
`paliad.deadline_concept_event_types` (32 rows, added mig 073/074) maps `(concept_id, jurisdiction) → event_type_id` so when the user creates a Deadline via the form by picking a Regel, the system can pre-fill the Typ chip with the canonical event_type. This is a **CONFIG layer, not a trigger model** — it doesn't say "when event X fires, these deadlines spawn." See §6.4.
### 2.5 Multi-deadline triggers
m's "specific events trigger specific deadlines, sometimes multiple" is implemented via **`parent_id` chains in Path A**. One root event (e.g. UPC_INF `inf.soc` = Klageerhebung) triggers a tree of dependent rules. Today the deepest live chain is **3 levels**:
```text
inf.soc (root, anchor)
├─ inf.sod (3mo after, Klageerwiderung)
│ ├─ inf.def_to_ccr ([with_ccr], 2mo after sod, Erwiderung auf CCR)
│ │ └─ inf.reply_def_ccr ([with_ccr], 2mo after, Replik auf Erwid CCR)
│ │ └─ inf.rejoin_reply_ccr ([with_ccr], 1mo after, Duplik)
│ ├─ inf.app_to_amend ([with_ccr,with_amend], 2mo after sod, Antrag Patentänderung)
│ │ ├─ inf.def_to_amend ([with_ccr,with_amend], 2mo after, Erwiderung)
│ │ └─ inf.reply_def_amd ([with_ccr,with_amend], 1mo after Reply, Replik Amend)
│ ├─ inf.reply (with_ccr → 2mo after sod RoP.029.a; without_ccr → swap to alt)
│ └─ inf.rejoin (with_ccr → 1mo after reply RoP.029.d)
└─ inf.interim (court-set, Zwischenverfahren)
└─ inf.oral (court-set, Mündliche Verhandlung)
└─ inf.decision (court-set, Entscheidung)
└─ inf.cost_app (1mo after decision, is_optional, Antrag Kostenentscheidung)
```
15 rules, 4 condition-flag-gated, 4 court-set placeholders (inf.interim / inf.oral / inf.decision are 0-duration court-set; inf.soc is 0-duration root), 1 optional. The structural fidelity is high.
### 2.6 Conditional triggers — the AND-only ceiling
`condition_flag` is `text[]` with **AND-of-array** semantic. To render the rule, every flag in the array must be in the caller's `Flags` set.
Live flag space: `{with_amend, with_ccr, with_cci}` — three flags, four combinations used. The empty array is the unconditional default.
This is enough to express:
- "with counterclaim for revocation" (with_ccr alone)
- "with counterclaim for revocation AND with amendment" (with_ccr + with_amend)
- "with counterclaim for infringement" (with_cci alone)
But not:
- "with_ccr OR with_cci" — would need OR, today not supported. (Live workaround: duplicate rules with each gate.)
- "NOT with_ccr" — also not supported.
- Compound: "with_ccr AND NOT expedited".
§6 flags this as a real coverage gap.
---
## 3. The 27 proceeding types — what's covered, what's a stub
### 3.1 Inventory
| Category | Code | Jurisdiction | Rule count | Notes |
|---|---|---|---|---|
| **fristenrechner** | DE_INF | DE | 9 | Verletzungsverfahren LG. |
| | DE_INF_OLG | DE | 7 | Berufung OLG. |
| | DE_INF_BGH | DE | 8 | Revision / NZB BGH. |
| | DE_NULL | DE | 10 | Nichtigkeit BPatG. |
| | DE_NULL_BGH | DE | 6 | Berufung BGH (Nichtigkeit). |
| | DPMA_OPP | DPMA | 4 | DPMA Einspruch. |
| | DPMA_BPATG_BESCHWERDE | DPMA | 5 | BPatG-Beschwerde nach DPMA. |
| | DPMA_BGH_RB | DPMA | 4 | Rechtsbeschwerde BGH. |
| | EPA_OPP | EPA | 8 | EPA Einspruch. |
| | EPA_APP | EPA | 8 | EPA Beschwerde. |
| | EP_GRANT | EPA | 7 | EP-Erteilung. One rule uses `anchor_alt='priority_date'`. |
| | UPC_INF | UPC | **15** | Verletzung. Richest corpus. |
| | UPC_REV | UPC | **15** | Nichtigkeit. Richest. |
| | UPC_APP | UPC | 7 | Berufung UPC. |
| | UPC_APP_ORDERS | UPC | 5 | Berufung gegen Anordnungen. |
| | UPC_COST_APPEAL | UPC | 2 | Kostenberufung. |
| | UPC_DAMAGES | UPC | 4 | Schadensbemessung. |
| | UPC_DISCOVERY | UPC | 4 | Bucheinsicht. |
| | UPC_PI | UPC | 4 | Einstweilige Maßnahmen. |
| **litigation** | INF | UPC | 8 | Infringement. |
| | REV | UPC | 7 | Revocation. |
| | CCR | UPC | 7 | Counterclaim for Revocation. |
| | APM | UPC | 4 | Provisional Measures. |
| | APP | UPC | 8 | Appeal. |
| | AMD | UPC | 2 | Application to Amend. |
| | ZPO_CIVIL | DE | 4 | ZPO Civil. |
Total: **172 rules across 27 proceeding types** (132 fristenrechner + 40 litigation).
### 3.2 Litigation vs Fristenrechner — the dual-corpus problem
The **same conceptual proceeding** (e.g. UPC Infringement) appears twice in `paliad.proceeding_types`:
- `INF` (category=`litigation`) — 8 rules, generic UPC labels (Statement of Claim, Statement of Defence, Reply, Rejoinder, Oral Hearing, Interim Conference, Decision, Preliminary Objection).
- `UPC_INF` (category=`fristenrechner`) — 15 rules, German labels + condition_flag variants.
The brief calls this out as "two parallel vocabularies." Live confirms:
- `paliad.projects.proceeding_type_id` accepts BOTH categories (no CHECK constraint enforces one or the other). Today all 11 projects are NULL anyway.
- `FristenrechnerService.Calculate(proceedingCode, …)` is **category-agnostic** — pass it `INF` or `UPC_INF`, you get back the respective corpus's timeline. No category guard.
- The Pathway-A wizard surfaces ONLY `category='fristenrechner'` codes (`internal/services/fristenrechner.go:735`: `WHERE category = 'fristenrechner' AND is_active = true`). So users can't pick `INF` from the wizard.
- `ProjectionService.computeProjections` resolves `proj.ProceedingTypeID → code` and calls Calculate with whatever code is on the project. So a project with `INF` would render the 8-rule litigation timeline; a project with `UPC_INF` would render the 15-rule fristenrechner timeline.
**This is a latent footgun.** Whichever code lands on a project first dictates which corpus drives its SmartTimeline. The two corpuses disagree on:
- Rule count (8 vs 15).
- Granularity (litigation has 1 ccr.defence row; fristenrechner has 7 with_ccr/with_amend gated rows).
- Language (litigation labels are English; fristenrechner German).
No code path treats this divergence intentionally. The likely intent at seed-time was:
- `litigation` codes = "the project model's coarse type enum" (Mandant-level taxonomy).
- `fristenrechner` codes = "the calculator's fine-grained variants".
But the actual schema doesn't enforce that contract. **Flagged as §6.2.**
### 3.3 Coverage observations
- **UPC corpus dominates fristenrechner.** 9 of the 20 fristenrechner codes are UPC (66 rules); 5 are DE (40); 3 are DPMA (13); 3 are EPA (23). Bias matches HLC's mandate mix.
- **DE_INF_OLG, DE_INF_BGH, DE_NULL_BGH** were split out late (mig 043). The instance dimension (LG / OLG / BGH) is NOT on `paliad.projects`, so you can't currently derive whether a DE project is at first instance, OLG, or BGH from the project model. This blocks fine-grained Akte → proceeding-code mapping (cross-referenced in t-paliad-166 §4.2).
- **EP_GRANT** is the only proceeding that uses `anchor_alt`. Other priority-date-anchored rules don't exist (yet).
- **UPC_REV.with_cci** — the [with_cci] flag is used for "revocation action with counterclaim for infringement" — i.e. when the defendant in a revocation files a CCI. Only UPC_REV uses with_cci today (4 rules).
### 3.4 Concept linkage gaps
9 of 56 deadline_concepts have `rule_count = 0` — i.e. cascade-reachable concepts that produce zero calculated deadlines:
| Concept slug | Why it's empty |
|---|---|
| `counterclaim-for-revocation` | The CCR flow is modelled inside UPC_INF via `[with_ccr]` flag-gated rules, not as a separate concept-linked rule. |
| `schriftsatznachreichung` | ZPO §296a "Schriftsatznachreichung" — cross-cutting concept, no rule encoding yet. |
| `versaeumnisurteil-einspruch` | ZPO §339 "Einspruch gegen Versäumnisurteil" — no rule. |
| `weiterbehandlung` | EPA Art. 121 EPÜ / R.135 — no rule. |
| `wiedereinsetzung` | Re-establishment of rights — cross-cutting; no rule. |
| `notice-of-defence-intention` | DE ZPO Verteidigungsanzeige — only ZPO_CIVIL has it; not linked. |
| Plus 3 more sparse concepts. | |
For each, the cascade can route the user to the concept card, but the card has no rule pills underneath. This is a real coverage gap surfaced as §6.
---
## 4. Anchor semantics — the 4-bucket model
Encoded in `fristenrechner.go:272-369`. For each rule with `duration_value = 0`:
| Bucket | parent_id | court-determined? | Behaviour |
|---|---|---|---|
| **1. Root anchor** | NULL | no | Due date = trigger date. `IsRootEvent=true`. The proceeding's "day zero" (e.g. SoC filing). |
| **2. Court-set absolute** | NULL | yes | Due date empty; UI shows "wird vom Gericht bestimmt". `IsCourtSet=true, IsCourtSetIndirect=false`. Used for top-level hearings / decisions that don't follow from another rule. |
| **3. Court-set chained** | set | yes | Due date empty (court determines); ancestor anchor. `IsCourtSet=true`. Used for derivative court actions. |
| **4. Filed-with-parent** | set | no | Inherits parent's calculated date. Used for "X is bundled into Y" (e.g. UPC_REV.rev.app_to_amend, rev.cc_inf — included in the Defence to revocation). |
For rules with `duration_value > 0`:
- **Override wins.** `AnchorOverrides[rule_code]` provided by user → use it; mark `IsOverridden=true`.
- **Parent court-set + no override** → mark `IsCourtSet=true, IsCourtSetIndirect=true`. The rule isn't directly court-determined, but its anchor (the court-set parent) hasn't been bound yet. UI shows "unbestimmt".
- **Otherwise:** baseDate = (anchor_alt=priority_date → priorityDate) || (parent_id → computed[parent.code]) || triggerDate. Add `duration_value × duration_unit`. Apply holiday adjustment. Done.
**Court-set detection** (`isCourtDeterminedRule` in calculator) keys on:
- `primary_party='court'`, OR
- `event_type ∈ {'hearing','decision','order'}`, OR
- Heuristic name match (legacy from migration 028).
This is brittle — the boolean is computed from columns that aren't strictly designed for it. §6.5 suggests promoting a real `is_court_set` column.
### 4.1 `AnchorOverrides` — the override map
The override surface is the bridge between "calculated forecast" and "real ground truth." Two consumers:
- **SmartTimeline (`ProjectionService.collectActualsForOverrides`)** — bind a real `paliad.deadlines` row's date back into the calculator: if a saved deadline has `rule_id=X` and `completed_at='2026-04-10'`, the next projection uses 2026-04-10 as the anchor for any rule whose parent is X.
- **Pathway A wizard "Anchor edits"** — the user can override a per-rule date inline in the timeline (paliad-088 era feature). Applies to court-set rules where the user finally knows the decision date.
The override map propagates **downstream**: child rules see the override as their parent's date.
This is a strong, well-implemented mechanism. No gap.
---
## 5. Adjustment semantics — weekends, holidays, court calendars
### 5.1 `HolidayService.AdjustForNonWorkingDaysWithReason(endDate, country, regime)`
Called after every offset computation. Returns `(adjusted, _, wasAdjusted, reason)`.
- If endDate is a weekend → roll to next Monday. Reason: `kind=weekend, original_weekday`.
- If endDate is a public holiday (region match in `paliad.holidays`) → roll to next business day. Reason: `kind=public_holiday, holidays=[…]`.
- If endDate is inside a court vacation (regime-specific date range) → roll to first non-vacation business day. Reason: `kind=vacation, vacation_name, vacation_start, vacation_end`.
Live `paliad.holidays`: **55 rows**, mix of public holidays and vacation periods. `region` axis covers DE federal + state-specific + UPC court-specific.
### 5.2 `CourtService.CountryRegime(courtID, defaultCountry, defaultRegime)`
`paliad.courts` (41 rows) carries `country` and `regime` per court. Defaults via jurisdiction:
- UPC-flavoured proceedings → DE+UPC (UPC München is the default venue).
- DE proceedings → DE.
- EPA / DPMA → DE.
Live regimes inferred from queries: DE state codes (BY, BW, …), UPC court-specific tags. No formal CHECK constraint listing valid regimes.
### 5.3 Working-day arithmetic — split between calculators
Pipeline C (`EventDeadlineService.addWorkingDays`) supports `duration_unit='working_days'`: step forward N business days, skipping weekends + holidays.
Pipeline A (`FristenrechnerService`) does NOT support working_days; only calendar days/weeks/months. Adjustment is post-hoc (compute the calendar date, then roll forward if it lands on a non-business day).
**The two calculators are not equivalent.** Some real-world deadlines are "10 working days after Z" — those can only be expressed in Pipeline C today. Cross-references §6.6.
---
## 6. Coverage gaps (the heart of the audit)
What m's mental model wants ("specific events trigger specific deadlines, sometimes multiple, sometimes conditional") that the data model cannot express today.
### 6.1 Two trigger systems — Pipeline A vs Pipeline C
**Symptom.** Two disjoint data corpuses (`deadline_rules` 172 vs `trigger_events`+`event_deadlines` 110+77) with overlapping intent. A change to a rule in Pipeline A doesn't propagate to Pipeline C. The user-facing "Was kommt nach…" tab (Pipeline C) renders different numbers than the wizard timeline (Pipeline A) for nominally-similar trigger events.
**Impact.** Pipeline C has capabilities Pipeline A lacks (`before` timing, `working_days` unit, `combine_op` min/max) — but no parent chains, no condition_flag, no court-set semantic. Choosing the "right" pipeline today means picking which subset of capabilities the user actually needs for that case.
**Root cause.** Pipeline C is a youpc.org port (mig 028). Pipeline A is paliad-native (mig 009 → 050 evolution). They were never reconciled.
### 6.2 Litigation vs fristenrechner corpus drift
**Symptom.** `paliad.projects.proceeding_type_id` accepts both `litigation` and `fristenrechner` codes. The same conceptual proceeding has rule corpuses of different size, granularity, and language depending on which category the project lands on.
**Impact.** SmartTimeline forecast for a project depends on which code is chosen at project-create time. Two HLC partners filing identical UPC infringement cases could see different timelines if one picked `INF` and the other `UPC_INF`.
**Root cause.** No CHECK constraint, no documentation, no UI guard. Likely intent: `litigation` for project-model coarse classification, `fristenrechner` for fine-grained calculator — but the contract was never formalised.
### 6.3 `is_mandatory` vs `is_optional` semantic overlap
**Symptom.** Two boolean columns with overlapping meaning. Current usage:
- `is_mandatory=true, is_optional=false` — default (most rules).
- `is_mandatory=true, is_optional=true` — surfaces in timeline but pre-unchecked in save-modal (only UPC_INF.inf.cost_app + a few others).
- `is_mandatory=false` — unclear semantics today; sparsely used.
**Impact.** Confusing for both developers and future rule authors. A rule with `is_mandatory=false, is_optional=true` (legal "may file but not required") versus `is_mandatory=true, is_optional=true` (legal "should file but isn't a hard deadline") versus `is_mandatory=true, is_optional=false` (legal "must file") — the four-way matrix isn't well-defined.
**Root cause.** `is_optional` was added late (mig 068) as a UX hack ("pre-uncheck in save modal") rather than a semantic axis.
### 6.4 `deadline_concept_event_types` is a config layer, not a trigger model
**Symptom.** The table maps `(concept, jurisdiction) → event_type` for the create-form's chip suggestion. It DOES NOT support "when an event of type X fires, spawn deadlines for these rules."
**Impact.** m's "specific events trigger specific deadlines" implies a directional pipeline: user logs an event → system computes the deadlines that flow from it. That pipeline today exists only via:
- Pipeline A's full-proceeding compute (heavy: gives everything, not just X's children).
- Pipeline C's trigger_event picker (decoupled corpus).
There's no event_type-keyed entry point into Pipeline A. The cascade gets close — leaf → concept → rules — but stops at "show the cards"; firing the rules requires the user to manually click a card → calculate-rule.
**Root cause.** Pipeline A was designed proceeding-first (mig 009, 2024). The event-first paradigm came later via concepts (mig 037+) but never produced a dedicated trigger endpoint.
### 6.5 Court-set detection is heuristic
**Symptom.** `isCourtDeterminedRule()` decides court-set status from `primary_party='court' OR event_type IN ('hearing','decision','order') OR name-heuristic`. No dedicated boolean column.
**Impact.** False positives possible if a rule names "decision" but isn't court-set (e.g. "preliminary decision to amend"). False negatives possible if a court-set rule isn't tagged with one of these signals.
**Root cause.** Court-set semantic was never formalised as a first-class column. Inferred at runtime.
### 6.6 Pipeline A lacks `before`, `working_days`, `combine_op`
**Symptom.** Specific gaps in expressive power:
- `before` timing: useful for "must be filed Y days BEFORE oral hearing." Pipeline C honours `timing='before'`; Pipeline A only renders `timing='after'` rules.
- `working_days` unit: useful for procedural deadlines like UPC R.220.3 ("3 working days from notification"). Pipeline C supports it; Pipeline A doesn't.
- `combine_op` (min/max): useful for "earlier of X or Y" (compound deadlines, e.g. EPC R.36 — "shortest of priority date+24mo or filing date+18mo"). Pipeline C supports it; Pipeline A doesn't.
**Impact.** Some legal deadlines can only be expressed in Pipeline C, fragmenting the rule corpus.
**Root cause.** Pipeline A grew from a "tree of forward offsets" model; backward / composite deadlines weren't anticipated.
### 6.7 Condition-flag grammar is AND-only
**Symptom.** `condition_flag` is `text[]` with AND semantics. No OR, no NOT, no nested expression.
**Impact.** Real legal scenarios that need OR (e.g. "rule X applies if CCR OR CCI is filed") get encoded as **two duplicate rules** today — one for each branch. Painful to maintain; easy to drift.
**Root cause.** The flag axis was designed for the small set of UPC variant flags (`with_ccr`, `with_amend`, `with_cci`); compound expressions weren't anticipated.
### 6.8 Cross-proceeding spawn is half-wired
**Symptom.** `is_spawn=true` rules exist (8 live), intended to express "when X happens in proceeding A, also trigger Y in proceeding B." The calculator code at `projection_service.go:896-901` explicitly notes: "Cross-proceeding spawn … We don't have that rule in our map; skip the dependency annotation but still surface the row."
**Impact.** A UPC_INF decision firing an APP proceeding (cross-proceeding) renders the spawned row, but the dependency-graph annotation breaks. SmartTimeline can't fully chain across proceedings.
**Root cause.** Cross-proceeding spawn was a late addition; the calculator's `ruleByID` map is per-proceeding, so it can't resolve spawns from other proceedings. Needs either a global rule index or a smarter resolver.
### 6.9 Nine orphan concepts with `rule_count=0`
Per §3.4: `counterclaim-for-revocation`, `schriftsatznachreichung`, `versaeumnisurteil-einspruch`, `weiterbehandlung`, `wiedereinsetzung`, `notice-of-defence-intention`, plus 3 more.
**Impact.** Cascade leaves can reach these concepts, but the user sees an empty result card. UX feels broken even though it's an unrelated coverage gap (no rules seeded yet).
**Root cause.** Cascade taxonomy was seeded ahead of the rule corpus for some concepts. The seed work never caught up.
### 6.10 No way to express "X is conditional on Y having fired"
**Symptom.** `condition_rule_id` exists as a column but is 0% populated. Was intended for "rule X applies only if rule Y was previously triggered" but never wired.
**Impact.** Today's flag mechanism (condition_flag) gates on **caller-supplied flags** (e.g. user toggles "with_ccr" in the UI). It doesn't gate on **runtime rule firing**. So you can't express "if the defendant filed Preliminary Objection (rule X), then rule Y is suspended for 2mo."
**Root cause.** Column added speculatively; never wired into the calculator.
### 6.11 The instance dimension (LG/OLG/BGH) isn't on `paliad.projects`
**Symptom.** The proceeding_types `DE_INF_OLG` / `DE_INF_BGH` exist, but a project can't carry "I'm at first instance" / "I'm on appeal at OLG" as data. The user has to manually pick a different `proceeding_type_id` if the case moves up the instances.
**Impact.** SmartTimeline forecast can't auto-advance from DE_INF → DE_INF_OLG when a Berufungsschrift fires on the actuals side.
**Root cause.** Project model treats proceeding-type as a static attribute, not a state machine.
### 6.12 No rule audit log
**Symptom.** Rules are modified by SQL migrations only. There's no `paliad.deadline_rule_audit` table tracking "rule X changed from 3mo to 2mo on 2026-04-15 by m, because Y." Migrations are technically the audit trail, but they aren't queryable in-app.
**Impact.** Rule-management UX (§8) needs an answer for "who changed this rule and why." Without an audit trail, rule-editing in-app is a step backward in compliance.
**Root cause.** Never needed before, because rules were never user-editable.
### 6.13 Zero deadline → rule linkage in live data
**Symptom.** Only **1 of 26** live deadlines has `rule_id` populated.
**Impact.** SmartTimeline's "anchor real deadlines into projection" feature (Pipeline A's strongest UX) is unusable on existing data. New deadlines saved via the wizard *do* get rule_id; legacy deadlines don't.
**Root cause.** Schema migrated incrementally; backfill never happened.
---
## 7. Extension proposals (one concrete change per §6 gap)
Each gap from §6 gets a concrete schema / service change, costed (migration + service + UI ripples).
### 7.1 Reconcile Pipelines A and C
**Proposal.** Migrate `paliad.event_deadlines` into `paliad.deadline_rules` with a new column `trigger_event_id` (nullable FK to `paliad.trigger_events`). A rule with `trigger_event_id NOT NULL` is event-triggered (Pipeline C semantics); with NULL it stays proceeding-triggered (Pipeline A).
Add the Pipeline-C-only columns to `deadline_rules`:
- `timing` already exists; backfill non-NULL `before` values.
- `combine_op``{min, max, NULL}` — new column.
- `working_days` as a valid `duration_unit` value — already a string column, no schema change.
Then deprecate Pipelines C, redirecting `/api/tools/event-deadlines` to the unified calculator.
**Cost.**
- Migration: 1 file, ~120 LoC SQL (column adds + data move + idx).
- Service: `FristenrechnerService.Calculate` extends to honour `timing='before'`, `working_days`, `combine_op`. ~80 LoC Go.
- Service: `EventDeadlineService` either deletes (clean) or proxies to FristenrechnerService (transitional).
- Handler: `/api/tools/event-deadlines` either deletes or 302s.
- Frontend: `client/fristenrechner.ts:833` — the "Was kommt nach…" tab can call the unified endpoint.
- Tests: a fresh table-driven test fixture covers the union behaviour.
**Ripple.** No data loss; trigger_event_id is additive. Frontend mostly transparent.
### 7.2 Formalise litigation vs fristenrechner contract
**Proposal.** Two options:
- **(a) Hard-split.** Add `CHECK constraint` to `paliad.projects.proceeding_type_id`: only `category='litigation'` codes allowed. Migrate the 8-rule litigation corpus to be the canonical "project-level proceeding type". Move the fine-grained `category='fristenrechner'` rules under each litigation code via a new `variant` column.
- **(b) Soft-merge.** Drop the `category` discriminator entirely. Every proceeding_type carries its full rule corpus. The dual-corpus today (8-rule INF + 15-rule UPC_INF) merges into ONE 15-rule UPC_INF, with the project model referencing only the rich variant.
**Cost.** (a) is invasive — migration to move 40 litigation-corpus rules under the fristenrechner codes; (b) is less invasive but means projects switch to picking `UPC_INF` instead of `INF`.
**Recommendation.** **(b)**. The dual-corpus is legacy from a project-model + calculator-model that grew separately. One canonical proceeding_type per case is cleaner.
**Ripple.** Project-create form picker changes from "INF / REV / CCR / APM / APP / AMD / ZPO_CIVIL" to the full 20-code fristenrechner picker (or a curated subset). t-paliad-166's mapping helper becomes unnecessary.
### 7.3 Clean up `is_mandatory` vs `is_optional`
**Proposal.** Replace both with a single `deadline_kind` enum:
- `mandatory` — must be addressed.
- `recommended` — should be addressed (pre-checked in save-modal but not required).
- `optional` — may be addressed (pre-unchecked in save-modal).
- `informational` — never saves as a deadline, surfaces as info.
Backfill: `is_mandatory=true, is_optional=false → mandatory`; `is_mandatory=true, is_optional=true → optional`; `is_mandatory=false → recommended`.
**Cost.** Migration ~30 LoC SQL. Service: `UIDeadline` exposes `Kind` instead of `IsMandatory`+`IsOptional`. Frontend: badge logic + save-modal pre-check.
### 7.4 Add a real event-driven trigger endpoint
**Proposal.** `POST /api/tools/event-trigger` with `{event_type_slug, trigger_date, project_id?}`. Resolves:
1. `event_types.slug → event_types.id`
2. `deadline_concept_event_types.event_type_id → concept_id` (per jurisdiction from project or explicit)
3. `deadline_rules.concept_id → rules`
4. Calculate the rules + their parent chains via Pipeline A.
Returns just the rules that flow from this event (filtered Pipeline A response).
**Cost.** Handler + service method, ~100 LoC. No schema change; uses existing junction.
**Ripple.** Lets the cascade UI offer "I just logged this event — here are the deadlines that follow" in one click. Also unlocks Phase-H-style email parsing → deadline spawn.
### 7.5 Promote court-set to a real column
**Proposal.** Add `is_court_set boolean NOT NULL DEFAULT false` to `paliad.deadline_rules`. Backfill from the heuristic. Calculator reads the column instead of inferring.
**Cost.** Migration ~20 LoC SQL (incl. backfill DO$$ block). Service: 1-line change in `isCourtDeterminedRule`.
**Ripple.** Faster + correct + no behaviour surprise. Cheap win.
### 7.6 Pipeline A gains `before` / `working_days` / `combine_op`
Covered in §7.1 (reconciliation).
### 7.7 Compound condition grammar
**Proposal.** Replace `condition_flag text[]` with `condition_expr jsonb`. Schema:
```json
{"op":"and", "args":[{"flag":"with_ccr"},{"op":"not","args":[{"flag":"expedited"}]}]}
```
Backfill: `['with_ccr','with_amend']``{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`.
**Cost.** Migration with backfill ~80 LoC. Service: small recursive evaluator (~50 LoC Go). UI: condition picker for rule-editor (§8) — more involved.
**Ripple.** Future rule authors can express OR / NOT cleanly. No data drift; backward-compatible eval.
### 7.8 Wire cross-proceeding spawn
**Proposal.** Change `DeadlineRuleService.List(proceedingTypeID *int)` to allow a "follow spawn" mode that returns rules from spawned proceedings as well. Or: in `projection_service.computeProjections`, when a rule has `is_spawn=true` and the calculator returns a row from a different proceeding code, load the target proceeding's rule corpus lazily.
**Cost.** Service: ~50 LoC. Calculator: ~30 LoC. Risk: cycle prevention (don't infinite-loop A→B→A).
**Ripple.** SmartTimeline can fully chain across proceedings. The dependency-annotation breakage at `projection_service.go:896-901` resolves.
### 7.9 Seed the 9 orphan concepts with rules
**Proposal.** Per concept, add 13 rules to the appropriate proceeding_types. e.g. `wiedereinsetzung` → UPC R.320.1 (`UPC_INF.wiedereinsetzung`), EPA R.136 (`EPA_OPP.wiedereinsetzung`), DE PatG §123 (`DE_INF.wiedereinsetzung`).
**Cost.** Per orphan concept: ~20 LoC SQL. Total ~150 LoC across 9 concepts. Legal review required per rule.
**Ripple.** Cascade no longer dead-ends. This is the "coverage" gap m's t-paliad-167 explicitly called for.
### 7.10 Wire `condition_rule_id` or drop it
**Proposal.** Either:
- (a) Implement: when calculator walks rules, gate a rule's render on `condition_rule_id`'s presence in the `computed` map.
- (b) Drop the dead column.
**Recommendation.** **(b)**. The semantic is rarely needed; `condition_flag` covers most variant cases. Future need can resurrect.
### 7.11 Add `instance_level` to `paliad.projects`
**Proposal.** New column `instance_level text``{first, appeal_olg, appeal_bgh, NULL}`. Combined with `proceeding_type.code` + `jurisdiction`, lets us derive `DE_INF_OLG` vs `DE_INF` from a project.
**Cost.** Migration ~10 LoC SQL. Project form: new picker. SmartTimeline forecast: small refactor in `proceedingCodeForProject`.
### 7.12 Rule audit log
**Proposal.** New table `paliad.deadline_rule_audit (id, rule_id, changed_by, changed_at, before_json, after_json, reason text)`. Trigger on UPDATE/INSERT/DELETE captures the diff. Required if §8 lands.
**Cost.** Migration ~40 LoC SQL (table + trigger). Read API for compliance review.
### 7.13 Backfill `rule_id` on existing deadlines
**Proposal.** One-time migration: for each `paliad.deadlines` row, fuzzy-match `title` against `paliad.deadline_concepts.aliases` + `paliad.deadline_rules.name`, link the highest-confidence match, leave low-confidence unlinked.
**Cost.** Migration ~100 LoC SQL. Run once.
**Ripple.** SmartTimeline anchor-from-actuals starts working for existing data. Bigger UX win than it sounds.
---
## 8. Rule-management UX — does m need an in-app rule editor?
m's "all in the Rules so we should be able to manage" reads as a direct ask.
### 8.1 The case for an in-app rule editor
- **Today: SQL migration only.** Every rule add/edit/disable requires a developer to write a migration, get reviewed, merge, deploy. The feedback loop is hours-to-days.
- **Domain experts ≠ developers.** m is the rule expert. He shouldn't need to write `INSERT INTO paliad.deadline_rules (proceeding_type_id, code, name, duration_value, …)` SQL.
- **Coverage gaps are persistent** (§3.4, §6.9). They stay open longer because the workflow is high-friction.
- **Real-world law changes.** Procedural rules update (e.g. UPC R.49 just had a 2026-Q1 revision). Capturing those in SQL migrations is fragile.
### 8.2 The case against
- **Compliance / audit.** Rules are legal infrastructure. Any user-edit must be auditable, reviewable, reversible.
- **Schema complexity.** 32 columns with semantic nuances (court-set heuristic, parent_id topology, condition_flag grammar). Naive form UI = footgun heaven.
- **Cross-rule validation.** parent_id chains must remain DAGs. sequence_order must be topologically consistent. condition_flag values must be in a valid vocabulary. No live constraint catches all of these today.
- **Build cost.** A real rule-editor with audit log, validation, preview, dry-run, and rollback is 46 PRs of work.
### 8.3 Three options
| Option | Description | Effort | When right |
|---|---|---|---|
| **(A) Status quo: SQL only** | Keep migrations as the rule-edit surface. Build tooling around migration authoring (mAi-assisted SQL gen, schema validators). | Low (~1 sprint of tooling). | If m's rule velocity is < 1 edit/week and audit trail is non-negotiable. |
| **(B) Read-only admin surface** | Add `/admin/rules` page that lists rules, lets m search/filter/inspect. No edits in-app; "edit this rule" links to a Gitea issue template that drafts the migration. | Medium (~1 PR backend listing + 1 PR frontend). | If the friction is "I can't see what's in there" more than "I can't change what's there". |
| **(C) Full rule editor** | `/admin/rules/{id}/edit` with form, validation, audit log, preview-on-trigger-date, "ship draft" migration generator. | High (~4-6 PRs). | If m is genuinely going to edit rules weekly and the rule corpus is going to grow significantly. |
### 8.4 Inventor recommendation
**Start with (B), graduate to (C).**
- (B) immediately removes the "I can't see what's in there" friction, which today requires running SQL by hand or asking a developer. Low risk.
- (B) makes the rule corpus discoverable inside the app which is itself a win for transparency and for spotting coverage gaps 3.4).
- The Gitea-issue handoff preserves the audit trail and review workflow.
- Once the corpus is browsable, the "I keep wanting to edit this thing" pressure tells us whether (C) is worth building.
- **(C) without (B) is over-engineering** we'd be building the form before we know which fields are actually edited often.
Hard requirement for (C) if we get there: `paliad.deadline_rule_audit` table 7.12) with mandatory `reason` field, reviewer workflow, and migration-export so changes still land in version control.
§9 Q5 surfaces this for m's call.
---
## 9. Open questions for m (Phase 2 steering)
These are the 1015 picks for m to make before Phase 2 starts.
**Q1 — Reconciliation of Pipelines A and C.** §6.1 + §7.1. Three options:
- (a) Merge into one table (recommended; ~120 LoC migration + 80 LoC Go).
- (b) Keep both but document the contract (cheap, but the drift continues).
- (c) Deprecate Pipeline C entirely (deletes "Was kommt nach…" tab UX loss).
**Q2 — Litigation vs fristenrechner corpus.** §6.2 + §7.2. Two options:
- (a) Hard-split with CHECK constraint + rule migration (invasive).
- (b) Soft-merge: drop the category discriminator, projects use fristenrechner codes only (recommended).
**Q3 — `is_mandatory` / `is_optional` cleanup.** §6.3 + §7.3. Pick the 4-value enum (`mandatory` / `recommended` / `optional` / `informational`) or keep the two booleans with formal docs.
**Q4 — Event-driven trigger endpoint.** §6.4 + §7.4. Build `POST /api/tools/event-trigger` (concept-keyed) now, or defer until rule corpus is reconciled?
**Q5 — Rule-management UX.** §8. Pick:
- (A) status quo SQL only,
- (B) read-only admin surface (recommended start),
- (C) full editor with audit log.
**Q6 — Compound condition grammar.** §6.7 + §7.7. Move to `condition_expr jsonb` with AND/OR/NOT, or stay with `condition_flag text[]` AND-only and live with duplicate rules?
**Q7 — Cross-proceeding spawn.** §6.8 + §7.8. Wire it (let SmartTimeline chain across proceedings), or accept the current half-wired state?
**Q8 — Orphan concept seed.** §3.4 + §7.9. Priority order for the 9 missing-rule concepts? My guess: wiedereinsetzung > schriftsatznachreichung > versaeumnisurteil > weiterbehandlung > others. Legal review per concept.
**Q9 — Instance level on `paliad.projects`.** §6.11 + §7.11. Add `instance_level` column to support the DE_INF / DE_INF_OLG / DE_INF_BGH ladder, or accept that users manually re-pick proceeding_type on appeal?
**Q10 — Backfill `rule_id` on existing deadlines.** §6.13 + §7.13. Run the one-time fuzzy-match migration, or live with the broken anchor-from-actuals on legacy rows?
**Q11 — `working_days` and `before` semantics in Pipeline A.** §5.3 + §6.6. Add (recommended) or live without them?
**Q12 — Court-set as a real column.** §6.5 + §7.5. Promote (cheap win), or keep the heuristic?
**Q13 — Drop `condition_rule_id` dead column.** §1.6 + §7.10. Drop or wire?
**Q14 — Phase 2 cadence.** How should we structure the iterative refinement? Options:
- (a) m drives via the worker pane — m raises concrete cases ("counterclaim with amendment in expedited proceedings"), worker proposes encoding, commits incrementally.
- (b) Inventor (pauli) drafts a Phase 2 design for the §7 extensions in priority order m picks here, m gates.
- (c) Mixed: m picks the top 2 from §9 (Q1Q13) for Phase 2, the rest deferred to Phase 3.
**Q15 — Phase 3 framing.** Once Phase 2 lands the data-model changes, is the goal:
- (a) Build the rule editor (§8 option C), or
- (b) Backfill coverage gaps (§7.9), or
- (c) Wire SmartTimeline cross-proceeding chains (§7.8), or
- (d) Some other priority m has in mind?
---
## AUDIT READY FOR REVIEW
Awaiting m's go/no-go on §9 Q1Q15 before Phase 2 starts. Inventor (pauli) parks after this commit — no implementation kickoff, no other-skill autoload, m gates the audit → Phase 2 transition.
Recommended Phase 2 worker: depends on m's Q14 pick. If (a) interactive pair-prog, then pauli or feynman. If (b) inventor design pass, pauli has the freshest context. If (c) mixed, pauli for design, hand off to a Sonnet coder for each landed extension. **NOT cronus per memory directive 2026-05-06.**

View File

@@ -0,0 +1,704 @@
# Design — Determinator B1 row-by-row cascade (replaces breadcrumb drilldown)
**Author:** pauli (inventor)
**Date:** 2026-05-13
**Task:** t-paliad-166
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
**Gitea:** m/paliad#25 (re-opened by m's 2026-05-13 11:17 comment).
---
## 0. Premises verified live (before designing)
CLAUDE.md, mai-memory and the task brief can all be stale by days. Every anchor below is verified against the live codebase or live DB on `mai/pauli/determinator-b1-row-by` (baseline `adf377c` — main as of Slice 1 of t-paliad-179 merge).
### 0.1 The Pathway B markup today
`frontend/src/fristenrechner.tsx:227-310` is the Pathway B shell. Four functionally different layers are stacked with four visually different treatments. Live, in source order:
| Layer | Element | Affordance | Visual |
|---|---|---|---|
| **L1 Mode** | `.fristen-mode-toggle` | `role=radiogroup` with two `<input type="radio">` | Radio buttons. Tree vs Filter. |
| **L2 Perspective** | `.fristen-perspective-bar` | Three `<button>` chips | Pill chips. Kläger / Beklagter / Beide. |
| **L3 Inbox** | `.fristen-inbox-bar` | Four `<button>` chips | Pill chips. CMS / beA / Posteingang / Alle. |
| **L4 Cascade** | `.fristen-b1-cascade` | Breadcrumb + question + button-grid (drill-down) | Cards in a grid, breadcrumb above. |
Below L4 sits `.fristen-b1-results` — the concept-card list that narrows as the cascade descends. That's content, not a decision layer.
**m's critique is exact:** L1/L2/L3/L4 are all "narrow the deadline-rule space" steps with the same conceptual weight, but the user sees a radio, two pill strips, and a card grid. The cascade itself (L4) hides previous steps behind a breadcrumb — so when you've drilled three levels deep you can no longer see "I picked CMS → vom Gericht → Hinweisbeschluss" in one glance unless you read tiny breadcrumb crumbs.
### 0.2 The cascade engine today
`frontend/src/client/fristenrechner.ts:2405-2574` (`renderB1Cascade`). For a given `?b1=<slug>`:
1. Build `trail = buildBreadcrumb(roots, currentSlug)`. The trail is the ancestors of the current node.
2. Render `<nav class="fristen-b1-breadcrumb">` = root-reset + ``-separated crumb buttons.
3. Render `<p class="fristen-b1-question">` = the current node's `step_question_de` (or `"Was ist passiert?"` at root).
4. Render `<div class="fristen-b1-buttons">` = child nodes as button cards (icon + label, `--leaf` modifier on terminal nodes).
5. Render `<button class="fristen-b1-step-back">` = "← Eine Stufe zurück".
Drilling = `navigateB1(child.slug)` = `pushState` + `renderB1Cascade(child.slug)`. The previous question disappears; only the breadcrumb crumb survives as text. **There is no "row of answered decisions."**
### 0.3 Where narrowing happens today
`fristenrechner.ts:2509-2522` filters cascade children by two predicates before rendering:
- `inboxFilterAllowsForums(c.forums)` — hides nodes whose `forums` tag doesn't match `activeForumOnPage()`. The active forum is resolved at `fristenrechner.ts:2960-2970` with a three-input precedence chain:
1. **Inbox chip** (`cms``upc`, `bea` / `posteingang``de`). User override beats everything.
2. **Ad-hoc chip** from Step 1's explore-mode bypass (`upc` / `de` / `epa` / `dpma`).
3. **Project context** (`project.proceeding_type_id``proceeding_types.code` → prefix → `upc` / `de` / `epa` / `dpma`).
- `perspectiveAllowsParty(c.party)` — hides leaves whose `party` tag contradicts the perspective chip. t-paliad-164 already auto-fills the chip from `project.our_side`.
**So project-driven narrowing for the FORUM axis is shipped.** What m is asking for in this task is (a) generalize the pattern so MORE rows get pre-answered, (b) make the answered-state visible in the same row format, (c) hide rows whose answer is fully implied (UPC project + L3 Inbox).
### 0.4 The taxonomy and rule corpus
Live data, `paliad.event_categories` (recursive tree, t-paliad-133):
- **6 root buckets** under `(root)`: `cms-eingang` ("Von wem ist das Schriftstück?"), `muendl-verhandlung` ("Mündliche Verhandlung"), `beschluss-entscheidung` ("Beschluss / Entscheidung"), `frist-verpasst` ("Frist verpasst"), `ich-moechte-einreichen` ("Ich möchte etwas einreichen"), `sonstiges` (terminal leaf).
- **103 leaves total.** 91 carry a `forums` tag (`upc` / `de` / `epa` / `dpma`); 12 are neutral. 16 leaves carry a `party` tag — all under `ich-moechte-einreichen.*` (claimant / defendant) — the perspective filter touches outgoing filings only, never incoming Gegenseiten-Schriftstücke (which are symmetric: you receive what the other side sent regardless of who you are).
- Cascade depth varies 24 levels. Slug encodes the path with dots, e.g. `cms-eingang.gegenseite.upc-inf.klageschrift` is 4 segments deep.
`paliad.proceeding_types`:
- **20 `category='fristenrechner'` codes** (the wizard / B1 cascade vocabulary): `UPC_INF`, `UPC_REV`, `UPC_APP`, `UPC_APP_ORDERS`, `UPC_COST_APPEAL`, `UPC_DAMAGES`, `UPC_DISCOVERY`, `UPC_PI`, `DE_INF`, `DE_INF_OLG`, `DE_INF_BGH`, `DE_NULL`, `DE_NULL_BGH`, `DPMA_OPP`, `DPMA_BPATG_BESCHWERDE`, `DPMA_BGH_RB`, `EPA_OPP`, `EPA_APP`, `EP_GRANT`.
- **7 `category='litigation'` codes** (the project model's vocabulary): `INF`, `REV`, `CCR`, `APM`, `APP`, `AMD`, `ZPO_CIVIL`. All `jurisdiction='UPC'` except `ZPO_CIVIL`.
- **The two vocabularies overlap conceptually but not row-wise.** Mapping `litigation_code × jurisdiction → fristenrechner_code` is required for Akte-derived narrowing beyond the 4-letter forum prefix. The brief lists this mapping; the live data confirms it's the only path.
`paliad.deadline_rules.condition_flag` — 4 distinct flag-sets live in production: `[with_amend]`, `[with_cci]`, `[with_ccr]`, `[with_ccr, with_amend]`. Only on `UPC_INF` and `UPC_REV`. This is a Determinator-style variant axis the cascade does not surface today; out of scope for this design.
### 0.5 Live state of `paliad.projects`
| Column | Live data shape | Used by today's cascade? |
|---|---|---|
| `court` | **Free-text.** 4 non-null values across 4 rows: `LG München I` (1), `UPC` (2), `UPC CoA` (1). 7 rows NULL. | No. |
| `proceeding_type_id` | FK → `proceeding_types.id`. **11/11 live rows are NULL.** | Yes — `forumFromProject` reads it, but it never fires in production today. |
| `our_side` | enum `claimant` / `defendant` / `both` / `court` / NULL. | Yes — t-paliad-164 perspective chip predefine. |
| `counterclaim_of` | uuid FK self-reference. | No (relevant for SmartTimeline, not Determinator). |
| `filing_date` / `grant_date` | dates. | No (relevant to Verfahrensablauf wizard). |
**Critical caveat:** 11/11 live projects have NULL `proceeding_type_id`. Until that's backfilled (a separate cleanup), Akte-driven narrowing degrades to "no opinion" for every existing project. The design honours this — silent degrade, no failed-load toast, the cascade simply doesn't narrow. m locked this v1 behaviour with kelvin on 2026-05-13.
### 0.6 Anchor files for the implementer
- `frontend/src/fristenrechner.tsx:227-310` — Pathway B markup (the four-layer mess).
- `frontend/src/client/fristenrechner.ts:2405-2574``renderB1Cascade`.
- `frontend/src/client/fristenrechner.ts:2914-3081` — forum + perspective narrowing engine (`activeForumOnPage`, `inboxFilterAllowsForums`, `perspectiveAllowsParty`, `applyOurSidePredefine`).
- `frontend/src/styles/global.css:1636-1822``.fristen-pathway-shell`, `.fristen-mode-toggle`, `.fristen-b1-breadcrumb`, `.fristen-b1-question`, `.fristen-b1-buttons`, `.fristen-b1-button`, `.fristen-b1-step-back` (the visuals this design overhauls).
- `frontend/src/styles/global.css:1965-2065``.fristen-inbox-bar`, `.fristen-perspective-bar`, `.fristen-inbox-chip` (the chip strip rules).
- `frontend/src/client/views/verfahrensablauf-core.ts` (t-paliad-179) — pure-functional core, verified to carry **zero** Pathway B / cascade code. The lift is clean; this design is independent of it.
### 0.7 Adjacent design docs
- `docs/design-tools-cleanup-2026-05-12.md` (kelvin, t-paliad-178). Slice 1 of that shipped today; Slice 2 (Step 0 toggle + Akte auto-derivation on `/tools/fristenrechner`) is adjacent and will share the `litigation_code × jurisdiction → fristenrechner_code` mapping with this design.
- `docs/research-determinator-coverage-2026-05-08.md` (curie, t-paliad-167). Identified leaves missing from the cascade. Out of scope here — this design is the UX shell that any future coverage additions will land into.
If any of these conflict with what the task brief or memory asserts, **the live state wins** and the brief is the bug — flagged in §13 for m.
---
## 1. Vision + the three pillars
m's framing (2026-05-13 11:17):
> When I select a project, it should already narrow down the options (at least if it is a court proceeding). If it is a UPC proceeding, there is no need to show "non-UPC options"; this starts with the "how did you receive it?" which - for the UPC - will always be the UPC CMS.
>
> Not only is the different format for the levels of the questions weird (this needs an overhaul!), also there is no narrowing at all. I already described before that I want each decision on the tree to remain visible (one row per decision, it may be more compact than the active question was) and then go through things until there are only the least possible options left.
Three pillars, intertwined:
### Pillar 1 — Project-driven narrowing
Pre-fill or hide decision rows whose answer is implied by the project. UPC project → "Wo kam es an?" is implied (CMS). Project with `our_side` → perspective implied. Project with `proceeding_type_id` → cascade root narrows to the matching forum (and deeper, if mappable).
### Pillar 2 — Visual hierarchy overhaul
All decision layers are **the same primitive**: a row with a question label, an answer-area, and an inline "ändern" affordance. Whether the layer is mode-toggle, perspective, inbox, or a cascade level, the visual shape is identical. The active layer expands inside its row; inactive (answered) layers compact to a single line.
### Pillar 3 — Row-by-row persistent cascade
Replace breadcrumb drilldown with stacked rows. Each answered decision stays visible as a compact row. The active question is the only row that expands. The cascade builds top-to-bottom; the user sees every choice they made in one glance, and the answered rows act as their own affordances for "ändern".
The pillars interact:
- Pillar 3 (row layout) needs to know what to skip (Pillar 1 narrowing). A skipped row can render as a compact "(aus Akte) UPC CMS" pseudo-row, or be absent. We pick per row in §5.
- Pillar 2 (visual hierarchy) defines how *answered* vs *active* vs *skipped-but-shown* rows look. The four-different-treatments mess gets resolved by a single `.fristen-row` primitive.
- Pillar 1 (narrowing) also affects *initial state*: in Akte-mode, several rows may render as already-answered on page load. The cascade jumps to the first un-answered row.
---
## 2. The row primitive
The whole new layout is built from one element shape. Call it `.fristen-row` (the existing `.fristen-b1-*` class names get retired or rebased).
```text
┌─ .fristen-row ──────────────────────────────────────────────────────┐
│ .fristen-row-num .fristen-row-label .fristen-row-edit │
│ [1] Wie suchen? [ändern] │
│ .fristen-row-body │
│ ✓ Schritt-für-Schritt │
└──────────────────────────────────────────────────────────────────────┘
```
Three states:
### 2.1 `state="active"` — the user is answering this row
```text
┌─ .fristen-row.is-active ────────────────────────────────────────────┐
│ [3] Von wem ist das Schriftstück? │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ ⚖️ │ │ 🏛️ │ │ ✉️ │ │
│ │ Vom Gericht │ │ Von der │ │ Vom Patent- │ │
│ │ │ │ Gegenseite │ │ amt │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ← zurück │
└──────────────────────────────────────────────────────────────────────┘
```
Same chip-style buttons regardless of which row it is. Mode pick = two big chips. Perspective = three chips. Inbox = four chips. Cascade step = N chips, one per child node. Leaf cascade chips get a subtle modifier (`.fristen-row-chip--leaf`) so the user can see "this one ends the cascade".
### 2.2 `state="answered"` — the user has picked, but the answer is below
```text
┌─ .fristen-row.is-answered ──────────────────────────────────────────┐
│ [1] Wie suchen? ✓ Schritt-für-Schritt │
│ [ändern] │
└──────────────────────────────────────────────────────────────────────┘
```
Single line. The label, the picked answer, an "ändern" affordance. Click anywhere on the row (or the explicit ändern link) re-opens the row as active and drops every row below it. (This matches the existing breadcrumb-click semantic: jumping back to an ancestor invalidates descendants.)
### 2.3 `state="prefilled"` — derived from the project (or other auto-source)
```text
┌─ .fristen-row.is-answered.is-prefilled ─────────────────────────────┐
│ [2] Ich vertrete ✓ Klägerseite │
│ aus Akte: HL-2024-001 [ändern] │
└──────────────────────────────────────────────────────────────────────┘
```
Visually identical to `is-answered` but carries a small "aus Akte: <reference>" tag and a slightly muted background. Clicking ändern flips it to active (and drops the prefilled marker — the user has now made an explicit choice).
This generalises t-paliad-164's perspective predefine: same shape, same hint, same override-by-click semantics. The hint becomes a row-level token rather than a one-off `<span>` next to the chip strip.
### 2.4 `state="hidden"` — row is implied by an earlier pre-fill
A row that adds no information given upstream rows can be omitted entirely. e.g. UPC project → forum is `upc` → inbox row's only valid answer is "CMS" → the row simply doesn't render. We **do not** render a `is-hidden` placeholder; the absence is the affordance. (This is m's "no need to show non-UPC options".)
The first user-actionable row floats up under the prefilled stack.
### 2.5 Why one primitive
The current four-layer mess works against m because each layer looks like a different *kind* of question. The row primitive collapses that: every decision row carries the same "label + answer + ändern" anatomy. The user reads top-to-bottom; the answered rows stack as a paper trail; the active row is the only thing that demands interaction.
This also implicitly solves the row-count tax of m's "see your selections" ask: the rows compact to ~28px each when answered, so even a deep cascade keeps the active question in the upper third of the viewport.
---
## 3. Answered / active / prefilled / hidden — visual treatment
Concrete CSS sketch (Slice 1 will tune; this is the contract):
| Token | Active | Answered | Prefilled | Hidden |
|---|---|---|---|---|
| `min-height` | auto (chips wrap) | `28px` | `28px` | 0 (not rendered) |
| `background` | `var(--surface-card)` | `transparent` | `color-mix(var(--color-accent) 4%, transparent)` | n/a |
| `border-left` | `4px solid var(--color-accent)` | none | `4px solid var(--color-accent-faded)` | n/a |
| `font-weight` (label) | 600 | 500 | 500 | n/a |
| `font-weight` (answer) | n/a | 600 | 600 | n/a |
| `cursor` | default | pointer (whole row) | pointer (whole row) | n/a |
| `ändern` affordance | hidden | shown on hover + always on focus-within | always shown | n/a |
| Row number badge | accent-filled | outlined | outlined (faded) | n/a |
**No `::before { inset: 0 }` overlay tricks.** The whole-row click is wired via a JS handler that calls `reopenRow(idx)` and skips clicks on `<a>` / `<button>` inside the row body — same pattern as `.entity-table` and the project-detail Verlauf items (CLAUDE.md anchor under "Whole-card / whole-row click").
Active vs answered transition: when the user picks an answer in an active row, the row collapses to `is-answered` and the **next un-prefilled row materialises as active**. The DOM is preserved across the transition (row stack is one container with `data-state` attribute switched on each row); the chip set inside the answered row replaces with the single ✓-prefixed answer span.
For the prefilled state's "aus Akte: <reference>" tag — reference comes from `project.reference` (e.g. `HL-2024-001`), falling back to the first 8 chars of `project.id` if no reference. Click on the reference tag is a navigation shortcut to the project (open in new tab — keeps the Fristenrechner state intact).
---
## 4. Project-driven narrowing — data mapping
What can we derive from a selected project, and where does each derivation land?
### 4.1 Mapping table
| Derivation | Source column(s) | Maps to | Pre-fills row | Hides row? |
|---|---|---|---|---|
| **Forum** (upc / de / epa / dpma) | `proceeding_type_id``proceeding_types.code` prefix. Fallback: `court` free-text contains UPC/LG/OLG/BGH/BPatG/EPA/DPMA. | Cascade filter (existing `inboxFilterAllowsForums`). | "Wo kam es an?" if forum=UPC (→ CMS). DE: prefills nothing (beA vs Posteingang is a Postal Realität, not on the project). | UPC: yes. DE/EPA/DPMA: no. |
| **Perspective** | `project.our_side` ∈ {claimant, defendant} | Cascade filter (existing `perspectiveAllowsParty`). | "Ich vertrete" → Klägerseite / Beklagtenseite. `both` / `court` / NULL: no prefill. | No — even when prefilled, the row stays visible (the user needs to see "ah yes, I'm the Beklagte here"). |
| **Proceeding type** | `proceeding_type_id` + jurisdiction → fristenrechner code via `mapLitigationToFristenrechner()` (new helper, shared with t-paliad-178 Slice 2) | Cascade depth: prunes root buckets that don't apply, and prunes inner buckets to those matching the proceeding code. e.g. UPC + INF → only `cms-eingang.gegenseite.upc-inf.*`, `cms-eingang.gericht.urteil-upc-cfi`, etc. | Pre-collapses cascade sub-branches; surfaces deeper-leaf rows directly when only one path applies. | Hides intermediate cascade rows whose only child matches the derived code. |
| **Counterclaim** | `counterclaim_of IS NOT NULL` | Implies `with_ccr` / `with_cci` condition flag context. | Not a cascade row today — surfaces as a `condition_flag` chip on the wizard. **Out of scope for this design**; flagged in §13 Q6. | n/a |
| **Filing / grant dates** | `filing_date`, `grant_date` | Wizard anchor pre-fill. | Not a cascade row. Out of scope. | n/a |
### 4.2 Detail: the litigation → fristenrechner mapping
t-paliad-178 §0 and the task brief both call out: `project.proceeding_type_id` points at the **7 `litigation` codes** (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL). The cascade speaks **`fristenrechner` codes** (UPC_INF, DE_INF, ...). A small mapping is needed:
```text
INF + UPC → UPC_INF
INF + DE → DE_INF (first instance; OLG/BGH not derivable from project)
REV + UPC → UPC_REV
REV + DE → DE_NULL
CCR + UPC → UPC_INF + condition_flag=[with_ccr] (linked via parent's proceeding)
CCR + DE → DE_NULL (German Nichtigkeit IS the counterclaim equivalent)
APP + UPC → UPC_APP
APP + DE → DE_INF_OLG | DE_NULL_BGH (ambiguous — needs court or instance hint; degrade)
APM + UPC → UPC_PI
AMD + UPC → UPC_INF + condition_flag=[with_amend]
ZPO_CIVIL + DE → ZPO civil only; ignore for cascade (no fristenrechner code)
```
The mapping lives in **one** place — a new `internal/services/proceeding_mapping.go` (or the same shared helper t-paliad-178 Slice 2 introduces). The frontend gets the **resolved fristenrechner code** plus `condition_flag` array as part of the project payload (`ProjectOption.derived_fristenrechner_code` + `.derived_condition_flags`).
**Honest about degrade:** the mapping isn't always 1:1. APP+DE is ambiguous, ZPO_CIVIL has no analogue, and projects without `proceeding_type_id` (all 11 live ones today) get no derivation at all. The cascade falls back to forum-only narrowing in every ambiguous case. **Never silent FK promotion.**
### 4.3 Detail: court free-text fallback
When `proceeding_type_id` is NULL but `court` has a recognisable substring:
```text
court contains "UPC" → forum=upc
court contains "BPatG" → forum=de (Nichtigkeit / DPMA-Beschwerde)
court contains "BGH" → forum=de
court contains "OLG" → forum=de
court contains "LG" → forum=de
court contains "EPA" / "EPO" → forum=epa
court contains "DPMA" → forum=dpma
otherwise → no narrowing
```
This is a UX nicety, not a correctness mechanism. The fuzzy match always loses to a real `proceeding_type_id` if both are set. Surfaces as the prefilled-row reference tag: "Forum: UPC (aus Gericht: UPC CoA)".
### 4.4 What the cascade hides given a forum
`event_categories.forums` is the live signal:
- 91/103 leaves carry a forum tag.
- 12 are neutral (cross-cutting: `frist-verpasst`, `sonstiges`, some Mündl-Verhandlung leaves, court actions).
With `forum=upc` active, ~73 leaves drop from the cascade. The user sees the same root buckets (cms-eingang / muendl / beschluss / frist-verpasst / ich-moechte-einreichen / sonstiges) but each bucket's children list collapses to the upc-relevant subset. **This is already wired today; the design doesn't change the filter, only its visual presentation.**
The new contribution: when a non-leaf bucket reduces to a single descendant chain (e.g. UPC project → `cms-eingang``gegenseite``upc-inf` is the only chain), the cascade should optionally **auto-walk** the chain and surface the leaf parent's siblings directly. §5 below.
### 4.5 What the cascade hides given perspective
Currently only the 16 `ich-moechte-einreichen.*` leaves carry `party` tags. So perspective filters outgoing-filing nodes only. Incoming `cms-eingang.gegenseite.*` nodes don't have party tags — receiving from the opposing side is symmetric (you receive what they sent, regardless of who you are). This is correct and doesn't need fixing.
**Design implication:** the perspective row is *always* visible (rows can never be `is-hidden` based on perspective alone), even when prefilled, because its filter affects user-write decisions that the user might still want to override. Match t-paliad-164.
---
## 5. What gets pre-answered, hidden, or skipped-but-shown
A concrete matrix per row, given live data + the rules above:
| Row | Question | Pre-fill source | UPC project | DE project | EPA / DPMA project | No project (ad-hoc) | No project (zero ctx) |
|---|---|---|---|---|---|---|---|
| **R0 Mode** | Wie suchen? | none | active | active | active | active | active |
| **R1 Perspective** | Ich vertrete | `project.our_side` | prefilled iff `our_side` ∈ {claimant, defendant}; else active | same | same (rare for EPA/DPMA — usually only `court` or NULL) | active | active |
| **R2 Inbox** | Wo kam es an? | forum derivation | **hidden** (forum=upc ⇒ CMS implied) | active (beA vs Posteingang) | active | active | active |
| **R3 Bucket** | Was ist passiert? | none — user always picks the bucket | active | active | active | active | active |
| **R4..Rn Cascade** | per-node `step_question_de` | proceeding-code derivation can pre-walk a single-child chain | optionally auto-walks single-child chains | same | same | active | active |
Notes:
- **R0 Mode**: kept active in all cases. The user always picks Tree vs Filter (or skips R0 entirely if we ditch the mode toggle — see §6). The mode pick is meta and not derivable from the project.
- **R1 Perspective**: a project with `our_side='both'` is rare but legitimate; it lands as active. `'court'` is even rarer (m's project model includes a "we are the court" perspective for hypothetical training scenarios). For now: `court` → active row.
- **R2 Inbox**: m's literal ask. UPC → hidden. DE → active (because beA vs Posteingang is meaningful for downstream Phase-0 manual workflows even if the cascade filter doesn't care). EPA/DPMA → active (e.g. EPA online filing vs Post). The "Alle" chip stays for "I don't know yet".
- **R3 Bucket**: the 6 root buckets are always shown. Even with a derived proceeding code, the user still has to say "I'm here because I received something / mündl. Verhandlung / Urteil / etc." This is too coarse to derive.
- **R4..Rn Cascade auto-walk**: when a derived proceeding code reduces a bucket's children to a single chain, the cascade should pre-walk that chain. e.g. UPC + INF + `cms-eingang` bucket → only `gegenseite.upc-inf.*` chain survives → R4 `gegenseite` is pre-answered (with the "aus Akte" badge), R5 jumps directly to `upc-inf` (also pre-answered), and R6 is the active question "Welcher Schriftsatz?". The user sees four R-rows (R0, R1 prefilled, R3 picked, R4 prefilled, R5 prefilled, R6 active) — clean paper trail of inference + one active question.
**Important constraint:** auto-walk is **descendants-of-the-picked-bucket only**. R3 (bucket) is always active because the bucket is the user's intent. We never auto-pick the bucket. So a UPC project doesn't pre-pick "cms-eingang" for you; it just makes the sub-cascade efficient once you've said "cms-eingang".
### 5.1 Compact summary diagram — UPC INF project drilling into a cms-eingang opposing-side schriftsatz
```text
┌─ Step 1: Akte (Step 1 surface, above Pathway B) ────────────────────┐
│ Akte: HL-2024-001 — Acme v. Globex (UPC INF) [Andere Akte] │
└─────────────────────────────────────────────────────────────────────┘
┌─ [1] Wie suchen? ✓ Schritt-für-Schritt [ändern]┐
└─────────────────────────────────────────────────────────────────────┘
┌─ [2] Ich vertrete ✓ Klägerseite [ändern]┐
│ aus Akte: HL-2024-001│
└─────────────────────────────────────────────────────────────────────┘
Row R2 (Inbox) hidden — UPC implies CMS
┌─ [3] Was ist passiert? ✓ CMS-Eingang [ändern]┐
└─────────────────────────────────────────────────────────────────────┘
┌─ [4] Von wem ist das Schriftstück? ✓ Von der Gegenseite [ändern]┐
│ aus Akte (UPC INF impliziert)│
└─────────────────────────────────────────────────────────────────────┘
┌─ [5] Welches Verfahren? ✓ UPC Verletzungsverfahren │
│ aus Akte: HL-2024-001 │
└─────────────────────────────────────────────────────────────────────┘
┌─ [6] Welcher Schriftsatz wurde eingereicht? (active, awaiting pick)│
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Klageschrift │ │ Klageerwiderung │ │ Replik │ │
│ │ (R.13) │ │ + Widerklagen │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ ... (rest of UPC_INF Schriftsätze) │
│ │
│ ← zurück │
└─────────────────────────────────────────────────────────────────────┘
```
Six rows. Three user picks (mode, bucket, leaf). Three Akte-derived prefills. One R2 absent. The user sees their full decision path at a glance.
For comparison, today's UI: the user clicks four times into the cascade, the top of the page is two chip strips and a radio they didn't touch, the breadcrumb at the top of `.fristen-b1-cascade` shows three crumb buttons in 12pt text, and there's no inline indication that the cascade is narrower than the full taxonomy. m's "no narrowing at all" is the literal reading of what's visible.
### 5.2 Compact summary diagram — DE project drilling into the same
```text
┌─ [1] Wie suchen? ✓ Schritt-für-Schritt [ändern]┐
└─────────────────────────────────────────────────────────────────────┘
┌─ [2] Ich vertrete ✓ Klägerseite [ändern]┐
│ aus Akte: HL-2024-002│
└─────────────────────────────────────────────────────────────────────┘
┌─ [3] Wo kam es an? (active, awaiting pick)┐
│ │
│ ┌──────┐ ┌──────────────┐ ┌──────┐ │
│ │ beA │ │ Posteingang │ │ Alle │ │
│ └──────┘ └──────────────┘ └──────┘ │
└─────────────────────────────────────────────────────────────────────┘
... and the cascade continues below once R3 is answered.
```
R2 (Inbox) is active because beA vs Posteingang is a real distinction for German projects. The forum is already known (`de`), so the cascade below R3 will be DE-only — but the user still tells us *how* the document arrived.
### 5.3 Compact summary diagram — abstract / no-Akte mode
```text
┌─ [1] Wie suchen? (active, awaiting pick)┐
│ │
│ ┌────────────────────────┐ ┌─────────────────────┐ │
│ │ Schritt-für-Schritt │ │ Filter / Suche │ │
│ │ (Entscheidungsbaum) │ │ │ │
│ └────────────────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
No prefills, no hidden rows. Every row is asked. Full taxonomy.
---
## 6. Filter / Suche mode — coexistence with the cascade
Today's mode toggle (radio) is a UX wart: it's the only radio on the page, it looks unlike everything else, and it sits at the top of Pathway B as if it were a primary axis.
Two options to fold it into the row model:
### Option A — Mode is R0, a row like any other
The mode toggle becomes the first row in the stack. Two chips. Pick determines what populates below: tree picker → R3 + cascade. Filter picker → R3 collapses into a search input + result list. The row stays visible (you can switch mid-flow via ändern), but the chrome is consistent.
Pros: simple, every decision is a row, the page reads top-to-bottom.
Cons: adds one always-active row to every flow including the "I know what I'm doing, just give me search" use case.
### Option B — Mode is an escape hatch, not a row
Filter is positioned as "ich weiß schon, wonach ich suche" — a small link / icon at the top of Pathway B that toggles between cascade and search. No R0 row. Default = cascade. Click → search replaces the row stack.
Pros: fewer rows, less for the common case to scan past.
Cons: more discoverable than the current radio? unclear. "Where did the radio go?" is a question.
### Option C — Filter as a *bottom-of-stack* affordance
Cascade is the only top-down flow. Below the cascade results, a "Sie wissen schon den Namen? → direkt suchen" link / row appears. Search is a graceful fallback, not a peer mode.
Pros: gives cascade the primary surface, search becomes a tool for "wait, I know better".
Cons: discoverability of search is reduced for power users who DO know.
**Inventor's pick:** Option B. The radio is dead weight, and the search use case is "I know the name; let me skip the cascade" — that's an escape hatch, not a peer axis. Visually: a small `🔍` icon-button at the top-right of Pathway B titled "Direkt suchen". Click expands a search input that replaces the row stack; result list appears below; "← Zurück zum Entscheidungsbaum" returns to the row stack with prior state preserved.
But this is design-question territory — m's call. §13 Q1.
---
## 7. Mobile + responsive
The row primitive is naturally responsive: rows stack vertically by default. Width concerns only the chip set inside an active row.
### 7.1 Breakpoints
`paliad` already uses 640 / 768 / 1023 px breakpoints. The rows live inside `.fristen-pathway-shell` which is already a column-flex.
| Width | Row chrome | Chip layout (active row) |
|---|---|---|
| ≥ 1024px | full label + answer + ändern on one line, badge left | chips in a 3-column grid (or auto-fill min 220px) |
| 7681023px | same | chips in a 2-column grid |
| 640767px | label + answer on line 1, ändern on line 2 right-aligned | chips in a 1-column stack |
| < 640px | label on line 1, answer on line 2, ändern as `` icon right-aligned | chips full-width, single column |
### 7.2 Active-row collapse on tap (mobile-only)
On `< 768px`, the row stack scrolls; the active row's chip set can be long (e.g. 9 Schriftsatz children). When the user picks an answer, the page autoscrolls so the next active row is at the top of the viewport. This is the same pattern as the Akte picker (Step 1) and existing form flows.
### 7.3 What we don't do on mobile
- **No drawer / modal for the cascade.** The whole point of the row stack is being able to see history at a glance; collapsing into a separate surface defeats it.
- **No fly-out for ändern.** Tap on an answered row's ändern affordance simply re-activates the row in place.
- **No "next" button.** Picking a chip advances automatically; mobile doesn't need an extra tap to confirm.
---
## 8. "Neu starten" / Reset semantics
Three flavours of reset, all need a home:
### 8.1 Reset the whole cascade (every row to empty)
Today: clicking the breadcrumb's "Pfad zurücksetzen" root crumb. In the new layout: a small `↺ Pfad zurücksetzen` link at the top of the row stack, right of the heading. Clicking it:
- Drops every cascade row (R3+).
- Leaves R0 (Mode), R1 (Perspective prefilled), R2 (Inbox if visible) as they are those are "context", not "the user's investigation".
- Re-activates R3.
Optional behaviour (per Q9): a confirm-dialog if the user has drilled 3 cascade levels deep. Probably overkill; current breadcrumb root-click is destructive without confirm. Match existing semantic.
### 8.2 Drop just one decision (ändern semantic)
Built into every answered row's `[ändern]` affordance and clicking on the row body. Effect: that row reverts to active; every row below it drops; URL ?b1= shortens to that row's prefix.
This is the workhorse of the row stack m's "you can see your selections" UX implies "you can also rewind to any of them at any time". Built-in.
### 8.3 Drop the Akte-derived prefills
Trickier: if the user clicks ändern on a `is-prefilled` row, the prefill is overridden. But what about "I want to ignore my Akte entirely for this exercise"? The Akte itself is bound at the Step 1 surface, above Pathway B. Clicking "Andere Akte" at the Step 1 summary unbinds the Akte and drops all `is-prefilled` markers. The cascade rows that were `is-answered` because they were prefilled now revert to `is-active` (or, if the user had already explicitly overridden via ändern, stay answered with no `is-prefilled` flag).
This semantic already half-exists for t-paliad-164's perspective predefine; we generalise it to every prefilled row. Implementation: hold a `prefillSources: Map<rowID, "akte" | "user">` and re-derive on Akte unbind / change.
### 8.4 The "Neu starten" button at the bottom
A second affordance at the bottom of the results area, after the user has reached a leaf and is reading concept-cards. "Andere Frist nachschlagen?" reset to R3. Optional but discoverable; today's UI lacks an equivalent, so this is a small UX win.
---
## 9. Search affordance integration
Tied to §6's mode-toggle question. Two integration points:
### 9.1 Search panel placement (Option B from §6)
The `🔍 Direkt suchen` button lives at the top-right of `.fristen-pathway-shell`. Click animates the row stack out (or simply replaces it), shows a search input row with a single text field + result list below. ESC or "← Zurück zum Entscheidungsbaum" returns; row stack restores via URL state.
The search is the existing `?q=` + B2 chips flow we don't rebuild it, just relocate its entry point. Existing forum-filter chip row stays inside the search panel.
### 9.2 Inline search on each cascade row (rejected)
An alternative: each cascade row's chip list gets a tiny "filter chips" input at the top. Reject. Adds chrome to every active row for a feature most users don't need.
### 9.3 "I searched but want to see the path" round-trip
When the user lands on a leaf via search, optionally show "Im Entscheidungsbaum öffnen " clicking restores the row stack with all ancestor rows pre-answered (which is what the cascade's slug already encodes). This is a small extra: lets a search-first user verify "yes, this is the leaf I thought, here's the proceeding context I missed".
---
## 10. Slicing for the coder pass
Three slices, each independently shippable, mergeable in order:
### Slice 1 — Visual hierarchy + row-by-row layout (no narrowing change)
Replaces the four-layer mess with the row primitive. **No backend or DB changes.** The narrowing engine stays the same (existing forum + perspective filters fire); the visual presentation moves from breadcrumb + chip strips + radio row stack.
In scope:
- New `.fristen-row` CSS primitive (with `.is-active`, `.is-answered`, `.is-prefilled` modifiers).
- Refactor `renderB1Cascade` into a row-stack renderer (`renderRowStack(rows: RowSpec[])`).
- Migrate L1 (mode) / L2 (perspective) / L3 (inbox) / L4..n (cascade) all to row instances.
- "ändern" semantic = re-activate row, drop rows below, push history state.
- Reset link at top of stack.
- i18n keys for row labels.
Out of scope for Slice 1:
- Project-derived proceeding-code narrowing (the `mapLitigationToFristenrechner` helper).
- Auto-walk single-child cascade chains.
- Hide-R2-on-UPC behaviour (Slice 2 needs the proceeding mapping helper anyway).
- Search affordance relocation (Slice 3).
Outcome: same data, same narrowing, **vastly better visual narrative**. The user can finally see their decision path. m's pillar 2 + 3 are addressed.
### Slice 2 — Project-driven narrowing depth
Adds the `litigation_code × jurisdiction → fristenrechner_code` mapping and uses it to:
- Pre-fill the proceeding-type sub-cascade rows (R5 in the §5.1 diagram).
- Hide R2 (Inbox) when project is UPC.
- Auto-walk single-child chains.
- Add the "aus Akte: <reference>" tag on prefilled rows.
This is where Pillar 1 fully lands. Depends on Slice 1's row primitive.
Includes a small backend helper (shared with t-paliad-178 Slice 2 if both ship in parallel): `internal/services/proceeding_mapping.go` exposes `MapLitigationToFristenrechner(litCode string, jurisdiction string) (fristenCode string, conditionFlags []string, ok bool)`.
Outcome: an Akte-bound user starts the cascade with three rows already answered, and only one or two active questions remain to drill to the leaf.
### Slice 3 — Search affordance + mobile polish
Relocates the mode-toggle / search affordance per §6 Option B. Adds the responsive breakpoints from §7. Polishes the autoscroll-to-active behaviour on mobile.
Mobile-only fixes ride here so Slices 1+2 can be reviewed by m at desktop width first.
### Why this order
- Slice 1 is purely visual. m can see the row stack and validate the layout BEFORE we change any narrowing semantic. If m hates the row primitive, we revert one PR. (We won't — but the option matters.)
- Slice 2 is the heavy correctness lift. It depends on the mapping helper, on Akte payload extensions, and on careful Test_DATABASE_URL integration tests.
- Slice 3 is final polish. Independently mergeable, lowest risk.
Each slice is roughly:
- Slice 1: 1 frontend PR (~700 LoC TSX + CSS + client). No backend, no migrations.
- Slice 2: 1 mixed PR (~150 LoC Go + 300 LoC client). No migrations.
- Slice 3: 1 frontend PR (~150 LoC).
---
## 11. Tradeoffs flagged
### 11.1 Row stack is taller than the current shell
A deep cascade (4 levels) plus 3 prefilled rows + R0 = 8 rows. Each ~28px compact + the active row's chip body (200400px depending on chip count) + spacing → ~600800px tall. The current shell is ~400px tall in the same scenario. Mitigation: rows are compact (28px), active-row autoscrolling keeps the chip set in view on mobile, and the visual narrative wins. m's ask explicitly trades vertical space for visibility.
### 11.2 "Aus Akte" tags are slightly noisy
Three rows showing "aus Akte: HL-2024-001" reads a bit redundant. Mitigation: only the first prefilled row shows the reference; subsequent rows show "(aus Akte)" without the reference. Saves vertical noise, keeps the source visible once.
### 11.3 Auto-walk single-child chains can confuse
The user picks "cms-eingang" → suddenly two rows materialise pre-answered. Looks magical. Mitigation: the two rows are clearly `is-prefilled` with an "aus Akte (UPC INF impliziert)" tag, and ändern is available on each. After the user has done it twice, the inference becomes a feature; before, a tooltip on first-render ("Diese Schritte ergeben sich aus Ihrer Akte") could help (deferred for v2 — see Q11).
### 11.4 Removing the radio mode-toggle is a behavioural change
Existing power users may know the radio. Mitigation: the new `🔍 Direkt suchen` icon-button at the top of Pathway B is a visible affordance; URL ?mode=filter still works as deep-link. Soft transition.
### 11.5 11/11 live projects have NULL `proceeding_type_id`
Slice 2's narrowing literally doesn't fire in production today. We're building UX that requires data nobody has yet. Mitigation: graceful degrade (forum-only narrowing via court free-text fuzzy match — already a feature today). Backfill of `proceeding_type_id` is a separate follow-up (see Q13).
### 11.6 The mapping table in §4.2 has ambiguities
APP+DE → ambiguous; ZPO_CIVIL → no analogue; CCR ↔ counterclaim modeling is fragile. Mitigation: every ambiguous case degrades to "no narrowing" — the row stays active rather than incorrectly pre-filled. Better silent than wrong.
### 11.7 ändern-on-an-ancestor invalidates descendants
Same as today's breadcrumb-click semantic — clicking a non-current crumb drops cascade depth. **No data is lost** (you can re-walk the cascade), but if the user was reading concept-cards at a leaf, those cards disappear. Mitigation: when ändern is clicked on an answered row, before dropping descendants, brief inline confirmation? Or just match today's behaviour (drop immediately). Inventor recommends match-today; Q12.
### 11.8 The row primitive may be over-engineered
A single visual primitive for four functionally different layers is a strong opinion. If a future cascade layer (e.g. variant chips for `condition_flag`) doesn't fit the primitive shape, we have to either extend the primitive or break the consistency. Mitigation: the primitive is shape (label + answer-area + ändern), not behaviour — variant chips fit because they're also "pick one (or several)". The contract is loose enough.
---
## 12. Files the implementer will touch (Slice 1 only)
### 12.1 Frontend
- **`frontend/src/fristenrechner.tsx:227-310`** — Pathway B markup. Replace `.fristen-mode-toggle` + `.fristen-perspective-bar` + `.fristen-inbox-bar` + `.fristen-b1-cascade` with a single `.fristen-row-stack` container. Add minimal scaffolding rows for mode / perspective / inbox / cascade-host. Keep `.fristen-b1-results` below — unchanged.
- **`frontend/src/client/fristenrechner.ts:2405-2574`** — Refactor `renderB1Cascade` into `renderRowStack(rows)`. The row spec is a discriminated union: `{kind: "mode" | "perspective" | "inbox" | "cascade", state: "active" | "answered" | "prefilled", question, options[], picked?}`. Rendering is one function per state; one switch on `kind` for the options builder.
- **`frontend/src/client/fristenrechner.ts:2914-3081`** — `inboxFilterAllowsForums` + `perspectiveAllowsParty` unchanged (Slice 1 is visual-only).
- **`frontend/src/client/fristenrechner.ts:initInboxFilter`** + perspective init — same handlers, new DOM targets.
- **`frontend/src/client/i18n.ts`** — ~20 new keys under `deadlines.row.*` (row labels, ändern affordance, prefilled tag, reset link, "next active" autoscroll-target announce).
- **`frontend/src/styles/global.css:1636-1822` + `:1965-2065`** — Retire `.fristen-mode-toggle`, `.fristen-perspective-bar`, `.fristen-inbox-bar`, `.fristen-b1-breadcrumb`, `.fristen-b1-question`, `.fristen-b1-buttons`, `.fristen-b1-button*`. Add `.fristen-row-stack`, `.fristen-row`, `.fristen-row-num`, `.fristen-row-label`, `.fristen-row-answer`, `.fristen-row-edit`, `.fristen-row-body`, `.fristen-row-chip`, `.fristen-row-chip--leaf`, `.is-active`, `.is-answered`, `.is-prefilled`.
### 12.2 Backend
No backend changes for Slice 1. The existing `/api/tools/fristenrechner/event-categories` and `/api/tools/fristenrechner/search` endpoints are unchanged.
### 12.3 Tests
- Pure-TS unit tests for `buildRowStack(currentState)` if extracted (table-driven: given URL state + Akte payload, output the RowSpec[]).
- Playwright smoke (post-deploy): land on Pathway B with `?path=b&project=<uuid>`, verify R1 prefilled with "aus Akte", R2 hidden for UPC project, ändern on R1 reopens, ändern on bucket drops cascade depth.
### 12.4 Anchoring back
t-paliad-164 perspective predefine code is the precedent. Re-read it before implementing — same hint mechanism, same override semantics, generalised.
t-paliad-178 Slice 2 (Step 0 toggle + Akte auto-derivation) is parallel; coordinate on the shared `proceeding_mapping.go` helper file (Slice 2 of this task introduces it; t-paliad-178 Slice 2 can adopt or vice versa, depending on which lands first).
---
## 13. Open questions for m
These are inventor's calls flagged for m's gate. Picking is on m, not the coder.
**Q1 — Mode-toggle disposition.** Three options in §6: (A) R0 row, (B) escape-hatch icon-button [inventor's pick], (C) bottom-of-stack affordance. Pick one or specify another.
**Q2 — UPC project: hide R2 entirely or show as compact prefilled?**
- Hide entirely (inventor's pick — matches m's "no need to show non-UPC options").
- Show as compact `[2] Wo kam es an? ✓ UPC CMS [ändern] aus Akte` row — verbose but explicit.
**Q3 — Auto-walk single-child cascade chains?**
- Yes, materialise R4..Rn-1 as prefilled (inventor's pick — strong UX, but feels magical first time).
- No, the user always picks their way down even when only one child applies (slower, more predictable).
- Yes-but-only-when-≥-2-rows-collapse (tradeoff).
**Q4 — "ändern" affordance shape on an answered row.**
- Hover-revealed link "ändern" (inventor's pick — keeps row clean by default).
- Always-visible pencil icon (more discoverable but more chrome).
- Whole-row click is the only handle (cleanest, but no visible affordance — newcomers won't discover it).
**Q5 — Drop confirmation when ändern invalidates descendants?**
- No (match today's breadcrumb-click — inventor's pick).
- Yes, when ≥ 3 cascade levels would be dropped.
- Always — even a one-row drop confirms.
**Q6 — Counterclaim awareness in the cascade.**
`project.counterclaim_of IS NOT NULL` implies `[with_ccr]` or `[with_cci]` condition flag depending on the parent's proceeding code. Should this surface as a prefilled row (e.g. "Variante: with_ccr"), or only as a backend filter on the result concept cards (silent)?
- Surface as a prefilled row (transparency — user sees the variant is active).
- Silent backend filter (no row tax, but mystery narrowing).
- Out of scope for this design — handle in a separate variant-chip task.
**Q7 — R0 mode-pick deep link.**
If a user lands on `?path=b` without `?mode=`, do we default to tree or to "no R0 picked yet"?
- Default to tree, R0 prefilled (today's behaviour — silent).
- R0 active until the user picks (more explicit, but adds one extra click for the common case).
**Q8 — Prefilled-row override permanence.**
After the user clicks ändern on a prefilled R1 (perspective) and explicitly picks "Beklagter" instead of the Akte's "Kläger", does this override persist if they re-bind the same Akte?
- No, re-bind re-applies (today's behaviour — clean, but overrides feel ephemeral).
- Yes, store override per-Akte in localStorage (sticky overrides — UX-friendly, but new state).
**Q9 — Reset confirm.**
A "Pfad zurücksetzen" link at the top of the row stack — confirm dialog?
- No confirm — match today's breadcrumb root-click (inventor's pick).
- Confirm if cascade depth ≥ 3.
- Always confirm.
**Q10 — Search escape-hatch position.**
Per §6 / §9, the `🔍 Direkt suchen` button sits at the top-right of Pathway B.
- Top-right (inventor's pick — discoverable, doesn't push down the row stack).
- Below the row stack, after results.
- As a permanent row at the bottom of the stack.
**Q11 — First-visit tooltip on auto-walked rows.**
"Diese Schritte ergeben sich aus Ihrer Akte" tooltip on the first prefilled-from-mapping row, dismissed forever on first close?
- Yes (helps onboarding).
- No (extra chrome; the "aus Akte" tag is enough).
- Inline help-icon (?) link to a docs page (longer-form).
**Q12 — Concept cards live below the row stack today. Should they collapse / hide when the user reopens an ancestor row (ändern)?**
- Collapse/hide on ändern, repopulate when the cascade reaches a leaf again (inventor's pick — matches the "no orphan content" rule).
- Keep visible as last-known until cascade resolves to a new leaf.
**Q13 — Backfill `paliad.projects.proceeding_type_id`?**
11/11 live rows are NULL. Slice 2's narrowing depends on this. Should the Slice 2 PR also include a one-off Akte-edit nudge ("Projekt-Setup vervollständigen: Verfahrensart fehlt"), or do we wait until m manually fills them in over time?
- Inline "Verfahrensart ergänzen" link on Akten with NULL proceeding_type_id.
- Backfill script (inferring from `court` free-text where unambiguous).
- Defer entirely; live with degraded narrowing until users fill it organically.
**Q14 — Reorder rows so prefilled stack at top, user-picked at bottom?**
The §5.1 diagram orders rows R0..Rn in their natural cascade sequence (mode → perspective → inbox → bucket → cascade depth). The prefilled rows happen to be R1, R4, R5 (not contiguous). Alternative: visually float all prefilled rows to a single "aus Akte" group at the top, with user-picked rows below. Tradeoff: cleaner separation vs. losing the temporal narrative of the decision path.
- Keep natural order (inventor's pick — narrative wins).
- Group prefilled at top.
**Q15 — Should `Filter / Suche` mode also see Akte prefills?**
If the user enters search mode with a project bound, do we silently scope results to the project's forum, or show the full taxonomy?
- Scope (consistent with cascade narrowing — inventor's pick).
- Don't scope (search is a "I know what I'm looking for" mode; the project is incidental).
- Scope with a visible toggle "Auch andere Foren anzeigen".
---
## DESIGN READY FOR REVIEW
Awaiting m's go/no-go on the questions in §13 before the coder shift starts. Inventor (pauli) parks after this commit — no implementation kickoff, no other-skill autoload, head gates the transition.
Recommended implementer: pattern-fluent Sonnet coder. The row primitive is straightforward CSS + a small state machine refactor; the precedent code (t-paliad-164 + t-paliad-133 cascade engine) is well-understood. **NOT cronus per memory directive 2026-05-06.**

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,172 @@
# Proceeding-code taxonomy (t-paliad-204 ratified 2026-05-18)
> Source of truth for `paliad.proceeding_types.code`. Every active row's
> `code` MUST conform to the convention below. This document anchors
> migration 096 (`internal/db/migrations/096_proceeding_code_rename.up.sql`)
> and the post-migration determinator + fristenrechner mapping in
> `internal/services/proceeding_mapping.go`.
## 0. Why we renamed
The historical `code` strings (`UPC_INF`, `DE_INF`, `EPA_OPP`, …) were
UPPER_SNAKE jurisdiction-glued-to-acronym slugs. They were structurally
opaque and the taxonomy grew unevenly as more proceedings entered the
fristenrechner — `UPC_APP` covers all UPC appeals, `DE_INF_OLG` /
`DE_INF_BGH` carry the instance hint inline, `EP_GRANT` is the only EPA
row with no `EPA_` prefix at all. The mapping in
`internal/services/proceeding_mapping.go` had to special-case appeal
ambiguities (no instance hint on UPC_APP, none on the DE side either).
After mig 095 landed the t-paliad-205 fristen gap-fill, m and paliadin
ratified a uniform convention for the corpus, captured here.
## 0.1 Convention
Active proceeding codes are lowercase, dot-separated, three positions:
<jurisdiction>.<X>.<Y>
* **`<jurisdiction>`** — one of `upc`, `de`, `epa`, `dpma`.
* **`<X>` / `<Y>`** — contextual; for first-instance proceedings they are
`<substantive-type>.<forum>` (e.g. `de.inf.lg` for Verletzungsklage am
Landgericht). For appeals they are `<appeal-type>.<scope>` (e.g.
`upc.apl.merits`, `upc.apl.cost`, `upc.apl.order`).
* The CHECK constraint installed by mig 096 enforces
`code ~ '^[a-z]+\.[a-z]+\.[a-z]+$'` on every active row, with a
carve-out for the legacy `_archived_litigation` bucket
(`code ~ '^_archived_'`).
The convention is forward-looking: any new fristenrechner row added
after mig 096 MUST conform — no further UPPER_SNAKE codes.
## 0.2 Ratified taxonomy
### UPC
| New code | Old code | id | Notes |
|--------------------|------------------|----|------------------------------------------------------------------------|
| `upc.inf.cfi` | `UPC_INF` | 8 | Verletzungsverfahren, CFI |
| `upc.rev.cfi` | `UPC_REV` | 9 | Nichtigkeitsverfahren, CFI |
| `upc.ccr.cfi` | _new_ | _new_ | Widerklage auf Nichtigkeit — illustrative peer of `upc.inf.cfi`. Rules live on `upc.inf.cfi` with `with_ccr=true`. See §1 sub-decision S1. |
| `upc.pi.cfi` | `UPC_PI` | 10 | Einstweilige Maßnahmen |
| `upc.dmgs.cfi` | `UPC_DAMAGES` | 17 | Schadensbemessung |
| `upc.disc.cfi` | `UPC_DISCOVERY` | 18 | Bucheinsicht |
| `upc.apl.merits` | `UPC_APP` | 11 | Hauptberufung — covers inf + rev + ccr + damages-merits appeals |
| `upc.apl.order` | `UPC_APP_ORDERS` | 20 | 15-Tage-Beschwerde gegen Anordnungen (R.220 (1)(c)) |
| `upc.apl.cost` | `UPC_COST_APPEAL`| 19 | Kostenbeschwerde |
### DE
| New code | Old code | id | Notes |
|---------------------|------------------------|----|-------------------------------------------------------------|
| `de.inf.lg` | `DE_INF` | 12 | Verletzungsklage am Landgericht |
| `de.inf.olg` | `DE_INF_OLG` | 25 | Berufung am OLG |
| `de.inf.bgh` | `DE_INF_BGH` | 26 | Revision + NZB merged — `with_nzb` flag on NZB-detour rules |
| `de.null.bpatg` | `DE_NULL` | 13 | Nichtigkeitsverfahren am BPatG |
| `de.null.bgh` | `DE_NULL_BGH` | 27 | Nichtigkeitsberufung am BGH |
### EPA
| New code | Old code | id | Notes |
|---------------------|--------------|----|------------------------------------------------|
| `epa.grant.exa` | `EP_GRANT` | 16 | EP-Erteilungsverfahren |
| `epa.opp.opd` | `EPA_OPP` | 14 | Einspruchsverfahren |
| `epa.opp.boa` | `EPA_APP` | 15 | Einspruchsbeschwerde (Board of Appeal) |
### DPMA
| New code | Old code | id | Notes |
|-----------------------|-------------------------|----|----------------------------------------------------------------|
| `dpma.opp.dpma` | `DPMA_OPP` | 28 | Einspruch beim DPMA |
| `dpma.appeal.bpatg` | `DPMA_BPATG_BESCHWERDE` | 29 | Beschwerde am BPatG (generic — source differentiated at rule level) |
| `dpma.appeal.bgh` | `DPMA_BGH_RB` | 30 | Rechtsbeschwerde am BGH (generic — source differentiated at rule level) |
### Archived
| Code | id | Notes |
|-------------------------|----|----------------------------------------|
| `_archived_litigation` | 32 | Unchanged — Pipeline-A retired corpus |
IDs are stable. Only the `code` STRING changes. The FKs
`deadline_rules.proceeding_type_id`, `projects.proceeding_type_id`, and
`deadline_rules.spawn_proceeding_type_id` reference IDs, so the existing
rule corpus and spawn wiring (incl. mig 095's `spawn_proceeding_type_id=11`)
continue to work unchanged.
## 0.3 Sub-decisions (m's calls, 2026-05-18)
### S1 — `upc.ccr.cfi` visibility
`is_active=true`, visible in the determinator + dropdowns. **No rules
attached.** When the determinator surfaces it, the UI shows the hint:
> "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
> weiter."
Routing logic lands in `internal/services/proceeding_mapping.go` — when
the cascade resolves to `upc.ccr.cfi`, the mapping returns the
`upc.inf.cfi` id (=8) with `with_ccr=true` as a default flag. The peer
exists for taxonomic completeness so users searching for
"Widerklage" find an entry; it is not a separate rule namespace.
### S2 — Abbreviations
`dmgs` for damages, `disc` for discovery. m's call: short form keeps the
codes terse and the dot-separated shape readable.
### S3 — Damages appeal
**NO separate code.** `upc.apl.merits` covers damages appeals — the
spawn rules from `upc.dmgs.cfi` (none seeded today) would carry their
own `spawn_label`. Avoids a code like `upc.apl.dmgs` whose rules would
be empty for the foreseeable future.
### S4 — NZB at BGH
Single bucket `de.inf.bgh`. Rules diverging in the NZB-detour-path
(Nichtzulassungsbeschwerde when the OLG didn't grant Revision) use a
`with_nzb` flag instead of a separate proceeding type. Keeps the dropdown
list shorter and matches how m practitioners think about the BGH
instance — same destination, two ways to arrive.
### S5 — DPMA appeals
Generic `dpma.appeal.bpatg` / `dpma.appeal.bgh` — source-of-decision
differentiation (was it a DPMA decision being appealed? a BPatG
decision being further appealed to BGH?) lives at the rule level, not
the proceeding-type level. Keeps the code namespace flat.
## 0.4 Spawn-FK invariant
After mig 096, the spawn FK invariant from mig 095 still holds:
deadline_rules.spawn_proceeding_type_id = 11
↔ paliad.proceeding_types[id=11].code = 'upc.apl.merits'
Spawn rules from `upc.inf.cfi` / `upc.rev.cfi` chain to the appeal-merits
proceeding without code-string awareness. Same for any future spawn FK.
## 0.5 Not in scope
* `paliad.event_categories.slug` segments (`upc-inf`, `de-bgh-null`, …)
are NOT renamed. They are stable identifiers in a separate taxonomy and
their kebab form is presentation-layer (it appears in URL fragments).
Mig 096 only updates the `proceeding_type_code` text column on
`paliad.event_category_concepts` rows so the soft join through
`event_category_concepts → proceeding_types.code` keeps resolving.
* Fee-table keys (`EPA_OPPOSITION`, `UPC_APPEAL`, …) in
`internal/calc/fees.go` are NOT proceeding codes — they are fee-table
bucket keys with their own naming. Untouched.
* Forum bucket slugs (`upc_cfi`, `de_lg`, …) in
`ForumToProceedingCodes` are presentation buckets, not codes. The
values inside (`UPC_INF`, …) are the codes being renamed.
## 0.6 References
* `internal/db/migrations/096_proceeding_code_rename.up.sql` — the
migration that lands this rename.
* `internal/services/proceeding_mapping.go` — post-mig 096 mapping with
the ccr-routing helper (S1).
* `internal/services/proceeding_codes_shape_test.go` — Go test asserting
every active fristenrechner-category code matches the new shape regex.
* mig 095 (`internal/db/migrations/095_fristen_gap_fill.up.sql`) — the
immediate predecessor; spawn_proceeding_type_id=11 carries through.

View File

@@ -0,0 +1,435 @@
# Fristenrechner Gap-Fill Proposals — t-paliad-203
**Date:** 2026-05-18
**Author:** curie (researcher)
**Status:** DRAFT — for m's review, not yet ingested via `/admin/rules`
**Branch:** `mai/curie/fristenrechner-gap`
**Supersedes:** t-paliad-201 (cancelled)
**Source audit:** the four gaps surfaced by mig 093 commit message (t-paliad-200, `internal/db/migrations/093_retire_litigation_category.up.sql:40-54`) when 40 Pipeline-A litigation rules were archived under `_archived_litigation` and 7 litigation proceeding_types were dropped
---
## 0. Read-this-first — what was archived, what's left
mig 093 (commit `40e49e8`) retired the entire `category='litigation'` rule corpus by:
1. Snapshotting the 40 rules into `paliad.deadline_rules_pre_093` and the 7 proceeding_types into `paliad.proceeding_types_pre_093`.
2. Re-homing all 40 rules under a holding proceeding_type `_archived_litigation` (id 32, `category='archived'`, `is_active=false`, `lifecycle_state='archived'`).
3. Dropping `INF`, `REV`, `CCR`, `APM`, `APP`, `AMD`, `ZPO_CIVIL` from `paliad.proceeding_types`.
The commit's own body listed four open coverage questions for legal review (lines 40-54 of `093_retire_litigation_category.up.sql`):
| # | Pipeline-A rule(s) | Claim in commit body | This doc's verdict |
|---|---|---|---|
| 1 | `inf.prelim` (R.19, 1 month) | "not present on UPC_INF — possible coverage gap" | **Real gap.** Drafts 1.1 + 1.2 below. |
| 2 | `inf.appeal` / `rev.appeal` / `ccr.appeal` (RoP.220.1, 2 months) into UPC_APP | "fristenrechner UPC_APP starts standalone with no spawn" | **Real gap.** Drafts 2.1 + 2.2 below. Pipeline-A's three rules collapse to two in the unified UPC_INF (CCR-as-flag) world — see § 2 FLAG. |
| 3 | `ccr.amend` / `rev.amend` (spawn into AMD) | "superseded by `inf.app_to_amend` / `rev.app_to_amend` — safe to drop" | **Claim confirmed for patent amendment.** No new rules. § 3 documents the verification and surfaces R.263 (case-amendment) as a separate not-modelled item. |
| 4 | `zpo.klage` / `zpo.vertanz` / `zpo.klageerw` / `zpo.berufung` | "no UPC analogue; redundant with DE_INF / DE_INF_OLG / DE_INF_BGH / DE_NULL / DE_NULL_BGH" | **Claim confirmed for klage / vertanz / berufung.** `klageerw` exists on DE_INF but with a duration discrepancy worth m's attention. § 4 details. |
**Net: 4 substantive rule drafts** (1 PO on UPC_INF + 1 PO on UPC_REV + 2 merits-appeal spawns) — well under the "~4-10" estimate in the brief, and at the low end because two of the four gaps don't need new rules.
### 0.1 Naming convention notes
- **Appeal proceeding code referenced by ROLE, not by current code.** Per task brief and pairing with t-paliad-204 (proceeding-code abbreviation rework, m's review pending), the current `UPC_APP` (id=11) is referred to in proposals 2.1/2.2 as **"UPC infringement-appeal proceeding (RoP 220.1(a) main-judgment appeal)"** rather than by code. m picks the final `spawn_proceeding_type_id` when ingesting via `/admin/rules`.
- **Existing rule-code pattern.** Live `UPC_INF` rules use bare prefix `inf.*` (not `upc.inf.*`), e.g. `inf.sod`, `inf.def_to_ccr`. Live `UPC_REV` rules use `rev.*`. I follow that pattern: proposed PO rules are `inf.prelim` (matching Pipeline-A's archived name) and `rev.prelim`; proposed spawn rules are `inf.appeal_spawn` / `rev.appeal_spawn` (the `_spawn` suffix disambiguates them from the existing UPC_APP-root `app.notice`, which is the *target*, not the *source*).
- **Anchor semantics** (per `docs/audit-fristen-logic-2026-05-13.md` § 4 and `docs/proposals/orphan-concepts-2026-05-15.md` § 0.2): `parent_id NOT NULL` chains the new rule off an existing rule in the same proceeding. `trigger_event_id NOT NULL` roots the rule on a paliad/youpc trigger event. The unified Phase 2 schema (Slice 4, mig 081+082) supports both — proposals use `parent_id` whenever the natural anchor is an existing intra-proceeding rule (e.g. `inf.soc` for inf.prelim), which matches the pattern set by `inf.sod`, `inf.def_to_ccr`, etc.
- **`condition_expr` form.** Existing UPC_INF / UPC_REV conditional rules use `{"flag":"with_ccr"}` or `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`. The proposals add three new flag names — `with_po`, `with_appeal`, and reuse `with_amend` only where existing. Flag names are surfaced as **FLAG** items for m to confirm before ingest.
### 0.2 What's deliberately out of scope
- **Order-appeals (R.220.2/R.220.3) spawn wiring** — the brief specifies RoP 220.1(a) (main-judgment, 2-month appeal → `UPC_APP`). The 15-day order/discretion track lives in `UPC_APP_ORDERS` and has its own root rules (`app_ord.with_leave`, `app_ord.discretion`). Spawn rules from UPC_INF/UPC_REV/UPC_PI for that track would be a separate proposal — flagged as future-work in § 6.
- **Cost-decision-appeal spawn (R.221.1)** — `UPC_COST_APPEAL` exists with `cost.leave_app` as a root rule. Same shape as the order-appeals: future-work, not this proposal.
- **R.263 application to amend the case** — surfaced in § 3 but not drafted as a rule because it's court-discretion (no calendar deadline computable from a fixed anchor).
- **Vertagungsantrag (ZPO §227)** — the brief's description of Gap 4 named "Vertagungsantrag" but the Pipeline-A rule code `zpo.vertanz` is actually *Verteidigungsanzeige* (contraction of "Verteidigungs-Anzeige"), not Vertagungsantrag. There is no Vertagungsantrag rule anywhere in the corpus today; if m wants one, that's a fresh proposal. Documented in § 4 FLAG.
---
## 0.3 m's decisions on the open FLAGs (2026-05-18)
Captured live with paliadin/head. Anything not explicitly answered defaults to curie's recommendation.
### Gap 1 — Preliminary Objection
- **F1.4 (CCR-defendant PO):** **NO** — do not seed a third PO rule for the patentee on a CCR. Final shape stays at 2 PO rules: `inf.prelim` + `rev.prelim`.
- F1.1 (flag name): default to curie's `with_po`.
- F1.2 (priority): default to curie's `optional`.
- F1.3 (citation pattern): default to curie's `UPC.RoP.19.1` substantive-cite for both rules (cross-ref to R.46 lives in the description, not the legal_source field).
### Gap 2 — Appeal spawns
- **F2.1 (drop `ccr.appeal`):** **CONFIRMED** — one decision under R.118 = one 2-month appeal window. Rule 2.3 explicitly NOT seeded. Final shape stays at 2 spawn rules.
- **F2.3 (appeal flag-gated or always-fire):** **ALWAYS-FIRE.** Rationale (m): "the appeal deadline should always be triggered by a decision … the flags for ccr / amend are different because that is something which only comes up during the proceedings and depends on a party. Appeal is always a possibility." So both `inf.appeal_spawn` and `rev.appeal_spawn` ship **without `condition_expr`** — the 2-month window unconditionally appears once `inf.decision` / `rev.decision` is anchored. Visibility filtering ("hide appeal deadlines on projects where the user doesn't care") is a frontend concern, not a rule-level flag — surfaced as follow-up (see § 6.X below).
- F2.2 (anchor): default to curie's `parent_id = inf.decision` / `rev.decision` (consistent with how `inf.cost_app` already chains).
### Gap 3 — `ccr.amend` / `rev.amend`
- **F3.1 (model R.263?):** **NO** — court-discretion, no calendar deadline computable. If R.263 ever needs surfacing, it goes on the project page as a checklist item, not the fristenrechner.
### Gap 4 — `zpo.*` family
- **§4.3 — `de_inf.erwidg` discrepancy (6 weeks vs. court-set 2-week minimum):** **FLIP to court-set.** Klageerwiderung is statutorily court-set with a 2-week minimum under ZPO §276(1) S.2; the existing 6-week fixed-duration rule is wrong. Action at ingest: `is_court_set=true`, keep `duration_value=6, duration_unit='weeks'` as the **default display value** when no court order is yet attached, with the description noting "Gericht setzt eine Frist von mindestens zwei Wochen ab Verteidigungsanzeige (§276 Abs. 1 S. 2 ZPO)." This matches the pattern existing court-set rules use elsewhere.
- F4.1 (legal_source backfills on `de_inf.klage` etc.): default to curie's "yes — apply the polish patches in § 4.1, § 4.2, § 4.4".
### Final delta to ingest via `/admin/rules`
```
NEW RULES (4):
inf.prelim UPC_INF parent=inf.soc 1mo RoP.19.1 flag=with_po optional
rev.prelim UPC_REV parent=rev.app 1mo RoP.19.1 flag=with_po optional
inf.appeal_spawn UPC_INF parent=inf.decision 2mo RoP.220.1.a (no flag) optional spawn→merits-appeal
rev.appeal_spawn UPC_REV parent=rev.decision 2mo RoP.220.1.a (no flag) optional spawn→merits-appeal
PATCHES on existing rules:
de_inf.klage set legal_source = 'DE.ZPO.253'
de_inf.anzeige (no change — already correct)
de_inf.erwidg flip is_court_set = true; description note about §276 Abs.1 S.2
de_inf.berufung (verify legal_source — curie's §4.4 polish patch)
```
### Follow-up surfaced — not for this proposal
- **Frontend visibility toggle for appeal deadlines** — m flagged that appeals "always fire" at the rule level but the UI could hide them on projects where the user doesn't want to see them. NOT a rule-corpus question; file as a separate frontend task if/when m signals.
- **`ccr.appeal` in `_archived_litigation`** — the Pipeline-A `ccr.appeal` row stays archived (m's call F2.1). No further action.
- **Vertagungsantrag (ZPO §227)** — never modelled; not in scope. Open follow-up if m wants it.
---
## 1. Gap 1 — Preliminary Objection (RoP 19)
**Status:** Real gap. Pipeline-A had `inf.prelim` (defendant, 1 month, R.19, "Rarely triggers separate decision; usually decided with main case") — archived without a fristenrechner replacement.
Verification — current UPC_INF / UPC_REV corpus has zero rules with `rule_code` matching `R.19`, `RoP.019`, or any "Preliminary Objection" variant; verified via `SELECT * FROM paliad.deadline_rules WHERE rule_code ILIKE '%19%' OR name ILIKE '%vorab%' OR name ILIKE '%prelim%' AND lifecycle_state <> 'archived'` returns empty.
Legal context — RoP 19 itself (Application of the Rules of Procedure, Part 1, Chapter 1, Section 4):
- **R.19.1**: The defendant may, within 1 month of service of the Statement of claim, lodge a Preliminary objection concerning (a) jurisdiction and competence of the Court including any objection to the decision of the Registry to assign a case to a particular division, (b) the language of the Statement of claim (R.14), or (c) the competence of the panel to which the action has been assigned.
- **R.19.7 / R.19.8**: The Court decides on a preliminary objection by way of order, typically before the interim conference, but may join it to the main proceedings.
- **R.46**: The Rules in Part 1, Chapter 1 (including R.19) apply *mutatis mutandis* to revocation actions — i.e. the defendant in a revocation action (the patent proprietor) may also lodge a preliminary objection within 1 month of service of the Statement for revocation.
The Pipeline-A note "Rarely triggers separate decision; usually decided with main case" is accurate practice — but the **1-month deadline to raise the objection** is hard and statutory. That deadline is what the fristenrechner needs to model.
### Rule 1.1 — Preliminary Objection on UPC_INF
- **Rule code:** `inf.prelim`
- **Proceeding type:** UPC_INF (id=8)
- **Name (DE):** Vorab-Einrede (R. 19 VerfO)
- **Name (EN):** Preliminary Objection (RoP 19)
- **Party:** defendant
- **Anchor:** `parent_id = inf.soc` (the existing root rule "Klageerhebung") — same anchor pattern as `inf.sod` (Klageerwiderung, also parent=inf.soc). `inf.soc` is the trigger-date anchor; computing 1 month after `inf.soc` reads as "1 month from service of the Statement of Claim", consistent with R.19.1's wording.
- **Duration:** 1, months
- **Timing:** after
- **Priority:** optional *(party decides whether to raise the objection; the 1-month period is statutory once invoked)*
- **is_court_set:** false *(statutory period from service; not court-set)*
- **condition_expr:** `{"flag":"with_po"}` *(only renders when the defendant indicates a PO will be filed — same shape as existing `with_ccr` / `with_amend` flags)*
- **Legal source:** `UPC.RoP.19.1`
- **`rule_code`:** `RoP.019.1`
- **event_type:** `filing`
- **Notes:** R.19.1 covers three independent grounds (a) jurisdiction/competence, (b) language under R.14, (c) panel competence. All share the same 1-month deadline. The UI rendering decision (one row vs. three rows by ground) is downstream UX, not a rule-corpus question.
- **FLAG (F1.1):** Flag name — `with_po` is suggested by analogy to `with_ccr` / `with_amend` / `with_cci`. Alternative names: `with_preliminary_objection`, `prelim`. m's call.
- **FLAG (F1.2):** Priority — proposed `optional` (defendant chooses); m may prefer `recommended` to surface it as a sanity-check chip on every defendant timeline. The Pipeline-A predecessor had `is_optional=true / is_mandatory=false` per the old binary schema, which maps cleanly to `priority='optional'` in the post-Slice-3 enum.
### Rule 1.2 — Preliminary Objection on UPC_REV
- **Rule code:** `rev.prelim`
- **Proceeding type:** UPC_REV (id=9)
- **Name (DE):** Vorab-Einrede (R. 19 i.V.m. R. 46 VerfO)
- **Name (EN):** Preliminary Objection (RoP 19 in conjunction with RoP 46)
- **Party:** defendant *(in a revocation action the patentee is the defendant)*
- **Anchor:** `parent_id = rev.app` (the existing root rule "Nichtigkeitsklage" — analogous to `rev.defence` which also parents off `rev.app`)
- **Duration:** 1, months
- **Timing:** after
- **Priority:** optional
- **is_court_set:** false
- **condition_expr:** `{"flag":"with_po"}` *(same flag as 1.1 — a PO is a PO; the user sets `with_po=true` on a UPC_REV project when the patentee plans to lodge one)*
- **Legal source:** `UPC.RoP.46` *(R.46 makes R.19 applicable to revocation actions; cite R.46 as the operative provision because RoP 19's literal text only addresses infringement)*
- **`rule_code`:** `RoP.046` *(or `RoP.019.1` with a note — m's call; see FLAG F1.3)*
- **event_type:** `filing`
- **Notes:** Functionally identical to Rule 1.1 but rooted on UPC_REV. The grounds are narrower in practice (language and panel competence are the main triggers — jurisdiction is rarely contested in pure revocation actions because the UPC's jurisdiction over revocation of unitary patents is exclusive). But the 1-month statutory window is identical.
- **FLAG (F1.3):** Legal-source citation — should this read `UPC.RoP.46` (operative provision for revocation) or `UPC.RoP.19.1` (substantive content)? Existing rules use the substantive citation (e.g. `inf.def_to_ccr` cites `UPC.RoP.29.a`, not the cross-reference that brings R.29 into the UPC_INF flow). I lean `UPC.RoP.19.1` with `rule_code='RoP.019.1'` to match that pattern; the cross-reference to R.46 belongs in the description, not the citation field.
- **FLAG (F1.4):** Does paliad want **counterclaim-defendant** PO rules too? Specifically, when UPC_INF has `with_ccr=true`, the *claimant* (patentee) becomes the de-facto-defendant for the CCR portion. Does the claimant get a 1-month PO window from service of the CCR? My read of R.19 + R.46 + R.25: yes — the CCR triggers a fresh R.19 window for the claimant, anchored on service of the SoD-with-CCR. But this would be a third rule (`inf.prelim_ccr`, party=claimant, parent=inf.sod, 1 month, condition_expr={"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_po_ccr"}]}). I'm **not** drafting it pending m's confirmation; either it's truly there in the case law or it's an over-reading on my part. Lex-research won't help here because there's no relevant published UPC PO case on a CCR yet (R.46 + R.25 cross-reads are theoretical).
**Summary for Gap 1:** 2 new rules drafted (one on UPC_INF, one on UPC_REV). 4 FLAGs. Potential third rule (CCR-PO) deferred pending m's read.
---
## 2. Gap 2 — Cross-proceeding APP spawns (RoP 220.1(a))
**Status:** Real gap. Pipeline-A had three placeholder rules (`inf.appeal`, `rev.appeal`, `ccr.appeal`, all 2 months, RoP.220.1, is_spawn=true) — but their `spawn_proceeding_type_id` was NULL so they weren't functional spawns either. Fristenrechner UPC_APP currently starts standalone with `app.notice` as its root rule (party=both, 2 months, RoP.220.1).
Verification — current corpus has zero `is_spawn=true AND is_active=true AND lifecycle_state<>'archived'` rules; the `spawn_proceeding_type_id` column on `paliad.deadline_rules` is unused in the live data (Slice 7 wiring was the design intent but no real spawns have been seeded yet).
Legal context — RoP 220 (Decisions and orders which may be appealed):
- **R.220.1(a)**: Final decisions under R.118 may be appealed. The appeal period is **2 months of service** of the decision (R.224.1(a)).
- **R.224.1(a)**: The Statement of appeal must be lodged within 2 months of service of the decision.
- **R.224.2(a)**: The Statement of grounds of appeal must be lodged within 4 months of service of the decision (independent from R.224.1(a), not chained off it).
The spawn target — the proceeding rooted by `app.notice` (Berufungseinlegung, RoP.220.1, 2 months) and `app.grounds` (Berufungsbegründung, 4 months from decision) — is what the task brief calls the "UPC infringement-appeal (RoP 220.1(a) main-judgment appeal)" proceeding. Today that's `UPC_APP` (id=11); per t-paliad-204, the code may be renamed before m ingests these proposals, so I refer to it by role only.
### Rule 2.1 — Appeal spawn from UPC_INF
- **Rule code:** `inf.appeal_spawn`
- **Proceeding type:** UPC_INF (id=8)
- **Name (DE):** Berufung gegen Endentscheidung
- **Name (EN):** Appeal against final decision
- **Party:** both *(either party may appeal an R.118 final decision adverse to them)*
- **Anchor:** `parent_id = inf.decision` (existing court-set rule "Entscheidung"). The chain: `inf.soc → … → inf.decision (court-set, no statutory date) → inf.appeal_spawn (2 months after service of decision)`. Because `inf.decision` is `IsCourtSet=true` (per `isCourtDeterminedRule` in `internal/services/fristenrechner.go`), the appeal-spawn deadline only becomes a concrete date once the user anchors `inf.decision` via the smart-timeline click-to-anchor mechanism (Slice 2, `POST /api/projects/{id}/timeline/anchor` per memory `ab966313-cae6-49b0-8223-9adb62a64370`).
- **Duration:** 2, months
- **Timing:** after
- **Priority:** optional *(party decides whether to appeal; the 2-month period is statutory once invoked)*
- **is_court_set:** false *(deadline is statutory once the decision is served)*
- **condition_expr:** `{"flag":"with_appeal"}` *(only renders when the user has indicated an appeal is contemplated — keeps non-appealing projects' timelines clean)*
- **Legal source:** `UPC.RoP.220.1`
- **`rule_code`:** `RoP.220.1.a`
- **event_type:** `filing`
- **is_spawn:** true
- **spawn_proceeding_type_id:** → UPC infringement-appeal proceeding (currently `UPC_APP`, id=11; m picks final code at ingest per t-paliad-204).
- **spawn_label (DE):** "Berufungsverfahren öffnen"
- **spawn_label (EN):** "Open appeal proceedings"
- **Notes:** Spawning into the appeal proceeding creates a child project (or routes into the standalone UPC_APP fristenrechner depending on how spawn rendering works on the project page). The 4-month Statement of grounds period (R.224.2(a), `app.grounds`) is already a root rule on UPC_APP — once the appeal child opens, that timeline takes over. **No need** to also model `app.grounds` as a spawn rule from UPC_INF; the existing UPC_APP root rules cover it.
- **FLAG (F2.1):** Does the spawn fire on the CCR portion of the decision too? In a `with_ccr=true` UPC_INF, the R.118 final decision adjudicates both the infringement *and* the counterclaim for revocation. Either side may appeal either part. My read: **one spawn covers both** — there's only one R.118 decision, one 2-month window. The Pipeline-A `ccr.appeal` was a relic of the days when CCR was a separate proceeding type. **Recommend dropping the third "ccr.appeal" entirely**, because in the unified UPC_INF (CCR-as-flag) model it would duplicate Rule 2.1. m to confirm.
- **FLAG (F2.2):** Anchor — should the spawn rule chain off `inf.decision` (court-set, requires anchor-click) or be event-rooted on a `final_decision_service` trigger event (paliad has trigger_event id=88 "Endentscheidung (Zustellung)")? Both work. Chaining on `inf.decision` keeps the rule visually attached to its parent proceeding in the UI; event-rooted is more flexible if the user wants to compute an appeal deadline standalone without a project. Recommend `parent_id = inf.decision` to match how `inf.cost_app` chains off `inf.decision` already.
- **FLAG (F2.3):** Flag name — `with_appeal` mirrors the existing `with_ccr` / `with_amend` flag naming. Alternative: spawn rules might always fire (no flag), letting the timeline show the appeal window as a "predicted/court-set" placeholder. The latter is closer to what the SmartTimeline projection (`projection_service.go`) already does for cross-proceeding rules per memory `686f0b8c-02ed-4807-8785-b088e3a3e515` § 6 gap 7. If m wants the appeal window to *always* appear after the decision (unconditionally), drop `condition_expr` here and on Rule 2.2.
### Rule 2.2 — Appeal spawn from UPC_REV
- **Rule code:** `rev.appeal_spawn`
- **Proceeding type:** UPC_REV (id=9)
- **Name (DE):** Berufung gegen Endentscheidung (Nichtigkeit)
- **Name (EN):** Appeal against final decision (revocation)
- **Party:** both
- **Anchor:** `parent_id = rev.decision` (existing court-set rule "Entscheidung")
- **Duration:** 2, months
- **Timing:** after
- **Priority:** optional
- **is_court_set:** false
- **condition_expr:** `{"flag":"with_appeal"}`
- **Legal source:** `UPC.RoP.220.1`
- **`rule_code`:** `RoP.220.1.a`
- **event_type:** `filing`
- **is_spawn:** true
- **spawn_proceeding_type_id:** → same UPC infringement-appeal proceeding as Rule 2.1. The UPC CoA hears both INF and REV appeals; in a `with_cci=true` UPC_REV (Verletzungswiderklage / counterclaim-for-infringement), the R.118 decision may also adjudicate the infringement piece, but again it's one decision, one appeal window.
- **spawn_label (DE):** "Berufungsverfahren öffnen"
- **spawn_label (EN):** "Open appeal proceedings"
- **Notes:** Functionally a mirror of Rule 2.1 on the revocation proceeding. Same FLAGs F2.1-F2.3 apply.
### Rule 2.3 — (proposed) NOT drafted: separate `ccr.appeal` from UPC_INF with_ccr
**See FLAG F2.1.** In the unified model, the CCR portion of an UPC_INF decision is appealed via the same R.118 final-decision spawn (Rule 2.1) — a single 2-month window covers infringement, revocation, and patent-amendment claims because they all sit in one R.118 decision. Drafting `ccr.appeal` as a third rule would duplicate Rule 2.1 conditionally (`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_appeal"}]}`) and produce a redundant timeline row. **Recommendation: do not seed.** If m disagrees, the rule shape would be:
```
inf.appeal_spawn_ccr (UPC_INF)
condition_expr: {"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_appeal"}]}
spawn_label: "Berufung Nichtigkeit öffnen" (specifically the CCR portion)
```
Only useful if the appeal UI needs to distinguish "appealing the infringement finding" from "appealing the revocation finding". Today's fristenrechner UI doesn't make that distinction; the appeal proceeding handles both.
**Summary for Gap 2:** 2 new spawn rules drafted. 3 FLAGs. The third Pipeline-A relic (`ccr.appeal`) is structurally redundant and recommended **not** to seed.
---
## 3. Gap 3 — `ccr.amend` / `rev.amend` (verification of "safe to drop" claim)
**Status:** No new rules needed. The migration's claim ("superseded by `inf.app_to_amend` / `rev.app_to_amend` — safe to drop") is **confirmed for the patent-amendment scope**. There is a separate concept (R.263 application to amend the case) that has never been modelled and probably shouldn't be — see § 3.2.
### 3.1 Verification — patent-amendment coverage
Pipeline-A's `ccr.amend` and `rev.amend` were both:
- duration_value=0, duration_unit='months', event_type='filing', is_spawn=true, party='claimant'
- legal_source=NULL, rule_code=NULL
- source proceeding=AMD (now archived)
- "Application to Amend Patent" / no German name
These were placeholder spawns into a hypothetical "AMD" (Application to amend the patent) proceeding type that never existed as a real fristenrechner tree. They modelled the concept "filing a patent amendment", not its deadline.
The unified UPC_INF / UPC_REV corpus already covers patent amendment with real deadlines and flag-gated chains:
| Existing rule | Proceeding | Trigger / parent | Duration | Legal source | Flag-gating |
|---|---|---|---|---|---|
| `inf.app_to_amend` | UPC_INF | parent=inf.sod | 2 months | UPC.RoP.30.1 | `with_ccr+with_amend` |
| `inf.def_to_amend` | UPC_INF | parent=inf.app_to_amend | 2 months | UPC.RoP.32.1 | `with_ccr+with_amend` |
| `inf.reply_def_amd` | UPC_INF | parent=inf.def_to_amend | 1 month | UPC.RoP.32.3 | `with_ccr+with_amend` |
| `inf.rejoin_amd` | UPC_INF | parent=inf.reply_def_amd | 1 month | UPC.RoP.32.3 | `with_ccr+with_amend` |
| `rev.app_to_amend` | UPC_REV | parent=rev.defence | 0 months (filed-with-parent) | UPC.RoP.49.2.a | `with_amend` |
| `rev.def_to_amend` | UPC_REV | parent=rev.app_to_amend | 2 months | UPC.RoP.43.3 | `with_amend` |
| `rev.reply_def_amd` | UPC_REV | parent=rev.def_to_amend | 1 month | UPC.RoP.32.3 | `with_amend` |
| `rev.rejoin_amd` | UPC_REV | parent=rev.reply_def_amd | 1 month | UPC.RoP.32.3 | `with_amend` |
The flag-gated chain on UPC_INF (`with_ccr+with_amend`) is the post-2026-05-05 ship from t-paliad-131 PR-2 (memory `ba1517a3-2294-4c58-aeb6-87e82067834d`); the UPC_REV chain (`with_amend` and `with_cci`) is from the same PR. Both fully replace what `ccr.amend` / `rev.amend` ever could have represented.
**Verdict on Gap 3:** "Safe to drop" is correct. **No new rules.**
### 3.2 R.263 — Application to amend the case (not modelled, probably shouldn't be)
R.263 ("Leave to change claim or amend case") is conceptually different from R.30 (Application to amend the patent). R.263 governs amendment of the **pleadings / case** — adding a new infringement allegation, narrowing claims, etc. The current corpus has no R.263 rule.
I'm **not proposing one** because R.263 is purely court-discretionary (R.263.1: "An application may be made by a party at any time to … amend its case … Leave shall be granted only if … the requesting party could not with reasonable diligence have made the application earlier and the amendment will not unreasonably hinder the other party in the conduct of its action"). There is no statutory deadline computable from a fixed anchor — the party files when it needs to, and the court grants or refuses leave by order. Modelling it as a deadline_rule would either:
- (a) Produce a phantom row with no computable date (the existing `is_court_set=true` pattern would technically work but offers no UX value because the deadline is "whenever you need to amend").
- (b) Produce a misleading row anchored on the SoC date with some heuristic period.
**Recommendation: don't seed.** If m wants R.263 surfaced anywhere, it belongs as a checklist item on the project page, not as a fristenrechner rule.
**FLAG (F3.1):** Confirm "don't model R.263" is acceptable. If R.263 *should* be modelled, what anchor + duration heuristic should it use?
**Summary for Gap 3:** 0 new rules. 1 FLAG. The claim "safe to drop" is verified for patent amendment. R.263 is a separate concept and intentionally left unmodelled.
---
## 4. Gap 4 — `zpo.*` family vs. existing DE_INF / DE_INF_OLG / DE_INF_BGH
**Status:** No new rules needed for `klage`, `vertanz`, `berufung`. **Existing rule `de_inf.erwidg` (Klageerwiderung) has a duration discrepancy worth m's attention.** Task brief's mention of "Klageerweiterung" / "Vertagungsantrag" is a misread of Pipeline-A rule names — those concepts are not in scope here. § 4.1-4.4 verify each Pipeline-A rule; § 4.5 surfaces what *would* be a real gap if m wants ZPO §227 modelled.
### 4.1 `zpo.klage` (Klageerhebung, ZPO §253) — ✓ redundant
Pipeline-A: claimant, 0 months, filing, `§ 253 ZPO`, legal_source=NULL.
Existing rule `de_inf.klage` on DE_INF: claimant, 0 months, filing. Functionally identical as a root rule (a 0-duration "trigger" anchor). Legal source on the existing rule is NULL — could be backfilled to `DE.ZPO.253` as a minor polish, but no new rule needed.
**Verdict: no gap.** *Optional polish:* set `de_inf.klage.legal_source = 'DE.ZPO.253'` (one-line UPDATE; not a new rule). FLAG F4.1.
### 4.2 `zpo.vertanz` (Verteidigungsanzeige, ZPO §276(1) Satz 1) — ✓ redundant
**Task-brief naming note:** the brief described this gap as "Vertagungsantrag" but Pipeline-A's `zpo.vertanz` is actually *Verteidigungsanzeige* (contraction "VertAnz" not "VertA. (Antrag)"). The rule name in the snapshot reads "Verteidigungsanzeige" verbatim. Vertagungsantrag (§ 227 ZPO) is a different concept entirely — see § 4.5.
Pipeline-A: defendant, 2 weeks, filing, `§ 276 Abs. 1 S. 1 ZPO`, deadline_notes "Notfrist ab Zustellung der Klageschrift".
Existing rule `de_inf.anzeige` on DE_INF: defendant, 2 weeks, `DE.ZPO.276.1`, "Anzeige der Verteidigungsbereitschaft". Same period, same legal basis, same party.
**Verdict: no gap.**
### 4.3 `zpo.klageerw` (Klageerwiderung, ZPO §276(1) Satz 2) — ⚠ duration discrepancy
Pipeline-A: defendant, **2 weeks**, filing, `§ 276 Abs. 1 S. 2 ZPO`, legal_source=NULL, deadline_notes "Vom Gericht gesetzt, mindestens 2 Wochen".
Existing rule `de_inf.erwidg` on DE_INF: defendant, **6 weeks**, `DE.ZPO.276.1`, "Klageerwiderung", is_court_set=false.
**This is a substantive discrepancy.** Both rules cite the same statutory anchor (ZPO §276(1) Satz 2), but:
- Pipeline-A modelled the **statutory floor** ("mindestens 2 Wochen") with `is_court_set` implicit (the deadline_notes said "Vom Gericht gesetzt").
- DE_INF models a **typical court-practice heuristic** (6 weeks is a common Munich/Düsseldorf LG setting, though 4-8 weeks is the realistic range).
The DE_INF rule is **strictly more useful** for a practitioner planning a defence schedule (the 2-week floor is rarely the actual deadline; the court order sets the real date). But it's **technically wrong** to mark `is_court_set=false` because the date *is* set by court order — the 6 weeks is a guess at what the court will set, not a statutory period.
**No new rule needed**, but two corrections are worth flagging on the existing rule:
- **FLAG F4.2 (correctness):** Set `de_inf.erwidg.is_court_set = true`. The deadline date is set by the court's Klageerwiderungsfrist order under §276(1) Satz 2, not by the statute directly. This matches how Schriftsatznachreichung (§296a) was flagged in `docs/proposals/orphan-concepts-2026-05-15.md` § 2.1 FLAG F8.
- **FLAG F4.3 (heuristic transparency):** 6 weeks vs. the statutory 2-week floor — the deadline_notes (DE) on `de_inf.erwidg` should probably say "Vom Gericht gesetzt, mindestens 2 Wochen (§ 276 Abs. 1 S. 2 ZPO); typische Praxis: 4-8 Wochen" rather than just rendering as a hard 6-week deadline. UX consideration, not a rule-shape question.
Neither change is a new rule; both are PATCH operations on the existing row via `/admin/rules`.
### 4.4 `zpo.berufung` (Berufung, ZPO §517) — ✓ redundant (twice over)
Pipeline-A: both, 1 month, filing, `§ 517 ZPO`, `DE.ZPO.517`, deadline_notes "Notfrist ab Zustellung des vollständigen Urteils".
Existing rules:
- `de_inf.berufung` on DE_INF: both, 1 month, `DE.ZPO.517`. Same shape.
- `de_inf_olg.berufung` on DE_INF_OLG: both, 1 month, `DE.ZPO.517`. Same shape (covers the OLG-instance entry point).
Either rule covers it. **Verdict: no gap.**
### 4.5 Real gap (if m wants): Vertagungsantrag (ZPO §227)
The task brief mentioned "Vertagungsantrag" by name. Pipeline-A had no Vertagungsantrag rule (the `zpo.vertanz` rule code is a contraction of *Verteidigungsanzeige*, not Vertagungsantrag — see § 4.2). The current corpus has no Vertagungsantrag rule either.
ZPO §227 governs applications to adjourn a hearing ("Aufhebung und Verlegung von Terminen, Vertagung der Verhandlung"). §227.1 requires "erhebliche Gründe", §227.2 gives examples (verhinderter Anwalt etc.), §227.3 restricts adjournment of evidence hearings (Beweisaufnahme). **There is no statutory deadline for filing a Vertagungsantrag** — it's "as soon as the ground arises and, in practice, as early as possible before the hearing date". The application is court-discretionary (§227.1: "kann").
I would **not** recommend modelling Vertagungsantrag as a deadline_rule for the same reason as R.263 in § 3.2: there's no statutory deadline anchor; it's a checklist concept, not a calendar deadline. But m may have a different view — flag F4.4.
**FLAG (F4.4):** Should Vertagungsantrag be modelled? If yes, what anchor + duration? Most natural seed would be `condition_expr={"flag":"with_vertagung"}` on the relevant hearing rule (de_inf.termin, de_null.termin, etc.), is_court_set=true, no duration. But that's an oddly-shaped rule that produces no useful date.
**Summary for Gap 4:** 0 new rules. 4 FLAGs (F4.1-F4.4). The migration's "redundant — safe to drop" claim is confirmed for `klage` / `vertanz` / `berufung`. `klageerw` exposes a discrepancy on the existing `de_inf.erwidg` rule (`is_court_set=false` is wrong; 6-weeks heuristic should be transparent in notes) — both are PATCH operations on the existing row, not new rules. Vertagungsantrag is a separate concept that probably shouldn't be modelled as a deadline_rule.
---
## 5. Track A — Polish UPDATEs on existing rows (no new rules, no legal review)
Distinct from new rules, three existing rows could be PATCH'd via `/admin/rules` to improve correctness or transparency. **None of these are required for the gap-fill to be considered "done"** — they're flagged so they don't get lost if m wants to address them in the same ingest session.
| # | Row | Field | From | To | Reason |
|---|---|---|---|---|---|
| P1 | `de_inf.klage` (DE_INF) | `legal_source` | NULL | `DE.ZPO.253` | Polish; matches existing convention (Rule 1.1's `UPC.RoP.19.1` etc.). |
| P2 | `de_inf.erwidg` (DE_INF) | `is_court_set` | false | true | Correctness; deadline is court-order-set per ZPO §276(1) Satz 2. |
| P3 | `de_inf.erwidg` (DE_INF) | `deadline_notes` (DE) | (current text) | "Vom Gericht gesetzt, mindestens 2 Wochen (§ 276 Abs. 1 S. 2 ZPO); typische Praxis: 4-8 Wochen" | Transparency; the 6-week duration is a heuristic, not statutory. |
---
## 6. Track B — Genuinely new rule drafts (this proposal's substantive output)
| # | Gap | Rule code | Proceeding (by role) | Source |
|---|---|---|---|---|
| 1.1 | 1 (PO) | `inf.prelim` | UPC_INF | RoP 19.1 |
| 1.2 | 1 (PO) | `rev.prelim` | UPC_REV | RoP 19.1 i.V.m. R.46 |
| 2.1 | 2 (APP spawn) | `inf.appeal_spawn` | UPC_INF, spawn → UPC infringement-appeal proceeding | RoP 220.1(a) / R.224.1(a) |
| 2.2 | 2 (APP spawn) | `rev.appeal_spawn` | UPC_REV, spawn → UPC infringement-appeal proceeding | RoP 220.1(a) / R.224.1(a) |
**Total new rules: 4.** Plus 3 optional polish PATCHes in § 5. None of the proposed rules introduce new flag-name conventions (other than `with_po` and `with_appeal`, which mirror existing `with_ccr` / `with_amend` / `with_cci`).
### Future-work (not this proposal)
- Order-appeals spawn (R.220.2 / R.220.3) from UPC_INF / UPC_REV / UPC_PI → UPC_APP_ORDERS (15-day track). Today UPC_APP_ORDERS has only standalone root rules.
- Cost-decision-appeal spawn (R.221.1) from UPC_INF / UPC_REV → UPC_COST_APPEAL.
- CCR-defendant PO (FLAG F1.4): claimant's 1-month PO window when receiving SoD-with-CCR — only if confirmed against real case law or m's read.
- R.263 (case amendment) and ZPO §227 (Vertagungsantrag): both court-discretionary, no statutory deadline — recommend leaving unmodelled (FLAGs F3.1, F4.4).
- DE_NULL / DE_NULL_BGH appeal spawns: PatG §110 chains DE_NULL → DE_NULL_BGH (Berufung BGH). Currently DE_NULL_BGH is a standalone tree rooted on `de_null_bgh.urteil_bpatg`. Same pattern as the UPC spawn gap. Out of brief scope but worth a parallel proposal.
---
## 7. Open questions / FLAGs index
For convenience, all `**FLAG**`-marked items in one place. m's decision is needed on each before `/admin/rules` ingest of the corresponding rule (or rule edit).
| ID | Section | Question |
|---|---|---|
| F1.1 | § 1.1 | Flag name for Preliminary Objection — `with_po` vs `with_preliminary_objection` vs `prelim`. |
| F1.2 | § 1.1 | Priority for PO — `optional` (recommended) vs `recommended` (always-surface as sanity-check chip). |
| F1.3 | § 1.2 | Legal-source citation for UPC_REV PO — `UPC.RoP.19.1` (substantive) vs `UPC.RoP.46` (operative). Recommend substantive. |
| F1.4 | § 1.2 | Add a third PO rule for CCR-defendant (party=claimant, fires when `with_ccr=true`)? |
| F2.1 | § 2.1 | Recommend **not seeding** `ccr.appeal` as a third rule — CCR appeal is covered by `inf.appeal_spawn` (one R.118 decision, one window). Confirm. |
| F2.2 | § 2.1 | Anchor for spawn — `parent_id = inf.decision` (chain) vs `trigger_event_id = 88 final_decision_service` (event-rooted). Recommend chain. |
| F2.3 | § 2.1 | Flag-gated (`with_appeal`) vs always-rendered. Recommend flag-gated to keep non-appealing timelines clean; SmartTimeline's "predicted" rendering of cross-proceeding rules is the alternative. |
| F3.1 | § 3.2 | R.263 (case amendment) — confirm not modelled as a deadline_rule. |
| F4.1 | § 4.1 | Polish P1: backfill `de_inf.klage.legal_source = 'DE.ZPO.253'`? |
| F4.2 | § 4.3 | Polish P2: set `de_inf.erwidg.is_court_set = true`? |
| F4.3 | § 4.3 | Polish P3: improve `de_inf.erwidg.deadline_notes` to expose the 6-week heuristic vs the 2-week statutory floor? |
| F4.4 | § 4.5 | Vertagungsantrag (ZPO §227) — confirm not modelled. |
---
## 8. Sources cited
| Citation key | Reference |
|---|---|
| `UPC.RoP.19.1` | UPC Rules of Procedure, Rule 19(1) — Preliminary objection |
| `UPC.RoP.19.7` | UPC RoP Rule 19(7) — Court decides preliminary objection by order |
| `UPC.RoP.25` | UPC RoP Rule 25 — Lodging of Counterclaim for Revocation (cross-ref for FLAG F1.4) |
| `UPC.RoP.30.1` | UPC RoP Rule 30(1) — Application to amend the patent (cross-ref for § 3.1) |
| `UPC.RoP.46` | UPC RoP Rule 46 — Part 1 Chapter 1 (incl. R.19) applies *mutatis mutandis* to revocation actions |
| `UPC.RoP.118` | UPC RoP Rule 118 — Final decisions on the merits |
| `UPC.RoP.151` | UPC RoP Rule 151 — Cost decision (cross-ref for existing `inf.cost_app`) |
| `UPC.RoP.220.1.a` | UPC RoP Rule 220(1)(a) — Appeal against R.118 final decision |
| `UPC.RoP.220.2` | UPC RoP Rule 220(2) — Order appeals with leave (cross-ref, future work) |
| `UPC.RoP.220.3` | UPC RoP Rule 220(3) — Discretionary review (cross-ref, future work) |
| `UPC.RoP.221.1` | UPC RoP Rule 221(1) — Cost-decision appeal (cross-ref, future work) |
| `UPC.RoP.224.1.a` | UPC RoP Rule 224(1)(a) — Statement of appeal lodged within 2 months |
| `UPC.RoP.224.2.a` | UPC RoP Rule 224(2)(a) — Statement of grounds within 4 months |
| `UPC.RoP.263` | UPC RoP Rule 263 — Leave to change claim or amend case |
| `DE.ZPO.227` | ZPO §227 — Vertagung und Terminsänderung |
| `DE.ZPO.253` | ZPO §253 — Klageschrift |
| `DE.ZPO.276.1` | ZPO §276(1) — Verteidigungsanzeige (S.1) und Klageerwiderungsfrist (S.2) |
| `DE.ZPO.517` | ZPO §517 — Berufungsfrist (1 Monat ab Zustellung) |
---
## 9. What's next (if m approves)
1. **Decide the 12 FLAGs in § 7** (mostly flag names, priorities, and the three PATCH operations on existing rows). None require legal-side research — they're product/UX calls.
2. **Confirm the appeal target's final proceeding-code** post-t-paliad-204 rename. Until then, ingest using whatever code lives at id=11 (currently `UPC_APP`) and rename via mig if t-paliad-204 lands with a different code.
3. **Ingest the 4 new rules** via `/admin/rules` POST (Slice 11a backend, Slice 11b frontend). Each goes into `lifecycle_state='draft'` first. Promote to `published` after spot-checking via the calculator preview endpoint with a test project (e.g. UPC_INF with `with_po=true` should show the new `inf.prelim` row 1 month after the trigger date).
4. **Optionally apply the 3 PATCHes in § 5** in the same session.
5. **Verify spawn rendering** end-to-end — the spawn_proceeding_type_id column is unused in live data today, so this is the first real consumer. The SmartTimeline projection (per `internal/services/projection_service.go`, memory `686f0b8c-…`) early-returns on spawn rules when "we don't have that rule in our map" — that code path needs to actually render a spawn row now, not no-op. May require a Slice 7 follow-up tweak in `projection_service.go` to honour `spawn_proceeding_type_id` and surface the appeal proceeding's root deadline as a spawned child row.
**Estimated corpus delta after ingest:** Track B = 4 new rules → `paliad.deadline_rules` row count grows from 249 to **253**. Track A polish = 3 row-level PATCHes (no row count change). One new `is_spawn=true` row goes live for the first time, exercising the previously-unused `spawn_proceeding_type_id` wiring.

View File

@@ -0,0 +1,429 @@
# Legal-citation Backfill Proposals — t-paliad-208 (Workstream A)
**Date:** 2026-05-18
**Author:** huygens (researcher)
**Status:** DRAFT — for m's review, not yet migrated
**Branch:** `mai/huygens/workstream-a-backfill`
**Adjacent:** parallel-track with t-paliad-209 (workstream B — `code` rename + UI cleanup; different fields, no overlap)
**Successor:** mig 097 will UPDATE the rows m approves; backup snapshot `deadline_rules_pre_097`
---
## 0. Read-this-first
### 0.1 What this doc is
Today's audit (paliadin/head, 2026-05-18) found that **130 of 213 active+published rows in `paliad.deadline_rules`** have `rule_code IS NULL`, and 122 have `legal_source IS NULL`. The internal slug field `code` (e.g. `inf.sod`, `de_null.berufung`) had been mistaken for a legal citation; it is just the per-proceeding submission identifier. The actual RoP / ZPO / EPÜ / PatG / UPCA citation belongs in `rule_code` (display form) + `legal_source` (structured locator).
This document proposes a citation per rule. m approves; head re-tasks for migration 097.
### 0.2 Field convention (profiled from the 83 already-populated rows)
| Field | Purpose | Examples from live data |
|---|---|---|
| `rule_code` | **Human display form**, what we'd write in a brief | `§ 276 ZPO`, `§ 110 PatG`, `Art. 99 EPÜ`, `R. 71(3) EPÜ`, `R. 116 EPÜ`, `RPBA Art. 12`, `RoP.029.a`, `RoP.220.1.a`, `RoP.151`, `RoP.49.1` |
| `legal_source` | **Structured locator** (forum-prefixed, no zero padding) for cross-system joins / lex extraction | `DE.ZPO.276.1`, `DE.PatG.111.1`, `EU.EPÜ.108`, `EU.EPC-R.71.3`, `EU.RPBA.12.1.c`, `UPC.RoP.29.a`, `UPC.RoP.220.1` |
**Sub-conventions observed in live data**
- `legal_source` prefixes: `DE.<statute>.<n>.<para>`, `EU.EPÜ.<n>.<para>`, `EU.EPC-R.<n>.<para>`, `EU.RPBA.<n>.<para>.<letter>`, `UPC.RoP.<n>.<sub>`.
- `rule_code` padding for UPC RoP is **inconsistent today**: rules below 100 are mostly 3-digit padded (`RoP.029.a`, `RoP.030.1`, `RoP.049.2.a`, `RoP.056.1`) but `rev.defence` carries an un-padded `RoP.49.1`. Rules ≥100 are never padded (`RoP.137.2`, `RoP.220.1`).
- **Proposed normalization:** 3-digit pad for rules <100, no pad for 100. mig 097 should also normalize `RoP.49.1 → RoP.049.1` (1 outlier row, `rev.defence`) as a side-fix. m to confirm.
- `legal_source` for UPC RoP **never** pads (`UPC.RoP.29.a`, not `UPC.RoP.029.a`). I follow that.
### 0.3 Triage philosophy — events vs. deadlines
Of the 130 NULL-rule_code rows, 53 carry a `proceeding_type_id` and 77 are orphans (`proceeding_type_id IS NULL`, also `code IS NULL`). Within the proceeding-typed bucket, most are **event markers** (zero `duration_value`, `event_type ∈ {hearing, decision, filing}`) that anchor other deadlines rather than computing one of their own.
I classify each row as one of:
| Category | Treatment | Examples |
|---|---|---|
| **Deadline** (positive duration, fires off an anchor) | Cite the operative procedural norm. Confidence usually HIGH. | `inf.sod` Klageerwiderung 3 months RoP.23 |
| **Constitutive event** (zero duration, but a statute defines it) | Cite the constitutive norm (matches existing convention: `de_inf.klage` already has `DE.ZPO.253`). Confidence HIGH where the norm is canonical. | Klageerhebung § 253 ZPO; Anmeldung EP Art. 75 EPÜ; Klage UPC RoP.13.1 |
| **Service / trigger event** (zero duration, third-party delivery) | Cite the service norm 317 ZPO etc.) with MEDIUM confidence these are anchor events for downstream timers, not deadlines on a party. m may prefer NULL here. **FLAG.** | `de_inf_olg.urteil_lg` Zustellung LG-Urteil |
| **Court-scheduled event** (hearing, judgment-issuance) | Either NULL (recommended) or cite the general norm authorising the court to schedule. **FLAG.** | Mündliche Verhandlung BGH; OLG-Urteil |
| **Court-set duration** (positive duration but `is_court_set=true`, or local practice) | Cite the framing norm (e.g. § 273 ZPO for ZPO patent practice), MEDIUM, FLAG. | `de_inf.replik` 4 weeks (LG patent practice) |
**Where I am proposing NULL**, the row stays as-is on the DB side (mig 097 simply doesn't touch it). The FLAG list at the bottom of this doc enumerates every NULL proposal so m can override with an explicit citation if desired.
### 0.4 Counts
- 130 rows in scope (rule_code IS NULL; is_active=true; lifecycle_state='published')
- 53 proceeding-typed + 77 orphan (no proceeding_type_id, no code)
- 8 rows already carry a `legal_source` those are **easy wins**: only `rule_code` needs proposing
- ~ 40 HIGH-confidence proposals
- ~ 35 MEDIUM-confidence proposals
- ~ 55 FLAG entries (court-scheduled events, combined-pleading rows, ambiguous orphans)
The orphan bucket carries a noticeable number of **duplicates** (six "Mängelbeseitigung / Zahlung" rows, two "Beginn des Hauptsacheverfahrens", two "Antrag auf Patentänderung", etc.). Those are likely vestiges of older Fristenrechner pipelines; backfilling them with the same citation is fine, but m may want a separate dedup pass (out of scope here; flag in § 4).
---
## 1. Easy wins — rows with `legal_source` already set, `rule_code` missing (8)
For these, the structured locator is already in the DB; only the display form is missing.
| id | code / name | duration | existing `legal_source` | proposed `rule_code` | conf |
|---|---|---|---|---|---|
| `1f532c82…` | `de_inf.klage` / Klageerhebung | event | `DE.ZPO.253` | `§ 253 ZPO` | HIGH |
| `20254f4e…` | (orphan) Einspruch gegen Versäumnisurteil | 2 weeks | `DE.ZPO.339.1` | `§ 339 ZPO` | HIGH |
| `3c36f149…` | (orphan) Schriftsatznachreichung 296a ZPO) | 3 weeks | `DE.ZPO.296a` | `§ 296a ZPO` | HIGH |
| `f1099cf6…` | (orphan) Weiterbehandlungsantrag (Art. 121 EPÜ) | 2 months | `EU.EPC-R.135.1` | `R. 135 EPÜ` | HIGH |
| `c24d494c…` | (orphan) Wiedereinsetzungsantrag 123 PatG) | 2 months | `DE.PatG.123.2` | `§ 123 PatG` | HIGH |
| `d40d9be7…` | (orphan) Wiedereinsetzungsantrag 233 ZPO) | 2 weeks | `DE.ZPO.234.1` | `§ 234 ZPO` | HIGH |
| `23c6f445…` | (orphan) Wiedereinsetzungsantrag (Art. 122 EPÜ) | 2 months | `EU.EPC-R.136.1` | `R. 136 EPÜ` | HIGH |
| `b588fa64…` | (orphan) Wiedereinsetzungsantrag (DPMA) | 2 months | `DE.PatG.123.2` | `§ 123 PatG` | HIGH |
**Naming note on the two Wiedereinsetzung-`§ 123 PatG` rows.** Both `c24d494c…` ("§ 123 PatG" name) and `b588fa64…` ("DPMA" name) map to the same statute § 123 PatG (Wiedereinsetzung) applies to all DPMA-Verfahren, so the duplication is a pure naming choice. mig 097 fills both; potential dedup is a separate question 4 FLAG-A).
---
## 2. Proceeding-typed rows (53)
Grouped by `proceeding_types.code`. Within each group: alphabetical by `code`.
### 2.1 `upc.inf.cfi` — Verletzungsverfahren CFI (4 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `inf.decision` | Entscheidung | event | decision | *(NULL)* | *(NULL)* | RoP.118 but this is the court's own decision, not a party deadline | **FLAG-B** |
| `inf.interim` | Zwischenverfahren | event | hearing | *(NULL)* | *(NULL)* | RoP.101 ff. governs interim procedure; not a single norm | **FLAG-B** |
| `inf.oral` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | RoP.111-117 (oral procedure); court-scheduled | **FLAG-B** |
| `inf.soc` | Klageerhebung (Statement of claim) | event | filing | `RoP.013.1` | `UPC.RoP.13.1` | RoP.13 Statement of claim contents | HIGH |
### 2.2 `upc.rev.cfi` — Nichtigkeitsverfahren CFI (6 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `rev.app` | Nichtigkeitsklage | event | filing | `RoP.042` | `UPC.RoP.42` | RoP.42 Statement for revocation | HIGH |
| `rev.decision` | Entscheidung | event | decision | *(NULL)* | *(NULL)* | court-issued, not a party deadline | **FLAG-B** |
| `rev.interim` | Zwischenverfahren | event | hearing | *(NULL)* | *(NULL)* | not a single norm | **FLAG-B** |
| `rev.oral` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | court-scheduled | **FLAG-B** |
| `rev.reply` | Replik | 2 months | filing | `RoP.052` | `UPC.RoP.52` | RoP.52 Reply to defence in revocation | MED (**FLAG-C**: duration vs. norm) |
| `rev.rejoin` | Duplik | 2 months | filing | `RoP.052` | `UPC.RoP.52` | RoP.52 Rejoinder | MED (**FLAG-C**: duration vs. norm) |
**FLAG-C:** RoP.52(1) sets the reply to 2 months but RoP.52(2) sets the rejoinder to 1 month from service of the reply. m's `rev.rejoin` says 2 months verify whether the rule duration is correct or whether `RoP.52.2` (1 month) is the right citation. Cross-check with the existing `rev.rejoin_cci` row which uses RoP.056.4 (cci context); the main-pleadings rejoinder lives in RoP.52.
### 2.3 `upc.pi.cfi` — Einstweilige Maßnahmen (4 rules)
All four rules are currently NULL on both fields.
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `pi.app` | Antrag | event | filing | `RoP.206` | `UPC.RoP.206` | RoP.206 Application for provisional measures | HIGH |
| `pi.oral` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | RoP.209 at judge's discretion | **FLAG-B** |
| `pi.order` | Beschluss | event | decision | *(NULL)* | *(NULL)* | RoP.211 court-issued | **FLAG-B** |
| `pi.response` | Erwiderung | event | filing | *(NULL)* | *(NULL)* | RoP.209.1 judge sets time; no statutory period | **FLAG-B** (alt: `RoP.209.1` / `UPC.RoP.209.1` to flag as court-set) |
### 2.4 `upc.apl.merits` — Berufungsverfahren Merits (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `app.decision` | Entscheidung | event | decision | *(NULL)* | *(NULL)* | RoP.350 appellate decision | **FLAG-B** |
| `app.oral` | Mündliche Verhandlung | event | hearing | `RoP.243` | `UPC.RoP.243` | RoP.243 oral procedure in appeal | MED |
| `app.response` | Berufungserwiderung | 2 months | filing | `RoP.235.1` | `UPC.RoP.235.1` | RoP.235.1 Statement of response | MED (**FLAG-C**: RoP.235.1 says 3 months for main-judgment appeals; 2 months may be a residual from a different appeal track. Verify duration vs. norm.) |
### 2.5 `upc.apl.order` — Berufungsverfahren Anordnungen (1 rule)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `app_ord.order` | Anordnung / angegriffene Entscheidung | event | decision | *(NULL)* | *(NULL)* | trigger event for orders-appeal; RoP.220.1.c references it | **FLAG-B** (alt: `RoP.220.1.c` to surface) |
### 2.6 `upc.apl.cost` — Berufungsverfahren Kosten (1 rule)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `cost.decision` | Kostenfestsetzungsbeschluss | event | decision | *(NULL)* | *(NULL)* | RoP.150 ff. cost decision in the assessment proceedings | **FLAG-B** |
### 2.7 `upc.dmgs.cfi` — Schadensbemessungsverfahren (1 rule)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `damages.app` | Antrag auf Schadensbemessung | event | filing | `RoP.131` | `UPC.RoP.131` | RoP.131 Application for damages determination | HIGH |
### 2.8 `upc.disc.cfi` — Bucheinsichtsverfahren (1 rule)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `disc.app` | Antrag auf Bucheinsicht | event | filing | `RoP.141` | `UPC.RoP.141` | RoP.141 Application for order to lay open books | HIGH |
### 2.9 `de.inf.lg` — Verletzungsverfahren LG (5 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `de_inf.klage` | Klageerhebung | event | filing | `§ 253 ZPO` | `DE.ZPO.253` *(already set)* | § 253 ZPO Klageschrift | HIGH (rule_code only) |
| `de_inf.replik` | Replik | 4 weeks | filing | `§ 273 ZPO` | `DE.ZPO.273` | § 273 ZPO vorbereitende Anordnungen / court-set period (Düsseldorfer Praxis) | MED (**FLAG-D**: 4 weeks is local LG practice, no statutory period; flag `is_court_set=true` already true in DB) |
| `de_inf.duplik` | Duplik | 4 weeks | filing | `§ 273 ZPO` | `DE.ZPO.273` | same | MED (**FLAG-D**) |
| `de_inf.termin` | Haupttermin | event | hearing | *(NULL)* | *(NULL)* | § 272 / § 137 ZPO court-scheduled | **FLAG-B** |
| `de_inf.urteil` | Urteil | event | decision | *(NULL)* | *(NULL)* | § 300 ZPO court-issued | **FLAG-B** |
### 2.10 `de.inf.olg` — Berufungsverfahren OLG Verletzung (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `de_inf_olg.urteil_lg` | Zustellung LG-Urteil | event | filing (trigger) | `§ 317 ZPO` | `DE.ZPO.317` | § 317 ZPO Zustellung von Urteilen | MED (**FLAG-E**: service-trigger event may be NULL per philosophy) |
| `de_inf_olg.termin` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | court-scheduled | **FLAG-B** |
| `de_inf_olg.urteil_olg` | OLG-Urteil | event | decision | *(NULL)* | *(NULL)* | court-issued | **FLAG-B** |
### 2.11 `de.inf.bgh` — Revision/NZB BGH Verletzung (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `de_inf_bgh.urteil_olg` | Zustellung OLG-Urteil | event | filing (trigger) | `§ 317 ZPO` | `DE.ZPO.317` | § 317 ZPO Zustellung | MED (**FLAG-E**) |
| `de_inf_bgh.termin` | Mündliche Verhandlung BGH | event | hearing | *(NULL)* | *(NULL)* | § 555 i.V.m. § 137 ZPO court-scheduled | **FLAG-B** |
| `de_inf_bgh.urteil_bgh` | BGH-Urteil | event | decision | *(NULL)* | *(NULL)* | § 562, § 563 ZPO court-issued | **FLAG-B** |
### 2.12 `de.null.bpatg` — Nichtigkeitsverfahren BPatG (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `de_null.klage` | Nichtigkeitsklage | event | filing | `§ 81 PatG` | `DE.PatG.81.1` | § 81 PatG Nichtigkeitsklage einreichen | HIGH |
| `de_null.termin` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | § 89 PatG | **FLAG-B** |
| `de_null.urteil` | Urteil | event | decision | *(NULL)* | *(NULL)* | § 84 PatG | **FLAG-B** |
### 2.13 `de.null.bgh` — Berufung BGH Nichtigkeit (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `de_null_bgh.urteil_bpatg` | Zustellung BPatG-Urteil | event | filing (trigger) | `§ 99 PatG` | `DE.PatG.99.1` | § 99 PatG verweist auf ZPO; Zustellung der BPatG-Urteile | MED (**FLAG-E**) |
| `de_null_bgh.termin` | Mündliche Verhandlung BGH | event | hearing | *(NULL)* | *(NULL)* | § 113 PatG i.V.m. ZPO | **FLAG-B** |
| `de_null_bgh.urteil_bgh` | BGH-Urteil | event | decision | *(NULL)* | *(NULL)* | § 119 PatG | **FLAG-B** |
### 2.14 `dpma.opp.dpma` — Einspruchsverfahren DPMA (2 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `dpma_opp.publish` | Veröffentlichung der Erteilung | event | filing (trigger) | `§ 58 PatG` | `DE.PatG.58.1` | § 58(1) PatG Veröffentlichung der Erteilung im Patentblatt | HIGH |
| `dpma_opp.entscheidung` | DPMA-Entscheidung | event | decision | *(NULL)* | *(NULL)* | § 47 PatG ff. | **FLAG-B** |
### 2.15 `dpma.appeal.bpatg` — Beschwerdeverfahren BPatG vs. DPMA (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `dpma_bpatg.entscheidung` | Zustellung DPMA-Entscheidung | event | filing (trigger) | `§ 47 PatG` | `DE.PatG.47.1` | § 47 PatG Zustellung der Entscheidung im DPMA-Verfahren | MED (**FLAG-E**: trigger-event citation. Alternative `§ 127 PatG` for service procedure.) |
| `dpma_bpatg.entsch_bpatg` | BPatG-Entscheidung | event | decision | *(NULL)* | *(NULL)* | § 79 PatG | **FLAG-B** |
| `dpma_bpatg.termin` | Mündliche Verhandlung BPatG | event | hearing | *(NULL)* | *(NULL)* | § 78 PatG | **FLAG-B** |
### 2.16 `dpma.appeal.bgh` — Rechtsbeschwerdeverfahren BGH (2 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `dpma_bgh.entsch_bpatg` | Zustellung BPatG-Entscheidung | event | filing (trigger) | `§ 79 PatG` | `DE.PatG.79.1` | § 79 PatG Zustellung der BPatG-Entscheidung | MED (**FLAG-E**) |
| `dpma_bgh.entsch_bgh` | BGH-Entscheidung | event | decision | *(NULL)* | *(NULL)* | § 107 PatG | **FLAG-B** |
### 2.17 `epa.grant.exa` — EP-Erteilungsverfahren (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `ep_grant.filing` | Anmeldung | event | filing | `Art. 75 EPÜ` | `EU.EPÜ.75` | Art. 75 EPÜ Filing of European patent application | HIGH |
| `ep_grant.search` | Recherchenbericht | 6 months | decision | `Art. 92 EPÜ` | `EU.EPÜ.92` | Art. 92 EPÜ Drawing up of the European search report | MED (the 6-month figure is a Richtwert per `deadline_notes` not a statutory deadline. Could also cite `R. 65 EPÜ` if we want the issuance procedure.) |
| `ep_grant.grant` | Erteilung (B1) | event | decision | `Art. 97 EPÜ` | `EU.EPÜ.97.1` | Art. 97(1) EPÜ Decision to grant | HIGH |
### 2.18 `epa.opp.opd` — Einspruchsverfahren EPA (2 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `epa_opp.grant` | Veröffentlichung der Erteilung | event | filing (trigger) | `Art. 97 EPÜ` | `EU.EPÜ.97.3` | Art. 97(3) EPÜ mention of grant; trigger for the 9-month Einspruchsfrist (Art. 99(1) EPÜ) | HIGH |
| `epa_opp.entsch` | Entscheidung | event | decision | `Art. 101 EPÜ` | `EU.EPÜ.101` | Art. 101 EPÜ Decision on opposition | HIGH |
### 2.19 `epa.opp.boa` — Beschwerdeverfahren BoA (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `epa_app.entsch` | Zustellung der Beschwerdeentscheidung | event | filing (trigger) | `R. 111 EPÜ` | `EU.EPC-R.111` | R. 111 EPÜ Form and notification of decisions | MED (**FLAG-E**: service-trigger citation. Could also cite `Art. 119 EPÜ` for notification.) |
| `epa_app.oral` | Mündliche Verhandlung | event | hearing | `Art. 116 EPÜ` | `EU.EPÜ.116` | Art. 116 EPÜ Oral proceedings | HIGH |
| `epa_app.entsch2` | Entscheidung | event | decision | `Art. 111 EPÜ` | `EU.EPÜ.111` | Art. 111 EPÜ Decision in respect of appeals | HIGH |
---
## 3. Orphan rows — `proceeding_type_id IS NULL` and `code IS NULL` (77)
Identified by `id` (UUID first 8 chars) + name. These are the older Fristenrechner catalogue rows that pre-date the proceeding-typed slice and were never re-anchored to a proceeding. Many are 1:1 duplicates of rules that now live in proceeding-typed form.
### 3.1 UPC RoP — main-pleadings track (15)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|---|---|---|---|---|---|---|---|
| `e34097d6…` | Klageerwiderung | 3 mo | `RoP.023` | `UPC.RoP.23.1` | RoP.23.1 Statement of defence | HIGH | dup of `inf.sod` |
| `7d8a4804…` | Nichtigkeitswiderklage | 3 mo | `RoP.025.1` | `UPC.RoP.25.1` | RoP.25.1 Counterclaim for revocation | HIGH | |
| `c7523e6b…` | Verletzungswiderklage | 2 mo | `RoP.049.2.b` | `UPC.RoP.49.2.b` | RoP.49.2.b Counterclaim for infringement in revocation | HIGH | dup of `rev.cc_inf` |
| `c57f62f8…` | Vorgängige Einrede | 1 mo | `RoP.019.1` | `UPC.RoP.19.1` | RoP.19.1 Preliminary objection | HIGH | dup of `inf.prelim` / `rev.prelim` |
| `cec1a865…` | Erwiderung Nichtigkeitswiderklage **+** Replik Klageerwiderung | 2 mo | `RoP.029.a` | `UPC.RoP.29.a` | RoP.29.a / .b combined Defence-to-CCR + Reply to SoD | HIGH (**FLAG-F**: combined-pleading orphan m to confirm one citation is sufficient or whether row should be split) |
| `84b390e0…` | Replik auf die Klageerwiderung | 2 mo | `RoP.029.b` | `UPC.RoP.29.b` | RoP.29.b Reply to defence | HIGH | dup of `inf.reply` |
| `176cc1ca…` | Duplik zur Replik auf die Klageerwiderung | 1 mo | `RoP.029.c` | `UPC.RoP.29.c` | RoP.29.c Rejoinder | HIGH | dup of `inf.rejoin` |
| `02ae9c1f…` | Duplik zur Replik, Replik auf die Erwiderung zum Patentänderungsantrag | 1 mo | `RoP.029.c` | `UPC.RoP.29.c` | combined: RoP.29.c + RoP.32.3 | MED (**FLAG-F**) |
| `ec2a1274…` | Replik auf Erwiderung Widerklage, Duplik Replik Klageerwiderung, Erwiderung Patentänderungsantrag | 2 mo | `RoP.029.d` | `UPC.RoP.29.d` | combined: RoP.29.d + RoP.29.c + RoP.32.1 | MED (**FLAG-F**: three-norm combined row) |
| `a32dcec1…` | Erwiderung auf die Nichtigkeitsklage | 2 mo | `RoP.049.1` | `UPC.RoP.49.1` | RoP.49.1 Defence to revocation | HIGH | dup of `rev.defence` |
| `37bd034b…` | Replik Erwiderung Nichtigkeitsklage + Erwiderung Patentänderungsantrag + Erwiderung Verletzungswiderklage | 2 mo | `RoP.051` | `UPC.RoP.51` | combined: RoP.51 + RoP.49.2.a-reply + RoP.56.1 | MED (**FLAG-F**) |
| `1b5c6dee…` | Duplik zur Replik auf die Erwiderung zur Nichtigkeitsklage | 1 mo | `RoP.052` | `UPC.RoP.52` | RoP.52 Rejoinder in revocation | MED |
| `bea86f9b…` | Erwiderung auf die Verletzungswiderklage | 2 mo | `RoP.056.1` | `UPC.RoP.56.1` | RoP.56.1 | HIGH | dup of `rev.def_cci` |
| `4834c957…` | Replik auf die Erwiderung zur Verletzungswiderklage | 1 mo | `RoP.056.3` | `UPC.RoP.56.3` | RoP.56.3 | HIGH | dup of `rev.reply_def_cci` |
| `7b548c48…` | Duplik (Verletzungswiderklage + Patentänderungsantrag) | 1 mo | `RoP.056.4` | `UPC.RoP.56.4` | combined: RoP.56.4 + RoP.32.3 | MED (**FLAG-F**) |
### 3.2 UPC RoP — Patentänderungs-Track (5)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|---|---|---|---|---|---|---|---|
| `fb7050c6…` | Antrag auf Patentänderung | 2 mo | `RoP.030.1` | `UPC.RoP.30.1` | RoP.30.1 (infringement context) | MED (**FLAG-G**: 2 rows with identical name + 2-month dur; one likely refers to `RoP.30.1` infringement, other to `RoP.49.2.a` revocation) |
| `21e67ac1…` | Antrag auf Patentänderung | 2 mo | `RoP.049.2.a` | `UPC.RoP.49.2.a` | RoP.49.2.a (revocation context) | MED (**FLAG-G**) |
| `7e65a434…` | Erwiderung auf den Antrag auf Patentänderung | 2 mo | `RoP.032.1` | `UPC.RoP.32.1` | RoP.32.1 Defence to application to amend | HIGH | dup of `inf.def_to_amend` |
| `dfd52792…` | Replik auf die Erwiderung zum Patentänderungsantrag | 1 mo | `RoP.032.3` | `UPC.RoP.32.3` | RoP.32.3 Reply | HIGH | dup of `inf.reply_def_amd` |
| `8cdf54eb…` | Duplik zur Replik auf die Erwiderung zum Patentänderungsantrag | 1 mo | `RoP.032.3` | `UPC.RoP.32.3` | RoP.32.3 Rejoinder | HIGH | dup of `inf.rejoin_amd` |
### 3.3 UPC RoP — appeal track (16)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|---|---|---|---|---|---|---|---|
| `1dfba5b1…` | Berufungsschrift gegen Entscheidung nach R. 220.1(a)/(b) | 2 mo | `RoP.224.1.a` | `UPC.RoP.224.1.a` | RoP.224.1.a Notice of appeal, main-judgment track | HIGH | dup of `app.notice` |
| `5c0508f4…` | Berufungsschrift gegen Entscheidung nach R. 220.1(a)/(b) | 2 mo | `RoP.224.1.a` | `UPC.RoP.224.1.a` | same | HIGH | duplicate-of-duplicate (**FLAG-A**) |
| `d560b3b6…` | Berufungsschrift gegen Anordnung R. 220.1(c) / R. 220.2 / 221.3 | 15 d | `RoP.224.1.b` | `UPC.RoP.224.1.b` | RoP.224.1.b Notice of appeal, orders/leave track | HIGH | dup of `app_ord.with_leave`-family |
| `791fd0f7…` | Berufungsbegründung Entscheidung R. 220.1(a)/(b) | 4 mo | `RoP.225.1` | `UPC.RoP.225.1` | RoP.225.1 Statement of grounds, main track | HIGH | dup of `app.grounds` |
| `573df3d1…` | Berufungsbegründung Entscheidung R. 220.1(a)/(b) | 4 mo | `RoP.225.1` | `UPC.RoP.225.1` | same | HIGH | duplicate-of-duplicate (**FLAG-A**) |
| `c3a369f9…` | Berufungsbegründung Anordnung R. 220.1(c) / R. 220.2 / 221.3 | 15 d | `RoP.225.2` | `UPC.RoP.225.2` | RoP.225.2 Statement of grounds, orders/leave | MED (**FLAG-H**: RoP.225.2 form; verify 15d figure aligns with current RoP version) |
| `91e367dd…` | Berufung (Anordnungen & mit Zulassung) | 15 d | `RoP.224.1.b` | `UPC.RoP.224.1.b` | same | MED | dup of `app_ord.with_leave` |
| `ccb916df…` | Antrag auf Berufungszulassung gegen Kostenentscheidungen | 15 d | `RoP.221.1` | `UPC.RoP.221.1` | RoP.221.1 Leave to appeal cost decisions | HIGH | dup of `cost.leave_app` |
| `342e749d…` | Antrag auf Ermessensüberprüfung | 15 d | `RoP.220.3` | `UPC.RoP.220.3` | RoP.220.3 Discretionary review | HIGH | dup of `app_ord.discretion` |
| `d4f739cd…` | Anfechtung einer Entscheidung über Verwerfung der Berufung als unzulässig | 1 mo | `RoP.234.1` | `UPC.RoP.234.1` | RoP.234 Inadmissibility of appeal review | MED (**FLAG-H**: confirm sub-paragraph; RoP.234 governs the topic but the 1-month review window may sit elsewhere) |
| `10374392…` | Berufungserwiderung (zur Berufung nach R. 224.2(a)) | 3 mo | `RoP.235.1` | `UPC.RoP.235.1` | RoP.235.1 Statement of response, main track | HIGH |
| `4c585c6d…` | Berufungserwiderung (zur Berufung nach R. 224.2(b)) | 15 d | `RoP.235.4` | `UPC.RoP.235.4` | RoP.235.4 Statement of response, orders/leave track | MED (**FLAG-H**: confirm RoP.235.4 vs. RoP.235.2 in current RoP version) |
| `6e39b653…` | Anschlussberufungsschrift (zur Berufung R. 224.2(a)) | 3 mo | `RoP.237.1` | `UPC.RoP.237.1` | RoP.237.1 Cross-appeal | HIGH |
| `a00e51bb…` | Anschlussberufungsschrift (zur Berufung R. 224.2(b)) | 15 d | `RoP.237.2` | `UPC.RoP.237.2` | RoP.237 Cross-appeal in orders track | MED (**FLAG-H**) |
| `6b989e85…` | Erwiderung auf Anschlussberufungsschrift (R. 224.2(a)) | 2 mo | `RoP.238.1` | `UPC.RoP.238.1` | RoP.238.1 Reply to cross-appeal | HIGH | dup of `app.cross_a_reply` |
| `e78f4652…` | Erwiderung auf Anschlussberufungsschrift (R. 224.2(b)) | 15 d | `RoP.238.2` | `UPC.RoP.238.2` | RoP.238.2 Reply to cross-appeal, orders track | HIGH | dup of `app_ord.cross_reply` |
### 3.4 UPC RoP — Schadensbemessung / Rechnungslegung (7)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|---|---|---|---|---|---|---|---|
| `d414f603…` | Erwiderung Antrag auf Schadensersatzbemessung | 2 mo | `RoP.137.2` | `UPC.RoP.137.2` | RoP.137.2 | HIGH | dup of `damages.defence` |
| `9f39e263…` | Replik Erwiderung Schadensersatzbemessung | 1 mo | `RoP.139` | `UPC.RoP.139` | RoP.139 | HIGH | dup of `damages.reply` |
| `067ffdf0…` | Duplik Replik Schadensersatzbemessung | 1 mo | `RoP.139` | `UPC.RoP.139` | RoP.139 | HIGH | dup of `damages.rejoin` |
| `429b8ec0…` | Erwiderung Antrag auf Rechnungslegung | 2 mo | `RoP.142.2` | `UPC.RoP.142.2` | RoP.142.2 Defence in account procedure | HIGH | dup of `disc.defence` |
| `8d36fc76…` | Replik Erwiderung Rechnungslegung | 14 d | `RoP.142.3` | `UPC.RoP.142.3` | RoP.142.3 | HIGH | dup of `disc.reply` |
| `ed82fec9…` | Duplik Replik Erwiderung Rechnungslegung | 14 d | `RoP.142.3` | `UPC.RoP.142.3` | RoP.142.3 | HIGH | dup of `disc.rejoin` |
| `eed69e8b…` | Antrag auf Kostenentscheidung | 1 mo | `RoP.151` | `UPC.RoP.151` | RoP.151 Application for cost decision | HIGH | dup of `inf.cost_app` |
### 3.5 UPC RoP — provisional / PI (6)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|
| `ba335c99…` | Beginn des Hauptsacheverfahrens | 31 d | `RoP.213.1` | `UPC.RoP.213.1` | RoP.213.1 31 days or 20 working days after PI granted | HIGH |
| `d886f46f…` | Beginn des Hauptsacheverfahrens | 31 d | `RoP.213.1` | `UPC.RoP.213.1` | same duplicate row (**FLAG-A**) | HIGH |
| `1f1f72ef…` | Antrag auf Überprüfung der Beweissicherungsanordnung | 30 d | `RoP.197.3` | `UPC.RoP.197.3` | RoP.197.3 Review of evidence preservation order | HIGH |
| `3e2f5697…` | Erneuerung der Schutzschrift | 6 mo | `RoP.207.9` | `UPC.RoP.207.9` | RoP.207.9 Protective letter, 6-month validity | HIGH |
### 3.6 UPC RoP — feststellungs / Widerruf-Track (4)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|
| `521bf607…` | Erwiderung auf negative Feststellungsklage | 2 mo | *(NULL)* | *(NULL)* | UPC declaration of non-infringement procedure follows RoP.49 ff. by analogy (RoP.69 references) | **FLAG-I**: negative declaration track has no single statutory norm; cite either `RoP.069` / `UPC.RoP.69` (general procedure) or leave NULL pending m's call |
| `e887b1fb…` | Replik Erwiderung negative Feststellungsklage | 1 mo | *(NULL)* | *(NULL)* | same | **FLAG-I** |
| `0cf1d755…` | Duplik Replik Erwiderung negative Feststellungsklage | 1 mo | *(NULL)* | *(NULL)* | same | **FLAG-I** |
### 3.7 UPC RoP — formalities / Registry (14)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|
| `d058f412…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | RoP.16.4 Notice to remedy defects | HIGH |
| `c690c323…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | same duplicate (**FLAG-A**) | HIGH |
| `5f2884a4…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
| `13600049…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
| `ceb780ba…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
| `d51c50eb…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
| `3bc40027…` | Mängelbeseitigung / Einreichung schriftlicher Stellungnahme | 14 d | `RoP.016.5` | `UPC.RoP.16.5` | RoP.16.5 Written observations after Registry notice | MED |
| `69e356b7…` | Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit | 14 d | `RoP.262.2` | `UPC.RoP.262.2` | RoP.262.2 Confidentiality vis-à-vis public (note in DB confirms) | HIGH |
| `57e6eeca…` | Berichtigung von Entscheidungen und Anordnungen | 1 mo | `RoP.353` | `UPC.RoP.353` | RoP.353 Rectification of decisions/orders | HIGH |
| `8ec233b9…` | Antrag auf Überprüfung verfahrensleitender Anordnung | 15 d | `RoP.333.1` | `UPC.RoP.333.1` | RoP.333.1 Review of procedural order | HIGH |
| `d124c95b…` | Antrag auf Aufhebung oder Änderung Entscheidung des Amtes | 1 mo | *(NULL)* | *(NULL)* | unclear which Amts-Entscheidung this targets Registry order? Unitary-effect refusal? | **FLAG-J** (recommend NULL; ask m what proceeding-context this row maps to) |
| `0531b6ba…` | Antrag auf Aufhebung Entscheidung EPA über einheitliche Wirkung | 3 wk | `RoP.097.1` | `UPC.RoP.97.1` | RoP.97.1 Action against EPO decision on unitary effect | MED (**FLAG-H**: verify 3-week period vs. norm; current RoP gives 1 month for such applications under R.88 EPÜ-UPC; possibly outdated) |
| `6b6b967c…` | Antrag auf Verweisung an die Zentralkammer | 10 d | `RoP.037.4` | `UPC.RoP.37.4` | RoP.37 governs division apportionment; .4 is the 10-day observation period | MED (**FLAG-H**: confirm sub-paragraph) |
| `002c2ba7…` | Antrag auf Folgemaßnahmen rechtskräftiger Validitätsentscheidung | 2 mo | *(NULL)* | *(NULL)* | likely refers to post-revocation register-correction request; norm uncertain | **FLAG-J** |
### 3.8 UPC RoP — translation / interpretation (3)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|
| `bb7bafcb…` | Antrag auf Simultanübersetzung | 1 mo (before) | `RoP.109.1` | `UPC.RoP.109.1` | RoP.109.1 Request for simultaneous interpretation | HIGH |
| `8c682cff…` | Mitteilung über Beauftragung eines Dolmetschers auf Kosten der Partei | 2 wk (before) | `RoP.109.5` | `UPC.RoP.109.5` | RoP.109.5 Notice of own-cost interpreter | MED (**FLAG-H**: confirm sub-paragraph; RoP.109 governs interpretation but the specific 2-week notice rule may sit at .4 or .5) |
| `9ed513c1…` | Einreichung von Übersetzungen von Schriftstücken | 1 mo | `RoP.007.2` | `UPC.RoP.7.2` | RoP.7.2 Language of documents | MED (**FLAG-H**: alternative `RoP.7.4` for translations of party-submitted documents) |
| `902cc5d5…` | Klärung von Übersetzungsfragen | 2 wk | *(NULL)* | *(NULL)* | unclear which "Übersetzungsfrage" rule | **FLAG-J** |
### 3.9 UPC RoP — review / rehearing (2)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|
| `372e86e3…` | Antrag auf Wiederaufnahme (schwerwiegender Verfahrensmangel) | 2 mo | `RoP.247.2` | `UPC.RoP.247.2` | RoP.247.2 Application for rehearing within 2 months | HIGH |
| `58de9573…` | Antrag auf Wiederaufnahme (Straftat) | 2 mo | `RoP.247.2` | `UPC.RoP.247.2` | RoP.247.1(b) substantively (criminal act ground); RoP.247.2 for the 2-month period | HIGH |
### 3.10 Already-cited orphans (covered in § 1 Easy wins, 7 rows)
`20254f4e…`, `3c36f149…`, `f1099cf6…`, `c24d494c…`, `d40d9be7…`, `23c6f445…`, `b588fa64…` see § 1.
---
## 4. FLAG summary — items needing m's call
| FLAG | Topic | Count | Decision needed |
|---|---|---|---|
| **A** | Genuine duplicate orphan rows (same name + dur + citation) | ~10 | Confirm the dedup pass should happen in mig 097 (or a follow-up). Recommended: leave duplicates in place for mig 097 (fills all of them with the same citation); dedup separately so the rule-resolution semantics don't drift. |
| **B** | Court-scheduled / court-issued event rows (Mündliche Verhandlung, Urteil, Entscheidung) | ~22 | Confirm NULL is the right default. Alternative: cite the framing norm with a "context" note. |
| **C** | UPC RoP duration vs. norm mismatch (`rev.reply` / `rev.rejoin` / `app.response`) | 3 | Verify the rule durations are correct as stored proposed citations are canonical but rule duration may be from an older RoP version. |
| **D** | German LG patent practice: 4-week replik/duplik (court-set) | 2 | Confirm `§ 273 ZPO` is the cite m wants (no statutory period, framing norm only). |
| **E** | Service / trigger-event citations (`§ 317 ZPO`, `R. 111 EPÜ` etc.) | 6 | These are anchor-events for downstream timers, not deadlines. Confirm whether to cite (current proposal) or leave NULL. |
| **F** | Combined-pleading orphan rows (one row = several norms) | 5 | Confirm one citation is acceptable, or whether the rows should be split before mig 097 (out of scope here). |
| **G** | Twin "Antrag auf Patentänderung" orphans (2-mo, identical name) | 2 | Confirm one is infringement-context (`RoP.30.1`), the other revocation-context (`RoP.49.2.a`). |
| **H** | RoP sub-paragraph uncertainty (current text vs. older version) | ~8 | Spot-check against current published RoP; my citations are canonical but small `.x` numbers may need a tweak. |
| **I** | Negative-declaration track (no single UPC norm) | 3 | Confirm citing `RoP.69` (procedure-by-analogy) vs. leaving NULL. |
| **J** | Orphan with unclear scope | 3 | `d124c95b…` (Aufhebung Entscheidung des Amtes), `002c2ba7…` (Folgemaßnahmen Validitätsentscheidung), `902cc5d5…` (Klärung Übersetzungsfragen). m to identify which UPC norm. |
---
## 5. Side-fix (recommend bundled in mig 097)
**RoP-display normalization**: `rev.defence` currently carries `rule_code = "RoP.49.1"`. All other RoP rules under 100 use 3-digit padding (`RoP.029.a`, `RoP.049.2.a` etc.). mig 097 should normalize `RoP.49.1 → RoP.049.1` in that one row, while filling the 130 NULL rows with consistently padded values.
```sql
-- side-fix candidate
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.049.1'
WHERE rule_code = 'RoP.49.1'
AND code = 'rev.defence'; -- only one row; idempotent
```
This is opt-in; m to confirm before mig 097 ships.
---
## 6. Migration 097 hints (for the coder who writes it)
**Shape m has asked for:**
- `UPDATE paliad.deadline_rules SET rule_code = …, legal_source = … WHERE id = … AND rule_code IS NULL AND legal_source IS [NULL|expected];`
- Idempotent: `WHERE rule_code IS NULL` (or `IS DISTINCT FROM`) guard so re-applying is a no-op.
- Backup snapshot: `CREATE TABLE paliad.deadline_rules_pre_097 AS SELECT * FROM paliad.deadline_rules` before any UPDATEs.
- Wrap in `audit_reason = 't-paliad-208 legal-citation backfill'` (matches `paliad.audit_log` pattern used elsewhere).
- Touch only the m-approved rows from § 1, § 2, § 3 FLAG rows (those with `*(NULL)*` in the proposed columns) stay untouched until m resolves them.
- Side-fix § 5 (`RoP.49.1 → RoP.049.1`) only if m confirms.
**Counts the migration should match (assuming m approves all HIGH proposals as-is):**
- Easy wins 1): 8 `rule_code` UPDATEs (legal_source already set)
- Proceeding-typed HIGH/MED proposals 2): ~25 rows
- Orphan HIGH/MED proposals 3): ~50 rows
- Total expected `rule_code` writes: ~83 rows
- Total expected `legal_source` writes: ~75 rows (8 of the easy wins already have one)
- FLAG rows left NULL: ~47 rows pending m's decisions
---
## 7. Open questions for m
1. **NULL for event-markers (FLAG-B):** confirm NULL is correct for the 22 court-scheduled / court-issued event rows. If m wants citations there too, I'll do a second pass.
2. **Trigger-event citations (FLAG-E):** apply `§ 317 ZPO` to LG/OLG service rows, or leave NULL?
3. **Duplicates (FLAG-A):** mig 097 fills duplicates with the same citation; do you want a separate dedup pass scheduled (filing `t-paliad-21x`) or is the duplicate count acceptable for now?
4. **Combined-pleading orphans (FLAG-F):** keep one citation per row, or split each row into N rows before mig 097?
5. **Negative-declaration track (FLAG-I):** cite `RoP.69` by analogy, or leave NULL?
6. **Side-fix (§ 5):** normalize the one `RoP.49.1` outlier as part of mig 097?
Once m answers, head can re-task this same worker (or a fresh coder) to write mig 097 against the approved proposals.

View File

@@ -0,0 +1,577 @@
# Orphan Concept Seed Proposals — Fristen Phase 3 Slice 12 (t-paliad-196)
**Date:** 2026-05-15
**Author:** curie (researcher)
**Status:** DRAFT — for m's review, not yet ingested via `/admin/rules`
**Branch:** `mai/curie/fristen-phase-3-slice-12`
**Source audit:** `docs/audit-fristen-logic-2026-05-13.md` § 3.4 + § 7.9 (pauli)
---
## 0. Read-this-first — orphan count discrepancy
m's task description (and pauli's audit dated 2026-05-13) cited **nine** orphan concepts with `rule_count=0`. Today's live `paliad` DB shows **five**:
| # | Slug | Party | Category |
|---|------|-------|----------|
| 1 | `wiedereinsetzung` | both | submission |
| 2 | `schriftsatznachreichung` | both | submission |
| 3 | `versaeumnisurteil-einspruch` | defendant | submission |
| 4 | `weiterbehandlung` | claimant | submission |
| 5 | `counterclaim-for-revocation` | defendant | submission |
Four of the audit's nine were almost certainly seeded between 2026-05-13 and 2026-05-15 by Slice 10 (migration 090, fuzzy backfill) and the Slice-11 admin rule-editor work. `notice-of-defence-intention` is one of them: today's `DE_INF` corpus contains `de_inf.anzeige` (Anzeige der Verteidigungsbereitschaft, ZPO §276.1) linked to its own concept, which removes it from the orphan list.
**FLAG (count discrepancy):** I drafted proposals for the **5** remaining orphans, not 9. m should confirm whether the other 4 audit-named concepts were intentionally seeded or whether something else is going on before treating this as "done".
### 0.1 A second, more important framing problem
The orphan query `deadline_concepts.id NOT IN (SELECT concept_id FROM deadline_rules)` counts only **direct** `concept_id` linkages on `paliad.deadline_rules`. But the schema has two alternate rooting columns: `proceeding_type_id` (Pipeline A) and `trigger_event_id` (Pipeline C). The Pipeline-C migration (Slice 4, m/paliad#…) imported 77 event-rooted rules from `paliad.event_deadlines` but left their `concept_id` **NULL** on the unified `deadline_rules` table — even when the source trigger event had a matching `concept_id` slug already set on `paliad.trigger_events`.
Concretely, the following rules **already exist** in `paliad.deadline_rules` but lack `concept_id`:
| Rule name | `trigger_event_id` | Trigger event code | Owning concept (via `trigger_events.concept_id` slug) |
|---|---|---|---|
| Wiedereinsetzungsantrag (§ 123 PatG) | 200 | `wegfall_hindernisses_de_patg` | `wiedereinsetzung` |
| Wiedereinsetzungsantrag (§ 233 ZPO) | 201 | `wegfall_hindernisses_de_zpo` | `wiedereinsetzung` |
| Wiedereinsetzungsantrag (Art. 122 EPÜ) | 202 | `wegfall_hindernisses_eu_epc` | `wiedereinsetzung` |
| Wiedereinsetzungsantrag (DPMA) | 203 | `wegfall_hindernisses_dpma` | `wiedereinsetzung` |
| Einspruch gegen Versäumnisurteil (§ 339 ZPO) | 204 | `zustellung_versaeumnisurteil` | `versaeumnisurteil-einspruch` |
| Schriftsatznachreichung (§ 296a ZPO) | 205 | `ende_muendl_verhandlung` | `schriftsatznachreichung` |
| Weiterbehandlungsantrag (Art. 121 EPÜ) | 206 | `mitteilung_rechtsverlust_eu` | `weiterbehandlung` |
| *(none yet)* | 207 | `wegfall_hindernisses_upc` | `wiedereinsetzung` |
**Net effect:** four of the five "orphan" concepts already have at least one workable rule — it is just disconnected from the concept by a NULL `concept_id`. The genuine coverage gap is much smaller than "5 concepts × ~5 rules each = 25 rules to draft". Practical Phase-3-Slice-12 work splits into:
- **Track A (linkage, no legal review needed):** `UPDATE paliad.deadline_rules SET concept_id = … WHERE trigger_event_id IN (200,201,202,203,204,205,206)`. 7 rows, zero new legal substance. See § 6 of this doc.
- **Track B (new rule drafts, this doc's main body):** UPC R.320 Wiedereinsetzung (`trigger_event_id=207` truly has no rule yet), proceeding-rooted variants for the four jurisdictions where having a rule under the UPC_INF / DE_INF / EPA_OPP / DPMA_OPP umbrella makes the cascade complete, plus the schema-correct way to resolve `counterclaim-for-revocation` (which is intentionally encoded as flag-gated UPC_INF rules and probably should not get fresh rules at all).
**FLAG (audit framing):** I recommend the orphan KPI be redefined as "concepts where NO rule references the concept, **directly via `deadline_rules.concept_id` OR transitively via `deadline_rules.trigger_event_id → trigger_events.concept_id`**". Until that happens, the orphan list will keep over-reporting work that has already been done in another column. The Phase 2 design (`docs/design-fristen-phase2-2026-05-15.md` § 3 Step C) anticipates dropping the `paliad.trigger_events` table entirely in Slice 9 and copying `concept_id` onto `deadline_rules` at that point — once that migration runs, the discrepancy resolves itself.
### 0.2 Convention notes
- Rule **code** column (`paliad.deadline_rules.code`) uses `<proceeding_short>.<action>` for proceeding-rooted rules (e.g. `inf.sod`, `de_inf.berufung`). For event-rooted rules `code` is NULL today; I follow that pattern.
- **Anchor semantics** (audit § 4): `parent_id NULL + duration_value=0` = root anchor / court-set absolute. `parent_id NULL + duration_value>0 + trigger_event_id` = event-rooted, anchored to the trigger event's date. `parent_id NOT NULL` = chained off another rule.
- **Priority values** (post-Slice-3): `mandatory` | `recommended` | `optional` | `informational`. Wiedereinsetzung-class rules are conceptually `optional` for the user (they may decide not to file), but the legal-source side is mandatory once invoked. I tag them `optional` with the legal source making the obligation conditional — m to confirm.
- **`is_court_set`** is true when the deadline date is set by court order rather than computed from a statutory period. For Schriftsatznachreichung this is the relevant case; for Wiedereinsetzung/Weiterbehandlung it's false (statutory period).
- **`legal_source`** uses the existing convention seen on live rules (`UPC.RoP.29.a`, `DE.ZPO.234.1`, `EU.EPC-R.135.1`, `EU.EPÜ.99.1`).
---
## 1. Concept: `wiedereinsetzung` (Wiedereinsetzung in den vorigen Stand)
**Concept ID:** `00b737bf-58a6-4f41-9650-ac3f2e7079e8`
**Party:** both · **Category:** submission
**Linked event_categories (cascade leaves):**
- `cms-eingang.gericht.rechtsverlust-epa` (Mitteilung über Rechtsverlust, EPA)
- `frist-verpasst.de-patg` (DE Patentverfahren, PatG §123)
- `frist-verpasst.de-zpo` (DE Zivilverfahren, ZPO §233)
- `frist-verpasst.dpma` (DPMA, PatG §123)
- `frist-verpasst.epa` (EPA, Art. 122 EPÜ)
- `frist-verpasst.upc` (UPC, R.320 RoP)
**Existing trigger-event-rooted rules:** trigger events 200/201/202/203 already have rules in `paliad.deadline_rules` (DE PatG, DE ZPO, EPC, DPMA respectively). Only te 207 (UPC R.320) has no rule yet. See § 6 for the linkage UPDATE that brings the existing four into the concept's rule list.
**Drafts below:**
### Rule 1.1 — UPC R.320 Wiedereinsetzungsantrag
- **Rule code:** `upc.wiedereinsetzung` *(proceeding-rooted) ORalt. NULL code + `trigger_event_id=207` (event-rooted, matches pattern of te 200-206 rules)*
- **Proceeding type:** UPC_INF (id=8) — primary. Also relevant for UPC_REV (9), UPC_PI (10), UPC_APP (11), UPC_DAMAGES (17), UPC_DISCOVERY (18), UPC_COST_APPEAL (19), UPC_APP_ORDERS (20). **FLAG:** Wiedereinsetzung applies across the full UPC corpus; m to decide whether to (a) seed one event-rooted rule referencing te 207 — pattern matches the existing four jurisdictions — or (b) seed seven proceeding-rooted clones. Recommend (a): cleaner, mirrors the pattern already set for DE/EPC/DPMA, and Slice 9's table-drop migration in Phase 2 will canonicalise it.
- **Name (DE):** Wiedereinsetzungsantrag (R. 320 RoP UPC)
- **Name (EN):** Application for re-establishment of rights (UPC R.320 RoP)
- **Party:** both (claimant or defendant, whoever missed)
- **Anchor:** `trigger_event_id = 207` (`wegfall_hindernisses_upc`)
- **Duration:** 2, months
- **Timing:** after
- **Priority:** optional *(filing is at the party's discretion — see § 0.2)*
- **is_court_set:** false
- **condition_expr:** NULL
- **Legal source:** `UPC.RoP.320.1`
- **Notes:** UPC R.320.1 sets a 2-month window from removal of the cause of non-compliance, capped by an absolute 1-year limit from expiry of the missed period (see Rule 1.2 below). The omitted act must be completed within the same 2-month window (R.320.2). Court fee per R.150(1)(p). UI may want to show the 1-year backstop as a sibling "Achtung" line; that is a renderer decision, not a separate rule.
### Rule 1.2 — UPC R.320 — 1-Jahres-Ausschlussfrist (informational)
- **Rule code:** `upc.wiedereinsetzung.cutoff` (or trigger-rooted with a sibling `sequence_order` after Rule 1.1)
- **Proceeding type:** same as Rule 1.1
- **Name (DE):** Absolute Ausschlussfrist Wiedereinsetzung (1 Jahr)
- **Name (EN):** Absolute cut-off for re-establishment (1 year)
- **Party:** both
- **Anchor:** the **missed** deadline's date — not `wegfall_hindernisses_upc`. **FLAG:** Today's `trigger_events` model can't express "anchor = the missed deadline" because the trigger fires on removal of cause, not on the missed deadline. Either (a) add a new trigger event `frist_versaeumt_upc` and root this rule there, or (b) make this an `informational` UI-only rule rendered by the renderer next to Rule 1.1 with no real anchor. Recommend (b) for now; (a) is a Phase-3 schema follow-up.
- **Duration:** 12, months
- **Timing:** after
- **Priority:** informational
- **is_court_set:** false
- **condition_expr:** NULL
- **Legal source:** `UPC.RoP.320.1` (second half: "but at the latest within one year of the expiry of the unobserved time limit")
- **Notes:** Cosmetically important — practitioners forget the cut-off. Keep as informational rendering until the schema supports two-anchor rules.
### Rule 1.3 — EPC Art. 122 / R.136 Wiedereinsetzungsantrag (EPA)
- **Rule code:** *(event-rooted; NULL `code`, matches existing pattern for te 200-203)*
- **Proceeding type:** NULL (or EPA_OPP=14 / EPA_APP=15 / EP_GRANT=16 if proceeding-rooted)
- **Name (DE):** Wiedereinsetzungsantrag (Art. 122 EPÜ)
- **Name (EN):** Petition for re-establishment of rights (EPC Art.122)
- **Party:** both
- **Anchor:** `trigger_event_id = 202` (`wegfall_hindernisses_eu_epc`)
- **Duration:** 2, months
- **Timing:** after
- **Priority:** optional
- **is_court_set:** false
- **condition_expr:** NULL
- **Legal source:** `EU.EPC-R.136.1`
- **Notes:** **DUPLICATE of existing rule** `23c6f445-4ed2-4ade-8ea0-c4ab6b364bb6` — already in `deadline_rules`, just missing `concept_id`. See § 6 linkage UPDATE; do not double-seed.
### Rule 1.4 — EPC R.136 — 1-Jahres-Ausschlussfrist
- **Rule code:** as Rule 1.2 pattern
- **Name (DE):** Absolute Ausschlussfrist Wiedereinsetzung EPA (1 Jahr)
- **Name (EN):** Absolute cut-off for re-establishment, EPC (1 year)
- **Party:** both
- **Anchor:** missed-deadline date (same FLAG as Rule 1.2 — schema follow-up)
- **Duration:** 12, months
- **Timing:** after
- **Priority:** informational
- **is_court_set:** false
- **condition_expr:** NULL
- **Legal source:** `EU.EPC-R.136.1` (second sentence)
- **Notes:** R.136(1) third sentence carves out a special **2-month** cut-off for restoration of priority (Art. 87(1) in conjunction with R.136(1)). m may want a separate rule 1.4b for that priority variant; flagging rather than auto-resolving.
### Rule 1.5 — DE PatG §123 Wiedereinsetzungsantrag (DPMA + national)
- **Rule code:** event-rooted, te=200 (PatG) and te=203 (DPMA)
- **Name (DE):** Wiedereinsetzungsantrag (§ 123 PatG)
- **Name (EN):** Petition for re-establishment of rights (PatG §123)
- **Party:** both
- **Anchor:** `trigger_event_id = 200` (`wegfall_hindernisses_de_patg`) — for general DE PatG context — AND `trigger_event_id = 203` (`wegfall_hindernisses_dpma`) — for DPMA-specific context.
- **Duration:** 2, months
- **Timing:** after
- **Priority:** optional
- **is_court_set:** false
- **condition_expr:** NULL
- **Legal source:** `DE.PatG.123.2`
- **Notes:** **DUPLICATE of existing rules** `c24d494c-…` (te 200) and `b588fa64-…` (te 203). Linkage only — see § 6.
### Rule 1.6 — DE PatG §123 — 1-Jahres-Ausschlussfrist
- **Rule code:** as 1.2/1.4 pattern (informational)
- **Name (DE):** Absolute Ausschlussfrist Wiedereinsetzung PatG (1 Jahr)
- **Name (EN):** Absolute cut-off for re-establishment, PatG (1 year)
- **Party:** both
- **Anchor:** missed-deadline date (schema FLAG as 1.2)
- **Duration:** 12, months
- **Timing:** after
- **Priority:** informational
- **is_court_set:** false
- **condition_expr:** NULL
- **Legal source:** `DE.PatG.123.2` (Satz 4)
- **Notes:** PatG §123(2) Satz 4: "Innerhalb eines Jahres nach Ablauf der versäumten Frist ist keine Wiedereinsetzung mehr möglich." Same as PatG also for DPMA proceedings.
### Rule 1.7 — DE ZPO §233 Wiedereinsetzungsantrag (Notfrist, 2 Wochen)
- **Rule code:** event-rooted, te=201
- **Name (DE):** Wiedereinsetzungsantrag — Notfrist (§ 234 Abs. 1 S. 1 ZPO)
- **Name (EN):** Petition for re-establishment of rights — Notfrist (ZPO §234(1) sentence 1)
- **Party:** both
- **Anchor:** `trigger_event_id = 201` (`wegfall_hindernisses_de_zpo`)
- **Duration:** 2, weeks
- **Timing:** after
- **Priority:** optional
- **is_court_set:** false
- **condition_expr:** NULL — but see Rule 1.8 for the 1-month variant.
- **Legal source:** `DE.ZPO.234.1`
- **Notes:** **DUPLICATE of existing rule** `d40d9be7-…` — linkage only. ZPO §234(1) sentence 1: 2 weeks for Notfristen (Berufungsfrist, Revisionsfrist, Beschwerdefrist, etc.).
### Rule 1.8 — DE ZPO §234(1)2 Wiedereinsetzungsantrag (Begründungsfrist, 1 Monat)
- **Rule code:** event-rooted, te=201, sibling to 1.7
- **Name (DE):** Wiedereinsetzungsantrag — Begründungsfrist (§ 234 Abs. 1 S. 2 ZPO)
- **Name (EN):** Petition for re-establishment — appeal/revision grounds period (ZPO §234(1) sentence 2)
- **Party:** both
- **Anchor:** `trigger_event_id = 201` (`wegfall_hindernisses_de_zpo`)
- **Duration:** 1, months
- **Timing:** after
- **Priority:** optional
- **is_court_set:** false
- **condition_expr:** **FLAG** — needs a flag like `{"flag":"begruendungsfrist"}` or similar to distinguish from Rule 1.7 because today's data model can't differentiate "the missed deadline was a Berufungsbegründungsfrist" without an explicit flag from the caller. m to decide whether to add a flag or leave the rule as "informational alternative" rendered alongside 1.7.
- **Legal source:** `DE.ZPO.234.1`
- **Notes:** ZPO §234(1) Satz 2: "Die Frist beträgt einen Monat, wenn die Partei verhindert war, die Frist zur Begründung der Berufung, der Revision, der Nichtzulassungsbeschwerde oder der Rechtsbeschwerde oder die Frist des § 234 Abs. 3 einzuhalten."
### Rule 1.9 — DE ZPO §234(3) — 1-Jahres-Ausschlussfrist
- **Rule code:** informational sibling
- **Name (DE):** Absolute Ausschlussfrist Wiedereinsetzung ZPO (1 Jahr)
- **Name (EN):** Absolute cut-off for re-establishment, ZPO (1 year)
- **Party:** both
- **Anchor:** missed-deadline date (schema FLAG as 1.2)
- **Duration:** 12, months
- **Timing:** after
- **Priority:** informational
- **is_court_set:** false
- **condition_expr:** NULL
- **Legal source:** `DE.ZPO.234.3`
- **Notes:** "Nach Ablauf eines Jahres, von dem Ende der versäumten Frist an gerechnet, kann die Wiedereinsetzung nicht mehr beantragt … werden."
**Summary for `wiedereinsetzung`:** four of the five linked event categories (DE PatG, DE ZPO, EPC, DPMA) already have **existing rules** that just need `concept_id` set — see § 6. The genuinely new substance is **Rule 1.1** (UPC R.320, te 207), plus a set of informational 1-year cut-off rules (1.2/1.4/1.6/1.9), plus the optional ZPO §234(1) sentence-2 variant (1.8). Six new rules in total, one duplicate-flagged, four pure linkages. **FLAG:** UPC fee for Wiedereinsetzung (R.150(1)(p)) is not modelled as a rule — should it appear as a sibling informational rule with the fee amount? Today's model doesn't carry money, so probably no, but worth m's call.
---
## 2. Concept: `schriftsatznachreichung` (Schriftsatznachreichung, § 296a ZPO)
**Concept ID:** `b7a3cb3e-ef7e-47a1-8067-be0fe35a4235`
**Party:** both · **Category:** submission
**Linked event_categories:**
- `cms-eingang.gericht.ladung` (Ladung zur mündlichen Verhandlung)
- `muendl-verhandlung.gehalten` (Soeben gehalten / heute)
- `muendl-verhandlung.geladen` (Geladen — wann findet sie statt?)
**Existing rules:** te 205 (`ende_muendl_verhandlung`) already has rule `3c36f149-…` (3 weeks). Linkage only — see § 6.
### Rule 2.1 — DE ZPO §296a Schriftsatznachreichungsfrist
- **Rule code:** event-rooted, te=205
- **Proceeding type:** NULL (event-rooted) — primarily DE_INF/DE_NULL/OLG/BGH context but cross-cutting via the trigger event.
- **Name (DE):** Schriftsatznachreichung (§ 296a ZPO)
- **Name (EN):** Subsequent written submission (ZPO §296a)
- **Party:** both
- **Anchor:** `trigger_event_id = 205` (`ende_muendl_verhandlung`)
- **Duration:** 3, weeks
- **Timing:** after
- **Priority:** optional *(only available if court grants Schriftsatznachreichungsfrist; otherwise §296a bars new attack/defence means)*
- **is_court_set:** **true** — the deadline date is set by the court order granting the Schriftsatznachreichungsfrist, not by the statute itself. ZPO §296a permits the court to set it; typical practice is 2-3 weeks but the court fixes the exact date.
- **condition_expr:** NULL
- **Legal source:** `DE.ZPO.296a`
- **Notes:** **DUPLICATE of existing rule** — linkage only. **FLAG:** the existing rule sets `is_court_set=false` and a fixed 3-week duration. Strictly, the court sets the date, so `is_court_set=true` is more accurate; the 3-week duration is a typical-case estimate. m to decide whether to update the existing rule or leave the heuristic as-is and document the deviation.
### Rule 2.2 — Schriftsatznachreichung — Beschränkung auf in der Verhandlung erörterte Punkte (informational)
- **Rule code:** informational sibling
- **Name (DE):** Beschränkung der Schriftsatznachreichung (nur Bezug auf Verhandlungspunkte)
- **Name (EN):** Schriftsatznachreichung scope limit (only matters raised at the hearing)
- **Party:** both
- **Anchor:** same as 2.1
- **Duration:** 0
- **Timing:** after
- **Priority:** informational
- **is_court_set:** false
- **condition_expr:** NULL
- **Legal source:** `DE.ZPO.296a`
- **Notes:** Reminds the user that a Schriftsatznachreichung is limited to matters raised at the oral hearing — new attack/defence means are barred under §296a. Useful for the cascade card; not a calendar deadline.
### Rule 2.3 — Schriftsatznachreichung — UPC equivalent? (open question)
**FLAG:** UPC RoP has no direct §296a analogue. Post-hearing submissions in UPC proceedings are limited and require court leave (general practice; see R.117). I am intentionally **not** drafting a UPC rule under this concept and recommend m confirm the concept stays DE-only. If the cascade exposes the concept under a UPC entry, that is a cascade taxonomy bug, not a rule gap.
**Summary:** 2 substantive rules (1 duplicate-flagged, 1 informational). Concept is essentially solved by linkage + 1 informational sibling.
---
## 3. Concept: `versaeumnisurteil-einspruch` (Einspruch gegen Versäumnisurteil, § 339 ZPO)
**Concept ID:** `9f809d1d-ea06-4aa5-80d0-6feaa33b464e`
**Party:** defendant · **Category:** submission
**Linked event_categories:**
- `beschluss-entscheidung.versaeumnisurteil` (Versäumnisurteil DE)
- `cms-eingang.gericht.endentscheidung.versaeumnisurteil` (Versäumnisurteil DE)
**Existing rules:** te 204 (`zustellung_versaeumnisurteil`) already has rule `20254f4e-…` (2 weeks). Linkage only — see § 6.
### Rule 3.1 — DE ZPO §339(1) Einspruchsfrist (Inland-Zustellung, 2 Wochen)
- **Rule code:** event-rooted, te=204
- **Name (DE):** Einspruch gegen Versäumnisurteil (§ 339 Abs. 1 ZPO)
- **Name (EN):** Objection to default judgment, domestic service (ZPO §339(1))
- **Party:** defendant
- **Anchor:** `trigger_event_id = 204` (`zustellung_versaeumnisurteil`)
- **Duration:** 2, weeks
- **Timing:** after
- **Priority:** mandatory *(if defence wants to undo default; otherwise judgment becomes final)*
- **is_court_set:** false
- **condition_expr:** NULL — but see Rule 3.2 for the international-service variant.
- **Legal source:** `DE.ZPO.339.1`
- **Notes:** **DUPLICATE of existing rule** — linkage only. ZPO §339(1) sentence 1: 2-week Notfrist from Zustellung. §339(1) sentence 2 reserves longer periods for cases under §339(2) and §234(2).
### Rule 3.2 — DE ZPO §339(2) Einspruchsfrist (Auslands-Zustellung, ≥ 1 Monat)
- **Rule code:** event-rooted, te=204, sibling
- **Name (DE):** Einspruch gegen Versäumnisurteil — Auslandszustellung (§ 339 Abs. 2 ZPO)
- **Name (EN):** Objection to default judgment — service abroad (ZPO §339(2))
- **Party:** defendant
- **Anchor:** `trigger_event_id = 204`
- **Duration:** 1, months
- **Timing:** after
- **Priority:** mandatory
- **is_court_set:** **true** — §339(2) sentence 2 says the court sets the period in the order; "at least one month" is the statutory floor.
- **condition_expr:** **FLAG** — needs a flag like `{"flag":"auslandszustellung"}` to distinguish from Rule 3.1. m to decide flag naming.
- **Legal source:** `DE.ZPO.339.2`
- **Notes:** ZPO §339(2): "Bei einer Zustellung im Ausland nach § 183 Abs. 1 Nr. 1 wird die Einspruchsfrist auf mindestens einen Monat festgesetzt."
### Rule 3.3 — DE ZPO §340 Inhalt der Einspruchsschrift (informational)
- **Rule code:** informational sibling
- **Name (DE):** Inhalt der Einspruchsschrift (§ 340 ZPO)
- **Name (EN):** Required contents of the objection (ZPO §340)
- **Party:** defendant
- **Anchor:** same as Rule 3.1
- **Duration:** 0
- **Timing:** after
- **Priority:** informational
- **is_court_set:** false
- **condition_expr:** NULL
- **Legal source:** `DE.ZPO.340`
- **Notes:** Reminds the user that the Einspruchsschrift must contain the designation of the judgment, the declaration of objection, and the parties' applications. Not a calendar deadline.
### Rule 3.4 — Rechtsfolge Einspruch (informational)
- **Rule code:** informational sibling
- **Name (DE):** Rechtsfolge des zulässigen Einspruchs (§ 342 ZPO)
- **Name (EN):** Effect of admissible objection (ZPO §342)
- **Party:** defendant
- **Anchor:** same as Rule 3.1
- **Duration:** 0
- **Timing:** after
- **Priority:** informational
- **is_court_set:** false
- **condition_expr:** NULL
- **Legal source:** `DE.ZPO.342`
- **Notes:** Tells the user that an admissible Einspruch puts the case back in the state pre-default. Useful as a cascade-card pill; not a deadline.
**Summary:** 4 rules, 1 duplicate-flagged, 1 needing a condition flag, 2 informational.
---
## 4. Concept: `weiterbehandlung` (Weiterbehandlung, Art. 121 EPÜ)
**Concept ID:** `5a58f14c-3042-48e9-87fd-c94b62d13662`
**Party:** claimant · **Category:** submission
**Linked event_categories:**
- `cms-eingang.gericht.rechtsverlust-epa` (Mitteilung über Rechtsverlust, EPA)
- `frist-verpasst.epa` (EPA, Art. 122 EPÜ)
**Existing rules:** te 206 (`mitteilung_rechtsverlust_eu`) already has rule `f1099cf6-…` (2 months). Linkage only — see § 6.
### Rule 4.1 — EPC Art. 121 / R.135 Weiterbehandlungsantrag
- **Rule code:** event-rooted, te=206
- **Name (DE):** Weiterbehandlungsantrag (Art. 121 EPÜ)
- **Name (EN):** Request for further processing (Art.121 EPC)
- **Party:** claimant *(applicant during prosecution)*
- **Anchor:** `trigger_event_id = 206` (`mitteilung_rechtsverlust_eu`)
- **Duration:** 2, months
- **Timing:** after
- **Priority:** optional *(applicant's choice; preferred over Wiedereinsetzung when available because cheaper and no fault analysis)*
- **is_court_set:** false
- **condition_expr:** NULL
- **Legal source:** `EU.EPC-R.135.1`
- **Notes:** **DUPLICATE of existing rule** — linkage only. R.135(1): 2 months from notification of loss of rights. Missed act must be completed; Weiterbehandlungsgebühr payable per R.135(1) third sentence.
### Rule 4.2 — Weiterbehandlung Ausschlüsse (informational)
- **Rule code:** informational sibling
- **Name (DE):** Ausschlüsse Weiterbehandlung (R.135(2) EPÜ)
- **Name (EN):** Further-processing exclusions (EPC R.135(2))
- **Party:** claimant
- **Anchor:** same as Rule 4.1
- **Duration:** 0
- **Timing:** after
- **Priority:** informational
- **is_court_set:** false
- **condition_expr:** NULL
- **Legal source:** `EU.EPC-R.135.2`
- **Notes:** R.135(2): Weiterbehandlung not available for the priority period (Art. 87(1)), the period under Art. 112a(4), the periods for filing of opposition and appeal (Art. 99(1), 108), and various R.6/R.36(1)(a)/R.51(2)/R.158/R.27(3) periods. Cascade-card pill so the user knows when to fall back to Wiedereinsetzung instead. **FLAG:** could be modeled per excluded period as a fine-grained `condition_expr`-gated set; that is overkill for now — informational siblings are enough.
### Rule 4.3 — Weiterbehandlungsgebühr (informational)
- **Rule code:** informational sibling
- **Name (DE):** Weiterbehandlungsgebühr fällig
- **Name (EN):** Further-processing fee due
- **Party:** claimant
- **Anchor:** same as Rule 4.1
- **Duration:** 2, months
- **Timing:** after
- **Priority:** informational
- **is_court_set:** false
- **condition_expr:** NULL
- **Legal source:** `EU.EPC-R.135.1` (third sentence)
- **Notes:** Fee per Art. 2(1) item 12 of the EPA fee schedule. Mirrors the missed-act window — both must be completed in the same 2-month window for the request to be effective.
**Summary:** 3 rules, 1 duplicate-flagged, 2 informational.
---
## 5. Concept: `counterclaim-for-revocation` (Nichtigkeitswiderklage, UPC R.25)
**Concept ID:** `52134900-2bcf-4810-9de3-0b0681c79dd7`
**Party:** defendant · **Category:** submission
**Linked event_category:**
- `ich-moechte-einreichen.widerklage.nichtigkeit-upc` (Nichtigkeitswiderklage UPC R.25)
**Existing rules:** UPC R.25 / RoP 25-32 are **already encoded** in `UPC_INF` (proceeding_type_id=8) as flag-gated rules using `condition_expr.flag = "with_ccr"`:
| Rule code | Name | Duration | condition_expr | concept_slug today |
|---|---|---|---|---|
| `inf.def_to_ccr` | Erwiderung auf Nichtigkeitswiderklage | 2 months | `{"flag":"with_ccr"}` | `defence-to-counterclaim-for-revocation` |
| `inf.reply` (with_ccr variant) | Replik | 2 months | `{"flag":"with_ccr"}` | `reply-to-defence` |
| `inf.reply_def_ccr` | Replik auf Erwiderung zur Nichtigkeitswiderklage | 2 months | `{"flag":"with_ccr"}` | (not yet checked) |
| `inf.rejoin` (with_ccr) | Duplik | 1 month | `{"flag":"with_ccr"}` | `rejoinder` |
| `inf.rejoin_reply_ccr` | Duplik auf Replik | 1 month | `{"flag":"with_ccr"}` | (not yet checked) |
| `inf.def_to_amend` | Erwiderung auf Patentänderungsantrag | 2 months | `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` | `defence-to-application-to-amend` |
| `inf.app_to_amend` | Antrag auf Patentänderung | 2 months | `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` | **NULL** (orphan column) |
| `inf.reply_def_amd` | Replik auf Erwiderung zum Patentänderungsantrag | 1 month | same | `reply-to-defence-to-application-to-amend` (or similar) |
| `inf.rejoin_amd` | Duplik auf Replik zum Patentänderungsantrag | 1 month | same | `rejoinder-on-amend` (or similar) |
**The CCR itself** — the act of filing the Nichtigkeitswiderklage — is part of `inf.sod` (Statement of Defence) when `with_ccr=true`. The 3-month SoD period from R.23 doubles as the CCR-filing period from R.25.
### Proposal 5.1 — Do **not** seed new rules under this concept.
The concept models a logical artifact ("Nichtigkeitswiderklage") that is, in the data model, an attribute of the SoD rather than a separate timed event. Seeding new rules under `counterclaim-for-revocation.concept_id` would either:
- (a) Duplicate the existing `inf.sod` / `inf.def_to_ccr` / etc. rules — wasteful, fragile (two sources of truth for the same legal period).
- (b) Add a synthetic "filing CCR" rule with the same 3-month period as `inf.sod` — redundant once `inf.sod`'s `concept_id` is set correctly.
### Proposal 5.2 — Link existing UPC_INF rules to this concept (linkage only).
Specifically:
| Rule | Current `concept_id` link | Proposed action |
|---|---|---|
| `inf.sod` (UPC_INF) | `statement-of-defence` (presumably) | Leave as-is — SoD's primary concept is "Statement of Defence". |
| `inf.app_to_amend` (UPC_INF, with_ccr+with_amend) | NULL | **Link to `counterclaim-for-revocation`** — this is the genuine "CCR-derived deadline" that has no concept today. |
**FLAG:** Whether the cascade entry `ich-moechte-einreichen.widerklage.nichtigkeit-upc` should resolve to the SoD itself or to a CCR-card-with-derivative-deadlines is a UX question m needs to decide. My read: when a user clicks "I want to file Nichtigkeitswiderklage", they want to see the SoD deadline (because that's when the CCR is due — same period as SoD) plus the consequential deadlines (Defence to CCR, Replik, Duplik, Patent amendment etc.). A cleaner data-model fix is to add a junction `paliad.concept_rules` (many-to-many) so a rule can belong to multiple concepts (e.g. `inf.sod` ∈ {`statement-of-defence`, `counterclaim-for-revocation`}). That's a Phase 3+ schema add and outside Slice 12's scope.
### Proposal 5.3 — Alternative: event-rooted CCR rule.
Trigger event 1 (`statement_of_defence_which_includes_a_counterclaim_for_revocation`) exists but lacks `concept_id` text. Setting `paliad.trigger_events.concept_id = 'counterclaim-for-revocation'` on te 1 and seeding 1-3 event-rooted rules that fire from te 1 (Defence to CCR within 2 months, Reply within 2 months, etc.) would give the cascade card concrete deadlines without duplicating the SoD-tree rules. This is the pattern the audit § 3.4 description hints at.
**Recommendation:** Proposal 5.2 + 5.3 combined. m to confirm. Until decided, I'm **not** drafting fresh rules for this concept — it's a data-model question disguised as a coverage gap.
---
## 6. Track A — Linkage-only UPDATEs (no legal review needed)
The following `paliad.deadline_rules` rows already exist; they only need `concept_id` pointed at the right concept. These are the lowest-risk part of Slice 12 and can be applied via the admin UI as no-op edits (or as a one-off migration if m prefers).
```sql
-- DRAFT — do not run blindly; the admin UI route (PATCH /api/admin/rules/{id}) is the preferred path.
-- Wiedereinsetzung (DE PatG)
UPDATE paliad.deadline_rules
SET concept_id = '00b737bf-58a6-4f41-9650-ac3f2e7079e8'
WHERE id = 'c24d494c-0da1-4f01-aa74-0f37f99fe1ae';
-- Wiedereinsetzung (DE ZPO)
UPDATE paliad.deadline_rules
SET concept_id = '00b737bf-58a6-4f41-9650-ac3f2e7079e8'
WHERE id = 'd40d9be7-e1b6-451c-bee2-6eaee2307ec5';
-- Wiedereinsetzung (EPC)
UPDATE paliad.deadline_rules
SET concept_id = '00b737bf-58a6-4f41-9650-ac3f2e7079e8'
WHERE id = '23c6f445-4ed2-4ade-8ea0-c4ab6b364bb6';
-- Wiedereinsetzung (DPMA)
UPDATE paliad.deadline_rules
SET concept_id = '00b737bf-58a6-4f41-9650-ac3f2e7079e8'
WHERE id = 'b588fa64-a727-4cfb-a45d-69a835a3b05a';
-- Versäumnisurteil-Einspruch (ZPO §339)
UPDATE paliad.deadline_rules
SET concept_id = '9f809d1d-ea06-4aa5-80d0-6feaa33b464e'
WHERE id = '20254f4e-d213-4cf6-8f5f-1d9d36eeb6ac';
-- Schriftsatznachreichung (ZPO §296a)
UPDATE paliad.deadline_rules
SET concept_id = 'b7a3cb3e-ef7e-47a1-8067-be0fe35a4235'
WHERE id = '3c36f149-3a81-456e-aac1-d4d18bfcb16b';
-- Weiterbehandlung (EPC Art.121)
UPDATE paliad.deadline_rules
SET concept_id = '5a58f14c-3042-48e9-87fd-c94b62d13662'
WHERE id = 'f1099cf6-4c87-430e-b1c5-488bd44cb143';
```
After these 7 rows update, `counterclaim-for-revocation` is the only remaining concept with `direct rule_count = 0`, and that is by design (see § 5).
---
## 7. Track B — Genuinely new rule drafts
Pure-new (not in DB today), to be added through `/admin/rules`:
| # | Concept | Rule | Status |
|---|---|---|---|
| 1.1 | `wiedereinsetzung` | UPC R.320 Wiedereinsetzungsantrag (te 207) | NEW |
| 1.2 | `wiedereinsetzung` | UPC R.320 1-Jahres-Ausschlussfrist | NEW, schema FLAG |
| 1.4 | `wiedereinsetzung` | EPC R.136 1-Jahres-Ausschlussfrist | NEW, schema FLAG |
| 1.6 | `wiedereinsetzung` | DE PatG §123 1-Jahres-Ausschlussfrist | NEW, schema FLAG |
| 1.8 | `wiedereinsetzung` | DE ZPO §234(1)2 — 1-Monat Begründungsfrist | NEW, condition_expr FLAG |
| 1.9 | `wiedereinsetzung` | DE ZPO §234(3) 1-Jahres-Ausschlussfrist | NEW, schema FLAG |
| 2.2 | `schriftsatznachreichung` | §296a-Beschränkung (informational) | NEW |
| 3.2 | `versaeumnisurteil-einspruch` | ZPO §339(2) Auslandszustellung 1 Monat | NEW, condition_expr FLAG |
| 3.3 | `versaeumnisurteil-einspruch` | ZPO §340 Inhalt der Einspruchsschrift (info) | NEW |
| 3.4 | `versaeumnisurteil-einspruch` | ZPO §342 Rechtsfolge (info) | NEW |
| 4.2 | `weiterbehandlung` | R.135(2) Ausschlüsse (info) | NEW |
| 4.3 | `weiterbehandlung` | Weiterbehandlungsgebühr (info) | NEW |
| 5.x | `counterclaim-for-revocation` | (none — see § 5 proposal) | — |
**Total new rule drafts: 12.** That is well under the "50 rule drafts" estimate in the task brief, because the linkage path covers the bulk of what looked like missing coverage. **FLAG:** if m wants me to draft additional UPC R.320 jurisdiction-specific variants (UPC_REV, UPC_PI, UPC_APP, UPC_DAMAGES, UPC_DISCOVERY) as separate proceeding-rooted rules instead of one shared event-rooted rule (Rule 1.1), that adds ~6-7 more drafts.
---
## 8. Open questions / FLAGs index
For convenience, all `**FLAG**`-marked items in one place. m's decision is needed on each before /admin/rules ingest of the corresponding rule.
| ID | Section | Question |
|---|---|---|
| F1 | § 0 | Count discrepancy: 9 vs 5 — confirm the other 4 audit-named orphans were intentionally resolved, not lost. |
| F2 | § 0 | Redefine the orphan KPI to also count `trigger_event_id → trigger_events.concept_id`, so the count reflects actual UX coverage. |
| F3 | § 1.1 | UPC R.320: one event-rooted rule (te 207) vs seven proceeding-rooted clones (UPC_INF/UPC_REV/UPC_PI/UPC_APP/UPC_DAMAGES/UPC_DISCOVERY/UPC_APP_ORDERS). |
| F4 | § 1.2, 1.4, 1.6, 1.9 | 1-year cut-off rules have no clean anchor in the current schema; informational rendering vs new `frist_versaeumt_*` trigger event. |
| F5 | § 1.4 | EPC R.136(1) third sentence: priority-restoration 2-month cut-off — separate rule? |
| F6 | § 1.8 | ZPO §234(1) sentence 2 (Begründungsfrist) — flag-gated or informational sibling? |
| F7 | § 1.x | UPC Wiedereinsetzungs-Gebühr (R.150(1)(p)) — surface as informational rule or out of scope? |
| F8 | § 2.1 | Schriftsatznachreichung existing rule has `is_court_set=false`; strictly it's court-set. Update the row or leave the heuristic in place? |
| F9 | § 2.3 | Confirm `schriftsatznachreichung` is DE-only — cascade should not expose it under UPC entries. |
| F10 | § 3.2 | ZPO §339(2) Auslandszustellung — flag name for `condition_expr` (e.g. `auslandszustellung`). |
| F11 | § 5 | `counterclaim-for-revocation` — link existing UPC_INF rules (proposal 5.2) vs event-rooted CCR rule under te 1 (proposal 5.3) vs both. |
| F12 | § 5 | Many-to-many concept↔rule junction (`paliad.concept_rules`) as a Phase 3+ schema add. |
---
## 9. Sources cited
| Citation key | Reference |
|---|---|
| `UPC.RoP.320.1` | UPC Rules of Procedure, Rule 320(1) — Application for re-establishment of rights, time limits |
| `UPC.RoP.320.2` | UPC RoP Rule 320(2) — Completion of omitted act |
| `UPC.RoP.150.1.p` | UPC RoP Rule 150(1)(p) — Re-establishment fee |
| `UPC.RoP.25` | UPC RoP Rule 25 — Lodging of Counterclaim for Revocation |
| `UPC.RoP.23.1` | UPC RoP Rule 23(1) — Statement of Defence period (existing rule reference) |
| `EU.EPC-R.136.1` | EPC Implementing Regulations Rule 136(1) |
| `EU.EPC-R.136.2` | EPC Implementing Regulations Rule 136(2) — Exclusions |
| `EU.EPC-R.135.1` | EPC Implementing Regulations Rule 135(1) — Further processing |
| `EU.EPC-R.135.2` | EPC Implementing Regulations Rule 135(2) — Exclusions |
| `EU.EPÜ.122` | European Patent Convention Article 122 |
| `EU.EPÜ.121` | European Patent Convention Article 121 |
| `DE.PatG.123.2` | German Patent Act §123(2) — Wiedereinsetzung |
| `DE.ZPO.233` | German ZPO §233 — Wiedereinsetzung in den vorigen Stand |
| `DE.ZPO.234.1` | German ZPO §234(1) — Antragsfrist (2 Wochen / 1 Monat) |
| `DE.ZPO.234.3` | German ZPO §234(3) — 1-year cut-off |
| `DE.ZPO.296a` | German ZPO §296a — Schriftsatznachreichung |
| `DE.ZPO.339.1` | German ZPO §339(1) — Einspruchsfrist 2 Wochen |
| `DE.ZPO.339.2` | German ZPO §339(2) — Einspruchsfrist Auslandszustellung |
| `DE.ZPO.340` | German ZPO §340 — Inhalt der Einspruchsschrift |
| `DE.ZPO.342` | German ZPO §342 — Rechtsfolge des zulässigen Einspruchs |
---
## 10. What's next (if m approves)
1. **Track A first** (low risk): apply the 7 linkage UPDATEs from § 6 via `/admin/rules` PATCH. Cascade UX immediately recovers for 4 of 5 concepts.
2. **Track B legal-review pass:** m or HLC lawyer signs off on the 12 new drafts in § 7 — adjust durations / phrasings as needed.
3. **Ingest Track B** via `/admin/rules` POST, one rule at a time. Each new rule goes into `lifecycle_state='draft'` first; m promotes to `published` after spot-checking via the calculator preview endpoint (Slice 11a).
4. **Schema follow-ups** (FLAGs F2, F4, F12) deferred to Phase 3 follow-up tickets — not in Slice 12 scope.
**Estimated rule count after Slice 12 land:** Track A linkage = 7 connections, Track B new rules = 12 drafts → total `paliad.deadline_rules` row count grows from 249 to **261**; orphan-concept count drops from 5 to **1** (only `counterclaim-for-revocation`, which is by design — see § 5).

View File

@@ -0,0 +1,52 @@
# t-paliad-207 follow-up scope — close-out assessment
**Author:** fermi (inventor)
**Date:** 2026-05-20
**Verdict:** **(A) DONE** — interactive session scope is shipped; remaining tail is filed-or-fileable as discrete issues, not a fresh fermi slice.
---
## 0. What shipped under t-paliad-207
Six substantive deliveries on `mai/fermi/interactive-session`, all merged to main as of 2026-05-20 morning:
1. **Verfahrensablauf + Fristenrechner polish** — jurisdiction prefix on the picked proceeding, trigger-event label derived from the root rule, flag rows lifted to `/tools/verfahrensablauf`, rule references rendered as `youpc.org/laws#…` links via new `BuildLegalSourceURL`, `Vorab-Einrede → Einspruch` rename (DE i18n).
2. **DE proceeding picker — sub-group headers** (`Verletzungsverfahren` / `Nichtigkeitsverfahren`) + parallel labels (`LG (1. Instanz)` / `OLG (Berufung)` / …).
3. **mig 099** — drop the `with_po` flag from the two RoP 19 rules (Einspruch is always-available, not flag-gated).
4. **mig 100**`upc.inf.cfi.ccr` visible rule (`Nichtigkeitswiderklage`) so the CCR filing event surfaces when `with_ccr` is set; later corrected to `priority='optional'` via mig 101.
5. **mig 101** — strip rule-cite brackets from the two Einspruch names + flip the CCR priority `informational → optional`.
6. **mig 102** — track-aware sequence reshuffle on `upc.inf.cfi` so at any tied date the order is infringement (Replik) → revocation (Erwiderung Nichtigkeitswiderklage) → amendment.
7. **Notes toggle**`Hinweise anzeigen` checkbox in the view-toggle bar; compact ⓘ hover hint when off (default), inline `timeline-notes` block when on. `localStorage` shared across both tool pages.
Filed two follow-up issues during the session:
- **m/paliad#39** — link DE + EPA + EU rule references to `youpc.org/laws` (depends on youpc.org ingesting the corpus).
- **m/paliad#41** — DE proceedings as one combined timeline per type (LG→OLG→BGH, BPatG→BGH) — corpus + spawn + de-duplication + multi-instance UI.
## 1. Why (A) DONE
Every concrete thing m surfaced in the session was addressed and merged. The two larger unaddressed asks — combined-timeline behaviour for DE proceedings, and DE/EPA rule-link coverage — are already captured in #39 and #41 with concrete scope notes. Neither belongs as a fermi "next slice" because:
- **#41** is a corpus + UI design pass of its own (3 new spawn rules, de-duplication of the existing `de.inf.lg.berufung ↔ de.inf.olg.berufung` pair, multi-court picker shape, instance markers in the timeline body). That's its own design ticket, not a fermi follow-up.
- **#39** is primarily a youpc.org-side ingest task; the paliad-side change is a 5-line `switch` extension once youpc serves the URLs. Wait for the dependency, then small.
Everything else I surfaced in the read-only audit is either pre-existing (not introduced by this session) or speculative (no user complaint behind it).
## 2. Optional tail — would file as discrete issues, not a fermi slice
Surfacing these for completeness; none are blocking, and most would be small enough to either roll into the existing tickets or land as one-off polish:
| # | Candidate | Size | Already covered? |
|---|---|---|---|
| 1 | **`legal_source` backfill on 47 unsourced active rules** — query: 4 of `upc.inf.cfi`, 4 of `upc.pi.cfi` (100% gap), 6 of `upc.rev.cfi`, others. Pre-condition for #39's links to bite. | Medium — corpus research per rule | Partially: huygens did the broader citation backfill in t-paliad-208 / mig 097. This is the remaining tail. |
| 2 | **`upc.pi.cfi` corpus completeness audit** — all 4 of its rules lack `legal_source`; likely also missing the analogous track-of-decision spawn rules to `upc.apl.merits`. | Small audit, medium fix | No — would be a fresh task. |
| 3 | **Touch-device fallback for the ⓘ hover hint**`title=` attribute degrades poorly on phones (no hover, no tap-to-show). Either a click-to-popover variant, or accept the gap. | Tiny | No, but no user complaint yet. |
| 4 | **R.46 mutatis-mutandis distinction in `upc.rev.cfi.prelim` description** — when mig 101 stripped the `(R. 19 i.V.m. R. 46)` cite, the legal nuance dropped from the user-visible name. Could be surfaced in the description text where it doesn't crowd the timeline cell. | Tiny (one row update) | No. |
| 5 | **Save-modal warning on SoD + CCR double-check** — with mig 100's new `upc.inf.cfi.ccr` rule, a user can save both `sod` and `ccr` from the same modal and get two `paliad.deadlines` rows on the same date. Today's pre-uncheck behaviour for optional priority mitigates accidental double-write but doesn't surface the duplication actively. | Small | No. |
| 6 | **Deferred slices from earlier design docs that touch this surface**: t-paliad-179 Slice 2-4 (variant chips, lane view, side-by-side compare on `/tools/verfahrensablauf`); t-paliad-169 "+ Eintrag" CTA on the SmartTimeline (project-bound) path. | Each a separate slice. | Yes — parked from their original tasks; would be revisited when m prioritises. |
None of these warrant a "next fermi slice" right now. They're polish + corpus tail, and best handled as individual issues that m can pick from.
## 3. Recommendation
Close t-paliad-207. Fire fermi. The remaining tail (items 16 above) is appropriate as a small "polish backlog" m can dip into when relevant, but not a coherent unit of work that needs a parked inventor.

View File

@@ -4,6 +4,7 @@ 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 { renderDownloads } from "./src/downloads";
import { renderLinks } from "./src/links";
import { renderGlossary } from "./src/glossary";
@@ -15,6 +16,7 @@ import { renderCourts } from "./src/courts";
import { renderProjects } from "./src/projects";
import { renderProjectsNew } from "./src/projects-new";
import { renderProjectsDetail } from "./src/projects-detail";
import { renderProjectsChart } from "./src/projects-chart";
import { renderEvents } from "./src/events";
import { renderDeadlinesNew } from "./src/deadlines-new";
import { renderDeadlinesDetail } from "./src/deadlines-detail";
@@ -40,6 +42,9 @@ 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 { renderNotFound } from "./src/notfound";
@@ -234,6 +239,7 @@ async function build() {
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/downloads.ts"),
join(import.meta.dir, "src/client/links.ts"),
join(import.meta.dir, "src/client/glossary.ts"),
@@ -245,6 +251,7 @@ async function build() {
join(import.meta.dir, "src/client/projects.ts"),
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/events.ts"),
join(import.meta.dir, "src/client/deadlines-new.ts"),
join(import.meta.dir, "src/client/deadlines-detail.ts"),
@@ -270,6 +277,9 @@ async function build() {
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
@@ -354,6 +364,7 @@ async function build() {
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, "downloads.html"), renderDownloads());
await Bun.write(join(DIST, "links.html"), renderLinks());
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
@@ -365,6 +376,7 @@ async function build() {
await Bun.write(join(DIST, "projects.html"), renderProjects());
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());
// 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
@@ -394,6 +406,9 @@ async function build() {
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, "notfound.html"), renderNotFound());

View File

@@ -0,0 +1,352 @@
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/{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
// through a reason modal that enforces the ≥10-char rule from Slice 11a
// edge case #4.
//
// The id of the rule is parsed from the URL path on hydration —
// frontend never reads it from a server-injected blob, so the static
// HTML shell is reusable for every rule. condition_expr ships with a
// raw JSON textarea + a simple AND/OR/NOT tree-builder (toggle).
export function renderAdminRulesEdit(): 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.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" />
<main>
<section className="tool-page">
<div className="container">
<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>
</p>
<h1 id="rules-edit-heading" data-i18n="admin.rules.edit.heading.loading">Regel laden...</h1>
<div className="admin-rules-edit-meta">
<span id="rules-edit-lifecycle" className="admin-rules-pill admin-rules-pill-draft" />
<span id="rules-edit-id" className="admin-rules-edit-uuid" />
</div>
</div>
</div>
<div id="rules-edit-feedback" className="form-msg" style="display:none" />
<div className="admin-rules-edit-grid">
<form id="rules-edit-form" className="entity-form admin-rules-edit-form" autocomplete="off">
<fieldset className="admin-rules-fieldset">
<legend data-i18n="admin.rules.edit.section.identity">Identit&auml;t</legend>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-name" data-i18n="admin.rules.edit.field.name">Name (DE)</label>
<input type="text" id="f-name" className="admin-rules-input" />
</div>
<div className="form-field">
<label htmlFor="f-name-en" data-i18n="admin.rules.edit.field.name_en">Name (EN)</label>
<input type="text" id="f-name-en" className="admin-rules-input" />
</div>
</div>
<div className="form-field">
<label htmlFor="f-description" data-i18n="admin.rules.edit.field.description">Beschreibung</label>
<textarea id="f-description" className="admin-rules-input" rows={2} />
</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>
<input type="text" id="f-submission-code" className="admin-rules-input" readonly placeholder="z. B. upc.inf.cfi.soc" />
</div>
<div className="form-field">
<label htmlFor="f-rule-code" data-i18n="admin.rules.edit.field.rule_code">Rechtsgrundlage (Kurzform)</label>
<input type="text" id="f-rule-code" className="admin-rules-input" placeholder="z. B. RoP.151" />
</div>
<div className="form-field">
<label htmlFor="f-legal-source" data-i18n="admin.rules.edit.field.legal_source">Rechtsgrundlage (Langform)</label>
<input type="text" id="f-legal-source" className="admin-rules-input" placeholder="z. B. UPC.RoP.151" />
</div>
</div>
</fieldset>
<fieldset className="admin-rules-fieldset">
<legend data-i18n="admin.rules.edit.section.proceeding">Verfahren &amp; Trigger</legend>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-proceeding" data-i18n="admin.rules.edit.field.proceeding">Verfahrenstyp</label>
<select id="f-proceeding" className="admin-rules-select">
<option value="" data-i18n="admin.rules.edit.field.proceeding.none"></option>
</select>
</div>
<div className="form-field">
<label htmlFor="f-trigger" data-i18n="admin.rules.edit.field.trigger">Trigger-Ereignis</label>
<select id="f-trigger" className="admin-rules-select">
<option value="" data-i18n="admin.rules.edit.field.trigger.none"></option>
</select>
</div>
</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>
<input type="text" id="f-parent" className="admin-rules-input" placeholder="UUID oder leer" />
</div>
<div className="form-field">
<label htmlFor="f-concept" data-i18n="admin.rules.edit.field.concept">Konzept (UUID)</label>
<input type="text" id="f-concept" className="admin-rules-input" placeholder="UUID oder leer" />
</div>
<div className="form-field">
<label htmlFor="f-sequence" data-i18n="admin.rules.edit.field.sequence_order">Reihenfolge</label>
<input type="number" id="f-sequence" className="admin-rules-input" min="0" />
</div>
</div>
</fieldset>
<fieldset className="admin-rules-fieldset">
<legend data-i18n="admin.rules.edit.section.timing">Berechnung</legend>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-duration" data-i18n="admin.rules.edit.field.duration_value">Dauer</label>
<input type="number" id="f-duration" className="admin-rules-input" min="0" />
</div>
<div className="form-field">
<label htmlFor="f-duration-unit" data-i18n="admin.rules.edit.field.duration_unit">Einheit</label>
<select id="f-duration-unit" className="admin-rules-select">
<option value="days">days</option>
<option value="weeks">weeks</option>
<option value="months">months</option>
<option value="working_days">working_days</option>
</select>
</div>
<div className="form-field">
<label htmlFor="f-timing" data-i18n="admin.rules.edit.field.timing">Timing</label>
<select id="f-timing" className="admin-rules-select">
<option value=""></option>
<option value="after">after</option>
<option value="before">before</option>
</select>
</div>
<div className="form-field">
<label htmlFor="f-combine-op" data-i18n="admin.rules.edit.field.combine_op">Combine-Op</label>
<select id="f-combine-op" className="admin-rules-select">
<option value=""></option>
<option value="max">max</option>
<option value="min">min</option>
</select>
</div>
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-alt-duration" data-i18n="admin.rules.edit.field.alt_duration_value">Alt-Dauer</label>
<input type="number" id="f-alt-duration" className="admin-rules-input" min="0" />
</div>
<div className="form-field">
<label htmlFor="f-alt-duration-unit" data-i18n="admin.rules.edit.field.alt_duration_unit">Alt-Einheit</label>
<select id="f-alt-duration-unit" className="admin-rules-select">
<option value=""></option>
<option value="days">days</option>
<option value="weeks">weeks</option>
<option value="months">months</option>
<option value="working_days">working_days</option>
</select>
</div>
<div className="form-field">
<label htmlFor="f-alt-rule-code" data-i18n="admin.rules.edit.field.alt_rule_code">Alt-Rule-Code</label>
<input type="text" id="f-alt-rule-code" className="admin-rules-input" />
</div>
<div className="form-field">
<label htmlFor="f-anchor-alt" data-i18n="admin.rules.edit.field.anchor_alt">Alt-Anchor</label>
<input type="text" id="f-anchor-alt" className="admin-rules-input" />
</div>
</div>
</fieldset>
<fieldset className="admin-rules-fieldset">
<legend data-i18n="admin.rules.edit.section.party">Partei &amp; Ereignis</legend>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-primary-party" data-i18n="admin.rules.edit.field.primary_party">Prim&auml;re Partei</label>
<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>
<input type="text" id="f-event-type" className="admin-rules-input" />
</div>
</div>
</fieldset>
<fieldset className="admin-rules-fieldset">
<legend data-i18n="admin.rules.edit.section.display">Anzeige &amp; Notizen</legend>
<div className="form-field">
<label htmlFor="f-notes" data-i18n="admin.rules.edit.field.deadline_notes">Hinweise (DE)</label>
<textarea id="f-notes" className="admin-rules-input" rows={2} />
</div>
<div className="form-field">
<label htmlFor="f-notes-en" data-i18n="admin.rules.edit.field.deadline_notes_en">Hinweise (EN)</label>
<textarea id="f-notes-en" className="admin-rules-input" rows={2} />
</div>
</fieldset>
<fieldset className="admin-rules-fieldset">
<legend data-i18n="admin.rules.edit.section.lifecycle">Priorit&auml;t &amp; Flags</legend>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-priority" data-i18n="admin.rules.edit.field.priority">Priorit&auml;t</label>
<select id="f-priority" className="admin-rules-select">
<option value="mandatory">mandatory</option>
<option value="recommended">recommended</option>
<option value="optional">optional</option>
<option value="informational">informational</option>
</select>
</div>
<div className="form-field admin-rules-checkbox-field">
<label>
<input type="checkbox" id="f-is-court-set" />
<span data-i18n="admin.rules.edit.field.is_court_set">Gerichtlich gesetzt</span>
</label>
</div>
<div className="form-field admin-rules-checkbox-field">
<label>
<input type="checkbox" id="f-is-spawn" />
<span data-i18n="admin.rules.edit.field.is_spawn">Spawn</span>
</label>
</div>
</div>
<div className="admin-rules-edit-row" id="f-spawn-row" style="display:none">
<div className="form-field">
<label htmlFor="f-spawn-label" data-i18n="admin.rules.edit.field.spawn_label">Spawn-Label</label>
<input type="text" id="f-spawn-label" className="admin-rules-input" />
</div>
<div className="form-field">
<label htmlFor="f-spawn-proceeding" data-i18n="admin.rules.edit.field.spawn_proceeding">Spawn-Verfahren</label>
<select id="f-spawn-proceeding" className="admin-rules-select">
<option value="" data-i18n="admin.rules.edit.field.spawn_proceeding.none"></option>
</select>
</div>
</div>
</fieldset>
<fieldset className="admin-rules-fieldset">
<legend data-i18n="admin.rules.edit.section.condition">Bedingung (condition_expr)</legend>
<p className="admin-rules-hint" data-i18n="admin.rules.edit.field.condition_hint">
JSON-Grammatik: <code>&#123;"flag":"name"&#125;</code> · <code>&#123;"op":"and|or","args":[...]&#125;</code> · <code>&#123;"op":"not","args":[...]&#125;</code>
</p>
<div className="form-field">
<textarea id="f-condition-expr" className="admin-rules-input admin-rules-code-input" rows={5} placeholder='z. B. {"flag":"with_ccr"}' />
<p className="admin-rules-hint" id="f-condition-msg" />
</div>
</fieldset>
</form>
<aside className="admin-rules-edit-side">
{/* Preview widget */}
<div className="admin-rules-edit-card">
<h3 data-i18n="admin.rules.edit.preview.heading">Preview</h3>
<p className="admin-rules-hint" data-i18n="admin.rules.edit.preview.hint">
Nur f&uuml;r Drafts. Berechnet die Fristenkette mit dieser Draft-Regel anstelle der publizierten Variante.
</p>
<div className="form-field">
<label htmlFor="preview-trigger-date" data-i18n="admin.rules.edit.preview.trigger_date">Trigger-Datum</label>
<input type="date" lang="de" id="preview-trigger-date" className="admin-rules-input" />
</div>
<div className="form-field">
<label htmlFor="preview-flags" data-i18n="admin.rules.edit.preview.flags">Flags (komma-separiert)</label>
<input type="text" id="preview-flags" className="admin-rules-input" placeholder="z. B. with_ccr,is_appeal" />
</div>
<button type="button" id="preview-run" className="btn-secondary" data-i18n="admin.rules.edit.preview.run">
Preview berechnen
</button>
<div id="preview-result" className="admin-rules-preview-result" style="display:none" />
</div>
{/* Audit-log timeline */}
<div className="admin-rules-edit-card">
<h3 data-i18n="admin.rules.edit.audit.heading">Audit-Log</h3>
<ol id="rules-edit-audit" className="admin-rules-audit-list">
<li className="admin-rules-loading" data-i18n="admin.rules.edit.audit.loading">Lade...</li>
</ol>
<button type="button" id="audit-loadmore" className="btn-secondary" style="display:none" data-i18n="admin.rules.edit.audit.loadmore">
Weitere laden
</button>
</div>
</aside>
</div>
{/* Action bar */}
<div className="admin-rules-actionbar">
<button type="button" id="action-save-draft" className="btn-primary" style="display:none" data-i18n="admin.rules.edit.action.save_draft">
Draft speichern
</button>
<button type="button" id="action-publish" className="btn-primary" style="display:none" data-i18n="admin.rules.edit.action.publish">
Publish
</button>
<button type="button" id="action-clone" className="btn-secondary" style="display:none" data-i18n="admin.rules.edit.action.clone">
Als Draft klonen
</button>
<button type="button" id="action-archive" className="btn-secondary" style="display:none" data-i18n="admin.rules.edit.action.archive">
Archivieren
</button>
<button type="button" id="action-restore" className="btn-secondary" style="display:none" data-i18n="admin.rules.edit.action.restore">
Wiederherstellen
</button>
</div>
</div>
</section>
</main>
{/* Reason modal — shared for every lifecycle action. Action-specific
body text is set by the client at open time. */}
<div className="modal-overlay" id="rules-action-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="rules-action-modal-title">Aktion best&auml;tigen</h2>
<button className="modal-close" id="rules-action-modal-close" type="button" aria-label="Close">&times;</button>
</div>
<p id="rules-action-modal-body" className="invite-modal-body" />
<form id="rules-action-modal-form" className="entity-form" autocomplete="off">
<div className="form-field">
<label htmlFor="rules-action-modal-reason" data-i18n="admin.rules.modal.reason">Grund</label>
<textarea
id="rules-action-modal-reason"
className="admin-rules-input"
rows={3}
required
minlength={10}
/>
<p className="admin-rules-hint" data-i18n="admin.rules.modal.reason.hint">
Mindestens 10 Zeichen.
</p>
</div>
<p className="form-msg" id="rules-action-modal-msg" style="display:none" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="rules-action-modal-cancel" data-i18n="common.cancel">Abbrechen</button>
<button type="submit" className="btn-primary" id="rules-action-modal-submit" data-i18n="admin.rules.modal.confirm">
Best&auml;tigen
</button>
</div>
</form>
</div>
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-rules-edit.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,80 @@
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

@@ -0,0 +1,187 @@
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 — 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
// shallow — the count badge on the Orphans tab is loaded eagerly on
// first paint so the editor sees the legal-review backlog every visit.
export function renderAdminRulesList(): 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.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" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.rules.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">
+ Neue Regel
</button>
</div>
</div>
<div className="admin-rules-tabs">
<button type="button" className="admin-rules-tab active" id="rules-tab-rules" data-tab="rules" data-i18n="admin.rules.tab.rules">
Regeln
</button>
<button type="button" className="admin-rules-tab" id="rules-tab-orphans" data-tab="orphans">
<span data-i18n="admin.rules.tab.orphans">Orphans</span>
<span className="admin-rules-tab-badge" id="rules-orphans-badge" style="display:none">0</span>
</button>
</div>
<div id="rules-feedback" className="form-msg" style="display:none" />
{/* Rules tab */}
<div id="rules-pane-rules" className="admin-rules-pane">
<div className="admin-rules-filters">
<div className="admin-rules-filter">
<label htmlFor="rules-filter-proceeding" data-i18n="admin.rules.filter.proceeding">Verfahrenstyp</label>
<select id="rules-filter-proceeding" className="admin-rules-select">
<option value="" data-i18n="admin.rules.filter.proceeding.any">Alle</option>
</select>
</div>
<div className="admin-rules-filter">
<label htmlFor="rules-filter-trigger" data-i18n="admin.rules.filter.trigger">Trigger-Ereignis</label>
<select id="rules-filter-trigger" className="admin-rules-select">
<option value="" data-i18n="admin.rules.filter.trigger.any">Alle</option>
</select>
</div>
<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="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" data-state="archived" data-i18n="admin.rules.lifecycle.archived">Archived</button>
</div>
</div>
<div className="admin-rules-filter admin-rules-filter-search">
<label htmlFor="rules-filter-search" data-i18n="admin.rules.filter.search">Suche</label>
<input
type="text"
id="rules-filter-search"
className="admin-rules-input"
placeholder="Name, Submission Code, Rechtsgrundlage..."
data-i18n-placeholder="admin.rules.filter.search.placeholder"
autocomplete="off"
/>
</div>
</div>
<div className="entity-table-wrap admin-rules-table-wrap">
<table className="entity-table admin-rules-table">
<thead>
<tr>
<th data-i18n="admin.rules.col.submission_code">Submission Code</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>
</tr>
</thead>
<tbody id="rules-tbody">
<tr><td colspan={7} className="admin-rules-loading" data-i18n="admin.rules.loading">Lade...</td></tr>
</tbody>
</table>
</div>
<div className="entity-empty" id="rules-empty" style="display:none">
<p data-i18n="admin.rules.empty">Keine Regeln f&uuml;r die gew&auml;hlten Filter.</p>
</div>
</div>
{/* Orphans tab */}
<div id="rules-pane-orphans" className="admin-rules-pane" style="display:none">
<p className="tool-subtitle" data-i18n="admin.rules.orphans.subtitle">
Legacy-Deadlines aus dem fuzzy-match Backfill (Slice 10), die nicht eindeutig einer Regel zugeordnet werden konnten. Bitte die richtige Kandidaten-Regel ausw&auml;hlen.
</p>
<div id="rules-orphans-list" className="admin-rules-orphans">
<p className="admin-rules-loading" data-i18n="admin.rules.orphans.loading">Lade...</p>
</div>
</div>
</div>
</section>
</main>
{/* Reason modal — reused for "+ Neue Regel" (creates a draft) and for
the orphan resolve flow. Both writes go through audit-reason
session config server-side, so the modal enforces the 10-char
minimum client-side per Slice 11a edge case #4. */}
<div className="modal-overlay" id="rules-reason-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="rules-reason-title" data-i18n="admin.rules.modal.new.title">Neue Regel anlegen</h2>
<button className="modal-close" id="rules-reason-close" type="button" aria-label="Close">&times;</button>
</div>
<p id="rules-reason-body" className="invite-modal-body" data-i18n="admin.rules.modal.new.body">
Eine neue Regel wird als Draft angelegt. Bitte einen Grund (mind. 10 Zeichen) angeben &mdash; dieser wandert ins Audit-Log und beim Export in die Migration.
</p>
<form id="rules-reason-form" className="entity-form" autocomplete="off">
<div id="rules-reason-extra" />
<div className="form-field">
<label htmlFor="rules-reason-text" data-i18n="admin.rules.modal.reason">Grund</label>
<textarea
id="rules-reason-text"
className="admin-rules-input"
rows={3}
required
minlength={10}
placeholder="z. B. „Neue Regel f&uuml;r RoP.198 nach UPC-Reform 2026..."
data-i18n-placeholder="admin.rules.modal.reason.placeholder"
/>
<p className="admin-rules-hint" data-i18n="admin.rules.modal.reason.hint">
Mindestens 10 Zeichen.
</p>
</div>
<p className="form-msg" id="rules-reason-msg" style="display:none" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="rules-reason-cancel" data-i18n="common.cancel">Abbrechen</button>
<button type="submit" className="btn-primary" id="rules-reason-submit" data-i18n="admin.rules.modal.confirm">
Best&auml;tigen
</button>
</div>
</form>
</div>
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-rules-list.js"></script>
</body>
</html>
);
}

View File

@@ -95,6 +95,11 @@ 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">
<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>
</a>
</div>
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>

View File

@@ -0,0 +1,667 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// admin-rules-edit.ts — /admin/rules/{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
// Slice 11a edge case #4.
interface Rule {
id: string;
proceeding_type_id?: number | null;
parent_id?: 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 (legal citation, e.g. `RoP.013.1`).
submission_code?: string | null;
rule_code?: string | null;
name: string;
name_en: string;
description?: string | null;
primary_party?: string | null;
event_type?: string | null;
duration_value: number;
duration_unit: string;
timing?: string | null;
alt_duration_value?: number | null;
alt_duration_unit?: string | null;
alt_rule_code?: string | null;
anchor_alt?: string | null;
combine_op?: string | null;
legal_source?: string | null;
deadline_notes?: string | null;
deadline_notes_en?: string | null;
priority: string;
is_court_set: boolean;
is_spawn: boolean;
spawn_label?: string | null;
spawn_proceeding_type_id?: number | null;
trigger_event_id?: number | null;
condition_expr?: unknown;
sequence_order: number;
concept_id?: string | null;
lifecycle_state: string;
draft_of?: string | null;
published_at?: string | null;
updated_at: string;
created_at: string;
}
interface ProceedingType {
id: number;
code: string;
name_de: string;
name_en: string;
}
interface TriggerEvent {
id: number;
code: string;
name: string;
name_de: string;
}
interface AuditEntry {
id: string;
rule_id: string;
changed_by?: string | null;
changed_by_display_name?: string | null;
changed_at: string;
action: string;
before_json?: unknown;
after_json?: unknown;
reason: string;
migration_exported: boolean;
}
let ruleId = "";
let rule: Rule | null = null;
let proceedings: ProceedingType[] = [];
let triggers: TriggerEvent[] = [];
let auditEntries: AuditEntry[] = [];
let auditOffset = 0;
const AUDIT_PAGE = 20;
let auditHasMore = false;
let previewDebounce: number | undefined;
function esc(s: string | null | undefined): string {
const d = document.createElement("div");
d.textContent = s ?? "";
return d.innerHTML;
}
function fmtDateTime(iso: string): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const locale = getLang() === "de" ? "de-DE" : "en-GB";
return d.toLocaleString(locale, {
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit",
});
}
function parseRuleIDFromPath(): string {
// /admin/rules/{uuid}/edit
const m = /^\/admin\/rules\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
return m ? decodeURIComponent(m[1]) : "";
}
function showFeedback(msg: string, isError: boolean) {
const el = document.getElementById("rules-edit-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);
}
}
function lifecycleLabel(state: string): string {
return tDyn(`admin.rules.lifecycle.${state}`) || state;
}
function lifecycleClass(state: string): string {
switch (state) {
case "draft": return "admin-rules-pill admin-rules-pill-draft";
case "published": return "admin-rules-pill admin-rules-pill-published";
case "archived": return "admin-rules-pill admin-rules-pill-archived";
default: return "admin-rules-pill";
}
}
// --------------------------------------------------------------------
// Loaders.
// --------------------------------------------------------------------
async function loadProceedings(): Promise<void> {
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
if (!resp.ok) return;
proceedings = (await resp.json()) as ProceedingType[];
fillProceedingSelect("f-proceeding", proceedings);
fillProceedingSelect("f-spawn-proceeding", proceedings);
}
async function loadTriggers(): Promise<void> {
const resp = await fetch("/api/tools/trigger-events");
if (!resp.ok) return;
triggers = (await resp.json()) as TriggerEvent[];
const sel = document.getElementById("f-trigger") as HTMLSelectElement | null;
if (!sel) return;
const placeholder = sel.querySelector('option[value=""]');
sel.innerHTML = "";
if (placeholder) sel.appendChild(placeholder);
for (const te of triggers) {
const opt = document.createElement("option");
opt.value = String(te.id);
opt.textContent = `${te.code} · ${getLang() === "en" ? te.name : te.name_de}`;
sel.appendChild(opt);
}
}
function fillProceedingSelect(selectId: string, list: ProceedingType[]) {
const sel = document.getElementById(selectId) as HTMLSelectElement | null;
if (!sel) return;
const placeholder = sel.querySelector('option[value=""]');
sel.innerHTML = "";
if (placeholder) sel.appendChild(placeholder);
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}`;
sel.appendChild(opt);
}
}
async function loadRule(): Promise<void> {
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`);
if (!resp.ok) {
if (resp.status === 404) {
showFeedback(t("admin.rules.edit.error.not_found") || "Regel nicht gefunden.", true);
} else {
showFeedback(t("admin.rules.edit.error.load") || "Konnte Regel nicht laden.", true);
}
return;
}
rule = await resp.json() as Rule;
populateForm();
updateLifecycleUI();
}
async function loadAudit(reset: boolean = true): Promise<void> {
if (reset) {
auditEntries = [];
auditOffset = 0;
}
const resp = await fetch(`/admin/api/rules/${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[] : [];
auditEntries.push(...rows);
auditOffset += rows.length;
auditHasMore = rows.length === AUDIT_PAGE;
renderAudit();
}
// --------------------------------------------------------------------
// Form binding.
// --------------------------------------------------------------------
function setInput(id: string, val: unknown) {
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null;
if (!el) return;
if (val == null) {
el.value = "";
return;
}
el.value = String(val);
}
function setCheckbox(id: string, val: boolean) {
const el = document.getElementById(id) as HTMLInputElement | null;
if (!el) return;
el.checked = !!val;
}
function getInput(id: string): string {
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null;
return el ? el.value.trim() : "";
}
function getCheckbox(id: string): boolean {
const el = document.getElementById(id) as HTMLInputElement | null;
return el ? el.checked : false;
}
function getOptionalInt(id: string): number | null {
const v = getInput(id);
if (!v) return null;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : null;
}
function getOptionalString(id: string): string | null {
const v = getInput(id);
return v ? v : null;
}
function populateForm() {
if (!rule) return;
const heading = document.getElementById("rules-edit-heading") as HTMLElement;
const idEl = document.getElementById("rules-edit-id") as HTMLElement;
const lifecycleEl = document.getElementById("rules-edit-lifecycle") as HTMLElement;
heading.textContent = (getLang() === "en" ? rule.name_en : rule.name) || rule.name;
idEl.textContent = rule.id;
lifecycleEl.className = lifecycleClass(rule.lifecycle_state);
lifecycleEl.textContent = lifecycleLabel(rule.lifecycle_state);
setInput("f-name", rule.name);
setInput("f-name-en", rule.name_en);
setInput("f-description", rule.description ?? "");
setInput("f-submission-code", rule.submission_code ?? "");
setInput("f-rule-code", rule.rule_code ?? "");
setInput("f-legal-source", rule.legal_source ?? "");
setInput("f-proceeding", rule.proceeding_type_id ?? "");
setInput("f-trigger", rule.trigger_event_id ?? "");
setInput("f-parent", rule.parent_id ?? "");
setInput("f-concept", rule.concept_id ?? "");
setInput("f-sequence", rule.sequence_order);
setInput("f-duration", rule.duration_value);
setInput("f-duration-unit", rule.duration_unit);
setInput("f-timing", rule.timing ?? "");
setInput("f-combine-op", rule.combine_op ?? "");
setInput("f-alt-duration", rule.alt_duration_value ?? "");
setInput("f-alt-duration-unit", rule.alt_duration_unit ?? "");
setInput("f-alt-rule-code", rule.alt_rule_code ?? "");
setInput("f-anchor-alt", rule.anchor_alt ?? "");
setInput("f-primary-party", rule.primary_party ?? "");
setInput("f-event-type", rule.event_type ?? "");
setInput("f-notes", rule.deadline_notes ?? "");
setInput("f-notes-en", rule.deadline_notes_en ?? "");
setInput("f-priority", rule.priority);
setCheckbox("f-is-court-set", rule.is_court_set);
setCheckbox("f-is-spawn", rule.is_spawn);
setInput("f-spawn-label", rule.spawn_label ?? "");
setInput("f-spawn-proceeding", rule.spawn_proceeding_type_id ?? "");
toggleSpawnRow();
setInput("f-condition-expr", rule.condition_expr ? JSON.stringify(rule.condition_expr, null, 2) : "");
}
function toggleSpawnRow() {
const row = document.getElementById("f-spawn-row") as HTMLElement | null;
if (!row) return;
row.style.display = getCheckbox("f-is-spawn") ? "" : "none";
}
function updateLifecycleUI() {
const draftOnly = (id: string, show: boolean) => {
const el = document.getElementById(id) as HTMLElement | null;
if (el) el.style.display = show ? "" : "none";
};
if (!rule) return;
const isDraft = rule.lifecycle_state === "draft";
const isPublished = rule.lifecycle_state === "published";
const isArchived = rule.lifecycle_state === "archived";
draftOnly("action-save-draft", isDraft);
draftOnly("action-publish", isDraft);
draftOnly("action-clone", isPublished || isArchived);
draftOnly("action-archive", isDraft || isPublished);
draftOnly("action-restore", isArchived);
// Lock form fields when not editable (i.e. not draft). Published /
// archived rules show the form read-only so editors can confirm
// they're about to clone the right row.
const readOnly = !isDraft;
document.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(
"#rules-edit-form input, #rules-edit-form select, #rules-edit-form textarea",
).forEach((el) => {
el.disabled = readOnly;
});
}
function renderAudit() {
const list = document.getElementById("rules-edit-audit") as HTMLElement | null;
const more = document.getElementById("audit-loadmore") as HTMLElement | null;
if (!list) return;
if (auditEntries.length === 0) {
list.innerHTML = `<li class="admin-rules-audit-empty">${esc(t("admin.rules.edit.audit.empty") || "Keine Audit-Eintr&auml;ge.")}</li>`;
} else {
list.innerHTML = auditEntries.map((e) => {
const actor = e.changed_by_display_name || (e.changed_by ? e.changed_by.slice(0, 8) : (t("admin.rules.edit.audit.actor.system") || "System"));
const actionLabel = tDyn(`admin.rules.edit.audit.action.${e.action}`) || e.action;
const exported = e.migration_exported
? `<span class="admin-rules-audit-badge">${esc(t("admin.rules.edit.audit.exported") || "exported")}</span>`
: "";
return `
<li class="admin-rules-audit-entry admin-rules-audit-action-${esc(e.action)}">
<div class="admin-rules-audit-head">
<span class="admin-rules-audit-action">${esc(actionLabel)}</span>
<span class="admin-rules-audit-time">${esc(fmtDateTime(e.changed_at))}</span>
${exported}
</div>
<div class="admin-rules-audit-actor">${esc(actor)}</div>
${e.reason ? `<div class="admin-rules-audit-reason">${esc(e.reason)}</div>` : ""}
</li>
`;
}).join("");
}
if (more) more.style.display = auditHasMore ? "" : "none";
}
// --------------------------------------------------------------------
// Validation helpers.
// --------------------------------------------------------------------
function validateConditionExpr(): { ok: boolean; value: unknown | undefined; msg: string } {
const raw = getInput("f-condition-expr");
const msgEl = document.getElementById("f-condition-msg") as HTMLElement | null;
if (!raw) {
if (msgEl) {
msgEl.textContent = "";
msgEl.className = "admin-rules-hint";
}
return { ok: true, value: undefined, msg: "" };
}
try {
const parsed = JSON.parse(raw);
if (msgEl) {
msgEl.textContent = "✓ " + (t("admin.rules.edit.field.condition.valid") || "JSON gültig.");
msgEl.className = "admin-rules-hint admin-rules-hint-ok";
}
return { ok: true, value: parsed, msg: "" };
} catch (err) {
const m = err instanceof Error ? err.message : String(err);
if (msgEl) {
msgEl.textContent = "⚠ " + m;
msgEl.className = "admin-rules-hint admin-rules-hint-error";
}
return { ok: false, value: undefined, msg: m };
}
}
// --------------------------------------------------------------------
// Action modal (reason + lifecycle handler).
// --------------------------------------------------------------------
type Action = "save-draft" | "publish" | "clone" | "archive" | "restore";
let pendingAction: Action | null = null;
function openActionModal(action: Action) {
pendingAction = action;
const modal = document.getElementById("rules-action-modal") as HTMLElement;
const title = document.getElementById("rules-action-modal-title") as HTMLElement;
const body = document.getElementById("rules-action-modal-body") as HTMLElement;
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
const reasonInput = document.getElementById("rules-action-modal-reason") as HTMLTextAreaElement;
msg.style.display = "none";
reasonInput.value = "";
switch (action) {
case "save-draft":
title.textContent = t("admin.rules.edit.modal.save_draft.title") || "Draft speichern";
body.textContent = t("admin.rules.edit.modal.save_draft.body") || "Bitte einen Grund für die Änderung angeben (mind. 10 Zeichen). Wird ins Audit-Log geschrieben.";
break;
case "publish":
title.textContent = t("admin.rules.edit.modal.publish.title") || "Publish";
body.textContent = t("admin.rules.edit.modal.publish.body") || "Diese Draft-Regel wird live geschaltet. Bestehende publizierte Variante wird archiviert.";
break;
case "clone":
title.textContent = t("admin.rules.edit.modal.clone.title") || "Als Draft klonen";
body.textContent = t("admin.rules.edit.modal.clone.body") || "Eine neue Draft-Kopie dieser Regel wird angelegt. Sie werden auf die neue Draft-Seite weitergeleitet.";
break;
case "archive":
title.textContent = t("admin.rules.edit.modal.archive.title") || "Archivieren";
body.textContent = t("admin.rules.edit.modal.archive.body") || "Regel wird archiviert. Calculator nutzt sie nicht mehr.";
break;
case "restore":
title.textContent = t("admin.rules.edit.modal.restore.title") || "Wiederherstellen";
body.textContent = t("admin.rules.edit.modal.restore.body") || "Regel wird wiederhergestellt (archived → published).";
break;
}
modal.style.display = "flex";
reasonInput.focus();
}
function closeActionModal() {
(document.getElementById("rules-action-modal") as HTMLElement).style.display = "none";
pendingAction = null;
}
async function submitActionModal(ev: Event) {
ev.preventDefault();
if (!pendingAction || !rule) return;
const reasonInput = document.getElementById("rules-action-modal-reason") as HTMLTextAreaElement;
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
const submit = document.getElementById("rules-action-modal-submit") as HTMLButtonElement;
const reason = reasonInput.value.trim();
if (reason.length < 10) {
msg.textContent = t("admin.rules.modal.reason.too_short") || "Grund muss mindestens 10 Zeichen enthalten.";
msg.className = "form-msg form-msg-error";
msg.style.display = "block";
return;
}
submit.disabled = true;
try {
if (pendingAction === "save-draft") {
await doSaveDraft(reason);
} else if (pendingAction === "publish") {
await doLifecycle("publish", reason);
} else if (pendingAction === "clone") {
await doClone(reason);
} else if (pendingAction === "archive") {
await doLifecycle("archive", reason);
} else if (pendingAction === "restore") {
await doLifecycle("restore", reason);
}
} finally {
submit.disabled = false;
}
}
function buildPatchPayload(): Record<string, unknown> {
const validation = validateConditionExpr();
if (!validation.ok) throw new Error(validation.msg);
const payload: Record<string, unknown> = {
name: getInput("f-name"),
name_en: getInput("f-name-en"),
description: getInput("f-description"),
primary_party: getInput("f-primary-party"),
event_type: getInput("f-event-type"),
duration_value: getOptionalInt("f-duration") ?? 0,
duration_unit: getInput("f-duration-unit"),
timing: getOptionalString("f-timing"),
alt_duration_value: getOptionalInt("f-alt-duration"),
alt_duration_unit: getOptionalString("f-alt-duration-unit"),
alt_rule_code: getOptionalString("f-alt-rule-code"),
anchor_alt: getOptionalString("f-anchor-alt"),
combine_op: getOptionalString("f-combine-op"),
rule_code: getOptionalString("f-rule-code"),
legal_source: getOptionalString("f-legal-source"),
deadline_notes: getInput("f-notes"),
deadline_notes_en: getInput("f-notes-en"),
priority: getInput("f-priority"),
is_court_set: getCheckbox("f-is-court-set"),
is_spawn: getCheckbox("f-is-spawn"),
spawn_label: getOptionalString("f-spawn-label"),
spawn_proceeding_type_id: getOptionalInt("f-spawn-proceeding"),
trigger_event_id: getOptionalInt("f-trigger"),
sequence_order: getOptionalInt("f-sequence") ?? 0,
};
if (validation.value !== undefined) {
payload.condition_expr = validation.value;
}
return payload;
}
async function doSaveDraft(reason: string) {
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
let payload: Record<string, unknown>;
try {
payload = buildPatchPayload();
} catch (e) {
msg.textContent = e instanceof Error ? e.message : String(e);
msg.className = "form-msg form-msg-error";
msg.style.display = "block";
return;
}
payload.reason = reason;
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
msg.textContent = body.error || (t("admin.rules.edit.action.save_draft.error") || "Speichern fehlgeschlagen.");
msg.className = "form-msg form-msg-error";
msg.style.display = "block";
return;
}
rule = await resp.json() as Rule;
closeActionModal();
populateForm();
updateLifecycleUI();
await loadAudit(true);
showFeedback(t("admin.rules.edit.action.save_draft.ok") || "Draft gespeichert.", false);
}
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}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
msg.textContent = body.error || (tDyn(`admin.rules.edit.action.${op}.error`) || "Aktion fehlgeschlagen.");
msg.className = "form-msg form-msg-error";
msg.style.display = "block";
return;
}
rule = await resp.json() as Rule;
closeActionModal();
populateForm();
updateLifecycleUI();
await loadAudit(true);
showFeedback(tDyn(`admin.rules.edit.action.${op}.ok`) || (t("admin.rules.edit.action.ok") || "Erledigt."), false);
}
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`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
msg.textContent = body.error || (t("admin.rules.edit.action.clone.error") || "Klonen fehlgeschlagen.");
msg.className = "form-msg form-msg-error";
msg.style.display = "block";
return;
}
const newRule = await resp.json() as Rule;
window.location.href = `/admin/rules/${encodeURIComponent(newRule.id)}/edit`;
}
// --------------------------------------------------------------------
// Preview.
// --------------------------------------------------------------------
async function runPreview() {
const out = document.getElementById("preview-result") as HTMLElement;
if (!rule) return;
if (rule.lifecycle_state !== "draft") {
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(t("admin.rules.edit.preview.only_drafts") || "Preview ist nur für Drafts verfügbar.")}</p>`;
out.style.display = "";
return;
}
const triggerDate = getInput("preview-trigger-date");
if (!triggerDate) {
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(t("admin.rules.edit.preview.trigger_required") || "Bitte Trigger-Datum angeben.")}</p>`;
out.style.display = "";
return;
}
const flagsRaw = getInput("preview-flags");
const qs = new URLSearchParams();
qs.set("trigger_date", triggerDate);
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()}`);
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>`;
return;
}
const body = await resp.json();
renderPreview(body);
}
function renderPreview(resp: unknown) {
const out = document.getElementById("preview-result") as HTMLElement;
type Result = { deadlines?: Array<{ name?: string; titleDE?: string; due_date?: string; dueDate?: string; ruleCode?: string; rule_code?: string }>; deadline?: Array<unknown> };
const r = resp as Result;
const list = (r && (r.deadlines || r.deadline)) as Array<Record<string, unknown>> | undefined;
if (!list || list.length === 0) {
out.innerHTML = `<p class="admin-rules-hint">${esc(t("admin.rules.edit.preview.empty") || "Keine Deadlines.")}</p>`;
return;
}
out.innerHTML = `<ul class="admin-rules-preview-list">${list.map((d) => {
const name = String(d.name || d.titleDE || d.title || "");
const date = String(d.due_date || d.dueDate || "");
const code = String(d.rule_code || d.ruleCode || "");
return `<li>
${code ? `<code>${esc(code)}</code>` : ""}
<span class="admin-rules-preview-name">${esc(name)}</span>
<span class="admin-rules-preview-date">${esc(date)}</span>
</li>`;
}).join("")}</ul>`;
}
// --------------------------------------------------------------------
// Init.
// --------------------------------------------------------------------
async function init() {
initI18n();
initSidebar();
ruleId = parseRuleIDFromPath();
if (!ruleId) {
showFeedback(t("admin.rules.edit.error.bad_id") || "Ungültige Regel-ID in der URL.", true);
return;
}
(document.getElementById("rules-action-modal-close") as HTMLElement).addEventListener("click", closeActionModal);
(document.getElementById("rules-action-modal-cancel") as HTMLElement).addEventListener("click", closeActionModal);
(document.getElementById("rules-action-modal-form") as HTMLFormElement).addEventListener("submit", submitActionModal);
(document.getElementById("action-save-draft") as HTMLElement).addEventListener("click", () => openActionModal("save-draft"));
(document.getElementById("action-publish") as HTMLElement).addEventListener("click", () => openActionModal("publish"));
(document.getElementById("action-clone") as HTMLElement).addEventListener("click", () => openActionModal("clone"));
(document.getElementById("action-archive") as HTMLElement).addEventListener("click", () => openActionModal("archive"));
(document.getElementById("action-restore") as HTMLElement).addEventListener("click", () => openActionModal("restore"));
(document.getElementById("f-is-spawn") as HTMLInputElement).addEventListener("change", toggleSpawnRow);
(document.getElementById("f-condition-expr") as HTMLTextAreaElement).addEventListener("input", () => {
validateConditionExpr();
});
(document.getElementById("preview-run") as HTMLElement).addEventListener("click", () => {
window.clearTimeout(previewDebounce);
previewDebounce = window.setTimeout(runPreview, 100);
});
(document.getElementById("audit-loadmore") as HTMLElement).addEventListener("click", () => loadAudit(false));
await Promise.all([loadProceedings(), loadTriggers()]);
await loadRule();
await loadAudit(true);
onLangChange(() => {
if (rule) {
populateForm();
updateLifecycleUI();
}
renderAudit();
});
}
document.addEventListener("DOMContentLoaded", init);

View File

@@ -0,0 +1,100 @@
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

@@ -0,0 +1,524 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// admin-rules-list.ts — /admin/rules. 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
// "Pick" affordance with an inline reason prompt that posts to
// /admin/api/orphans/{id}/resolve.
interface Rule {
id: string;
proceeding_type_id?: number | 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`).
submission_code?: string | null;
rule_code?: string | null;
name: string;
name_en: string;
priority: string;
lifecycle_state: string;
updated_at: string;
trigger_event_id?: number | null;
duration_value: number;
duration_unit: string;
}
interface ProceedingType {
id: number;
code: string;
name_de: string;
name_en: string;
category: string;
}
interface TriggerEvent {
id: number;
code: string;
name: string;
name_de: string;
}
interface OrphanCandidate {
id: string;
rule_code?: string | null;
name: string;
name_en: string;
}
interface Orphan {
id: string;
deadline_id: string;
title: string;
project_id?: string | null;
project_title?: string | null;
proceeding_code?: string | null;
reason: string;
candidate_count: number;
candidate_ids: string[];
candidates: OrphanCandidate[];
created_at: string;
}
let rules: Rule[] = [];
let orphans: Orphan[] = [];
let proceedings: ProceedingType[] = [];
let triggerEvents: TriggerEvent[] = [];
let activeProceeding = "";
let activeTrigger = "";
let activeLifecycle = "";
let activeQuery = "";
let searchDebounce: number | undefined;
function esc(s: string | null | undefined): string {
const d = document.createElement("div");
d.textContent = s ?? "";
return d.innerHTML;
}
function fmtDateTime(iso: string): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const locale = getLang() === "de" ? "de-DE" : "en-GB";
return d.toLocaleString(locale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function showFeedback(msg: string, isError: boolean) {
const el = document.getElementById("rules-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);
}
}
function lifecycleLabel(state: string): string {
return tDyn(`admin.rules.lifecycle.${state}`) || state;
}
function lifecycleClass(state: string): string {
switch (state) {
case "draft": return "admin-rules-pill admin-rules-pill-draft";
case "published": return "admin-rules-pill admin-rules-pill-published";
case "archived": return "admin-rules-pill admin-rules-pill-archived";
default: return "admin-rules-pill";
}
}
function priorityLabel(p: string): string {
return tDyn(`admin.rules.priority.${p}`) || p;
}
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;
return `${pt.code} · ${name}`;
}
function buildFilterURL(): string {
const qs = new URLSearchParams();
if (activeProceeding) qs.set("proceeding_type_id", activeProceeding);
if (activeTrigger) qs.set("trigger_event_id", activeTrigger);
if (activeLifecycle) qs.set("lifecycle_state", activeLifecycle);
if (activeQuery) qs.set("q", activeQuery);
qs.set("limit", "500");
return "/admin/api/rules?" + qs.toString();
}
async function loadProceedings(): Promise<void> {
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
if (!resp.ok) return;
proceedings = (await resp.json()) as ProceedingType[];
const sel = document.getElementById("rules-filter-proceeding") as HTMLSelectElement | null;
if (!sel) return;
// Preserve the "Alle" placeholder option then append every proceeding.
// The placeholder is the one with empty value already in the markup.
const placeholder = sel.querySelector('option[value=""]');
sel.innerHTML = "";
if (placeholder) sel.appendChild(placeholder);
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}`;
sel.appendChild(opt);
}
}
async function loadTriggerEvents(): Promise<void> {
const resp = await fetch("/api/tools/trigger-events");
if (!resp.ok) return;
triggerEvents = (await resp.json()) as TriggerEvent[];
const sel = document.getElementById("rules-filter-trigger") as HTMLSelectElement | null;
if (!sel) return;
const placeholder = sel.querySelector('option[value=""]');
sel.innerHTML = "";
if (placeholder) sel.appendChild(placeholder);
for (const te of triggerEvents) {
const opt = document.createElement("option");
opt.value = String(te.id);
opt.textContent = `${te.code} · ${getLang() === "en" ? te.name : te.name_de}`;
sel.appendChild(opt);
}
}
async function loadRules(): Promise<void> {
const resp = await fetch(buildFilterURL());
if (!resp.ok) {
showFeedback(t("admin.rules.error.load") || "Konnte Regeln nicht laden.", true);
rules = [];
return;
}
const body = await resp.json();
rules = Array.isArray(body) ? body as Rule[] : [];
}
async function loadOrphans(): Promise<void> {
const resp = await fetch("/admin/api/orphans");
if (!resp.ok) {
orphans = [];
return;
}
const body = await resp.json();
orphans = Array.isArray(body) ? body as Orphan[] : [];
updateOrphansBadge();
}
function updateOrphansBadge() {
const badge = document.getElementById("rules-orphans-badge") as HTMLElement | null;
if (!badge) return;
if (orphans.length === 0) {
badge.style.display = "none";
} else {
badge.style.display = "";
badge.textContent = String(orphans.length);
}
}
function renderRulesTable() {
const tbody = document.getElementById("rules-tbody") as HTMLElement | null;
const empty = document.getElementById("rules-empty") as HTMLElement | null;
if (!tbody || !empty) return;
if (rules.length === 0) {
tbody.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
const name = (r: Rule) => (getLang() === "en" ? r.name_en : r.name) || r.name;
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-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>
</tr>
`).join("");
tbody.querySelectorAll<HTMLElement>(".admin-rules-row").forEach((row) => {
row.addEventListener("click", (ev) => {
const target = ev.target as HTMLElement | null;
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`;
});
});
}
function renderOrphans() {
const list = document.getElementById("rules-orphans-list") as HTMLElement | null;
if (!list) return;
if (orphans.length === 0) {
list.innerHTML = `<p class="entity-empty" data-i18n="admin.rules.orphans.empty">${esc(t("admin.rules.orphans.empty") || "Keine offenen Orphans. ✔")}</p>`;
return;
}
list.innerHTML = orphans.map((o) => renderOrphanCard(o)).join("");
list.querySelectorAll<HTMLButtonElement>(".admin-rules-orphan-pick").forEach((btn) => {
btn.addEventListener("click", () => {
const orphanId = btn.dataset.orphanId!;
const ruleId = btn.dataset.ruleId!;
onPickOrphanCandidate(orphanId, ruleId);
});
});
}
function renderOrphanCard(o: Orphan): string {
const reasonLabel = tDyn(`admin.rules.orphans.reason.${o.reason}`) || o.reason;
const meta = [
o.project_title ? `<span class="admin-rules-orphan-meta">${esc(t("admin.rules.orphans.field.project") || "Projekt")}: ${esc(o.project_title)}</span>` : "",
o.proceeding_code ? `<span class="admin-rules-orphan-meta">${esc(t("admin.rules.orphans.field.proceeding") || "Verfahren")}: <code>${esc(o.proceeding_code)}</code></span>` : "",
`<span class="admin-rules-orphan-meta">${esc(t("admin.rules.orphans.field.reason") || "Grund")}: ${esc(reasonLabel)}</span>`,
].filter(Boolean).join(" · ");
let candidatesHTML = "";
if (o.candidates.length === 0) {
candidatesHTML = `<p class="admin-rules-orphan-empty">${esc(t("admin.rules.orphans.no_candidates") || "Keine Kandidaten gefunden. Bitte Regel manuell anlegen.")}</p>`;
} else {
candidatesHTML = `<div class="admin-rules-orphan-candidates">
${o.candidates.map((c) => {
const cname = getLang() === "en" ? c.name_en : c.name;
return `<button type="button" class="admin-rules-orphan-pick"
data-orphan-id="${esc(o.id)}" data-rule-id="${esc(c.id)}">
<code>${esc(c.rule_code || "")}</code>
<span class="admin-rules-orphan-pick-name">${esc(cname)}</span>
</button>`;
}).join("")}
</div>`;
}
return `
<div class="admin-rules-orphan-card" data-orphan-id="${esc(o.id)}">
<div class="admin-rules-orphan-header">
<div class="admin-rules-orphan-title">${esc(o.title)}</div>
<div class="admin-rules-orphan-metas">${meta}</div>
</div>
${candidatesHTML}
</div>
`;
}
// --------------------------------------------------------------------
// Reason modal — shared between "+ Neue Regel" and orphan resolve.
// --------------------------------------------------------------------
type ModalContext =
| { kind: "new-rule" }
| { kind: "orphan-resolve"; orphanId: string; ruleId: string };
let modalCtx: ModalContext | null = null;
function openReasonModal(ctx: ModalContext) {
modalCtx = ctx;
const modal = document.getElementById("rules-reason-modal") as HTMLElement;
const title = document.getElementById("rules-reason-title") as HTMLElement;
const body = document.getElementById("rules-reason-body") as HTMLElement;
const extra = document.getElementById("rules-reason-extra") as HTMLElement;
const msg = document.getElementById("rules-reason-msg") as HTMLElement;
const reasonInput = document.getElementById("rules-reason-text") as HTMLTextAreaElement;
msg.style.display = "none";
reasonInput.value = "";
extra.innerHTML = "";
if (ctx.kind === "new-rule") {
title.textContent = t("admin.rules.modal.new.title") || "Neue Regel anlegen";
body.textContent = t("admin.rules.modal.new.body") || "Eine neue Regel wird als Draft angelegt. Bitte einen Grund angeben.";
extra.innerHTML = `
<div class="form-field">
<label for="rules-new-name" data-i18n="admin.rules.modal.field.name">Name (DE)</label>
<input type="text" id="rules-new-name" class="admin-rules-input" required minlength="2" />
</div>
<div class="form-field">
<label for="rules-new-name-en" data-i18n="admin.rules.modal.field.name_en">Name (EN)</label>
<input type="text" id="rules-new-name-en" class="admin-rules-input" required minlength="2" />
</div>
<div class="form-field">
<label for="rules-new-duration" data-i18n="admin.rules.modal.field.duration">Dauer</label>
<div class="admin-rules-duration-row">
<input type="number" id="rules-new-duration" class="admin-rules-input" min="0" value="0" required />
<select id="rules-new-unit" class="admin-rules-select">
<option value="days">days</option>
<option value="weeks">weeks</option>
<option value="months">months</option>
<option value="working_days">working_days</option>
</select>
</div>
</div>
`;
} else {
title.textContent = t("admin.rules.modal.resolve.title") || "Orphan zuordnen";
body.textContent = t("admin.rules.modal.resolve.body") || "Bitte einen Grund (mind. 10 Zeichen) angeben.";
}
modal.style.display = "flex";
reasonInput.focus();
}
function closeReasonModal() {
const modal = document.getElementById("rules-reason-modal") as HTMLElement;
modal.style.display = "none";
modalCtx = null;
}
async function submitReasonModal(ev: Event) {
ev.preventDefault();
if (!modalCtx) return;
const reasonInput = document.getElementById("rules-reason-text") as HTMLTextAreaElement;
const msg = document.getElementById("rules-reason-msg") as HTMLElement;
const submit = document.getElementById("rules-reason-submit") as HTMLButtonElement;
const reason = reasonInput.value.trim();
if (reason.length < 10) {
msg.textContent = t("admin.rules.modal.reason.too_short") || "Grund muss mindestens 10 Zeichen enthalten.";
msg.className = "form-msg form-msg-error";
msg.style.display = "block";
return;
}
submit.disabled = true;
try {
if (modalCtx.kind === "new-rule") {
const name = (document.getElementById("rules-new-name") as HTMLInputElement).value.trim();
const nameEn = (document.getElementById("rules-new-name-en") as HTMLInputElement).value.trim();
const duration = parseInt((document.getElementById("rules-new-duration") as HTMLInputElement).value, 10);
const unit = (document.getElementById("rules-new-unit") as HTMLSelectElement).value;
if (!name || !nameEn) {
msg.textContent = t("admin.rules.modal.error.name_required") || "Bitte Name und Name (EN) angeben.";
msg.className = "form-msg form-msg-error";
msg.style.display = "block";
submit.disabled = false;
return;
}
const resp = await fetch("/admin/api/rules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
name_en: nameEn,
duration_value: Number.isFinite(duration) ? duration : 0,
duration_unit: unit,
priority: "mandatory",
is_court_set: false,
is_spawn: false,
sequence_order: 0,
reason,
}),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
msg.textContent = body.error || t("admin.rules.modal.error.create") || "Anlegen fehlgeschlagen.";
msg.className = "form-msg form-msg-error";
msg.style.display = "block";
submit.disabled = false;
return;
}
const created = await resp.json();
window.location.href = `/admin/rules/${encodeURIComponent(created.id)}/edit`;
return;
}
if (modalCtx.kind === "orphan-resolve") {
const resp = await fetch(`/admin/api/orphans/${encodeURIComponent(modalCtx.orphanId)}/resolve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rule_id: modalCtx.ruleId, reason }),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
msg.textContent = body.error || t("admin.rules.modal.error.resolve") || "Zuordnung fehlgeschlagen.";
msg.className = "form-msg form-msg-error";
msg.style.display = "block";
submit.disabled = false;
return;
}
closeReasonModal();
showFeedback(t("admin.rules.orphans.resolved") || "Orphan zugeordnet.", false);
await loadOrphans();
renderOrphans();
}
} finally {
submit.disabled = false;
}
}
function onPickOrphanCandidate(orphanId: string, ruleId: string) {
openReasonModal({ kind: "orphan-resolve", orphanId, ruleId });
}
// --------------------------------------------------------------------
// Tabs + filter wiring.
// --------------------------------------------------------------------
function setActiveTab(name: "rules" | "orphans") {
const paneRules = document.getElementById("rules-pane-rules") as HTMLElement;
const paneOrphans = document.getElementById("rules-pane-orphans") as HTMLElement;
const tabRules = document.getElementById("rules-tab-rules") as HTMLElement;
const tabOrphans = document.getElementById("rules-tab-orphans") as HTMLElement;
if (name === "rules") {
paneRules.style.display = "";
paneOrphans.style.display = "none";
tabRules.classList.add("active");
tabOrphans.classList.remove("active");
} else {
paneRules.style.display = "none";
paneOrphans.style.display = "";
tabRules.classList.remove("active");
tabOrphans.classList.add("active");
renderOrphans();
}
}
function wireFilters() {
const proc = document.getElementById("rules-filter-proceeding") as HTMLSelectElement;
const trig = document.getElementById("rules-filter-trigger") as HTMLSelectElement;
const search = document.getElementById("rules-filter-search") as HTMLInputElement;
proc.addEventListener("change", async () => {
activeProceeding = proc.value;
await loadRules();
renderRulesTable();
});
trig.addEventListener("change", async () => {
activeTrigger = trig.value;
await loadRules();
renderRulesTable();
});
search.addEventListener("input", () => {
window.clearTimeout(searchDebounce);
searchDebounce = window.setTimeout(async () => {
activeQuery = search.value.trim();
await loadRules();
renderRulesTable();
}, 220);
});
document.querySelectorAll<HTMLButtonElement>("#rules-filter-lifecycle .admin-rules-chip").forEach((chip) => {
chip.addEventListener("click", async () => {
document.querySelectorAll(".admin-rules-chip").forEach((c) => c.classList.remove("active"));
chip.classList.add("active");
activeLifecycle = chip.dataset.state || "";
await loadRules();
renderRulesTable();
});
});
}
function wireTabs() {
(document.getElementById("rules-tab-rules") as HTMLElement).addEventListener("click", () => setActiveTab("rules"));
(document.getElementById("rules-tab-orphans") as HTMLElement).addEventListener("click", () => setActiveTab("orphans"));
}
function wireModal() {
(document.getElementById("rules-new-btn") as HTMLElement).addEventListener("click", () => openReasonModal({ kind: "new-rule" }));
(document.getElementById("rules-reason-cancel") as HTMLElement).addEventListener("click", closeReasonModal);
(document.getElementById("rules-reason-close") as HTMLElement).addEventListener("click", closeReasonModal);
(document.getElementById("rules-reason-form") as HTMLFormElement).addEventListener("submit", submitReasonModal);
}
async function init() {
initI18n();
initSidebar();
wireFilters();
wireTabs();
wireModal();
await Promise.all([loadProceedings(), loadTriggerEvents()]);
await Promise.all([loadRules(), loadOrphans()]);
renderRulesTable();
// Re-render proceeding labels when language changes
onLangChange(() => {
renderRulesTable();
renderOrphans();
});
}
document.addEventListener("DOMContentLoaded", init);

View File

@@ -728,6 +728,13 @@ function wireRowHandlers(tbody: HTMLElement) {
if (cb && !cb.disabled) {
cb.addEventListener("change", async () => {
if (!cb.checked) return;
const titleCell = row.querySelector<HTMLElement>(".events-title");
const title = (titleCell?.textContent || "").trim();
const msg = t("deadlines.complete.confirm").replace("{title}", title || "?");
if (!window.confirm(msg)) {
cb.checked = false;
return;
}
cb.disabled = true;
try {
const resp = await fetch(`/api/deadlines/${id}/complete`, { method: "PATCH" });

File diff suppressed because it is too large Load Diff

View File

@@ -198,6 +198,12 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.title": "Fristenrechner \u2014 Paliad",
"deadlines.heading": "Fristenrechner",
"deadlines.subtitle": "Berechnung von Verfahrensfristen f\u00fcr UPC-, deutsche und EPA-Verfahren.",
// Verfahrensablauf (t-paliad-179 Slice 1)
"tools.verfahrensablauf.title": "Verfahrensablauf \u2014 Paliad",
"tools.verfahrensablauf.heading": "Verfahrensablauf",
"tools.verfahrensablauf.subtitle": "Typischen Verfahrensablauf einsehen \u2014 Verfahrensart w\u00e4hlen, Datum optional setzen.",
"deadlines.step1": "Verfahrensart w\u00e4hlen",
"deadlines.step2": "Ausgangsdatum eingeben",
"deadlines.step3": "Ergebnis",
@@ -205,9 +211,9 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.de": "Deutsche Gerichte",
"deadlines.epa": "EPA",
"deadlines.dpma": "DPMA",
"deadlines.dpma_opp": "Einspruch DPMA",
"deadlines.dpma_bpatg_beschwerde": "Beschwerde BPatG (DPMA)",
"deadlines.dpma_bgh_rb": "Rechtsbeschwerde BGH",
"deadlines.dpma.opp.dpma": "Einspruch DPMA",
"deadlines.dpma.appeal.bpatg": "Beschwerde BPatG (DPMA)",
"deadlines.dpma.appeal.bgh": "Rechtsbeschwerde BGH",
"deadlines.trigger.event": "Ausl\u00f6sendes Ereignis:",
"deadlines.trigger.date": "Datum:",
"deadlines.trigger.label": "Ausgangsdatum",
@@ -220,22 +226,25 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.calculate": "Fristen berechnen",
"deadlines.print": "Drucken",
"deadlines.reset": "\u2190 Neu berechnen",
"deadlines.upc_inf": "Verletzungsverfahren",
"deadlines.upc_rev": "Nichtigkeitsklage",
"deadlines.upc_pi": "Einstw. Ma\u00dfnahmen",
"deadlines.upc_app": "Berufung",
"deadlines.upc_damages": "Schadensbemessung",
"deadlines.upc_discovery": "Bucheinsicht",
"deadlines.upc_cost_appeal": "Berufung Kosten",
"deadlines.upc_app_orders": "Berufung Anordnungen",
"deadlines.de_inf": "Verletzungsklage (LG)",
"deadlines.de_inf_olg": "Berufung OLG",
"deadlines.de_inf_bgh": "Revision/NZB BGH",
"deadlines.de_null": "Nichtigkeitsverfahren",
"deadlines.de_null_bgh": "Berufung BGH (Nichtigk.)",
"deadlines.epa_opp": "Einspruchsverfahren",
"deadlines.epa_app": "Beschwerdeverfahren",
"deadlines.ep_grant": "EP-Erteilungsverfahren",
"deadlines.upc.inf.cfi": "Verletzungsverfahren",
"deadlines.upc.rev.cfi": "Nichtigkeitsklage",
"deadlines.upc.ccr.cfi": "Widerklage auf Nichtigkeit",
"deadlines.upc.pi.cfi": "Einstw. Ma\u00dfnahmen",
"deadlines.upc.apl.merits": "Berufung",
"deadlines.upc.dmgs.cfi": "Schadensbemessung",
"deadlines.upc.disc.cfi": "Bucheinsicht",
"deadlines.upc.apl.cost": "Berufung Kosten",
"deadlines.upc.apl.order": "Berufung Anordnungen",
"deadlines.de.group.inf": "Verletzungsverfahren",
"deadlines.de.group.null": "Nichtigkeitsverfahren",
"deadlines.de.inf.lg": "LG (1. Instanz)",
"deadlines.de.inf.olg": "OLG (Berufung)",
"deadlines.de.inf.bgh": "BGH (Revision / NZB)",
"deadlines.de.null.bpatg": "BPatG (1. Instanz)",
"deadlines.de.null.bgh": "BGH (Berufung)",
"deadlines.epa.opp.opd": "Einspruchsverfahren",
"deadlines.epa.opp.boa": "Beschwerdeverfahren",
"deadlines.epa.grant.exa": "EP-Erteilungsverfahren",
"deadlines.party.claimant": "Kl\u00e4ger",
"deadlines.party.defendant": "Beklagter",
"deadlines.party.court": "Gericht",
@@ -244,6 +253,17 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.court.set": "vom Gericht bestimmt",
"deadlines.court.indirect": "unbestimmt",
"deadlines.optional.badge": "auf Antrag",
"deadlines.priority.mandatory": "Pflicht",
"deadlines.priority.recommended": "empfohlen",
"deadlines.priority.optional": "Kann (auf Antrag)",
"deadlines.priority.informational": "Zur Kenntnis",
"deadlines.priority.informational.notice_label": "Hinweis",
"project.instance_level.first": "Erste Instanz",
"project.instance_level.appeal": "Berufung",
"project.instance_level.cassation": "Revision",
"project.instance_level.unset": "(nicht gesetzt)",
"verlauf.spawn.chip": "Spawnt:",
"verlauf.spawn.cycle_warning": "Einige proceeding-übergreifende Spawn-Regeln wurden wegen eines Zyklus übersprungen.",
"deadlines.proceeding.selected": "Verfahren:",
"deadlines.proceeding.reselect": "Anderes Verfahren wählen",
"deadlines.step1.heading": "Schritt 1 — Welche Akte?",
@@ -280,6 +300,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.label": "Ansicht:",
"deadlines.view.timeline": "Zeitstrahl",
"deadlines.view.columns": "Spalten",
"deadlines.notes.show": "Hinweise anzeigen",
"deadlines.col.proactive": "Proaktiv",
"deadlines.col.court": "Gericht",
"deadlines.col.reactive": "Reaktiv",
@@ -353,6 +374,19 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.pathway.b.tree.empty": "Keine Treffer für diesen Pfad.",
"deadlines.pathway.b.tree.reset": "Neu starten",
"deadlines.pathway.b.tree.start_question": "Was ist passiert?",
"deadlines.row.mode.question": "Wie suchen?",
"deadlines.row.edit": "ändern",
"deadlines.row.prefilled.from_akte": "aus Akte",
"deadlines.row.reset": "Pfad zurücksetzen",
"deadlines.row.reset.title": "Pfad zurücksetzen — alle Cascade-Antworten verwerfen",
"deadlines.row.search.link": "Direkt suchen",
"deadlines.row.search.link.title": "Direkt nach einer Frist suchen — überspringt den Entscheidungsbaum",
"deadlines.row.autowalk.tooltip": "Diese Schritte ergeben sich aus Ihrer Akte. Klicken Sie „ändern\", um eine Antwort manuell anzupassen.",
"deadlines.row.autowalk.dismiss": "Hinweis schließen",
"deadlines.row.search.panel.back": "Zurück zum Entscheidungsbaum",
"deadlines.row.search.panel.back.title": "Inline-Suche schließen und zum Entscheidungsbaum zurückkehren",
"deadlines.row.search.panel.placeholder": "Frist suchen — z. B. „Klageschrift\", „Posteingang Hinweisbeschluss\"…",
"deadlines.row.search.panel.clear": "Eingabe leeren",
"deadlines.inbox.label": "Wo kam es an?",
"deadlines.inbox.cms.title": "UPC — über CMS",
"deadlines.inbox.bea.title": "Nationale Verfahren — über beA",
@@ -694,6 +728,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.urgency.soon": "In K\u00fcrze",
"deadlines.urgency.later": "Sp\u00e4ter",
"deadlines.complete.action": "Erledigen",
"deadlines.complete.confirm": "Frist \u201e{title}\u201c wirklich als erledigt markieren?",
// t-paliad-139 \u2014 subtree aggregation toggle and attribution chip
"aggregation.toggle.subtree": "Inkl. Unterprojekte",
@@ -805,6 +840,18 @@ const translations: Record<Lang, Record<string, string>> = {
"cal.month.9": "Oktober",
"cal.month.10": "November",
"cal.month.11": "Dezember",
"cal.view.month": "Monat",
"cal.view.week": "Woche",
"cal.view.day": "Tag",
"cal.month.prev": "Vorheriger Monat",
"cal.month.next": "Nächster Monat",
"cal.week.prev": "Vorherige Woche",
"cal.week.next": "Nächste Woche",
"cal.day.prev": "Vorheriger Tag",
"cal.day.next": "Nächster Tag",
"cal.day.back_to_month": "Zurück zum Monat",
"cal.day.open_day": "Tagesansicht öffnen",
"cal.day.no_entries": "Keine Einträge an diesem Tag.",
// Akten detail — Fristen tab (Phase E)
@@ -1119,9 +1166,9 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.field.title.placeholder": "z.B. Siemens AG | Siemens v. Huawei | EP 1 234 567",
"projects.field.reference": "Interne Referenz (optional)",
"projects.field.reference.placeholder": `z.B. ${FIRM}-2026-0042`,
"projects.field.client_number": "Client-Nr. (7 Ziffern)",
"projects.field.matter_number": "Matter-Nr. (7 Ziffern)",
"projects.field.clientmatter.hint": `${FIRM}-Billing-Nummern. Format CCCCCCC.MMMMMMM. Client-Nr. wird an Unterprojekte vererbt (\u00fcberschreibbar).`,
"projects.field.client_number": "Client-Nr. (6 Ziffern)",
"projects.field.matter_number": "Matter-Nr. (6 Ziffern)",
"projects.field.clientmatter.hint": `${FIRM}-Billing-Nummern. Format CCCCCC.MMMMMM. Client-Nr. wird an Unterprojekte vererbt (\u00fcberschreibbar).`,
"projects.field.billing_reference": "Billing-Referenz (optional)",
"projects.field.netdocuments_url": "netDocuments-URL (optional)",
"projects.field.industry": "Branche",
@@ -1150,6 +1197,44 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.back": "\u2190 Zur\u00fcck zur \u00dcbersicht",
"projects.detail.loading": "L\u00e4dt\u2026",
"projects.detail.notfound": "Projekt nicht gefunden oder keine Berechtigung.",
"projects.detail.smarttimeline.open_chart": "Als Chart anzeigen \u2197",
"projects.chart.title": "Projekt-Chart \u2014 Paliad",
"projects.chart.back": "\u2190 Zur\u00fcck zum Verlauf",
"projects.chart.loading": "L\u00e4dt\u2026",
"projects.chart.notfound": "Projekt nicht gefunden oder keine Berechtigung.",
"projects.chart.error.mount": "Chart konnte nicht initialisiert werden.",
"projects.chart.control.layout.horizontal": "Layout: Horizontal",
"projects.chart.control.columns.auto": "Spalten: Auto",
"projects.chart.control.density.standard": "Dichte: Standard",
"projects.chart.control.palette.default": "Palette: Standard",
"projects.chart.control.export.soon": "Export \u2193 (Slice 2)",
"projects.chart.control.palette.label": "Palette:",
"projects.chart.palette.default": "Standard",
"projects.chart.palette.kind_coded": "Nach Ereignistyp",
"projects.chart.palette.track_coded": "Nach Spur",
"projects.chart.palette.high_contrast": "Hoher Kontrast",
"projects.chart.palette.print": "Druck (S/W)",
"projects.chart.control.density.label": "Dichte:",
"projects.chart.density.compact": "Kompakt",
"projects.chart.density.standard": "Standard",
"projects.chart.density.spacious": "Großzügig",
"projects.chart.control.range.label": "Zeitraum:",
"projects.chart.range.1y": "1 Jahr",
"projects.chart.range.2y": "2 Jahre",
"projects.chart.range.all": "Alles anzeigen",
"projects.chart.range.custom": "Eigener Bereich…",
"projects.chart.range.from": "Von:",
"projects.chart.range.to": "Bis:",
"projects.chart.permalink.copy": "🔗 Link kopieren",
"projects.chart.permalink.title": "URL mit allen Filtern in die Zwischenablage kopieren",
"nav.context.project_chart": "Als Chart anzeigen",
"projects.chart.export.menu": "⇓ Export",
"projects.chart.export.svg": "SVG (Vektorgrafik)",
"projects.chart.export.png": "PNG (Bild, 2× HiDPI)",
"projects.chart.export.print": "PDF (Drucken)",
"projects.chart.export.csv": "CSV (Excel-Tabelle)",
"projects.chart.export.json": "JSON (Rohdaten)",
"projects.chart.export.ics": "iCal (.ics — Outlook / Apple)",
"projects.detail.edit": "Bearbeiten",
"projects.detail.edit.modal.title": "Projekt bearbeiten",
"projects.detail.save": "Speichern",
@@ -2133,6 +2218,9 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.error.concurrent_pending": "Es liegt bereits eine Genehmigungsanfrage auf diesem Eintrag vor.",
"approvals.error.awaiting_approval": "Diese Anforderung wartet auf Genehmigung.",
"approvals.error.request_not_pending": "Diese Anfrage ist nicht mehr offen.",
"approvals.disabled.self_approval": "Du kannst eigene Anträge nicht genehmigen",
"approvals.disabled.not_authorized": "Du hast keine Genehmigungsberechtigung für diesen Antrag",
"approvals.disabled.revoke_not_requester": "Nur der Antragsteller kann zurückziehen",
"approvals.pending.badge": "Wartet auf Genehmigung",
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
@@ -2162,6 +2250,14 @@ const translations: Record<Lang, Record<string, string>> = {
"views.shape.list": "Liste",
"views.shape.cards": "Karten",
"views.shape.calendar": "Kalender",
"views.shape.timeline": "Timeline",
"views.timeline.caveat.body": "Custom Views zeigen nur eingetretene Ereignisse. Für prognostizierte Fristen das Projekt-Chart öffnen.",
"views.timeline.zoom.label": "Zoom",
"views.timeline.zoom.in": "Heranzoomen",
"views.timeline.zoom.out": "Herauszoomen",
"views.timeline.zoom.1y": "±1 J.",
"views.timeline.zoom.2y": "±2 J.",
"views.timeline.zoom.all": "Alles",
"views.save_as": "Als Ansicht speichern",
"views.action.edit": "Bearbeiten",
"views.empty.title": "Keine Einträge gefunden.",
@@ -2320,6 +2416,195 @@ const translations: Record<Lang, Record<string, string>> = {
"views.bar.save.error.slug_format": "Slug muss mit einem Buchstaben oder einer Ziffer beginnen und darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.",
"views.bar.save.error.slug_taken": "Dieser Slug ist bereits vergeben.",
"views.bar.save.error.network": "Netzwerkfehler — bitte erneut versuchen.",
// t-paliad-192 Slice 11b — Admin rule-editor UI.
"nav.admin.rules": "Regeln verwalten",
"nav.admin.rules_export": "Regel-Migrations",
"admin.card.rules.title": "Regeln verwalten",
"admin.card.rules.desc": "Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
"admin.rules.list.title": "Regeln verwalten — Paliad",
"admin.rules.list.heading": "Regeln verwalten",
"admin.rules.list.subtitle": "Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.",
"admin.rules.list.new": "+ Neue Regel",
"admin.rules.list.export": "Migrations exportieren",
"admin.rules.tab.rules": "Regeln",
"admin.rules.tab.orphans": "Orphans",
"admin.rules.loading": "Lade…",
"admin.rules.empty": "Keine Regeln für die gewählten Filter.",
"admin.rules.error.load": "Konnte Regeln nicht laden.",
"admin.rules.filter.proceeding": "Verfahrenstyp",
"admin.rules.filter.proceeding.any": "Alle",
"admin.rules.filter.trigger": "Trigger-Ereignis",
"admin.rules.filter.trigger.any": "Alle",
"admin.rules.filter.lifecycle": "Lifecycle",
"admin.rules.filter.lifecycle.any": "Alle",
"admin.rules.filter.search": "Suche",
"admin.rules.filter.search.placeholder": "Name, Submission Code, Rechtsgrundlage…",
"admin.rules.col.submission_code": "Submission Code / Einreichung-Kennung",
"admin.rules.col.legal_citation": "Rechtsgrundlage",
"admin.rules.col.name": "Name",
"admin.rules.col.proceeding": "Verfahrenstyp",
"admin.rules.col.priority": "Priorität",
"admin.rules.col.lifecycle": "Lifecycle",
"admin.rules.col.modified": "Zuletzt geändert",
"admin.rules.lifecycle.draft": "Draft",
"admin.rules.lifecycle.published": "Published",
"admin.rules.lifecycle.archived": "Archived",
"admin.rules.priority.mandatory": "Pflicht",
"admin.rules.priority.recommended": "Empfohlen",
"admin.rules.priority.optional": "Optional",
"admin.rules.priority.informational": "Information",
"admin.rules.orphans.subtitle": "Legacy-Deadlines aus dem fuzzy-match Backfill (Slice 10), die nicht eindeutig einer Regel zugeordnet werden konnten. Bitte die richtige Kandidaten-Regel auswählen.",
"admin.rules.orphans.loading": "Lade…",
"admin.rules.orphans.empty": "Keine offenen Orphans. ✔",
"admin.rules.orphans.no_candidates": "Keine Kandidaten gefunden. Bitte Regel manuell anlegen.",
"admin.rules.orphans.field.project": "Projekt",
"admin.rules.orphans.field.proceeding": "Verfahren",
"admin.rules.orphans.field.reason": "Grund",
"admin.rules.orphans.reason.no_match": "Kein Treffer",
"admin.rules.orphans.reason.ambiguous": "Mehrdeutig",
"admin.rules.orphans.reason.no_project": "Ohne Projekt",
"admin.rules.orphans.reason.manual_unbound": "Manuell entkoppelt",
"admin.rules.orphans.resolved": "Orphan zugeordnet.",
"admin.rules.modal.new.title": "Neue Regel anlegen",
"admin.rules.modal.new.body": "Eine neue Regel wird als Draft angelegt. Bitte einen Grund (mind. 10 Zeichen) angeben — dieser wandert ins Audit-Log und beim Export in die Migration.",
"admin.rules.modal.resolve.title": "Orphan zuordnen",
"admin.rules.modal.resolve.body": "Bitte einen Grund (mind. 10 Zeichen) angeben. Die Regel-Verknüpfung wird sofort auf der Deadline gespeichert.",
"admin.rules.modal.reason": "Grund",
"admin.rules.modal.reason.placeholder": "z. B. „Neue Regel für RoP.198 nach UPC-Reform 2026...",
"admin.rules.modal.reason.hint": "Mindestens 10 Zeichen.",
"admin.rules.modal.reason.too_short": "Grund muss mindestens 10 Zeichen enthalten.",
"admin.rules.modal.confirm": "Bestätigen",
"admin.rules.modal.field.name": "Name (DE)",
"admin.rules.modal.field.name_en": "Name (EN)",
"admin.rules.modal.field.duration": "Dauer",
"admin.rules.modal.error.name_required": "Bitte Name und Name (EN) angeben.",
"admin.rules.modal.error.create": "Anlegen fehlgeschlagen.",
"admin.rules.modal.error.resolve": "Zuordnung fehlgeschlagen.",
"admin.rules.edit.title": "Regel bearbeiten — Paliad",
"admin.rules.edit.heading.loading": "Regel laden…",
"admin.rules.edit.breadcrumb": "← Regeln verwalten",
"admin.rules.edit.error.bad_id": "Ungültige Regel-ID in der URL.",
"admin.rules.edit.error.not_found": "Regel nicht gefunden.",
"admin.rules.edit.error.load": "Konnte Regel nicht laden.",
"admin.rules.edit.section.identity": "Identität",
"admin.rules.edit.section.proceeding": "Verfahren & Trigger",
"admin.rules.edit.section.timing": "Berechnung",
"admin.rules.edit.section.party": "Partei & Ereignis",
"admin.rules.edit.section.display": "Anzeige & Notizen",
"admin.rules.edit.section.lifecycle": "Priorität & Flags",
"admin.rules.edit.section.condition": "Bedingung (condition_expr)",
"admin.rules.edit.field.name": "Name (DE)",
"admin.rules.edit.field.name_en": "Name (EN)",
"admin.rules.edit.field.description": "Beschreibung",
"admin.rules.edit.field.submission_code": "Submission Code / Einreichung-Kennung",
"admin.rules.edit.field.rule_code": "Rechtsgrundlage (Kurzform)",
"admin.rules.edit.field.legal_source": "Rechtsgrundlage (Langform)",
"admin.rules.edit.field.proceeding": "Verfahrenstyp",
"admin.rules.edit.field.proceeding.none": "—",
"admin.rules.edit.field.trigger": "Trigger-Ereignis",
"admin.rules.edit.field.trigger.none": "—",
"admin.rules.edit.field.parent": "Parent-Regel (UUID)",
"admin.rules.edit.field.concept": "Konzept (UUID)",
"admin.rules.edit.field.sequence_order": "Reihenfolge",
"admin.rules.edit.field.duration_value": "Dauer",
"admin.rules.edit.field.duration_unit": "Einheit",
"admin.rules.edit.field.timing": "Timing",
"admin.rules.edit.field.combine_op": "Combine-Op",
"admin.rules.edit.field.alt_duration_value": "Alt-Dauer",
"admin.rules.edit.field.alt_duration_unit": "Alt-Einheit",
"admin.rules.edit.field.alt_rule_code": "Alt-Rule-Code",
"admin.rules.edit.field.anchor_alt": "Alt-Anchor",
"admin.rules.edit.field.primary_party": "Primäre Partei",
"admin.rules.edit.field.event_type": "Event-Typ (frei)",
"admin.rules.edit.field.deadline_notes": "Hinweise (DE)",
"admin.rules.edit.field.deadline_notes_en": "Hinweise (EN)",
"admin.rules.edit.field.priority": "Priorität",
"admin.rules.edit.field.is_court_set": "Gerichtlich gesetzt",
"admin.rules.edit.field.is_spawn": "Spawn",
"admin.rules.edit.field.spawn_label": "Spawn-Label",
"admin.rules.edit.field.spawn_proceeding": "Spawn-Verfahren",
"admin.rules.edit.field.spawn_proceeding.none": "—",
"admin.rules.edit.field.condition_hint": "JSON-Grammatik: {\"flag\":\"name\"} · {\"op\":\"and|or\",\"args\":[...]} · {\"op\":\"not\",\"args\":[...]}",
"admin.rules.edit.field.condition.valid": "JSON gültig.",
"admin.rules.edit.preview.heading": "Preview",
"admin.rules.edit.preview.hint": "Nur für Drafts. Berechnet die Fristenkette mit dieser Draft-Regel anstelle der publizierten Variante.",
"admin.rules.edit.preview.trigger_date": "Trigger-Datum",
"admin.rules.edit.preview.flags": "Flags (komma-separiert)",
"admin.rules.edit.preview.run": "Preview berechnen",
"admin.rules.edit.preview.running": "Berechne…",
"admin.rules.edit.preview.empty": "Keine Deadlines.",
"admin.rules.edit.preview.error": "Preview fehlgeschlagen.",
"admin.rules.edit.preview.only_drafts": "Preview ist nur für Drafts verfügbar.",
"admin.rules.edit.preview.trigger_required": "Bitte Trigger-Datum angeben.",
"admin.rules.edit.audit.heading": "Audit-Log",
"admin.rules.edit.audit.loading": "Lade…",
"admin.rules.edit.audit.empty": "Keine Audit-Einträge.",
"admin.rules.edit.audit.loadmore": "Weitere laden",
"admin.rules.edit.audit.exported": "exported",
"admin.rules.edit.audit.actor.system": "System",
"admin.rules.edit.audit.action.create": "create",
"admin.rules.edit.audit.action.update": "update",
"admin.rules.edit.audit.action.publish": "publish",
"admin.rules.edit.audit.action.archive": "archive",
"admin.rules.edit.audit.action.restore": "restore",
"admin.rules.edit.audit.action.delete": "delete",
"admin.rules.edit.action.save_draft": "Draft speichern",
"admin.rules.edit.action.publish": "Publish",
"admin.rules.edit.action.clone": "Als Draft klonen",
"admin.rules.edit.action.archive": "Archivieren",
"admin.rules.edit.action.restore": "Wiederherstellen",
"admin.rules.edit.action.ok": "Erledigt.",
"admin.rules.edit.action.save_draft.ok": "Draft gespeichert.",
"admin.rules.edit.action.save_draft.error": "Speichern fehlgeschlagen.",
"admin.rules.edit.action.publish.ok": "Regel publiziert.",
"admin.rules.edit.action.publish.error": "Publish fehlgeschlagen.",
"admin.rules.edit.action.archive.ok": "Regel archiviert.",
"admin.rules.edit.action.archive.error": "Archivieren fehlgeschlagen.",
"admin.rules.edit.action.restore.ok": "Regel wiederhergestellt.",
"admin.rules.edit.action.restore.error": "Wiederherstellen fehlgeschlagen.",
"admin.rules.edit.action.clone.error": "Klonen fehlgeschlagen.",
"admin.rules.edit.modal.save_draft.title": "Draft speichern",
"admin.rules.edit.modal.save_draft.body": "Bitte einen Grund für die Änderung angeben (mind. 10 Zeichen). Wird ins Audit-Log geschrieben.",
"admin.rules.edit.modal.publish.title": "Publish",
"admin.rules.edit.modal.publish.body": "Diese Draft-Regel wird live geschaltet. Bestehende publizierte Variante wird archiviert.",
"admin.rules.edit.modal.clone.title": "Als Draft klonen",
"admin.rules.edit.modal.clone.body": "Eine neue Draft-Kopie dieser Regel wird angelegt. Sie werden auf die neue Draft-Seite weitergeleitet.",
"admin.rules.edit.modal.archive.title": "Archivieren",
"admin.rules.edit.modal.archive.body": "Regel wird archiviert. Calculator nutzt sie nicht mehr.",
"admin.rules.edit.modal.restore.title": "Wiederherstellen",
"admin.rules.edit.modal.restore.body": "Regel wird wiederhergestellt (archived → published).",
"admin.rules.export.title": "Regel-Migrations exportieren — Paliad",
"admin.rules.export.heading": "Regel-Migrations exportieren",
"admin.rules.export.subtitle": "Generiert ein *.up.sql-Blob mit allen unsynchronisierten Audit-Veränderungen. Manuell in internal/db/migrations/ einchecken.",
"admin.rules.export.breadcrumb": "← Regeln verwalten",
"admin.rules.export.field.since": "Startend ab Audit-ID (optional)",
"admin.rules.export.run": "Export generieren",
"admin.rules.export.running": "Lade…",
"admin.rules.export.download": "Als Datei herunterladen",
"admin.rules.export.copy": "In Zwischenablage kopieren",
"admin.rules.export.copied": "In Zwischenablage kopiert.",
"admin.rules.export.copy_failed": "Kopieren fehlgeschlagen.",
"admin.rules.export.count": "Audit-Zeilen: {n}",
"admin.rules.export.latest": "Letzte Audit-ID: {id}",
"admin.rules.export.ok": "{n} Audit-Zeilen exportiert.",
"admin.rules.export.error": "Export fehlgeschlagen.",
"admin.rules.export.no_pending": "Keine offenen Audit-Zeilen zum Export.",
},
en: {
@@ -2501,6 +2786,12 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.title": "Deadline Calculator \u2014 Paliad",
"deadlines.heading": "Patent Deadline Calculator",
"deadlines.subtitle": "Calculate procedural deadlines for UPC, German, and EPA proceedings.",
// Verfahrensablauf (t-paliad-179 Slice 1)
"tools.verfahrensablauf.title": "Procedure Roadmap \u2014 Paliad",
"tools.verfahrensablauf.heading": "Procedure Roadmap",
"tools.verfahrensablauf.subtitle": "Browse the typical proceeding shape \u2014 pick a proceeding type, optionally set a trigger date.",
"deadlines.step1": "Select Proceeding Type",
"deadlines.step2": "Enter Trigger Date",
"deadlines.step3": "Result",
@@ -2508,9 +2799,9 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.de": "German Courts",
"deadlines.epa": "EPO",
"deadlines.dpma": "DPMA",
"deadlines.dpma_opp": "Opposition DPMA",
"deadlines.dpma_bpatg_beschwerde": "Appeal BPatG (DPMA)",
"deadlines.dpma_bgh_rb": "Legal Appeal BGH",
"deadlines.dpma.opp.dpma": "Opposition DPMA",
"deadlines.dpma.appeal.bpatg": "Appeal BPatG (DPMA)",
"deadlines.dpma.appeal.bgh": "Legal Appeal BGH",
"deadlines.trigger.event": "Trigger event:",
"deadlines.trigger.date": "Date:",
"deadlines.trigger.label": "Trigger date",
@@ -2523,22 +2814,25 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.calculate": "Calculate Deadlines",
"deadlines.print": "Print",
"deadlines.reset": "\u2190 Start Over",
"deadlines.upc_inf": "Infringement",
"deadlines.upc_rev": "Revocation",
"deadlines.upc_pi": "Provisional Measures",
"deadlines.upc_app": "Appeal",
"deadlines.upc_damages": "Damages Determination",
"deadlines.upc_discovery": "Lay-open Books",
"deadlines.upc_cost_appeal": "Cost-Decision Appeal",
"deadlines.upc_app_orders": "Order Appeal (15-day)",
"deadlines.de_inf": "Infringement (Regional Court)",
"deadlines.de_inf_olg": "Appeal OLG",
"deadlines.de_inf_bgh": "Revision / NZB BGH",
"deadlines.de_null": "Nullity",
"deadlines.de_null_bgh": "Appeal BGH (Nullity)",
"deadlines.epa_opp": "Opposition",
"deadlines.epa_app": "Appeal",
"deadlines.ep_grant": "Grant Procedure",
"deadlines.upc.inf.cfi": "Infringement",
"deadlines.upc.rev.cfi": "Revocation",
"deadlines.upc.ccr.cfi": "Counterclaim for Revocation",
"deadlines.upc.pi.cfi": "Provisional Measures",
"deadlines.upc.apl.merits": "Appeal",
"deadlines.upc.dmgs.cfi": "Damages Determination",
"deadlines.upc.disc.cfi": "Lay-open Books",
"deadlines.upc.apl.cost": "Cost-Decision Appeal",
"deadlines.upc.apl.order": "Order Appeal (15-day)",
"deadlines.de.group.inf": "Infringement proceedings",
"deadlines.de.group.null": "Nullity proceedings",
"deadlines.de.inf.lg": "LG (1st instance)",
"deadlines.de.inf.olg": "OLG (Appeal)",
"deadlines.de.inf.bgh": "BGH (Revision / NZB)",
"deadlines.de.null.bpatg": "BPatG (1st instance)",
"deadlines.de.null.bgh": "BGH (Appeal)",
"deadlines.epa.opp.opd": "Opposition",
"deadlines.epa.opp.boa": "Appeal",
"deadlines.epa.grant.exa": "Grant Procedure",
"deadlines.party.claimant": "Claimant",
"deadlines.party.defendant": "Defendant",
"deadlines.party.court": "Court",
@@ -2547,6 +2841,17 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.court.set": "set by court",
"deadlines.court.indirect": "tbd",
"deadlines.optional.badge": "on request",
"deadlines.priority.mandatory": "Mandatory",
"deadlines.priority.recommended": "Recommended",
"deadlines.priority.optional": "Optional (on request)",
"deadlines.priority.informational": "For information only",
"deadlines.priority.informational.notice_label": "Note",
"project.instance_level.first": "First instance",
"project.instance_level.appeal": "Appeal",
"project.instance_level.cassation": "Cassation",
"project.instance_level.unset": "(unset)",
"verlauf.spawn.chip": "Spawns into:",
"verlauf.spawn.cycle_warning": "Some cross-proceeding spawn rules were skipped due to a cycle.",
"deadlines.proceeding.selected": "Proceeding:",
"deadlines.proceeding.reselect": "Choose another proceeding",
"deadlines.step1.heading": "Step 1 — Which matter?",
@@ -2583,6 +2888,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.label": "View:",
"deadlines.view.timeline": "Timeline",
"deadlines.view.columns": "Columns",
"deadlines.notes.show": "Show details",
"deadlines.col.proactive": "Proactive",
"deadlines.col.court": "Court",
"deadlines.col.reactive": "Reactive",
@@ -2663,6 +2969,19 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.pathway.b.tree.empty": "No matches for this path.",
"deadlines.pathway.b.tree.reset": "Restart",
"deadlines.pathway.b.tree.start_question": "What happened?",
"deadlines.row.mode.question": "How to search?",
"deadlines.row.edit": "edit",
"deadlines.row.prefilled.from_akte": "from matter",
"deadlines.row.reset": "Reset path",
"deadlines.row.reset.title": "Reset path — discard all cascade answers",
"deadlines.row.search.link": "Search directly",
"deadlines.row.search.link.title": "Search directly for a deadline — skips the decision tree",
"deadlines.row.autowalk.tooltip": "These steps were derived from your matter. Click \"edit\" to override any answer manually.",
"deadlines.row.autowalk.dismiss": "Dismiss hint",
"deadlines.row.search.panel.back": "Back to decision tree",
"deadlines.row.search.panel.back.title": "Close inline search and return to the decision tree",
"deadlines.row.search.panel.placeholder": "Search for a deadline — e.g. \"statement of claim\", \"hint order\"…",
"deadlines.row.search.panel.clear": "Clear input",
"deadlines.inbox.label": "Where did it arrive?",
"deadlines.inbox.cms.title": "UPC — via CMS",
"deadlines.inbox.bea.title": "National-DE — via beA",
@@ -2997,6 +3316,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.urgency.soon": "Soon",
"deadlines.urgency.later": "Later",
"deadlines.complete.action": "Complete",
"deadlines.complete.confirm": "Mark deadline \u201c{title}\u201d as completed?",
// t-paliad-139 \u2014 subtree aggregation toggle and attribution chip
"aggregation.toggle.subtree": "Incl. sub-projects",
@@ -3108,6 +3428,18 @@ const translations: Record<Lang, Record<string, string>> = {
"cal.month.9": "October",
"cal.month.10": "November",
"cal.month.11": "December",
"cal.view.month": "Month",
"cal.view.week": "Week",
"cal.view.day": "Day",
"cal.month.prev": "Previous month",
"cal.month.next": "Next month",
"cal.week.prev": "Previous week",
"cal.week.next": "Next week",
"cal.day.prev": "Previous day",
"cal.day.next": "Next day",
"cal.day.back_to_month": "Back to month",
"cal.day.open_day": "Open day view",
"cal.day.no_entries": "Nothing scheduled this day.",
// Akten detail — Fristen tab (Phase E)
@@ -3410,9 +3742,9 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.field.title.placeholder": "e.g. Siemens AG | Siemens v. Huawei | EP 1 234 567",
"projects.field.reference": "Internal reference (optional)",
"projects.field.reference.placeholder": `e.g. ${FIRM}-2026-0042`,
"projects.field.client_number": "Client no. (7 digits)",
"projects.field.matter_number": "Matter no. (7 digits)",
"projects.field.clientmatter.hint": `${FIRM} billing numbers. Format CCCCCCC.MMMMMMM. Client no. is inherited by sub-projects (overridable).`,
"projects.field.client_number": "Client no. (6 digits)",
"projects.field.matter_number": "Matter no. (6 digits)",
"projects.field.clientmatter.hint": `${FIRM} billing numbers. Format CCCCCC.MMMMMM. Client no. is inherited by sub-projects (overridable).`,
"projects.field.billing_reference": "Billing reference (optional)",
"projects.field.netdocuments_url": "netDocuments URL (optional)",
"projects.field.industry": "Industry",
@@ -3441,6 +3773,44 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.back": "\u2190 Back to overview",
"projects.detail.loading": "Loading\u2026",
"projects.detail.notfound": "Project not found or no access.",
"projects.detail.smarttimeline.open_chart": "View as chart \u2197",
"projects.chart.title": "Project Chart \u2014 Paliad",
"projects.chart.back": "\u2190 Back to Activity",
"projects.chart.loading": "Loading\u2026",
"projects.chart.notfound": "Project not found or no access.",
"projects.chart.error.mount": "Chart could not be initialised.",
"projects.chart.control.layout.horizontal": "Layout: horizontal",
"projects.chart.control.columns.auto": "Columns: auto",
"projects.chart.control.density.standard": "Density: standard",
"projects.chart.control.palette.default": "Palette: default",
"projects.chart.control.export.soon": "Export \u2193 (Slice 2)",
"projects.chart.control.palette.label": "Palette:",
"projects.chart.palette.default": "Default",
"projects.chart.palette.kind_coded": "By event kind",
"projects.chart.palette.track_coded": "By track",
"projects.chart.palette.high_contrast": "High contrast",
"projects.chart.palette.print": "Print (B/W)",
"projects.chart.control.density.label": "Density:",
"projects.chart.density.compact": "Compact",
"projects.chart.density.standard": "Standard",
"projects.chart.density.spacious": "Spacious",
"projects.chart.control.range.label": "Range:",
"projects.chart.range.1y": "1 year",
"projects.chart.range.2y": "2 years",
"projects.chart.range.all": "Show all",
"projects.chart.range.custom": "Custom range…",
"projects.chart.range.from": "From:",
"projects.chart.range.to": "To:",
"projects.chart.permalink.copy": "🔗 Copy link",
"projects.chart.permalink.title": "Copy the URL with all filters to clipboard",
"nav.context.project_chart": "View as chart",
"projects.chart.export.menu": "⇓ Export",
"projects.chart.export.svg": "SVG (vector graphic)",
"projects.chart.export.png": "PNG (raster, 2× HiDPI)",
"projects.chart.export.print": "PDF (print)",
"projects.chart.export.csv": "CSV (Excel table)",
"projects.chart.export.json": "JSON (raw data)",
"projects.chart.export.ics": "iCal (.ics — Outlook / Apple)",
"projects.detail.edit": "Edit",
"projects.detail.edit.modal.title": "Edit project",
"projects.detail.save": "Save",
@@ -4420,6 +4790,9 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.error.concurrent_pending": "Another approval request is already in flight on this entity.",
"approvals.error.awaiting_approval": "This entity is awaiting approval.",
"approvals.error.request_not_pending": "This request is no longer open.",
"approvals.disabled.self_approval": "You cannot approve your own requests",
"approvals.disabled.not_authorized": "You are not authorized to approve this request",
"approvals.disabled.revoke_not_requester": "Only the requester can withdraw",
"approvals.pending.badge": "Awaiting approval",
"approvals.withdraw.cta": "Withdraw approval request",
"approvals.withdraw.confirm": "Withdraw the approval request?",
@@ -4449,6 +4822,14 @@ const translations: Record<Lang, Record<string, string>> = {
"views.shape.list": "List",
"views.shape.cards": "Cards",
"views.shape.calendar": "Calendar",
"views.shape.timeline": "Timeline",
"views.timeline.caveat.body": "Custom Views show actual events only. Open the project's chart for projected rules.",
"views.timeline.zoom.label": "Zoom",
"views.timeline.zoom.in": "Zoom in",
"views.timeline.zoom.out": "Zoom out",
"views.timeline.zoom.1y": "±1 yr",
"views.timeline.zoom.2y": "±2 yr",
"views.timeline.zoom.all": "All",
"views.save_as": "Save as view",
"views.action.edit": "Edit",
"views.empty.title": "No matches found.",
@@ -4606,6 +4987,195 @@ const translations: Record<Lang, Record<string, string>> = {
"views.bar.save.error.slug_format": "Slug must start with a letter or digit and contain only lowercase letters, digits, and hyphens.",
"views.bar.save.error.slug_taken": "This slug is already in use.",
"views.bar.save.error.network": "Network error — please retry.",
// t-paliad-192 Slice 11b — Admin rule-editor UI.
"nav.admin.rules": "Manage Rules",
"nav.admin.rules_export": "Rule Migrations",
"admin.card.rules.title": "Manage Rules",
"admin.card.rules.desc": "Author, edit and publish deadline rules. Audit log, preview, migration export.",
"admin.rules.list.title": "Manage Rules — Paliad",
"admin.rules.list.heading": "Manage Rules",
"admin.rules.list.subtitle": "Author, edit and publish deadline rules. Lifecycle: draft → published → archived.",
"admin.rules.list.new": "+ New Rule",
"admin.rules.list.export": "Export migrations",
"admin.rules.tab.rules": "Rules",
"admin.rules.tab.orphans": "Orphans",
"admin.rules.loading": "Loading…",
"admin.rules.empty": "No rules for the chosen filters.",
"admin.rules.error.load": "Could not load rules.",
"admin.rules.filter.proceeding": "Proceeding type",
"admin.rules.filter.proceeding.any": "Any",
"admin.rules.filter.trigger": "Trigger event",
"admin.rules.filter.trigger.any": "Any",
"admin.rules.filter.lifecycle": "Lifecycle",
"admin.rules.filter.lifecycle.any": "Any",
"admin.rules.filter.search": "Search",
"admin.rules.filter.search.placeholder": "Name, submission code, legal citation…",
"admin.rules.col.submission_code": "Submission code",
"admin.rules.col.legal_citation": "Legal citation",
"admin.rules.col.name": "Name",
"admin.rules.col.proceeding": "Proceeding type",
"admin.rules.col.priority": "Priority",
"admin.rules.col.lifecycle": "Lifecycle",
"admin.rules.col.modified": "Last modified",
"admin.rules.lifecycle.draft": "Draft",
"admin.rules.lifecycle.published": "Published",
"admin.rules.lifecycle.archived": "Archived",
"admin.rules.priority.mandatory": "Mandatory",
"admin.rules.priority.recommended": "Recommended",
"admin.rules.priority.optional": "Optional",
"admin.rules.priority.informational": "Informational",
"admin.rules.orphans.subtitle": "Legacy deadlines from the fuzzy-match backfill (Slice 10) that could not be bound to a unique rule. Please pick the right candidate rule.",
"admin.rules.orphans.loading": "Loading…",
"admin.rules.orphans.empty": "No open orphans. ✔",
"admin.rules.orphans.no_candidates": "No candidate rules found. Please create one manually.",
"admin.rules.orphans.field.project": "Project",
"admin.rules.orphans.field.proceeding": "Proceeding",
"admin.rules.orphans.field.reason": "Reason",
"admin.rules.orphans.reason.no_match": "No match",
"admin.rules.orphans.reason.ambiguous": "Ambiguous",
"admin.rules.orphans.reason.no_project": "No project",
"admin.rules.orphans.reason.manual_unbound": "Manually unbound",
"admin.rules.orphans.resolved": "Orphan resolved.",
"admin.rules.modal.new.title": "Create new rule",
"admin.rules.modal.new.body": "A new rule will be created as a draft. Please supply a reason (≥10 chars) — recorded in the audit log and exported into the migration file.",
"admin.rules.modal.resolve.title": "Resolve orphan",
"admin.rules.modal.resolve.body": "Please supply a reason (≥10 chars). The rule binding is persisted immediately on the deadline.",
"admin.rules.modal.reason": "Reason",
"admin.rules.modal.reason.placeholder": "e.g. \"New rule for RoP.198 after UPC reform 2026…",
"admin.rules.modal.reason.hint": "Minimum 10 characters.",
"admin.rules.modal.reason.too_short": "Reason must be at least 10 characters.",
"admin.rules.modal.confirm": "Confirm",
"admin.rules.modal.field.name": "Name (DE)",
"admin.rules.modal.field.name_en": "Name (EN)",
"admin.rules.modal.field.duration": "Duration",
"admin.rules.modal.error.name_required": "Please supply both Name and Name (EN).",
"admin.rules.modal.error.create": "Creation failed.",
"admin.rules.modal.error.resolve": "Resolution failed.",
"admin.rules.edit.title": "Edit Rule — Paliad",
"admin.rules.edit.heading.loading": "Loading rule…",
"admin.rules.edit.breadcrumb": "← Manage Rules",
"admin.rules.edit.error.bad_id": "Invalid rule id in URL.",
"admin.rules.edit.error.not_found": "Rule not found.",
"admin.rules.edit.error.load": "Could not load rule.",
"admin.rules.edit.section.identity": "Identity",
"admin.rules.edit.section.proceeding": "Proceeding & Trigger",
"admin.rules.edit.section.timing": "Math",
"admin.rules.edit.section.party": "Party & Event",
"admin.rules.edit.section.display": "Display & Notes",
"admin.rules.edit.section.lifecycle": "Priority & Flags",
"admin.rules.edit.section.condition": "Condition (condition_expr)",
"admin.rules.edit.field.name": "Name (DE)",
"admin.rules.edit.field.name_en": "Name (EN)",
"admin.rules.edit.field.description": "Description",
"admin.rules.edit.field.submission_code": "Submission code",
"admin.rules.edit.field.rule_code": "Legal citation (short form)",
"admin.rules.edit.field.legal_source": "Legal citation (long form)",
"admin.rules.edit.field.proceeding": "Proceeding type",
"admin.rules.edit.field.proceeding.none": "—",
"admin.rules.edit.field.trigger": "Trigger event",
"admin.rules.edit.field.trigger.none": "—",
"admin.rules.edit.field.parent": "Parent rule (UUID)",
"admin.rules.edit.field.concept": "Concept (UUID)",
"admin.rules.edit.field.sequence_order": "Order",
"admin.rules.edit.field.duration_value": "Duration",
"admin.rules.edit.field.duration_unit": "Unit",
"admin.rules.edit.field.timing": "Timing",
"admin.rules.edit.field.combine_op": "Combine op",
"admin.rules.edit.field.alt_duration_value": "Alt duration",
"admin.rules.edit.field.alt_duration_unit": "Alt unit",
"admin.rules.edit.field.alt_rule_code": "Alt rule code",
"admin.rules.edit.field.anchor_alt": "Alt anchor",
"admin.rules.edit.field.primary_party": "Primary party",
"admin.rules.edit.field.event_type": "Event type (free)",
"admin.rules.edit.field.deadline_notes": "Notes (DE)",
"admin.rules.edit.field.deadline_notes_en": "Notes (EN)",
"admin.rules.edit.field.priority": "Priority",
"admin.rules.edit.field.is_court_set": "Court-set",
"admin.rules.edit.field.is_spawn": "Spawn",
"admin.rules.edit.field.spawn_label": "Spawn label",
"admin.rules.edit.field.spawn_proceeding": "Spawn proceeding",
"admin.rules.edit.field.spawn_proceeding.none": "—",
"admin.rules.edit.field.condition_hint": "JSON grammar: {\"flag\":\"name\"} · {\"op\":\"and|or\",\"args\":[...]} · {\"op\":\"not\",\"args\":[...]}",
"admin.rules.edit.field.condition.valid": "JSON valid.",
"admin.rules.edit.preview.heading": "Preview",
"admin.rules.edit.preview.hint": "Drafts only. Runs the calculator with this draft substituted for the published version.",
"admin.rules.edit.preview.trigger_date": "Trigger date",
"admin.rules.edit.preview.flags": "Flags (comma-separated)",
"admin.rules.edit.preview.run": "Run preview",
"admin.rules.edit.preview.running": "Computing…",
"admin.rules.edit.preview.empty": "No deadlines.",
"admin.rules.edit.preview.error": "Preview failed.",
"admin.rules.edit.preview.only_drafts": "Preview is only available for drafts.",
"admin.rules.edit.preview.trigger_required": "Please supply a trigger date.",
"admin.rules.edit.audit.heading": "Audit log",
"admin.rules.edit.audit.loading": "Loading…",
"admin.rules.edit.audit.empty": "No audit entries.",
"admin.rules.edit.audit.loadmore": "Load more",
"admin.rules.edit.audit.exported": "exported",
"admin.rules.edit.audit.actor.system": "System",
"admin.rules.edit.audit.action.create": "create",
"admin.rules.edit.audit.action.update": "update",
"admin.rules.edit.audit.action.publish": "publish",
"admin.rules.edit.audit.action.archive": "archive",
"admin.rules.edit.audit.action.restore": "restore",
"admin.rules.edit.audit.action.delete": "delete",
"admin.rules.edit.action.save_draft": "Save draft",
"admin.rules.edit.action.publish": "Publish",
"admin.rules.edit.action.clone": "Clone as draft",
"admin.rules.edit.action.archive": "Archive",
"admin.rules.edit.action.restore": "Restore",
"admin.rules.edit.action.ok": "Done.",
"admin.rules.edit.action.save_draft.ok": "Draft saved.",
"admin.rules.edit.action.save_draft.error": "Save failed.",
"admin.rules.edit.action.publish.ok": "Rule published.",
"admin.rules.edit.action.publish.error": "Publish failed.",
"admin.rules.edit.action.archive.ok": "Rule archived.",
"admin.rules.edit.action.archive.error": "Archive failed.",
"admin.rules.edit.action.restore.ok": "Rule restored.",
"admin.rules.edit.action.restore.error": "Restore failed.",
"admin.rules.edit.action.clone.error": "Clone failed.",
"admin.rules.edit.modal.save_draft.title": "Save draft",
"admin.rules.edit.modal.save_draft.body": "Please supply a reason for the change (≥10 chars). Written to the audit log.",
"admin.rules.edit.modal.publish.title": "Publish",
"admin.rules.edit.modal.publish.body": "This draft will go live. The existing published variant is archived.",
"admin.rules.edit.modal.clone.title": "Clone as draft",
"admin.rules.edit.modal.clone.body": "A new draft copy of this rule is created. You will be redirected to the new draft.",
"admin.rules.edit.modal.archive.title": "Archive",
"admin.rules.edit.modal.archive.body": "Rule will be archived. The calculator will no longer use it.",
"admin.rules.edit.modal.restore.title": "Restore",
"admin.rules.edit.modal.restore.body": "Rule will be restored (archived → published).",
"admin.rules.export.title": "Export rule migrations — Paliad",
"admin.rules.export.heading": "Export rule migrations",
"admin.rules.export.subtitle": "Generates a *.up.sql blob with every un-exported audit change. Commit manually into internal/db/migrations/.",
"admin.rules.export.breadcrumb": "← Manage Rules",
"admin.rules.export.field.since": "Starting from audit id (optional)",
"admin.rules.export.run": "Generate export",
"admin.rules.export.running": "Loading…",
"admin.rules.export.download": "Download as file",
"admin.rules.export.copy": "Copy to clipboard",
"admin.rules.export.copied": "Copied to clipboard.",
"admin.rules.export.copy_failed": "Copy failed.",
"admin.rules.export.count": "Audit rows: {n}",
"admin.rules.export.latest": "Latest audit id: {id}",
"admin.rules.export.ok": "{n} audit rows exported.",
"admin.rules.export.error": "Export failed.",
"admin.rules.export.no_pending": "No pending audit rows to export.",
},
};

View File

@@ -0,0 +1,489 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
import {
ALL_DENSITIES,
ALL_PALETTES,
ALL_RANGE_PRESETS,
mount,
type ChartHandle,
type Density,
type Palette,
type RangePreset,
} from "./views/shape-timeline-chart";
import {
exportCSV,
exportJSON,
exportPNG,
exportPrint,
exportSVG,
type ExportContext,
} from "./views/chart-export";
// t-paliad-177 Slice 1 — boot client for the standalone Project Timeline
// / Chart page. Reads the project id from the URL path, loads the
// project metadata (for title + breadcrumb), mounts the SVG renderer
// inside #projects-chart-host. Slice 1 keeps the controls inert; Slice 3
// wires density / palette / zoom against this same surface.
//
// Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
interface Project {
id: string;
title: string;
reference?: string;
client_matter?: string;
type?: string;
}
const PROJECT_ID_RE = /^\/projects\/([0-9a-fA-F-]{36})\/chart\/?$/;
function projectIdFromPath(): string | null {
const match = PROJECT_ID_RE.exec(window.location.pathname);
return match ? match[1] : null;
}
const PALETTE_SET: ReadonlySet<string> = new Set(ALL_PALETTES);
/** Reads ?palette=... from the URL; returns the default when missing /
* unknown so a hostile or stale URL can't break the chart. */
function paletteFromURL(): Palette {
const raw = new URLSearchParams(window.location.search).get("palette");
if (raw && PALETTE_SET.has(raw)) return raw as Palette;
return "default";
}
/** Mirrors paletteFromURL but for writing — pushes a new history entry
* so the URL stays bookmarkable / shareable per design §8.2. */
function writePaletteToURL(palette: Palette): void {
writeParamToURL("palette", palette, "default");
}
const DENSITY_SET: ReadonlySet<string> = new Set(ALL_DENSITIES);
function densityFromURL(): Density {
const raw = new URLSearchParams(window.location.search).get("density");
if (raw && DENSITY_SET.has(raw)) return raw as Density;
return "standard";
}
function writeDensityToURL(density: Density): void {
writeParamToURL("density", density, "standard");
}
const RANGE_SET: ReadonlySet<string> = new Set(ALL_RANGE_PRESETS);
interface RangeState {
preset: RangePreset;
from?: string;
to?: string;
}
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
function rangeFromURL(): RangeState {
const params = new URLSearchParams(window.location.search);
const raw = params.get("range");
const preset: RangePreset = raw && RANGE_SET.has(raw) ? (raw as RangePreset) : "1y";
if (preset === "custom") {
const from = params.get("from") || "";
const to = params.get("to") || "";
return {
preset,
from: ISO_DATE_RE.test(from) ? from : undefined,
to: ISO_DATE_RE.test(to) ? to : undefined,
};
}
return { preset };
}
function writeRangeToURL(state: RangeState): void {
const params = new URLSearchParams(window.location.search);
if (state.preset === "1y") {
params.delete("range");
} else {
params.set("range", state.preset);
}
if (state.preset === "custom") {
if (state.from) params.set("from", state.from);
else params.delete("from");
if (state.to) params.set("to", state.to);
else params.delete("to");
} else {
params.delete("from");
params.delete("to");
}
const qs = params.toString();
const next = window.location.pathname + (qs ? "?" + qs : "");
window.history.replaceState(null, "", next);
}
/** Read ?lanes=id1,id2 from the URL. Empty / missing → null (show all).
* Defence: ids that look hostile (commas embedded, oversized) are dropped
* on render via the renderer's allow-set intersection. */
function lanesFromURL(): string[] | null {
const raw = new URLSearchParams(window.location.search).get("lanes");
if (!raw) return null;
const ids = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0 && s.length < 200);
return ids.length === 0 ? null : ids;
}
function writeLanesToURL(lanes: string[] | null): void {
const params = new URLSearchParams(window.location.search);
if (!lanes || lanes.length === 0) {
params.delete("lanes");
} else {
params.set("lanes", lanes.join(","));
}
const qs = params.toString();
const next = window.location.pathname + (qs ? "?" + qs : "");
window.history.replaceState(null, "", next);
}
/** Shared URL writer — omits the param when it equals its default, so the
* canonical URL stays short and dedupable. */
function writeParamToURL(name: string, value: string, defaultValue: string): void {
const params = new URLSearchParams(window.location.search);
if (value === defaultValue) {
params.delete(name);
} else {
params.set(name, value);
}
const qs = params.toString();
const next = window.location.pathname + (qs ? "?" + qs : "");
window.history.replaceState(null, "", next);
}
async function loadProject(id: string): Promise<Project | null> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`);
if (!resp.ok) return null;
return (await resp.json()) as Project;
} catch {
return null;
}
}
function formatMeta(p: Project): string {
const parts: string[] = [];
if (p.reference) parts.push(p.reference);
if (p.client_matter) parts.push(p.client_matter);
return parts.join(" • ");
}
async function boot(): Promise<void> {
initI18n();
initSidebar();
const loadingEl = document.getElementById("projects-chart-loading");
const notfoundEl = document.getElementById("projects-chart-notfound");
const bodyEl = document.getElementById("projects-chart-body");
const titleEl = document.getElementById("projects-chart-title");
const metaEl = document.getElementById("projects-chart-meta");
const backLink = document.getElementById("projects-chart-back-link") as HTMLAnchorElement | null;
const host = document.getElementById("projects-chart-host");
const undatedHint = document.getElementById("projects-chart-undated");
const id = projectIdFromPath();
if (!id || !host || !bodyEl || !loadingEl || !notfoundEl) {
if (loadingEl) loadingEl.style.display = "none";
if (notfoundEl) notfoundEl.style.display = "block";
return;
}
const project = await loadProject(id);
if (!project) {
loadingEl.style.display = "none";
notfoundEl.style.display = "block";
return;
}
// Wire back-link to the Verlauf tab specifically — projects-detail.ts
// reads the /history sub-path on init and switches to that tab. Going
// back to the bare /projects/{id} also lands on Verlauf today, but the
// /history form is explicit + survives a future default-tab change.
if (backLink) backLink.href = `/projects/${encodeURIComponent(id)}/history`;
if (titleEl) titleEl.textContent = project.title || t("projects.chart.title");
if (metaEl) metaEl.textContent = formatMeta(project);
loadingEl.style.display = "none";
bodyEl.style.display = "";
const initialPalette = paletteFromURL();
const initialDensity = densityFromURL();
const initialRange = rangeFromURL();
const initialLanes = lanesFromURL();
let handle: ChartHandle | null = null;
// Module-scope mirrors so the chip click handlers (rendered later)
// can reach the live state without threading it through callbacks.
moduleVisibleLanes = initialLanes;
try {
handle = mount(host, {
projectId: id,
palette: initialPalette,
density: initialDensity,
rangePreset: initialRange.preset,
rangeFrom: initialRange.from,
rangeTo: initialRange.to,
visibleLanes: initialLanes,
onDataLoaded: ({ lanes }) => {
renderLaneFilter(lanes);
},
});
} catch (err) {
console.error("chart mount failed", err);
host.textContent = t("projects.chart.error.mount");
return;
}
moduleHandleRef = handle;
// Wire the palette picker. Reflect the URL-decoded initial value, then
// re-write the URL + flip the data-palette attribute on every change.
const paletteSel = document.getElementById("projects-chart-palette") as HTMLSelectElement | null;
if (paletteSel) {
paletteSel.value = initialPalette;
paletteSel.addEventListener("change", () => {
const next = paletteSel.value;
if (!PALETTE_SET.has(next)) return;
const p = next as Palette;
handle!.setPalette(p);
writePaletteToURL(p);
});
}
// Density picker — same URL-state pattern. Density triggers a repaint
// (lane height + mark radius change), palette is a pure CSS swap.
const densitySel = document.getElementById("projects-chart-density") as HTMLSelectElement | null;
if (densitySel) {
densitySel.value = initialDensity;
densitySel.addEventListener("change", () => {
const next = densitySel.value;
if (!DENSITY_SET.has(next)) return;
const d = next as Density;
handle!.setDensity(d);
writeDensityToURL(d);
});
}
// Range chips — 4-option select plus a custom date-pair that shows
// only when preset === "custom". Per design §8.2 + faraday-Q8 default.
const rangeSel = document.getElementById("projects-chart-range") as HTMLSelectElement | null;
const rangeCustomWrap = document.getElementById("projects-chart-range-custom");
const rangeFromInput = document.getElementById("projects-chart-range-from") as HTMLInputElement | null;
const rangeToInput = document.getElementById("projects-chart-range-to") as HTMLInputElement | null;
if (rangeSel && rangeCustomWrap && rangeFromInput && rangeToInput) {
rangeSel.value = initialRange.preset;
if (initialRange.preset === "custom") {
rangeCustomWrap.style.display = "";
if (initialRange.from) rangeFromInput.value = initialRange.from;
if (initialRange.to) rangeToInput.value = initialRange.to;
}
const applyRange = () => {
const preset = rangeSel.value;
if (!RANGE_SET.has(preset)) return;
const p = preset as RangePreset;
rangeCustomWrap.style.display = p === "custom" ? "" : "none";
const from = rangeFromInput.value || undefined;
const to = rangeToInput.value || undefined;
handle!.setRange(p, from, to);
writeRangeToURL({ preset: p, from, to });
};
rangeSel.addEventListener("change", applyRange);
rangeFromInput.addEventListener("change", applyRange);
rangeToInput.addEventListener("change", applyRange);
}
// Export menu. Each button maps to one chart-export function; the
// handle exposes the live SVG + last-fetched data needed to compose
// an ExportContext. Errors land in the host's message area so the
// user gets feedback instead of a silent failure.
function ctxNow(): ExportContext {
const data = handle!.getData();
return {
projectId: id,
projectTitle: project.title || t("projects.chart.title"),
svgEl: handle!.getSVGElement(),
events: data.events,
lanes: data.lanes,
};
}
function runExport(fn: (ctx: ExportContext) => void | Promise<void>): void {
void Promise.resolve()
.then(() => fn(ctxNow()))
.catch((err) => {
console.error("export failed", err);
if (host) {
host.setAttribute("data-export-error", "1");
}
});
}
wirePermalinkCopy("projects-chart-copylink");
wireExport("projects-chart-export-svg", () => runExport(exportSVG));
wireExport("projects-chart-export-png", () => runExport(exportPNG));
wireExport("projects-chart-export-csv", () => runExport(exportCSV));
wireExport("projects-chart-export-json", () => runExport(exportJSON));
wireExport("projects-chart-export-print", () => exportPrint());
// iCal goes server-side so it reuses the existing caldav_ical formatter
// (faraday-Q6 / m's pick: deadlines + appointments only — no projected).
wireExport("projects-chart-export-ics", () => {
window.location.href = `/api/projects/${encodeURIComponent(id)}/timeline.ics`;
});
// After the first paint, surface the undated hint when the renderer
// reports clipped/undated rows. Re-checked on resize-debounced repaint.
const checkUndated = () => {
if (!undatedHint || !handle) return;
const layout = handle.getLayout();
if (!layout) return;
if (layout.undatedCount > 0) {
undatedHint.style.display = "";
undatedHint.textContent = `${layout.undatedCount} Ereignis(se) ohne Datum (links angeheftet).`;
} else {
undatedHint.style.display = "none";
}
};
// Poll once after the initial fetch settles. mount() kicks the fetch
// synchronously; layout becomes available after the network round-trip.
setTimeout(checkUndated, 400);
setTimeout(checkUndated, 1500);
}
/** Render the lane-filter chip group once the renderer has lanes from
* the server. One toggle button per lane; clicking flips inclusion in
* the visible-lane allow-set. Hidden when there's only one lane (or
* none) — the filter is pointless on a single-track render. */
function renderLaneFilter(lanes: ReadonlyArray<{ id: string; label: string }>): void {
const container = document.getElementById("projects-chart-lanes-filter");
if (!container) return;
// Hide and bail when the filter wouldn't add value.
if (lanes.length < 2) {
container.innerHTML = "";
container.style.display = "none";
return;
}
container.style.display = "";
container.innerHTML = "";
const titleEl = document.createElement("span");
titleEl.className = "smart-timeline-chart-lanes-label";
titleEl.textContent = "Spuren:";
container.appendChild(titleEl);
for (const lane of lanes) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "smart-timeline-chart-lane-chip";
const isVisible = laneIsVisible(lane.id);
btn.setAttribute("aria-pressed", isVisible ? "true" : "false");
btn.dataset.laneId = lane.id;
btn.textContent = lane.label || lane.id;
btn.addEventListener("click", () => {
toggleLane(lane.id, lanes);
// Reflect new state immediately on this button + siblings.
for (const sibling of container.querySelectorAll<HTMLButtonElement>("button[data-lane-id]")) {
const sid = sibling.dataset.laneId || "";
sibling.setAttribute("aria-pressed", laneIsVisible(sid) ? "true" : "false");
}
});
container.appendChild(btn);
}
}
/** Permalink copy. The URL already aggregates every chip's state via the
* individual writeParamToURL writers (palette + density + range + lanes),
* so window.location.href IS the canonical shareable link. We copy it
* to the clipboard and flash a "kopiert" confirmation on the button. */
function wirePermalinkCopy(buttonId: string): void {
const btn = document.getElementById(buttonId) as HTMLButtonElement | null;
if (!btn) return;
const originalLabel = btn.textContent || "";
let resetTimer: ReturnType<typeof setTimeout> | null = null;
btn.addEventListener("click", async () => {
const url = window.location.href;
const ok = await copyToClipboard(url);
if (resetTimer) clearTimeout(resetTimer);
btn.textContent = ok ? "✓ Kopiert" : "⚠ Konnte nicht kopieren";
btn.classList.add(ok ? "is-success" : "is-error");
resetTimer = setTimeout(() => {
btn.textContent = originalLabel;
btn.classList.remove("is-success", "is-error");
}, 1800);
});
}
async function copyToClipboard(text: string): Promise<boolean> {
// Prefer the async Clipboard API. Falls back to the legacy exec hack
// for browsers / contexts where it's unavailable (some iframes, file://).
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// fall through
}
}
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
function wireExport(buttonId: string, handler: () => void): void {
const btn = document.getElementById(buttonId) as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", (e) => {
e.preventDefault();
handler();
// Close the <details> dropdown so the user sees the chart-area
// update (download notification, print preview, etc).
const details = btn.closest("details");
if (details) details.removeAttribute("open");
});
}
// Lane-filter mutable state lives at module scope so renderLaneFilter
// closures over the same set as toggleLane / laneIsVisible. We can't
// access boot()'s `visibleLanes` from here cleanly, so we mirror it.
let moduleVisibleLanes: string[] | null = null;
let moduleHandleRef: ChartHandle | null = null;
function laneIsVisible(id: string): boolean {
if (moduleVisibleLanes === null) return true;
return moduleVisibleLanes.includes(id);
}
function toggleLane(id: string, allLanes: ReadonlyArray<{ id: string }>): void {
if (moduleVisibleLanes === null) {
// Currently "show all" — turning a chip off means everyone except this one.
moduleVisibleLanes = allLanes.map((l) => l.id).filter((l) => l !== id);
} else if (moduleVisibleLanes.includes(id)) {
moduleVisibleLanes = moduleVisibleLanes.filter((l) => l !== id);
} else {
moduleVisibleLanes = [...moduleVisibleLanes, id];
}
// If user toggled every lane back on, collapse to null (show all).
if (moduleVisibleLanes.length === allLanes.length) {
moduleVisibleLanes = null;
}
// If user toggled every lane off, snap back to null too — an empty
// chart is never useful, treat as "you didn't mean that, show all".
if (moduleVisibleLanes !== null && moduleVisibleLanes.length === 0) {
moduleVisibleLanes = null;
}
if (moduleHandleRef) {
moduleHandleRef.setVisibleLanes(moduleVisibleLanes);
}
writeLanesToURL(moduleVisibleLanes);
}
document.addEventListener("DOMContentLoaded", () => {
void boot();
});

View File

@@ -1086,6 +1086,14 @@ function renderHeader() {
(document.getElementById("project-title-display") as HTMLElement).textContent = project.title;
(document.getElementById("project-ref-display") as HTMLElement).textContent = project.reference || "";
// t-paliad-177 — link from Verlauf header to standalone chart page.
// Wired here (not in the TSX shell) because we need the resolved
// project id, which only exists after the detail fetch settles.
const chartLink = document.getElementById("smart-timeline-open-chart") as HTMLAnchorElement | null;
if (chartLink) {
chartLink.href = `/projects/${encodeURIComponent(project.id)}/chart`;
}
const descDisplay = document.getElementById("project-description-display") as HTMLElement;
const description = project.description ?? "";
descDisplay.textContent = description;
@@ -1413,10 +1421,17 @@ interface ProceedingTypeRow {
let proceedingTypesCache: ProceedingTypeRow[] | null = null;
// loadProceedingTypes fetches active proceeding types for the project
// picker. Phase 3 Slice 5 (t-paliad-186) restricts project-binding to
// fristenrechner-category codes (design §3.F + m's Q2 ruling), so the
// picker only ever shows those — never the 7 legacy litigation codes
// (INF / REV / CCR / APM / APP / AMD / ZPO_CIVIL). The matching
// server-side service validation + DB trigger (mig 088) are the
// defence-in-depth backstops for any non-UI writer.
async function loadProceedingTypes(): Promise<ProceedingTypeRow[]> {
if (proceedingTypesCache) return proceedingTypesCache;
try {
const resp = await fetch("/api/proceeding-types-db");
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
if (!resp.ok) return [];
const rows = ((await resp.json()) ?? []) as ProceedingTypeRow[];
proceedingTypesCache = rows.filter((r) => r.is_active);
@@ -1457,7 +1472,7 @@ function initCounterclaimRoute(
msg.className = "form-msg";
}
// Populate proceeding-type select on first open. Only UPC types
// make sense for a CCR (Nichtigkeit/CCI); pre-select UPC_REV.
// make sense for a CCR (Nichtigkeit/CCI); pre-select upc.rev.cfi.
if (procedureSel && procedureSel.options.length === 0) {
const types = await loadProceedingTypes();
const upcTypes = types.filter((t) => (t.jurisdiction ?? "").toUpperCase() === "UPC");
@@ -1466,7 +1481,7 @@ function initCounterclaimRoute(
const opt = document.createElement("option");
opt.value = String(ty.id);
opt.textContent = `${ty.code}${langEN ? ty.name_en || ty.name : ty.name}`;
if (ty.code === "UPC_REV") opt.selected = true;
if (ty.code === "upc.rev.cfi") opt.selected = true;
procedureSel.appendChild(opt);
}
}

View File

@@ -73,9 +73,9 @@ export function initSidebar() {
initInboxBadge();
initAdminGroup();
initPaliadinLinks();
initProjectContextChartLink();
initUserViewsGroup();
initThemeToggle();
fixVerfahrensablaufActive();
const sidebar = document.querySelector<HTMLElement>(".sidebar");
if (!sidebar) return;
initSidebarResize(sidebar);
@@ -444,29 +444,10 @@ function initUserViewsGroup(): void {
});
}
// fixVerfahrensablaufActive disambiguates the two /tools/fristenrechner
// sidebar entries (t-paliad-168). The SSR navItem helper compares
// hrefs against pathname only, which can't tell ?path=a apart from
// the no-query Fristenrechner — both would render as Fristenrechner=
// active. At the client we know the search params; flip the active
// class so the sidebar lights up the entry the user actually opened.
function fixVerfahrensablaufActive(): void {
if (window.location.pathname !== "/tools/fristenrechner") return;
const path = new URLSearchParams(window.location.search).get("path");
const fristenrechner = document.querySelector<HTMLAnchorElement>(
'a.sidebar-item[href="/tools/fristenrechner"]',
);
const verfahrensablauf = document.querySelector<HTMLAnchorElement>(
'a.sidebar-item[href="/tools/fristenrechner?path=a"]',
);
if (path === "a") {
fristenrechner?.classList.remove("active");
verfahrensablauf?.classList.add("active");
} else {
verfahrensablauf?.classList.remove("active");
fristenrechner?.classList.add("active");
}
}
// fixVerfahrensablaufActive removed (t-paliad-179 Slice 1). The two
// sidebar entries now map 1:1 to distinct URLs (/tools/fristenrechner
// vs /tools/verfahrensablauf), so the SSR navItem helper picks the
// correct active class by pathname alone.
function renderUserViewItem(view: UserViewLite, currentPath: string): HTMLElement {
const a = document.createElement("a");
@@ -569,6 +550,31 @@ function initPaliadinLinks(): void {
});
}
// initProjectContextChartLink (t-paliad-177 Slice 3) reveals an "Als Chart
// anzeigen" entry in the sidebar when the user is browsing a project
// detail page. Hidden everywhere else, hidden on the chart page itself
// (the chart is the destination, not the source).
//
// Self-contained on URL parsing — no per-page handshake needed. Pages
// don't have to know about the sidebar slot; this function walks the
// pathname and renders the link if it matches.
//
// Layout intent: chip sits directly under the "Übersicht" group so it's
// visible on every project sub-tab (Verlauf / Team / Parteien / …).
function initProjectContextChartLink(): void {
const link = document.getElementById("sidebar-project-chart-link") as HTMLAnchorElement | null;
if (!link) return;
const match = /^\/projects\/([0-9a-fA-F-]{36})(\/.*)?$/.exec(window.location.pathname);
if (!match) return;
const id = match[1];
const rest = match[2] || "";
// Hide on the chart page itself — a reciprocal "Zurück zum Verlauf"
// affordance lives on the chart page header (separate slice).
if (rest === "/chart" || rest === "/chart/") return;
link.href = `/projects/${encodeURIComponent(id)}/chart`;
link.style.display = "";
}
// initAdminGroup reveals the Admin section in the sidebar when the caller's
// /api/me lookup confirms global_role='global_admin'. The markup is in the
// DOM with display:none for everyone — flipping it on after the fetch lands

View File

@@ -0,0 +1,347 @@
// /tools/verfahrensablauf client (t-paliad-179 Slice 1)
//
// Abstract-browse surface: pick a proceeding, pick a trigger date,
// see the typical timeline. No Akte, no save-to-project, no anchor
// override editing, no Pathway B cascade. Variant chips + lane view
// (Slice 3) and compare (Slice 4) layer on top of this in later
// slices. Court picker + view toggle + calc fetch + renderers all
// come from ./views/verfahrensablauf-core, which fristenrechner.ts
// shares.
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import {
type DeadlineResponse,
calculateDeadlines,
formatDate,
populateCourtPicker,
renderColumnsBody,
renderTimelineBody,
} from "./views/verfahrensablauf-core";
let selectedType = "";
let lastResponse: DeadlineResponse | null = null;
type ProcedureView = "timeline" | "columns";
let procedureView: ProcedureView = "columns";
// Notes toggle — when off (default), per-rule descriptive notes render
// as a compact ⓘ icon next to the meta line (hover for full text). When
// on, the full notes block expands under each card. Choice persists in
// localStorage so a reload or recalc keeps the user's preference.
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
function readNotesPref(): boolean {
try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
}
function writeNotesPref(on: boolean): void {
try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
}
let showNotes = readNotesPref();
// Jurisdiction display prefix for the proceeding-summary chip + the
// trigger-event placeholder. Same forum slugs the .proceeding-group
// `data-forum` attribute carries in verfahrensablauf.tsx /
// fristenrechner.tsx (upc / de / epa / dpma). Disambiguates the
// 4 redundancies in the corpus (UPC Verletzungsverfahren vs DE
// Verletzungsklage etc.) once the picker collapses.
const FORUM_LABEL: Record<string, string> = {
upc: "UPC",
de: "DE",
epa: "EPA",
dpma: "DPMA",
};
function jurisdictionFor(btn: HTMLButtonElement): string {
const group = btn.closest<HTMLElement>(".proceeding-group");
const forum = group?.dataset.forum || "";
return FORUM_LABEL[forum] || "";
}
function proceedingDisplayName(btn: HTMLButtonElement): string {
const name = btn.querySelector("strong")?.textContent || "";
const jur = jurisdictionFor(btn);
return jur ? `${jur} ${name}` : name;
}
function activeProceedingButton(): HTMLButtonElement | null {
return document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
}
// Auto-calc plumbing — sequence + debounce mirror /tools/fristenrechner
// so rapid input changes never let a stale response overwrite a fresh
// one.
let calcSeq = 0;
let calcTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleCalc(delayMs = 200) {
if (calcTimer !== null) clearTimeout(calcTimer);
calcTimer = setTimeout(() => {
calcTimer = null;
void doCalc();
}, delayMs);
}
function showStep(n: number) {
for (let i = 1; i <= 3; i++) {
const el = document.getElementById(`step-${i}`);
if (el) el.style.display = i <= n ? "block" : "none";
}
}
// Read the proceeding-specific flag checkboxes and assemble the
// payload the calculator expects. Mirrors fristenrechner.ts so the
// gating semantics stay identical: with_amend on upc.inf.cfi is
// nested under with_ccr (R.30 is only available with a CCR);
// upc.rev.cfi exposes with_amend + with_cci as two independent
// gates. R.19 Einspruch is NOT flag-gated (mig 098, m's 2026-05-18
// call): it's just an always-available optional submission, so it
// has no checkbox.
function readFlags(): string[] {
const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null;
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
const revAmend = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
const revCci = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
const flags: string[] = [];
if (selectedType === "upc.inf.cfi") {
if (ccr?.checked) flags.push("with_ccr");
if (ccr?.checked && infAmend?.checked) flags.push("with_amend");
}
if (selectedType === "upc.rev.cfi") {
if (revAmend?.checked) flags.push("with_amend");
if (revCci?.checked) flags.push("with_cci");
}
return flags;
}
async function doCalc() {
const seq = ++calcSeq;
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
const triggerDate = dateInput?.value || "";
if (!triggerDate || !selectedType) return;
const courtPickerRow = document.getElementById("court-picker-row");
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
const courtId = courtPickerRow && courtPickerRow.style.display !== "none" && courtPicker?.value
? courtPicker.value
: "";
const data = await calculateDeadlines({
proceedingType: selectedType,
triggerDate,
flags: readFlags(),
courtId,
});
if (seq !== calcSeq) return;
if (!data) return;
lastResponse = data;
renderResults(data);
showStep(3);
}
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
// label from the calc response. The root rule (isRootEvent=true) is
// the first event in the proceeding — e.g. Klageerhebung for
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
// active proceeding name if no root rule fires (shouldn't happen for
// healthy data, but safer than a blank).
function triggerEventLabelFor(data: DeadlineResponse): string {
const root = data.deadlines.find((d) => d.isRootEvent);
if (root) {
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
}
return data.proceedingName || "";
}
function syncTriggerEventLabel() {
const triggerEventEl = document.getElementById("trigger-event");
if (!triggerEventEl) return;
if (lastResponse) {
triggerEventEl.textContent = triggerEventLabelFor(lastResponse);
} else {
triggerEventEl.textContent = "—";
}
}
function renderResults(data: DeadlineResponse) {
const container = document.getElementById("timeline-container");
if (!container) return;
const printBtn = document.getElementById("fristen-print-btn");
const toggle = document.getElementById("fristen-view-toggle");
// Header shows the picked proceeding with its jurisdiction prefix
// so the user can tell UPC Verletzungsverfahren apart from DE
// Verletzungsklage once the picker collapses.
const activeBtn = activeProceedingButton();
const procName = activeBtn ? proceedingDisplayName(activeBtn)
: tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
const headerHtml = `<div class="timeline-header">
<strong>${procName}</strong>
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
</div>`;
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, { showNotes })
: renderTimelineBody(data, { showParty: true, showNotes });
container.innerHTML = headerHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block";
if (toggle) toggle.style.display = "";
syncTriggerEventLabel();
}
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
const groups = document.querySelectorAll<HTMLElement>(".proceeding-group");
const summary = document.getElementById("proceeding-summary") as HTMLElement | null;
const summaryName = document.getElementById("proceeding-summary-name");
groups.forEach((g) => { g.style.display = collapsed ? "none" : ""; });
if (summary) summary.style.display = collapsed ? "" : "none";
if (summaryName && displayName) summaryName.textContent = displayName;
}
// syncFlagRows shows/hides the proceeding-specific checkbox rows
// based on selectedType. Same disposition as fristenrechner.ts —
// the with_amend nested-under-ccr semantic is enforced via
// syncInfAmendEnabled().
function syncFlagRows() {
const show = (id: string, when: boolean) => {
const el = document.getElementById(id);
if (el) el.style.display = when ? "" : "none";
};
show("ccr-flag-row", selectedType === "upc.inf.cfi");
show("inf-amend-flag-row", selectedType === "upc.inf.cfi");
show("rev-amend-flag-row", selectedType === "upc.rev.cfi");
show("rev-cci-flag-row", selectedType === "upc.rev.cfi");
syncInfAmendEnabled();
}
// R.30 amendment-application is only available with a CCR — disable
// (and clear) the nested inf-amend checkbox while ccr is off so the
// calc payload stays coherent. Mirrors fristenrechner.ts.
function syncInfAmendEnabled() {
const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null;
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
if (!ccr || !infAmend) return;
infAmend.disabled = !ccr.checked;
if (!ccr.checked) infAmend.checked = false;
}
function selectProceeding(btn: HTMLButtonElement) {
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
selectedType = btn.dataset.code || "";
// Trigger-event label fires from the calc response (root rule).
// Until step 3 renders, fall back to an em-dash placeholder.
lastResponse = null;
syncTriggerEventLabel();
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
syncFlagRows();
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
showStep(2);
scheduleCalc(0);
}
function initViewToggle() {
const toggle = document.getElementById("fristen-view-toggle");
if (!toggle) return;
const initial = new URLSearchParams(window.location.search).get("view");
if (initial === "timeline") procedureView = "timeline";
toggle.querySelectorAll<HTMLInputElement>("input[name=fristen-view]").forEach((input) => {
input.checked = input.value === procedureView;
input.addEventListener("change", () => {
if (!input.checked) return;
procedureView = input.value === "columns" ? "columns" : "timeline";
const url = new URL(window.location.href);
if (procedureView === "columns") {
url.searchParams.delete("view");
} else {
url.searchParams.set("view", procedureView);
}
history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
if (lastResponse) renderResults(lastResponse);
});
});
toggle.style.display = "none";
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
btn.addEventListener("click", () => selectProceeding(btn));
});
document.getElementById("proceeding-summary-reselect")?.addEventListener("click", () => {
setProceedingPickerCollapsed(false);
});
document.getElementById("calculate-btn")?.addEventListener("click", () => scheduleCalc(0));
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
if (dateInput) {
dateInput.addEventListener("change", () => scheduleCalc());
dateInput.addEventListener("input", () => scheduleCalc());
dateInput.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") scheduleCalc(0);
});
}
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
// Flag-checkbox listeners — each flip triggers a fresh calc so the
// timeline re-projects with the new gating. ccr-flag additionally
// enables/disables the nested inf-amend row.
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
if (ccrFlag) ccrFlag.addEventListener("change", () => {
syncInfAmendEnabled();
scheduleCalc(0);
});
(["inf-amend-flag", "rev-amend-flag", "rev-cci-flag"]).forEach((id) => {
const cb = document.getElementById(id) as HTMLInputElement | null;
if (cb) cb.addEventListener("change", () => scheduleCalc(0));
});
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
// Notes toggle — restores last preference on load + re-renders when
// the user flips it. Lives in the same toggle bar as the view picker.
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
if (notesShowCb) {
notesShowCb.checked = showNotes;
notesShowCb.addEventListener("change", () => {
showNotes = notesShowCb.checked;
writeNotesPref(showNotes);
if (lastResponse) renderResults(lastResponse);
});
}
initViewToggle();
onLangChange(() => {
// Active-button name updates with language change (the data-i18n
// pass swaps the inner <strong>'s text). Re-collapse the summary
// chip and re-derive the trigger event label from the lang-current
// calc response.
const activeBtn = activeProceedingButton();
if (activeBtn) {
const summary = document.getElementById("proceeding-summary-name");
if (summary) summary.textContent = proceedingDisplayName(activeBtn);
}
if (lastResponse) renderResults(lastResponse);
syncTriggerEventLabel();
});
// Pre-select the first proceeding tile so users see a timeline
// immediately on landing — matches /tools/fristenrechner behaviour.
const firstBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
if (firstBtn) selectProceeding(firstBtn);
});

View File

@@ -1,14 +1,25 @@
import { initI18n, t, type I18nKey } from "./i18n";
import { initSidebar } from "./sidebar";
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape } from "./views/types";
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape, DataSource } from "./views/types";
import { renderListShape } from "./views/shape-list";
import { renderCardsShape } from "./views/shape-cards";
import { renderCalendarShape } from "./views/shape-calendar";
import { renderTimelineShape } from "./views/shape-timeline-cv";
import type { ChartHandle } from "./views/shape-timeline-chart";
import { mountFilterBar, type BarHandle, type AxisKey } from "./filter-bar";
// /views and /views/{slug} client. Loads the saved or system view, runs
// it via /api/views/{slug}/run, and dispatches to the matching render-
// shape component. Shape-switcher chips toggle the live render without
// re-fetching (the rows are already in memory).
//
// t-paliad-211 — the per-view filter bar (`mountFilterBar`) lives between
// the shape chips and the render hosts. The saved view's filter_spec is
// the baseline; the bar overlays the user's per-session tweaks and POSTs
// `/api/views/{slug}/run` with the effective spec as override (the
// substrate accepts `{filter: ...}` per views.go:283). Axes are picked
// from the spec's data sources so a deadline-only view doesn't expose
// the appointment-type chip cluster and vice versa.
initI18n();
initSidebar();
@@ -28,6 +39,8 @@ interface ViewMeta {
let currentMeta: ViewMeta | null = null;
let currentRows: ViewRunResult | null = null;
let currentRender: RenderSpec | null = null;
let bar: BarHandle | null = null;
document.addEventListener("DOMContentLoaded", () => {
bindShapeChips();
@@ -52,9 +65,10 @@ async function hydrate(): Promise<void> {
return;
}
currentMeta = meta;
currentRender = meta.render;
document.title = `${meta.name} — Paliad`;
updateHeader(meta);
await runAndRender(meta);
mountBar(meta);
if (meta.user_view_id) {
fireAndForget(`/api/user-views/${meta.user_view_id}/touch`, "POST");
}
@@ -95,66 +109,114 @@ async function resolveMeta(slug: string): Promise<ViewMeta | null> {
return null;
}
async function runAndRender(meta: ViewMeta): Promise<void> {
// mountBar wires the filter-bar to the view's saved spec. The bar runs
// the spec through `/api/views/{slug}/run` whenever the user tweaks an
// axis, and the onResult callback re-paints into the active shape host.
function mountBar(meta: ViewMeta): void {
const host = document.getElementById("views-filter-bar");
const toolbar = document.getElementById("views-toolbar");
const loading = document.getElementById("views-loading");
if (loading) loading.hidden = false;
if (toolbar) toolbar.hidden = false;
if (host) host.hidden = false;
if (!host) return;
// Tear down any prior bar (re-mount on lang change isn't supported
// here, but a future Phase-2 axis switch may need this).
if (bar) {
bar.destroy();
bar = null;
}
const axes = axesForSources(meta.filter.sources);
// surfaceKey scoped per-view-slug so two views remember their own
// density/sort prefs independently.
const surfaceKey = `views.${meta.slug}`;
bar = mountFilterBar(host, {
baseFilter: meta.filter,
baseRender: meta.render,
axes,
surfaceKey,
systemViewSlug: meta.slug,
// The saved view IS the baseline; "Speichern als Sicht" remains
// available for users who want to fork.
showSaveAsView: !meta.is_system,
userViewId: meta.user_view_id,
onResult: (result, effective) => {
if (loading) loading.hidden = true;
currentRows = result;
currentRender = effective.render;
paintRows(result, effective.render);
},
});
}
// axesForSources picks the filter-bar axes a saved view's data sources
// support. Universal axes (time / personal_only / sort) always render;
// per-source predicates only render when the view's spec actually
// queries that source — otherwise the chip would be a no-op.
function axesForSources(sources: DataSource[]): AxisKey[] {
const set = new Set(sources);
const out: AxisKey[] = ["time"];
if (set.has("deadline")) out.push("deadline_status");
if (set.has("appointment")) out.push("appointment_type");
if (set.has("approval_request")) {
out.push("approval_viewer_role");
out.push("approval_status");
out.push("approval_entity_type");
}
if (set.has("project_event")) out.push("project_event_kind");
out.push("personal_only");
out.push("sort");
return out;
}
function paintRows(result: ViewRunResult, render: RenderSpec): void {
const empty = document.getElementById("views-empty");
const errorEl = document.getElementById("views-error");
const toolbar = document.getElementById("views-toolbar");
if (loading) loading.hidden = false;
if (empty) empty.hidden = true;
if (errorEl) errorEl.hidden = true;
if (toolbar) toolbar.hidden = false;
let result: ViewRunResult;
try {
const r = await fetch(`/api/views/${encodeURIComponent(meta.slug)}/run`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!r.ok) {
showError(`${r.status}: ${r.statusText}`);
return;
}
result = (await r.json()) as ViewRunResult;
} catch (e) {
showError(t("views.error.network"));
return;
}
if (loading) loading.hidden = true;
currentRows = result;
if (result.inaccessible_project_ids && result.inaccessible_project_ids.length > 0) {
showInaccessibleToast(result.inaccessible_project_ids.length);
}
if (result.rows.length === 0) {
setActiveShape(null);
if (empty) {
empty.hidden = false;
const hint = document.getElementById("views-empty-hint");
if (hint) hint.textContent = filterSummary(meta.filter);
if (hint && currentMeta) hint.textContent = filterSummary(currentMeta.filter);
}
return;
}
if (empty) empty.hidden = true;
setActiveShape(meta.render.shape);
renderShape(meta.render.shape, meta.render, result.rows);
setActiveShape(render.shape);
renderShape(render.shape, render, result.rows);
}
function setActiveShape(shape: RenderShape): void {
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar"]) {
function setActiveShape(shape: RenderShape | null): void {
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar", "views-shape-timeline"]) {
const el = document.getElementById(host);
if (el) el.hidden = !host.endsWith("-" + shape);
if (el) el.hidden = shape === null ? true : !host.endsWith("-" + shape);
}
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.shape === shape);
});
}
let timelineHandle: ChartHandle | null = null;
function renderShape(shape: RenderShape, render: RenderSpec, rows: ViewRunResult["rows"]): void {
const host = document.getElementById(`views-shape-${shape}`);
if (!host) return;
// Switching away from timeline → dispose the prior chart handle so we
// don't leak resize listeners / SVG nodes between shape flips.
if (shape !== "timeline" && timelineHandle) {
timelineHandle.dispose();
timelineHandle = null;
}
switch (shape) {
case "list":
renderListShape(host, rows, render);
@@ -165,6 +227,47 @@ function renderShape(shape: RenderShape, render: RenderSpec, rows: ViewRunResult
case "calendar":
renderCalendarShape(host, rows, render);
break;
case "timeline": {
// Tear down any previous chart inside this host before re-mounting
// (the CV adapter clears chart-host innerHTML on its own, but we
// need to dispose the prior handle's resize/click listeners too).
if (timelineHandle) {
timelineHandle.dispose();
timelineHandle = null;
}
const chartHost = document.getElementById("views-timeline-chart-host");
if (chartHost) {
timelineHandle = renderTimelineShape(chartHost, rows, render);
}
maybeShowTimelineCaveat();
break;
}
}
}
/** First-open caveat banner. sessionStorage flag means the user sees it
* once per browser session — dismissive but not annoying. Design §13.4
* documents the limitation; this is the user-facing surface. */
function maybeShowTimelineCaveat(): void {
const FLAG = "paliad-views-timeline-caveat-dismissed";
const banner = document.getElementById("views-timeline-caveat");
const closeBtn = document.getElementById("views-timeline-caveat-close");
if (!banner) return;
if (sessionStorage.getItem(FLAG) === "1") {
banner.hidden = true;
return;
}
banner.hidden = false;
if (closeBtn && !closeBtn.dataset.bound) {
closeBtn.addEventListener("click", () => {
banner.hidden = true;
try {
sessionStorage.setItem(FLAG, "1");
} catch {
/* sessionStorage may be unavailable in strict modes — silently noop */
}
});
closeBtn.dataset.bound = "1";
}
}
@@ -172,9 +275,10 @@ function bindShapeChips(): void {
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
btn.addEventListener("click", () => {
const shape = (btn.dataset.shape ?? "list") as RenderShape;
if (!currentMeta || !currentRows) return;
if (!currentRows || !currentRender) return;
// Override the shape transiently — doesn't mutate the saved spec.
const overrideRender = { ...currentMeta.render, shape };
const overrideRender = { ...currentRender, shape };
currentRender = overrideRender;
setActiveShape(shape);
renderShape(shape, overrideRender, currentRows.rows);
});

View File

@@ -0,0 +1,274 @@
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
// chart-export (t-paliad-177 Slice 2) — client-side export helpers for
// the Project Timeline / Chart page.
//
// Five formats land in Slice 2 (per design §7.1, m's pick on faraday-Q4
// to rule out server-side PDF via chromedp):
//
// SVG — XMLSerializer of the live SVG element
// PNG — SVG → <img> → <canvas> at 2× HiDPI, toBlob("image/png")
// PDF — window.print() with @media print stylesheet (browser handles
// the PDF engine; no chromedp dep on Dokploy)
// CSV — flat tabular dump of TimelineEvent[] (UTF-8 BOM for Excel-DE)
// JSON — wire envelope verbatim + export-metadata header
//
// iCal lands in a follow-up commit (C5) and goes via a server-side
// endpoint that reuses internal/services/caldav_ical.go (faraday-Q6).
//
// Design ref: docs/design-project-chart-2026-05-09.md §7.
export interface ExportContext {
projectId: string;
projectTitle: string;
svgEl: SVGSVGElement;
events: ReadonlyArray<TimelineEvent>;
lanes: ReadonlyArray<LaneInfo>;
}
// ---------------------------------------------------------------------------
// Public surface
// ---------------------------------------------------------------------------
export async function exportSVG(ctx: ExportContext): Promise<void> {
const svgString = serialiseSVG(ctx.svgEl);
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
triggerDownload(blob, filename(ctx, "svg"));
}
export async function exportPNG(ctx: ExportContext): Promise<void> {
const svgString = serialiseSVG(ctx.svgEl);
const blob = await rasterise(svgString, ctx.svgEl);
if (!blob) {
throw new Error("PNG raster failed");
}
triggerDownload(blob, filename(ctx, "png"));
}
export function exportCSV(ctx: ExportContext): void {
const rows: string[][] = [csvHeader()];
for (const event of ctx.events) {
rows.push(csvRow(event, ctx));
}
// UTF-8 BOM keeps Excel-DE from mis-detecting ANSI; ISO-8601 dates
// round-trip correctly into German Excel as text.
const text = "" + rows.map(csvLine).join("\r\n") + "\r\n";
const blob = new Blob([text], { type: "text/csv;charset=utf-8" });
triggerDownload(blob, filename(ctx, "csv"));
}
export function exportJSON(ctx: ExportContext): void {
const envelope = {
project_id: ctx.projectId,
project_title: ctx.projectTitle,
exported_at: new Date().toISOString(),
events: ctx.events,
lanes: ctx.lanes,
};
const text = JSON.stringify(envelope, null, 2) + "\n";
const blob = new Blob([text], { type: "application/json;charset=utf-8" });
triggerDownload(blob, filename(ctx, "json"));
}
export function exportPrint(): void {
// The @media print stylesheet in global.css does the layout work;
// we just invoke the browser's print dialog. User picks "Save as PDF"
// (Chrome/Edge), "Drucken in Datei" (Firefox), etc.
window.print();
}
// ---------------------------------------------------------------------------
// SVG / PNG plumbing
// ---------------------------------------------------------------------------
function serialiseSVG(svgEl: SVGSVGElement): string {
// Clone so we can inline computed styles without polluting the live DOM.
// For a true cross-environment-portable SVG, we'd compute every used
// CSS-var into a literal value. v1 keeps it light: the receiver inherits
// colours via document context when opened standalone, and the rendered
// bars still work because palette tokens fall through to the .smart-
// timeline-chart root selector via inline class. Add a fallback width /
// height attribute so headless viewers don't render 0×0.
const clone = svgEl.cloneNode(true) as SVGSVGElement;
if (!clone.getAttribute("width") && svgEl.getAttribute("width")) {
clone.setAttribute("width", svgEl.getAttribute("width") || "1000");
}
if (!clone.getAttribute("height") && svgEl.getAttribute("height")) {
clone.setAttribute("height", svgEl.getAttribute("height") || "400");
}
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
// Inline the chart's computed palette tokens so the standalone SVG
// paints the same way when opened in an image viewer (which has no
// document.css). Read every --chart-* property off the live element.
const computed = window.getComputedStyle(svgEl);
const styleLines: string[] = [];
for (const prop of [
"--chart-mark-deadline",
"--chart-mark-appointment",
"--chart-mark-milestone",
"--chart-mark-projected",
"--chart-mark-overdue",
"--chart-mark-done",
"--chart-today-rule",
"--chart-grid-line",
"--chart-lane-label",
"--chart-tick-label",
"--chart-bg",
]) {
const val = computed.getPropertyValue(prop).trim();
if (val) styleLines.push(`${prop}: ${val};`);
}
if (styleLines.length > 0) {
const existing = clone.getAttribute("style") || "";
clone.setAttribute("style", existing + styleLines.join(" "));
}
return new XMLSerializer().serializeToString(clone);
}
async function rasterise(svgString: string, svgEl: SVGSVGElement): Promise<Blob | null> {
const widthAttr = svgEl.getAttribute("width") || "1000";
const heightAttr = svgEl.getAttribute("height") || "400";
const width = Number(widthAttr) || 1000;
const height = Number(heightAttr) || 400;
// 2× device pixel ratio for HiDPI exports (design §7.1 "PNG, 2× HiDPI").
const scale = 2;
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
try {
const img = await loadImage(url);
const canvas = document.createElement("canvas");
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
return await new Promise<Blob | null>((resolve) => {
canvas.toBlob((b) => resolve(b), "image/png");
});
} finally {
URL.revokeObjectURL(url);
}
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("Image load failed"));
img.src = src;
});
}
// ---------------------------------------------------------------------------
// CSV plumbing
// ---------------------------------------------------------------------------
const CSV_COLUMNS = [
"project_id",
"project_title",
"kind",
"status",
"track",
"lane_id",
"date",
"title",
"description",
"rule_code",
"depends_on_rule_code",
"depends_on_date",
"depends_on_rule_name",
"sub_project_id",
"sub_project_title",
"bubble_up",
"deadline_id",
"appointment_id",
"project_event_id",
"project_event_type",
] as const;
function csvHeader(): string[] {
return [...CSV_COLUMNS];
}
function csvRow(event: TimelineEvent, ctx: ExportContext): string[] {
return [
ctx.projectId,
ctx.projectTitle,
event.kind,
event.status,
event.track,
event.lane_id ?? "",
isoOnly(event.date),
event.title,
event.description ?? "",
event.rule_code ?? "",
event.depends_on_rule_code ?? "",
isoOnly(event.depends_on_date),
event.depends_on_rule_name ?? "",
event.sub_project_id ?? "",
event.sub_project_title ?? "",
event.bubble_up ? "true" : "false",
event.deadline_id ?? "",
event.appointment_id ?? "",
event.project_event_id ?? "",
event.project_event_type ?? "",
];
}
function csvLine(fields: string[]): string {
return fields.map(csvEscape).join(",");
}
/** RFC 4180 quoting: double quotes inside the field are doubled; wrap
* the whole field in quotes if it contains comma / quote / newline. */
function csvEscape(value: string): string {
if (/[,"\r\n]/.test(value)) {
return '"' + value.replace(/"/g, '""') + '"';
}
return value;
}
function isoOnly(date: string | null | undefined): string {
if (!date) return "";
return date.slice(0, 10);
}
// ---------------------------------------------------------------------------
// Download trigger
// ---------------------------------------------------------------------------
function triggerDownload(blob: Blob, name: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
// Some browsers (Safari < 14) ignore the download attribute unless
// the link is in the document tree. Inserting + removing is cheap.
a.style.display = "none";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Give the browser a tick to start the download before we revoke.
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function filename(ctx: ExportContext, ext: string): string {
// Keep filenames diff-friendly + filesystem-safe. Replace anything that
// isn't ASCII alnum/dot/hyphen with "_". Truncate the title to 60 chars.
const safeTitle = (ctx.projectTitle || "timeline")
.normalize("NFKD")
.replace(/[^\x20-\x7e]/g, "")
.replace(/[^A-Za-z0-9.-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "")
.slice(0, 60) || "timeline";
const dateStr = new Date().toISOString().slice(0, 10);
return `paliad-${safeTitle}-${dateStr}.${ext}`;
}

View File

@@ -1,14 +1,21 @@
import { t, type I18nKey, getLang } from "../i18n";
import { t, tDyn, type I18nKey, getLang } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
// shape-calendar: month grid. Toggleable to week-view via per-shape
// config. Mirrors the look of /events?view=calendar but generic across
// sources.
// shape-calendar: month / week / day views. The view switcher is rendered
// inline above the grid; the active view persists in the URL via
// ?cal_view= so /views/<slug>?cal_view=day&cal_date=2026-05-18 is a
// shareable deep-link. Each view buckets the same flat ViewRow[] by
// ISO-date — only the rendering differs.
type CalView = "month" | "week" | "day";
const VIEW_PARAM = "cal_view";
const DATE_PARAM = "cal_date";
const MAX_PILLS_PER_MONTH_CELL = 3;
export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
host.innerHTML = "";
const cfg = render.calendar ?? {};
const view = cfg.default_view ?? "month";
// Mobile fallback: viewport <600px collapses to cards (cleaner on narrow
// screens). Documented in design §9 trade-off 8.
@@ -19,15 +26,121 @@ export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render:
host.appendChild(notice);
}
const initialView = readView(cfg.default_view);
const anchor = readAnchor(rows);
paint(host, rows, anchor, initialView);
}
// paint redraws the calendar in the supplied view + anchor. Called from
// the view switcher and from the day/week navigation buttons. Each paint
// clears the host so we don't leak prior DOM.
function paint(host: HTMLElement, rows: ViewRow[], anchor: Date, view: CalView): void {
// Keep the mobile-notice (first child) if present; everything else is
// re-rendered each time.
const notice = host.querySelector<HTMLElement>(".views-calendar-mobile-notice");
host.innerHTML = "";
if (notice) host.appendChild(notice);
const wrap = document.createElement("div");
wrap.className = `views-calendar views-calendar--${view}`;
wrap.appendChild(renderToolbar(view, anchor, (nextView, nextAnchor) => {
writeURL(nextView, nextAnchor);
paint(host, rows, nextAnchor, nextView);
}));
if (view === "month") {
wrap.appendChild(renderMonth(anchor, rows, (clickedDate) => {
writeURL("day", clickedDate);
paint(host, rows, clickedDate, "day");
}));
} else if (view === "week") {
wrap.appendChild(renderWeek(anchor, rows));
} else {
wrap.appendChild(renderDay(anchor, rows));
}
const monthRef = pickMonthAnchor(rows);
wrap.appendChild(renderMonth(monthRef, rows));
host.appendChild(wrap);
}
function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
// --- Toolbar -------------------------------------------------------------
function renderToolbar(
view: CalView,
anchor: Date,
onNav: (view: CalView, anchor: Date) => void,
): HTMLElement {
const bar = document.createElement("div");
bar.className = "views-calendar-toolbar";
// View switcher: month / week / day chips.
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 CalView[]) {
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;
onNav(v, anchor);
});
switcher.appendChild(chip);
}
bar.appendChild(switcher);
// Prev / current-label / next. Step size depends on the view.
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", () => onNav(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", () => onNav(view, shift(anchor, view, 1)));
nav.appendChild(next);
// Day/week view: provide a "Zurück zum Monat" link so users can climb
// back without hunting for the switcher chip.
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", () => onNav("month", anchor));
nav.appendChild(backToMonth);
}
bar.appendChild(nav);
return bar;
}
function navLabelKey(view: CalView, 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";
}
// --- Month view ----------------------------------------------------------
function renderMonth(anchor: Date, rows: ViewRow[], onDayDrill: (d: Date) => void): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-month";
@@ -37,20 +150,22 @@ function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
wrap.appendChild(header);
// Weekday headers (Mon-Sun, ISO week).
const weekdayBar = document.createElement("div");
weekdayBar.className = "views-calendar-weekdays";
const weekdayKeys: I18nKey[] = ["cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu", "cal.day.fri", "cal.day.sat", "cal.day.sun"];
// Single grid with one column-template that the weekday row and the day
// cells share. The header row is added with `grid-column: span 7` so
// it spans the full width above the day grid (laid out below).
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);
weekdayBar.appendChild(cell);
grid.appendChild(cell);
}
wrap.appendChild(weekdayBar);
const grid = document.createElement("div");
grid.className = "views-calendar-grid";
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
@@ -63,47 +178,16 @@ function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
grid.appendChild(cell);
}
// Bucket rows by ISO date (yyyy-mm-dd).
const byDate = new Map<string, ViewRow[]>();
for (const row of rows) {
const d = new Date(row.event_date);
if (isNaN(d.getTime())) continue;
if (d.getMonth() !== anchor.getMonth() || d.getFullYear() !== anchor.getFullYear()) continue;
const key = isoDate(d);
const arr = byDate.get(key);
if (arr) arr.push(row);
else byDate.set(key, [row]);
}
// Bucket rows by ISO date (yyyy-mm-dd) within the visible month.
const byDate = bucketByDate(rows, (d) =>
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
);
for (let day = 1; day <= daysInMonth; day++) {
const cell = document.createElement("div");
cell.className = "views-calendar-cell";
const dayLabel = document.createElement("div");
dayLabel.className = "views-calendar-cell-day";
dayLabel.textContent = String(day);
cell.appendChild(dayLabel);
const dateKey = isoDate(new Date(anchor.getFullYear(), anchor.getMonth(), day));
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
const dateKey = isoDate(dayDate);
const dayRows = byDate.get(dateKey) ?? [];
if (dayRows.length > 0) {
const ul = document.createElement("ul");
ul.className = "views-calendar-pills";
const visible = dayRows.slice(0, 3);
for (const row of visible) {
const li = document.createElement("li");
li.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
li.textContent = row.title;
li.title = row.title + (row.project_title ? `${row.project_title}` : "");
ul.appendChild(li);
}
if (dayRows.length > visible.length) {
const more = document.createElement("li");
more.className = "views-calendar-pill views-calendar-pill--more";
more.textContent = `+${dayRows.length - visible.length}`;
ul.appendChild(more);
}
cell.appendChild(ul);
}
const cell = renderMonthCell(dayDate, day, dayRows, onDayDrill);
grid.appendChild(cell);
}
@@ -111,14 +195,269 @@ function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
return wrap;
}
function pickMonthAnchor(rows: ViewRow[]): Date {
// Anchor on the first row's month, or "this month" if empty.
function renderMonthCell(
dayDate: Date,
dayNum: number,
dayRows: ViewRow[],
onDayDrill: (d: Date) => void,
): 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");
// Day-number is a click-target that switches to the day view. We render
// it as a button to keep keyboard semantics; the surrounding cell stays
// a div so it doesn't compete with the inner row anchors.
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();
onDayDrill(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();
onDayDrill(dayDate);
});
more.appendChild(moreBtn);
ul.appendChild(more);
}
cell.appendChild(ul);
}
return cell;
}
// --- Week view -----------------------------------------------------------
function renderWeek(anchor: Date, rows: ViewRow[]): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-week";
const weekStart = startOfWeek(anchor);
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
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);
const col = renderWeekColumn(day, rows);
grid.appendChild(col);
}
wrap.appendChild(grid);
return wrap;
}
function renderWeekColumn(day: Date, rows: ViewRow[]): 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);
// No 3-row cap on week / day views — show everything for that day.
const dayRows = filterByDay(rows, 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 view ------------------------------------------------------------
function renderDay(anchor: Date, rows: ViewRow[]): 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(rows, 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: ViewRow): HTMLElement {
const li = document.createElement("li");
const a = document.createElement("a");
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
a.href = rowHref(row);
a.textContent = row.title;
a.title = row.title + (row.project_title ? `${row.project_title}` : "");
// Pills are anchors — month-cell day-button click ignores them via
// stopPropagation on the button; cell-level handlers would intercept
// them otherwise.
a.addEventListener("click", (e) => e.stopPropagation());
li.appendChild(a);
return li;
}
function renderRowAnchor(row: ViewRow, 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 = rowHref(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 rowHref(row: ViewRow): 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":
// project_events surface on the project's Verlauf — best we can do
// is link to the project. If no project, leave as a non-link target.
return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
}
}
// --- Bucketing / date helpers --------------------------------------------
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 bucketByDate(rows: ViewRow[], filter: (d: Date) => boolean): Map<string, ViewRow[]> {
const out = new Map<string, ViewRow[]>();
for (const row of rows) {
const d = new Date(row.event_date);
if (!isNaN(d.getTime())) return d;
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;
}
function filterByDay(rows: ViewRow[], day: Date): ViewRow[] {
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;
});
}
function startOfWeek(d: Date): Date {
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const offset = (out.getDay() + 6) % 7; // Mon=0
out.setDate(out.getDate() - offset);
return out;
}
function shift(d: Date, view: CalView, 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);
}
function isToday(d: Date): boolean {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate();
}
function isoDate(d: Date): string {
@@ -127,3 +466,60 @@ function isoDate(d: Date): string {
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function formatRangeLabel(view: CalView, 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}`;
}
// --- URL state -----------------------------------------------------------
function readView(defaultView: CalView | undefined): CalView {
const params = new URLSearchParams(window.location.search);
const raw = params.get(VIEW_PARAM);
if (raw === "month" || raw === "week" || raw === "day") return raw;
return defaultView ?? "month";
}
function readAnchor(rows: ViewRow[]): Date {
const params = new URLSearchParams(window.location.search);
const raw = params.get(DATE_PARAM);
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;
}
}
// No URL anchor — pick the first row's date, or today.
for (const row of rows) {
const d = new Date(row.event_date);
if (!isNaN(d.getTime())) return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
function writeURL(view: CalView, anchor: Date): void {
const url = new URL(window.location.href);
url.searchParams.set(VIEW_PARAM, view);
url.searchParams.set(DATE_PARAM, isoDate(anchor));
history.replaceState(null, "", url.toString());
}

View File

@@ -196,6 +196,12 @@ interface ApprovalDetail {
requester_kind?: "user" | "agent";
decider_name?: string;
decision_note?: string;
// Per-viewer eligibility flags resolved server-side against the caller
// (t-paliad-202). Used to grey out actions the server would reject.
// Optional so an older payload still renders — falsy means "treat as
// disabled" for the safety side (no false enables).
viewer_can_approve?: boolean;
viewer_is_requester?: boolean;
}
function renderApprovalList(rows: ViewRow[]): HTMLElement {
@@ -256,13 +262,15 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
actions.className = "inbox-row-actions";
if (detail.status === "pending") {
// The bar's approval_viewer_role distinguishes which actions are
// appropriate. The surface inspects the active role and decides
// which buttons to keep — but for default rendering we stamp all
// three with role-class hints and let the surface filter.
actions.appendChild(actionBtn("approve"));
actions.appendChild(actionBtn("reject"));
actions.appendChild(actionBtn("revoke"));
// All three actions are stamped on every pending row; the per-viewer
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
// decide which are enabled vs. greyed out with a tooltip. m's ask
// (2026-05-17): show what's possible but disable what isn't, rather
// than alert-after-click. The server still enforces — disabled buttons
// are a UI hint, not a security gate.
actions.appendChild(approvalActionBtn("approve", detail));
actions.appendChild(approvalActionBtn("reject", detail));
actions.appendChild(approvalActionBtn("revoke", detail));
} else if (detail.status) {
const pill = document.createElement("span");
pill.className = "approval-pill approval-pill--historic";
@@ -312,16 +320,39 @@ function renderDiff(detail: ApprovalDetail): HTMLElement | null {
return wrap;
}
function actionBtn(action: "approve" | "reject" | "revoke"): HTMLButtonElement {
function approvalActionBtn(
action: "approve" | "reject" | "revoke",
detail: ApprovalDetail,
): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.action = action;
const cls = action === "approve" ? "btn-primary" : action === "reject" ? "btn-danger" : "btn-secondary";
btn.className = `btn ${cls} inbox-row-action views-approval-action`;
btn.textContent = t(("approvals.action." + action) as I18nKey);
// approve / reject share the eligibility gate; revoke is requester-only.
const reason = disabledReasonFor(action, detail);
if (reason) {
btn.disabled = true;
btn.title = t(reason);
}
return btn;
}
function disabledReasonFor(
action: "approve" | "reject" | "revoke",
detail: ApprovalDetail,
): I18nKey | null {
if (action === "revoke") {
return detail.viewer_is_requester ? null : "approvals.disabled.revoke_not_requester";
}
// approve + reject — same gate as the server's canApprove.
if (detail.viewer_can_approve) return null;
if (detail.viewer_is_requester) return "approvals.disabled.self_approval";
return "approvals.disabled.not_authorized";
}
function formatRelativeTime(iso: string): string {
const t0 = Date.parse(iso);
if (isNaN(t0)) return iso;

View File

@@ -0,0 +1,254 @@
import { describe, expect, test } from "bun:test";
import { layout, type ChartViewport } from "./shape-timeline-chart";
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
// t-paliad-177 Slice 1 — table-driven tests for the pure `layout()`
// function. `layout` translates a TimelineEvent[] + LaneInfo[] + viewport
// into deterministic SVG-ready geometry. Tests pin the math so subtle
// drift (off-by-one days, axis tick density, lane stacking) surfaces fast.
//
// Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §15.
const vp = (overrides: Partial<ChartViewport> = {}): ChartViewport => ({
width: 1000,
height: 400,
laneLabelWidth: 200,
dateAxisHeight: 40,
todayISO: "2026-06-15",
rangeFrom: "2026-01-01",
rangeTo: "2026-12-31",
density: "standard",
...overrides,
});
const ev = (overrides: Partial<TimelineEvent> = {}): TimelineEvent => ({
kind: "deadline",
status: "open",
track: "parent",
date: "2026-06-15",
title: "Test event",
...overrides,
});
describe("layout — base geometry", () => {
test("chart canvas sits to the right of lane labels and below date axis", () => {
const out = layout([], [], vp());
expect(out.chartLeft).toBe(200);
expect(out.chartTop).toBe(40);
expect(out.chartWidth).toBe(800);
expect(out.chartHeight).toBeGreaterThan(0);
});
test("pxPerDay = chartWidth / total_days", () => {
// 2026 is 365 days; range Jan 1..Dec 31 is 364 day-deltas + 1 = 365 days.
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
expect(out.pxPerDay).toBeCloseTo(800 / 364, 5);
});
test("invalid range (to before from) falls back to a 1-day span", () => {
const out = layout([], [], vp({ rangeFrom: "2026-06-01", rangeTo: "2026-05-01" }));
// Sanity: pxPerDay finite, no division-by-zero.
expect(Number.isFinite(out.pxPerDay)).toBe(true);
expect(out.pxPerDay).toBeGreaterThan(0);
});
});
describe("layout — today rule", () => {
test("today inside range produces a non-null todayX in the chart canvas", () => {
const out = layout([], [], vp({ todayISO: "2026-06-15" }));
expect(out.todayX).not.toBeNull();
expect(out.todayX!).toBeGreaterThan(out.chartLeft);
expect(out.todayX!).toBeLessThan(out.chartLeft + out.chartWidth);
});
test("today before range.from → todayX is null", () => {
const out = layout([], [], vp({ todayISO: "2025-12-15" }));
expect(out.todayX).toBeNull();
});
test("today after range.to → todayX is null", () => {
const out = layout([], [], vp({ todayISO: "2027-01-15" }));
expect(out.todayX).toBeNull();
});
test("today equals range.from → todayX sits at chartLeft", () => {
const out = layout([], [], vp({ todayISO: "2026-01-01" }));
expect(out.todayX).toBeCloseTo(out.chartLeft, 1);
});
});
describe("layout — lane stacking", () => {
test("empty lanes synthesises a single 'self' lane", () => {
const out = layout([], [], vp());
expect(out.laneRows).toHaveLength(1);
expect(out.laneRows[0].id).toBe("self");
});
test("multiple lanes stack vertically in input order", () => {
const lanes: LaneInfo[] = [
{ id: "self", label: "Hauptverfahren" },
{ id: "counterclaim:abc", label: "Widerklage" },
{ id: "parent_context:xyz", label: "Parent" },
];
const out = layout([], lanes, vp());
expect(out.laneRows).toHaveLength(3);
expect(out.laneRows[0].y).toBe(out.chartTop);
expect(out.laneRows[1].y).toBeGreaterThan(out.laneRows[0].y);
expect(out.laneRows[2].y).toBeGreaterThan(out.laneRows[1].y);
// All same height.
expect(out.laneRows[0].height).toBe(out.laneRows[1].height);
expect(out.laneRows[1].height).toBe(out.laneRows[2].height);
});
test("density compact gives smaller lane height than spacious", () => {
const compact = layout([], [], vp({ density: "compact" }));
const spacious = layout([], [], vp({ density: "spacious" }));
expect(compact.laneRows[0].height).toBeLessThan(spacious.laneRows[0].height);
});
});
describe("layout — marks", () => {
test("single deadline maps to one mark in the self lane", () => {
const events: TimelineEvent[] = [ev({ date: "2026-06-15" })];
const out = layout(events, [], vp());
expect(out.marks).toHaveLength(1);
expect(out.marks[0].eventIndex).toBe(0);
expect(out.marks[0].laneId).toBe("self");
expect(out.marks[0].undated).toBe(false);
});
test("event's x position matches its date offset from range.from", () => {
// June 15 is day 165 of 2026 (0-indexed from Jan 1).
const events: TimelineEvent[] = [ev({ date: "2026-06-15" })];
const out = layout(events, [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
const expectedX = out.chartLeft + 165 * out.pxPerDay;
expect(out.marks[0].x).toBeCloseTo(expectedX, 1);
});
test("event bucketed by lane_id matches the corresponding lane row", () => {
const lanes: LaneInfo[] = [
{ id: "self", label: "Self" },
{ id: "ccr", label: "CCR" },
];
const events: TimelineEvent[] = [
ev({ date: "2026-06-15", lane_id: "ccr" }),
];
const out = layout(events, lanes, vp());
const ccrRow = out.laneRows.find((r) => r.id === "ccr")!;
expect(out.marks[0].laneId).toBe("ccr");
expect(out.marks[0].y).toBeCloseTo(ccrRow.y + ccrRow.height / 2, 1);
});
test("unknown lane_id falls back to the first lane (defensive)", () => {
const lanes: LaneInfo[] = [{ id: "self", label: "Self" }];
const events: TimelineEvent[] = [
ev({ date: "2026-06-15", lane_id: "deleted-lane-id" }),
];
const out = layout(events, lanes, vp());
expect(out.marks[0].laneId).toBe("self");
});
test("events outside range are clipped (not emitted)", () => {
const events: TimelineEvent[] = [
ev({ date: "2025-01-01", title: "before" }),
ev({ date: "2026-06-15", title: "inside" }),
ev({ date: "2027-12-31", title: "after" }),
];
const out = layout(events, [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
expect(out.marks).toHaveLength(1);
expect(out.marks[0].eventIndex).toBe(1);
});
test("undated events go to the undated zone with undated=true", () => {
const events: TimelineEvent[] = [ev({ date: null, title: "court-set" })];
const out = layout(events, [], vp());
expect(out.marks).toHaveLength(1);
expect(out.marks[0].undated).toBe(true);
// Undated marks sit in the lane label gutter (x < chartLeft).
expect(out.marks[0].x).toBeLessThan(out.chartLeft);
});
});
describe("layout — mark shapes by kind+status", () => {
test("deadline.done → dot, deadline.open → dot, deadline.overdue → dot", () => {
const events: TimelineEvent[] = [
ev({ kind: "deadline", status: "done" }),
ev({ kind: "deadline", status: "open" }),
ev({ kind: "deadline", status: "overdue" }),
];
const out = layout(events, [], vp());
expect(out.marks.map((m) => m.shape)).toEqual(["dot", "dot", "dot"]);
});
test("milestone → diamond", () => {
const events: TimelineEvent[] = [ev({ kind: "milestone", status: "done" })];
const out = layout(events, [], vp());
expect(out.marks[0].shape).toBe("diamond");
});
test("appointment → dot (Slice 1 keeps it simple; bar variant deferred)", () => {
const events: TimelineEvent[] = [ev({ kind: "appointment", status: "open" })];
const out = layout(events, [], vp());
expect(out.marks[0].shape).toBe("dot");
});
test("projected.predicted → hatched-dot", () => {
const events: TimelineEvent[] = [ev({ kind: "projected", status: "predicted" })];
const out = layout(events, [], vp());
expect(out.marks[0].shape).toBe("hatched-dot");
});
test("projected.court_set → dashed-dot", () => {
const events: TimelineEvent[] = [ev({ kind: "projected", status: "court_set" })];
const out = layout(events, [], vp());
expect(out.marks[0].shape).toBe("dashed-dot");
});
});
describe("layout — axis ticks", () => {
test("short range (<90d) emits month ticks", () => {
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-02-28" }));
const kinds = new Set(out.axisTicks.map((t) => t.kind));
expect(kinds.has("month")).toBe(true);
});
test("medium range (90-730d) emits quarter ticks", () => {
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
const kinds = new Set(out.axisTicks.map((t) => t.kind));
expect(kinds.has("quarter")).toBe(true);
});
test("long range (>730d) emits year ticks", () => {
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2029-12-31" }));
const kinds = new Set(out.axisTicks.map((t) => t.kind));
expect(kinds.has("year")).toBe(true);
});
test("year-boundary ticks are flagged", () => {
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2027-12-31" }));
const yearBoundaries = out.axisTicks.filter((t) => t.isYearBoundary);
expect(yearBoundaries.length).toBeGreaterThanOrEqual(1);
});
test("all ticks fall inside the chart canvas horizontally", () => {
const out = layout([], [], vp());
for (const tick of out.axisTicks) {
expect(tick.x).toBeGreaterThanOrEqual(out.chartLeft - 0.5);
expect(tick.x).toBeLessThanOrEqual(out.chartLeft + out.chartWidth + 0.5);
}
});
});
describe("layout — undated counting", () => {
test("undated marks tallied separately from inside-range count", () => {
const events: TimelineEvent[] = [
ev({ date: "2026-06-15" }),
ev({ date: null }),
ev({ date: null }),
ev({ date: "2025-01-01" }), // out of range
];
const out = layout(events, [], vp());
expect(out.undatedCount).toBe(2);
expect(out.marks).toHaveLength(3); // 1 dated + 2 undated, the out-of-range one is clipped
});
});

View File

@@ -0,0 +1,985 @@
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
// shape-timeline-chart (t-paliad-177 Slice 1) — horizontal SVG Gantt
// renderer for the standalone Project Timeline / Chart page.
//
// Split into two concerns:
//
// layout(events, lanes, viewport): ChartLayout
// pure function — translates the wire shape into deterministic
// SVG-ready geometry (axis ticks, lane row y/height, mark x/y/shape,
// today-rule x). No DOM access. Table-driven tests pin this in
// shape-timeline-chart.test.ts.
//
// paint(layout, root): void (Slice 1, next commit)
// DOM-mutates an SVGSVGElement. Reads layout, never recomputes
// positions. Idempotent — calling twice with the same layout
// produces the same DOM.
//
// mount(host, opts): ChartHandle (Slice 1, next commit)
// End-to-end: fetches /api/projects/{id}/timeline, computes layout,
// paints, returns a handle with .refresh() / .dispose().
//
// Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §3.2 + §12.
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export type Density = "compact" | "standard" | "spacious";
export interface ChartViewport {
width: number;
height: number;
/** Reserved on the left for lane labels (and the undated zone). */
laneLabelWidth: number;
/** Reserved on top for the date axis. */
dateAxisHeight: number;
/** Today's date as ISO YYYY-MM-DD. Used to position the today rule. */
todayISO: string;
/** Inclusive ISO YYYY-MM-DD start of the chart's date range. */
rangeFrom: string;
/** Inclusive ISO YYYY-MM-DD end of the chart's date range. */
rangeTo: string;
density: Density;
}
export interface AxisTick {
x: number;
label: string;
kind: "year" | "quarter" | "month";
isYearBoundary: boolean;
}
export interface LaneRow {
id: string;
label: string;
y: number;
height: number;
}
export type MarkShape =
| "dot"
| "diamond"
| "hatched-dot"
| "dashed-dot";
export interface Mark {
/** Index into the original events array — paint() reuses this for tooltips + deep-links. */
eventIndex: number;
x: number;
y: number;
/** Radius for dot / hatched-dot / dashed-dot, half-diagonal for diamond. */
radius: number;
shape: MarkShape;
kind: TimelineEvent["kind"];
status: TimelineEvent["status"];
laneId: string;
undated: boolean;
}
export interface ChartLayout {
viewport: ChartViewport;
pxPerDay: number;
chartLeft: number;
chartTop: number;
chartWidth: number;
chartHeight: number;
axisTicks: AxisTick[];
laneRows: LaneRow[];
marks: Mark[];
/** Pixel x of the today rule, or null when today is outside the range. */
todayX: number | null;
undatedCount: number;
}
// ---------------------------------------------------------------------------
// Density tokens — single source of truth, used by layout() and CSS swap.
// ---------------------------------------------------------------------------
const LANE_HEIGHT: Record<Density, number> = {
compact: 24,
standard: 40,
spacious: 64,
};
const MARK_RADIUS: Record<Density, number> = {
compact: 5,
standard: 7,
spacious: 10,
};
// ---------------------------------------------------------------------------
// Date helpers — UTC throughout to avoid DST drift in day-math.
// ---------------------------------------------------------------------------
const DAY_MS = 86_400_000;
function parseISODay(iso: string): number | null {
// Accept "YYYY-MM-DD" and "YYYY-MM-DDTHH:MM:SSZ" (substrate marshals
// deadline.due_date as the UTC-midnight form — see format.ts).
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
if (!m) return null;
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
if (
!Number.isFinite(y) || !Number.isFinite(mo) || !Number.isFinite(d) ||
mo < 1 || mo > 12 || d < 1 || d > 31
) {
return null;
}
return Date.UTC(y, mo - 1, d);
}
function dayDelta(fromMs: number, toMs: number): number {
return Math.round((toMs - fromMs) / DAY_MS);
}
// ---------------------------------------------------------------------------
// Mark shape resolution — single mapping table, mirrors §6.2 of the design.
// ---------------------------------------------------------------------------
function markShape(kind: TimelineEvent["kind"], status: TimelineEvent["status"]): MarkShape {
if (kind === "milestone") return "diamond";
if (kind === "projected") {
if (status === "court_set") return "dashed-dot";
return "hatched-dot"; // predicted, predicted_overdue, off_script
}
// deadline + appointment + everything else → plain dot. Status drives
// colour saturation (see CSS palette tokens), not shape.
return "dot";
}
// ---------------------------------------------------------------------------
// Axis tick generation — granularity by total span.
// ---------------------------------------------------------------------------
function generateTicks(
fromMs: number,
toMs: number,
chartLeft: number,
pxPerDay: number,
): AxisTick[] {
const totalDays = dayDelta(fromMs, toMs);
const ticks: AxisTick[] = [];
// Walk from the first day-of-month >= fromMs forward.
const start = new Date(fromMs);
const yStart = start.getUTCFullYear();
const mStart = start.getUTCMonth();
// Density rules:
// <90d → month ticks (every month-start)
// 90-730 → quarter ticks (Jan, Apr, Jul, Oct)
// >730 → year ticks (Jan only)
let kind: AxisTick["kind"];
let monthStep: number;
if (totalDays < 90) {
kind = "month";
monthStep = 1;
} else if (totalDays <= 730) {
kind = "quarter";
monthStep = 3;
} else {
kind = "year";
monthStep = 12;
}
// For quarter/year ticks, snap the starting month to the next aligned
// boundary so the labels are calendar-aligned (Jan/Apr/Jul/Oct, not
// Feb/May/Aug/Nov).
let mCursor = mStart;
let yCursor = yStart;
if (kind === "quarter") {
const offset = mCursor % 3;
if (offset !== 0) mCursor += 3 - offset;
} else if (kind === "year") {
if (mCursor !== 0) {
mCursor = 0;
yCursor += 1;
}
}
// If the first day of fromMs is not month-1, advance by one month so we
// don't double-print the partial month at the very start.
if (kind === "month" && start.getUTCDate() !== 1) {
mCursor += 1;
}
while (mCursor >= 12) {
mCursor -= 12;
yCursor += 1;
}
// Emit ticks until past toMs.
while (true) {
const tickMs = Date.UTC(yCursor, mCursor, 1);
if (tickMs > toMs) break;
const days = dayDelta(fromMs, tickMs);
const x = chartLeft + days * pxPerDay;
const label = formatTickLabel(yCursor, mCursor, kind);
ticks.push({
x,
label,
kind,
isYearBoundary: mCursor === 0,
});
mCursor += monthStep;
while (mCursor >= 12) {
mCursor -= 12;
yCursor += 1;
}
}
return ticks;
}
const MONTH_DE = [
"Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez",
];
function formatTickLabel(year: number, month: number, kind: AxisTick["kind"]): string {
if (kind === "year") return String(year);
if (kind === "quarter") {
const q = Math.floor(month / 3) + 1;
return `Q${q} ${year}`;
}
return MONTH_DE[month];
}
// ---------------------------------------------------------------------------
// Public: layout
// ---------------------------------------------------------------------------
export function layout(
events: ReadonlyArray<TimelineEvent>,
lanes: ReadonlyArray<LaneInfo>,
viewport: ChartViewport,
): ChartLayout {
// -- Canvas geometry --------------------------------------------------
const chartLeft = viewport.laneLabelWidth;
const chartTop = viewport.dateAxisHeight;
const chartWidth = Math.max(0, viewport.width - chartLeft);
// chartHeight is derived from the number of lane rows so the SVG grows
// / shrinks vertically with the data, not the supplied viewport.height
// (which the caller uses as a hint — actual height comes back in
// viewport.height after the paint pass).
const laneCount = Math.max(1, lanes.length);
const laneHeight = LANE_HEIGHT[viewport.density];
const chartHeight = laneCount * laneHeight;
// -- Date math --------------------------------------------------------
const fromMs = parseISODay(viewport.rangeFrom);
const toMsRaw = parseISODay(viewport.rangeTo);
if (fromMs === null || toMsRaw === null) {
// Degenerate input — return an empty layout rather than NaN-paint.
return {
viewport,
pxPerDay: 0,
chartLeft,
chartTop,
chartWidth,
chartHeight,
axisTicks: [],
laneRows: synthLaneRows(lanes, chartTop, laneHeight),
marks: [],
todayX: null,
undatedCount: 0,
};
}
// Guard against to < from. Clamp the inverted case to a 1-day span so
// pxPerDay stays positive and finite.
const toMs = toMsRaw <= fromMs ? fromMs + DAY_MS : toMsRaw;
const totalDays = Math.max(1, dayDelta(fromMs, toMs));
const pxPerDay = chartWidth / totalDays;
// -- Today rule -------------------------------------------------------
const todayMs = parseISODay(viewport.todayISO);
let todayX: number | null = null;
if (todayMs !== null && todayMs >= fromMs && todayMs <= toMs) {
todayX = chartLeft + dayDelta(fromMs, todayMs) * pxPerDay;
}
// -- Lane rows --------------------------------------------------------
const laneRows = synthLaneRows(lanes, chartTop, laneHeight);
const laneIndex = new Map<string, LaneRow>();
for (const row of laneRows) laneIndex.set(row.id, row);
const fallbackLane = laneRows[0];
// -- Marks ------------------------------------------------------------
const marks: Mark[] = [];
let undatedCount = 0;
const radius = MARK_RADIUS[viewport.density];
for (let i = 0; i < events.length; i++) {
const event = events[i];
const laneRow = (event.lane_id && laneIndex.get(event.lane_id)) || fallbackLane;
if (!event.date) {
// Undated rows live in a gutter to the left of the chart canvas.
// We pile them up vertically inside the lane label area so they
// remain hover-/click-targets, but they don't compete with the
// date-axis-positioned marks for screen space.
undatedCount++;
const undatedX = chartLeft - viewport.laneLabelWidth * 0.25;
marks.push({
eventIndex: i,
x: undatedX,
y: laneRow.y + laneRow.height / 2,
radius,
shape: markShape(event.kind, event.status),
kind: event.kind,
status: event.status,
laneId: laneRow.id,
undated: true,
});
continue;
}
const ms = parseISODay(event.date);
if (ms === null) continue; // unparseable date, drop defensively
if (ms < fromMs || ms > toMs) continue; // outside range — clipped
const x = chartLeft + dayDelta(fromMs, ms) * pxPerDay;
const y = laneRow.y + laneRow.height / 2;
marks.push({
eventIndex: i,
x,
y,
radius,
shape: markShape(event.kind, event.status),
kind: event.kind,
status: event.status,
laneId: laneRow.id,
undated: false,
});
}
// -- Axis ticks -------------------------------------------------------
const axisTicks = generateTicks(fromMs, toMs, chartLeft, pxPerDay);
return {
viewport,
pxPerDay,
chartLeft,
chartTop,
chartWidth,
chartHeight,
axisTicks,
laneRows,
marks,
todayX,
undatedCount,
};
}
function synthLaneRows(
lanes: ReadonlyArray<LaneInfo>,
chartTop: number,
laneHeight: number,
): LaneRow[] {
if (lanes.length === 0) {
return [{ id: "self", label: "", y: chartTop, height: laneHeight }];
}
return lanes.map((lane, idx) => ({
id: lane.id,
label: lane.label,
y: chartTop + idx * laneHeight,
height: laneHeight,
}));
}
// ---------------------------------------------------------------------------
// Public: paint
// ---------------------------------------------------------------------------
const SVG_NS = "http://www.w3.org/2000/svg";
function svg(name: string, attrs: Record<string, string | number> = {}): SVGElement {
const el = document.createElementNS(SVG_NS, name);
for (const [k, v] of Object.entries(attrs)) {
el.setAttribute(k, String(v));
}
return el;
}
/**
* paint mutates an existing SVGSVGElement to reflect a ChartLayout.
* Idempotent: clears prior children before painting, so calling twice
* with the same layout produces the same DOM.
*
* Events are *not* wired here — mount() attaches the delegated listeners
* after paint() returns. paint() stays pure-render so it stays cheap to
* call from a resize / palette swap.
*/
export function paint(
chart: ChartLayout,
root: SVGSVGElement,
events: ReadonlyArray<TimelineEvent>,
): void {
// Clear prior contents.
while (root.firstChild) root.removeChild(root.firstChild);
const totalHeight = chart.chartTop + chart.chartHeight + 24; // 24px bottom pad for axis labels
root.setAttribute("viewBox", `0 0 ${chart.viewport.width} ${totalHeight}`);
root.setAttribute("preserveAspectRatio", "xMinYMin meet");
root.setAttribute("role", "img");
root.setAttribute("aria-label", "Project Timeline / Chart");
// <defs> — hatched pattern for projected marks.
const defs = svg("defs");
const pattern = svg("pattern", {
id: "chart-hatch",
patternUnits: "userSpaceOnUse",
width: 4,
height: 4,
});
pattern.appendChild(svg("path", {
d: "M0,4 L4,0",
stroke: "currentColor",
"stroke-width": 1,
fill: "none",
}));
defs.appendChild(pattern);
root.appendChild(defs);
// Layer order: grid → lane separators → today rule → marks → labels.
const gGrid = svg("g", { class: "chart-grid" });
root.appendChild(gGrid);
// Date axis ticks — vertical guidelines + labels at top.
for (const tick of chart.axisTicks) {
gGrid.appendChild(svg("line", {
class: tick.isYearBoundary
? "chart-tick chart-tick--year"
: "chart-tick",
x1: tick.x,
y1: chart.chartTop,
x2: tick.x,
y2: chart.chartTop + chart.chartHeight,
}));
const label = svg("text", {
class: "chart-tick-label",
x: tick.x + 4,
y: chart.chartTop - 8,
});
label.textContent = tick.label;
gGrid.appendChild(label);
}
// Lane separators — horizontal lines between rows + labels in the gutter.
// Labels live inside <foreignObject> so HTML/CSS handles ellipsis +
// tooltip cleanly. SVG <text> has no auto-clipping and long titles
// would bleed into the chart canvas (t-paliad-211).
const labelPadding = 8;
const labelMaxWidth = Math.max(0, chart.viewport.laneLabelWidth - labelPadding * 2);
for (let i = 0; i < chart.laneRows.length; i++) {
const row = chart.laneRows[i];
if (i > 0) {
gGrid.appendChild(svg("line", {
class: "chart-lane-separator",
x1: 0,
y1: row.y,
x2: chart.viewport.width,
y2: row.y,
}));
}
if (row.label) {
const fo = svg("foreignObject", {
class: "chart-lane-label-fo",
x: labelPadding,
y: row.y,
width: labelMaxWidth,
height: row.height,
});
const div = document.createElement("div");
div.className = "chart-lane-label";
div.textContent = row.label;
div.title = row.label;
fo.appendChild(div);
gGrid.appendChild(fo);
}
}
// Today rule — vertical lime line + "Heute" label.
if (chart.todayX !== null) {
gGrid.appendChild(svg("line", {
class: "chart-today-rule",
x1: chart.todayX,
y1: chart.chartTop - 4,
x2: chart.todayX,
y2: chart.chartTop + chart.chartHeight + 4,
}));
const todayLabel = svg("text", {
class: "chart-today-label",
x: chart.todayX + 4,
y: chart.chartTop + chart.chartHeight + 18,
});
todayLabel.textContent = "Heute";
gGrid.appendChild(todayLabel);
}
// Marks.
const gMarks = svg("g", { class: "chart-marks" });
root.appendChild(gMarks);
for (const mark of chart.marks) {
const event = events[mark.eventIndex];
const markEl = paintMark(mark, event);
gMarks.appendChild(markEl);
}
}
function paintMark(mark: Mark, event: TimelineEvent): SVGElement {
// Wrap every mark in a <g> with data-* attributes so mount() can do
// event-delegation off the top-level <svg> without per-mark listeners.
const g = svg("g", {
class: markClassName(mark),
"data-event-index": mark.eventIndex,
"data-kind": mark.kind,
"data-status": mark.status,
"data-lane": mark.laneId,
"data-undated": mark.undated ? "1" : "0",
"data-deadline-id": event.deadline_id || "",
"data-appointment-id": event.appointment_id || "",
"data-project-event-id": event.project_event_id || "",
role: "img",
tabindex: 0,
});
// ARIA label so screen-readers can read each mark (§13).
const title = svg("title");
title.textContent = markAriaLabel(mark, event);
g.appendChild(title);
// Generous invisible hit-target so dots are easy to click without
// hunting (12px hit halo around a 7px standard radius).
g.appendChild(svg("circle", {
class: "chart-mark-hit",
cx: mark.x,
cy: mark.y,
r: mark.radius + 6,
fill: "transparent",
}));
switch (mark.shape) {
case "dot": {
const c = svg("circle", {
class: "chart-mark-dot",
cx: mark.x,
cy: mark.y,
r: mark.radius,
});
g.appendChild(c);
break;
}
case "diamond": {
const r = mark.radius;
g.appendChild(svg("polygon", {
class: "chart-mark-diamond",
points: `${mark.x},${mark.y - r} ${mark.x + r},${mark.y} ${mark.x},${mark.y + r} ${mark.x - r},${mark.y}`,
}));
break;
}
case "hatched-dot": {
g.appendChild(svg("circle", {
class: "chart-mark-hatched",
cx: mark.x,
cy: mark.y,
r: mark.radius,
fill: "url(#chart-hatch)",
}));
break;
}
case "dashed-dot": {
g.appendChild(svg("circle", {
class: "chart-mark-dashed",
cx: mark.x,
cy: mark.y,
r: mark.radius,
}));
break;
}
}
return g;
}
function markClassName(mark: Mark): string {
const parts = ["chart-mark", `chart-mark--${mark.kind}`, `chart-mark--status-${mark.status}`];
if (mark.undated) parts.push("chart-mark--undated");
return parts.join(" ");
}
function markAriaLabel(mark: Mark, event: TimelineEvent): string {
const dateStr = event.date ? event.date.slice(0, 10) : "Datum offen";
return `${event.title}${event.kind} (${event.status}) — ${dateStr}`;
}
// ---------------------------------------------------------------------------
// Public: mount
// ---------------------------------------------------------------------------
/** Palette presets from design §5.1. Each is a CSS-var override hung off
* `.smart-timeline-chart[data-palette="<name>"]`; the renderer never
* reads palette state directly. */
export type Palette =
| "default"
| "kind-coded"
| "track-coded"
| "high-contrast"
| "print";
export const ALL_PALETTES: ReadonlyArray<Palette> = [
"default",
"kind-coded",
"track-coded",
"high-contrast",
"print",
];
export const ALL_DENSITIES: ReadonlyArray<Density> = [
"compact",
"standard",
"spacious",
];
/** Range presets from design §10 + faraday-Q8 default. The chart caller
* drives the active preset via setRange; "all" derives bounds from the
* loaded events at repaint time so adding / completing a row reflows. */
export type RangePreset = "1y" | "2y" | "all" | "custom";
export const ALL_RANGE_PRESETS: ReadonlyArray<RangePreset> = [
"1y",
"2y",
"all",
"custom",
];
export interface ChartMountOpts {
projectId: string;
todayISO?: string;
density?: Density;
palette?: Palette;
/** Initial range preset. Default "1y" (today-1y..today+1y) per design Q8. */
rangePreset?: RangePreset;
/** When rangePreset === "custom", these supply the bounds. Ignored for
* preset values — those derive bounds from the preset + todayISO (or,
* for "all", from the loaded events). */
rangeFrom?: string;
rangeTo?: string;
/** Optional callback fired when the user clicks a mark with a known
* deep-link target. Receives the underlying TimelineEvent. */
onMarkClick?: (event: TimelineEvent) => void;
/** Optional callback fired after every refresh() so the host can
* re-render dynamic UI (e.g. lane filter chips). */
onDataLoaded?: (data: { events: TimelineEvent[]; lanes: LaneInfo[] }) => void;
/** Initial visible-lane allowlist. null = show all (default).
* Lane ids not present in the response are silently dropped. */
visibleLanes?: string[] | null;
/** Pre-loaded data — used by Custom Views (Slice 4) where the rows
* come from ViewService not /api/projects/{id}/timeline. When set,
* mount() skips the initial fetch and paints from this data; the
* handle's refresh() still hits the project endpoint (caller can
* swap the chart back to project-mode via the standalone /chart URL). */
staticData?: { events: TimelineEvent[]; lanes: LaneInfo[] };
}
export interface ChartHandle {
/** Re-fetches the timeline and re-paints. */
refresh: () => Promise<void>;
/** Removes event listeners + tears down the SVG. */
dispose: () => void;
/** Returns the last computed layout (useful for tests / debugging). */
getLayout: () => ChartLayout | null;
/** Swap palette via data-palette attribute. Pure CSS-var swap — no repaint. */
setPalette: (palette: Palette) => void;
/** Swap density. Re-runs layout() since lane height / mark radius change. */
setDensity: (density: Density) => void;
/** Switch range preset. "all" derives bounds from the loaded events;
* "custom" expects customFrom + customTo (otherwise it falls back to
* today-1y..today+1y). All others are time-shifted from todayISO. */
setRange: (preset: RangePreset, customFrom?: string, customTo?: string) => void;
/** Set the lane allowlist. null = show all lanes (default). Unknown
* ids in the passed array are silently dropped on repaint. */
setVisibleLanes: (lanes: string[] | null) => void;
/** The raw SVG node — chart-export.ts reads this for SVG / PNG / print. */
getSVGElement: () => SVGSVGElement;
/** Last-loaded data — chart-export.ts reads this for CSV / JSON / iCal. */
getData: () => { events: TimelineEvent[]; lanes: LaneInfo[] };
}
interface TimelineEnvelope {
events: TimelineEvent[];
lanes: LaneInfo[];
}
/**
* mount builds a chart inside the given host element. The host's
* dimensions drive the SVG width; height grows from the lane row count.
* Returns a handle for refresh / dispose.
*/
export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
host.classList.add("smart-timeline-chart-host");
// Empty / error placeholders.
const messageEl = document.createElement("div");
messageEl.className = "smart-timeline-chart-message";
messageEl.textContent = "";
host.appendChild(messageEl);
// The SVG root we paint into.
const svgEl = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
svgEl.classList.add("smart-timeline-chart");
svgEl.setAttribute("data-palette", opts.palette ?? "default");
svgEl.setAttribute("data-density", opts.density ?? "standard");
host.appendChild(svgEl);
let lastEvents: TimelineEvent[] = [];
let lastLayout: ChartLayout | null = null;
const todayISO = opts.todayISO ?? today();
let currentDensity: Density = opts.density ?? "standard";
let currentRangePreset: RangePreset = opts.rangePreset ?? "1y";
let customRangeFrom: string = opts.rangeFrom ?? shiftYears(todayISO, -1);
let customRangeTo: string = opts.rangeTo ?? shiftYears(todayISO, 1);
let currentVisibleLanes: Set<string> | null = opts.visibleLanes
? new Set(opts.visibleLanes)
: null;
function resolveRange(): { from: string; to: string } {
switch (currentRangePreset) {
case "1y":
return { from: shiftYears(todayISO, -1), to: shiftYears(todayISO, 1) };
case "2y":
return { from: shiftYears(todayISO, -2), to: shiftYears(todayISO, 2) };
case "all":
return rangeFromEvents(lastEvents, todayISO);
case "custom":
return { from: customRangeFrom, to: customRangeTo };
}
}
function repaint(): void {
const rect = host.getBoundingClientRect();
// Minimum width keeps the canvas usable when the host is hidden /
// about to be sized; resize listener will repaint on real layout.
const width = Math.max(640, rect.width || 1000);
const { from, to } = resolveRange();
const viewport: ChartViewport = {
width,
height: 400,
laneLabelWidth: 200,
dateAxisHeight: 40,
todayISO,
rangeFrom: from,
rangeTo: to,
density: currentDensity,
};
// Lane allowlist filter. null = show all; otherwise drop both the
// lane rows AND the events whose lane_id sits outside the allowlist.
// (We don't fall back to "first lane" here — that's only sensible
// when a stale id slips through; an explicit hide is a hide.)
let renderLanes = [...currentLanes];
let renderEvents: TimelineEvent[] = lastEvents;
if (currentVisibleLanes !== null) {
const allow = currentVisibleLanes;
renderLanes = currentLanes.filter((l) => allow.has(l.id));
renderEvents = lastEvents.filter((e) => {
// Empty / missing lane_id is treated as "self" — included only
// when the synthetic "self" lane is allowed.
const id = e.lane_id || "self";
return allow.has(id);
});
}
const chart = layout(renderEvents, renderLanes, viewport);
lastLayout = chart;
paint(chart, svgEl, renderEvents);
svgEl.setAttribute("width", String(width));
svgEl.setAttribute("height", String(chart.chartTop + chart.chartHeight + 32));
}
let currentLanes: LaneInfo[] = [];
async function refresh(): Promise<void> {
messageEl.textContent = "Lädt …";
messageEl.classList.remove("smart-timeline-chart-message--error");
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(opts.projectId)}/timeline`,
);
if (!resp.ok) {
messageEl.textContent = "Timeline konnte nicht geladen werden.";
messageEl.classList.add("smart-timeline-chart-message--error");
return;
}
const body = await resp.json();
// Defensive: tolerate the legacy []TimelineEvent shape (pre-Slice-4)
// even though the Slice-4 envelope is the contract today.
if (Array.isArray(body)) {
lastEvents = body as TimelineEvent[];
currentLanes = [];
} else {
const env = body as TimelineEnvelope;
lastEvents = env.events ?? [];
currentLanes = env.lanes ?? [];
}
if (lastEvents.length === 0) {
messageEl.textContent = "Keine Ereignisse im gewählten Zeitraum.";
} else {
messageEl.textContent = "";
}
// Drop stale lane ids from the allowlist — a deleted CCR / child
// case shouldn't keep its lane id alive across re-fetches.
if (currentVisibleLanes !== null) {
const valid = new Set(currentLanes.map((l) => l.id));
valid.add("self"); // synthetic lane always allowed
const trimmed = new Set<string>();
for (const id of currentVisibleLanes) {
if (valid.has(id)) trimmed.add(id);
}
currentVisibleLanes = trimmed.size === 0 ? null : trimmed;
}
repaint();
if (opts.onDataLoaded) {
opts.onDataLoaded({ events: lastEvents, lanes: currentLanes });
}
} catch (err) {
messageEl.textContent = "Netzwerkfehler beim Laden der Timeline.";
messageEl.classList.add("smart-timeline-chart-message--error");
}
}
// Click delegation — read data-* attrs to deep-link.
function handleClick(e: Event) {
const target = e.target as Element | null;
if (!target) return;
const g = target.closest("g.chart-mark") as Element | null;
if (!g) return;
const indexAttr = g.getAttribute("data-event-index");
if (!indexAttr) return;
const idx = Number(indexAttr);
const event = lastEvents[idx];
if (!event) return;
if (opts.onMarkClick) {
opts.onMarkClick(event);
return;
}
if (event.deadline_id) {
window.location.href = `/deadlines/${encodeURIComponent(event.deadline_id)}`;
} else if (event.appointment_id) {
window.location.href = `/appointments/${encodeURIComponent(event.appointment_id)}`;
}
// Milestones + projected rows have no detail page today — no-op.
}
// Resize handler — debounced.
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
function handleResize() {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
repaint();
}, 120);
}
svgEl.addEventListener("click", handleClick);
window.addEventListener("resize", handleResize);
// If the caller supplied data up front (Custom Views host path), skip
// the project-timeline fetch entirely — paint from the supplied rows.
// Otherwise kick off the initial /api/projects/{id}/timeline load.
if (opts.staticData) {
lastEvents = opts.staticData.events;
currentLanes = opts.staticData.lanes;
if (lastEvents.length === 0) {
messageEl.textContent = "Keine Ereignisse im gewählten Zeitraum.";
} else {
messageEl.textContent = "";
}
repaint();
if (opts.onDataLoaded) {
opts.onDataLoaded({ events: lastEvents, lanes: currentLanes });
}
} else {
void refresh();
}
return {
refresh,
getLayout: () => lastLayout,
setPalette: (palette: Palette) => {
svgEl.setAttribute("data-palette", palette);
},
setDensity: (density: Density) => {
currentDensity = density;
svgEl.setAttribute("data-density", density);
repaint();
},
setRange: (preset: RangePreset, customFrom?: string, customTo?: string) => {
currentRangePreset = preset;
if (preset === "custom") {
if (customFrom) customRangeFrom = customFrom;
if (customTo) customRangeTo = customTo;
}
svgEl.setAttribute("data-range-preset", preset);
repaint();
},
setVisibleLanes: (lanes: string[] | null) => {
currentVisibleLanes = lanes ? new Set(lanes) : null;
repaint();
},
getSVGElement: () => svgEl,
getData: () => ({ events: lastEvents, lanes: currentLanes }),
dispose: () => {
svgEl.removeEventListener("click", handleClick);
window.removeEventListener("resize", handleResize);
if (resizeTimer) clearTimeout(resizeTimer);
if (svgEl.parentNode) svgEl.parentNode.removeChild(svgEl);
if (messageEl.parentNode) messageEl.parentNode.removeChild(messageEl);
},
};
}
/** Resolve the "all" preset bounds from the loaded events. Empty data
* falls back to the 1y default so the chart canvas isn't degenerate. */
function rangeFromEvents(
events: ReadonlyArray<TimelineEvent>,
todayISO: string,
): { from: string; to: string } {
let minMs: number | null = null;
let maxMs: number | null = null;
for (const ev of events) {
if (!ev.date) continue;
const ms = parseISODay(ev.date);
if (ms === null) continue;
if (minMs === null || ms < minMs) minMs = ms;
if (maxMs === null || ms > maxMs) maxMs = ms;
}
if (minMs === null || maxMs === null) {
return { from: shiftYears(todayISO, -1), to: shiftYears(todayISO, 1) };
}
// Pad +30d at the right so the last event isn't flush against the edge.
const fromDate = new Date(minMs);
const toDate = new Date(maxMs + 30 * 86_400_000);
return {
from: toISO(fromDate),
to: toISO(toDate),
};
}
function toISO(d: Date): string {
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
}
function today(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${dd}`;
}
function shiftYears(iso: string, delta: number): string {
const ms = parseISODay(iso);
if (ms === null) return iso;
const d = new Date(ms);
return `${d.getUTCFullYear() + delta}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
}

View File

@@ -0,0 +1,140 @@
import { describe, expect, test } from "bun:test";
import { adapt } from "./shape-timeline-cv";
import type { ViewRow } from "./types";
// t-paliad-177 Slice 4 — adapter contract tests for ViewRow →
// TimelineEvent + LaneInfo. Pure function, no DOM access.
// The actual chart-render math is pinned by shape-timeline-chart.test.ts;
// this file pins the adapter's lossy translation rules from §13.4.
const baseRow = (overrides: Partial<ViewRow> = {}): ViewRow => ({
kind: "deadline",
id: "d1",
title: "Test",
event_date: "2026-06-15",
detail: {},
...overrides,
});
describe("adapt — kind mapping", () => {
test("deadline → kind='deadline' + deadline_id", () => {
const out = adapt([baseRow({ kind: "deadline", id: "abc" })]);
expect(out.events).toHaveLength(1);
expect(out.events[0].kind).toBe("deadline");
expect(out.events[0].deadline_id).toBe("abc");
expect(out.events[0].appointment_id).toBeUndefined();
expect(out.events[0].project_event_id).toBeUndefined();
});
test("appointment → kind='appointment' + appointment_id", () => {
const out = adapt([baseRow({ kind: "appointment", id: "x" })]);
expect(out.events[0].kind).toBe("appointment");
expect(out.events[0].appointment_id).toBe("x");
});
test("project_event → kind='milestone' + project_event_id", () => {
const out = adapt([baseRow({ kind: "project_event", id: "y" })]);
expect(out.events[0].kind).toBe("milestone");
expect(out.events[0].project_event_id).toBe("y");
});
test("approval_request is skipped", () => {
const out = adapt([
baseRow({ kind: "deadline" }),
baseRow({ kind: "approval_request" }),
baseRow({ kind: "appointment" }),
]);
expect(out.events).toHaveLength(2);
expect(out.events.map((e) => e.kind)).toEqual(["deadline", "appointment"]);
});
});
describe("adapt — lane bucketing by project_id (cross-project chart)", () => {
test("one lane per unique project_id, first-seen order", () => {
const out = adapt([
baseRow({ project_id: "p1", project_title: "Project 1" }),
baseRow({ project_id: "p2", project_title: "Project 2" }),
baseRow({ project_id: "p1", project_title: "Project 1" }),
]);
expect(out.lanes).toHaveLength(2);
expect(out.lanes[0].id).toBe("p1");
expect(out.lanes[0].label).toBe("Project 1");
expect(out.lanes[1].id).toBe("p2");
});
test("project_title preferred over project_reference for the label", () => {
const out = adapt([
baseRow({ project_id: "p1", project_title: "Nice Name", project_reference: "REF-1" }),
]);
expect(out.lanes[0].label).toBe("Nice Name");
});
test("falls back to project_reference when title missing", () => {
const out = adapt([
baseRow({ project_id: "p1", project_reference: "REF-1" }),
]);
expect(out.lanes[0].label).toBe("REF-1");
});
test("missing project_id collapses to synthetic 'self' lane", () => {
const out = adapt([baseRow({ project_id: undefined })]);
expect(out.lanes).toHaveLength(1);
expect(out.lanes[0].id).toBe("self");
expect(out.events[0].lane_id).toBe("self");
expect(out.events[0].track).toBe("parent");
});
test("event lane_id matches its lane row id", () => {
const out = adapt([
baseRow({ project_id: "p1", project_title: "A" }),
baseRow({ project_id: "p2", project_title: "B" }),
]);
expect(out.events[0].lane_id).toBe("p1");
expect(out.events[1].lane_id).toBe("p2");
});
});
describe("adapt — status extraction", () => {
test("deadline status 'done' comes through from detail", () => {
const out = adapt([
baseRow({ kind: "deadline", detail: { status: "done" } }),
]);
expect(out.events[0].status).toBe("done");
});
test("deadline status 'overdue' comes through", () => {
const out = adapt([
baseRow({ kind: "deadline", detail: { status: "overdue" } }),
]);
expect(out.events[0].status).toBe("overdue");
});
test("unknown / missing detail.status defaults to 'open'", () => {
const out = adapt([
baseRow({ kind: "deadline", detail: { status: "weird-value" } }),
baseRow({ kind: "appointment" }),
baseRow({ kind: "project_event" }),
]);
expect(out.events.map((e) => e.status)).toEqual(["open", "open", "open"]);
});
});
describe("adapt — date passthrough", () => {
test("event_date is forwarded to TimelineEvent.date", () => {
const out = adapt([baseRow({ event_date: "2026-08-15T00:00:00Z" })]);
expect(out.events[0].date).toBe("2026-08-15T00:00:00Z");
});
test("empty event_date becomes null (undated)", () => {
const out = adapt([baseRow({ event_date: "" })]);
expect(out.events[0].date).toBeNull();
});
});
describe("adapt — empty input", () => {
test("empty rows array returns empty events + empty lanes", () => {
const out = adapt([]);
expect(out.events).toHaveLength(0);
expect(out.lanes).toHaveLength(0);
});
});

View File

@@ -0,0 +1,254 @@
import {
mount,
type ChartHandle,
type Density,
type Palette,
type RangePreset,
} from "./shape-timeline-chart";
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
import type { RenderSpec, ViewRow } from "./types";
import { t } from "../i18n";
// shape-timeline-cv (t-paliad-177 Slice 4, faraday-Q7) — Custom Views
// host for the chart renderer.
//
// Adapter contract: ViewRow → TimelineEvent + LaneInfo.
// - deadline + appointment + project_event rows render as actual marks.
// - approval_request rows are skipped (no chart-meaningful date).
// - Lane axis = project_id; the cross-project chart use case (design
// §10) groups events by their owning project. Rows without a
// project_id collapse into a synthetic "self" lane.
// - NO projected rows. ViewService doesn't run the fristenrechner
// calculator, so the CV chart shows actuals only. The host page
// ships a one-time caveat tooltip (see C3) explaining this.
//
// Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5 + §13.4.
// Zoom levels in ascending span (t-paliad-211). Width-only — the chart's
// existing range presets already provide three meaningful zoom levels.
// Stored in URL as ?tl_zoom=1y|2y|all.
const ZOOM_LEVELS: RangePreset[] = ["1y", "2y", "all"];
const ZOOM_PARAM = "tl_zoom";
export function renderTimelineShape(
host: HTMLElement,
rows: ReadonlyArray<ViewRow>,
render: RenderSpec,
): ChartHandle {
// Tear down any previous mount so re-rendering the shape (e.g. shape
// chip switch on /views/{slug}) doesn't stack SVGs.
host.innerHTML = "";
const { events, lanes } = adapt(rows);
const cfg = render.timeline ?? {};
// Resolve the initial zoom: URL > render spec > "1y" default.
const initialZoom = resolveInitialZoom(cfg.range_preset);
// Toolbar lives above the chart in its own row so it doesn't compete
// with the date-axis / lane labels for space.
const toolbar = document.createElement("div");
toolbar.className = "views-timeline-toolbar";
host.appendChild(toolbar);
const chartHost = document.createElement("div");
chartHost.className = "views-timeline-chart-host-inner";
host.appendChild(chartHost);
// The CV adapter has no per-project "id" to fetch live timeline data
// for — we hand mount() a placeholder projectId and the staticData
// pre-loaded array so it skips the project endpoint entirely. If the
// user clicks a mark, the renderer's default click handler still
// resolves /deadlines/{id} / /appointments/{id} from the adapted
// event's id field, so deep-links land on the correct entity page.
const handle = mount(chartHost, {
projectId: "cv",
staticData: { events, lanes },
palette: (cfg.palette as Palette | undefined) ?? "default",
density: (cfg.density as Density | undefined) ?? "standard",
rangePreset: initialZoom,
rangeFrom: cfg.range_from,
rangeTo: cfg.range_to,
});
let currentZoom = initialZoom;
const setZoom = (next: RangePreset) => {
if (next === currentZoom) return;
currentZoom = next;
handle.setRange(next);
writeZoomURL(next);
paintToolbar();
};
const paintToolbar = () => {
toolbar.innerHTML = "";
const zoomGroup = document.createElement("div");
zoomGroup.className = "views-timeline-zoom-group";
const zoomLabel = document.createElement("span");
zoomLabel.className = "views-timeline-zoom-label";
zoomLabel.textContent = t("views.timeline.zoom.label");
zoomGroup.appendChild(zoomLabel);
const zoomOut = document.createElement("button");
zoomOut.type = "button";
zoomOut.className = "btn-secondary btn-small views-timeline-zoom-btn";
zoomOut.setAttribute("aria-label", t("views.timeline.zoom.out"));
zoomOut.title = t("views.timeline.zoom.out");
zoomOut.textContent = "";
zoomOut.disabled = currentZoom === ZOOM_LEVELS[ZOOM_LEVELS.length - 1];
zoomOut.addEventListener("click", () => {
const idx = ZOOM_LEVELS.indexOf(currentZoom);
if (idx < ZOOM_LEVELS.length - 1) setZoom(ZOOM_LEVELS[idx + 1]);
});
zoomGroup.appendChild(zoomOut);
// Active-level chips (1y / 2y / all). Clicking jumps directly.
const chips = document.createElement("div");
chips.className = "views-timeline-zoom-chips agenda-chip-row";
for (const level of ZOOM_LEVELS) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "agenda-chip views-timeline-zoom-chip"
+ (level === currentZoom ? " agenda-chip-active" : "");
chip.dataset.zoom = level;
chip.textContent = t(zoomLevelKey(level));
chip.addEventListener("click", () => setZoom(level));
chips.appendChild(chip);
}
zoomGroup.appendChild(chips);
const zoomIn = document.createElement("button");
zoomIn.type = "button";
zoomIn.className = "btn-secondary btn-small views-timeline-zoom-btn";
zoomIn.setAttribute("aria-label", t("views.timeline.zoom.in"));
zoomIn.title = t("views.timeline.zoom.in");
zoomIn.textContent = "+";
zoomIn.disabled = currentZoom === ZOOM_LEVELS[0];
zoomIn.addEventListener("click", () => {
const idx = ZOOM_LEVELS.indexOf(currentZoom);
if (idx > 0) setZoom(ZOOM_LEVELS[idx - 1]);
});
zoomGroup.appendChild(zoomIn);
toolbar.appendChild(zoomGroup);
};
paintToolbar();
// Apply the URL zoom if it differed from the spec — mount() already
// used initialZoom so this is a no-op when URL was empty. But when URL
// disagreed with the spec, mount() honoured the URL and the toolbar
// already reflects that, so nothing extra to do here.
return handle;
}
function zoomLevelKey(level: RangePreset): "views.timeline.zoom.1y" | "views.timeline.zoom.2y" | "views.timeline.zoom.all" {
if (level === "1y") return "views.timeline.zoom.1y";
if (level === "2y") return "views.timeline.zoom.2y";
return "views.timeline.zoom.all";
}
function resolveInitialZoom(spec: string | undefined): RangePreset {
const params = new URLSearchParams(window.location.search);
const raw = params.get(ZOOM_PARAM);
if (raw && (ZOOM_LEVELS as string[]).includes(raw)) return raw as RangePreset;
if (spec && (ZOOM_LEVELS as string[]).includes(spec)) return spec as RangePreset;
return "1y";
}
function writeZoomURL(zoom: RangePreset): void {
const url = new URL(window.location.href);
url.searchParams.set(ZOOM_PARAM, zoom);
history.replaceState(null, "", url.toString());
}
export interface AdapterResult {
events: TimelineEvent[];
lanes: LaneInfo[];
}
/** Exported for tests (shape-timeline-cv.test.ts). Pure — no DOM. */
export function adapt(rows: ReadonlyArray<ViewRow>): AdapterResult {
const events: TimelineEvent[] = [];
// Lane order = first-seen order of project_ids in rows, so the user
// sees lanes in the order their data was returned (typically date-
// sorted). Deterministic, no surprise re-ordering on re-renders.
const laneIndex = new Map<string, LaneInfo>();
for (const row of rows) {
if (row.kind === "approval_request") {
// Approval requests have no event_date in the chart sense; they
// represent pending decisions, not scheduled work. Skip.
continue;
}
const laneId = row.project_id || "self";
if (!laneIndex.has(laneId)) {
laneIndex.set(laneId, {
id: laneId,
label: row.project_title || row.project_reference || laneLabelFallback(laneId),
project_id: row.project_id,
});
}
const event: TimelineEvent = {
kind: toTimelineKind(row.kind),
status: extractStatus(row),
track: laneId === "self" ? "parent" : "child:" + laneId,
date: row.event_date || null,
title: row.title,
description: row.subtitle,
lane_id: laneId,
};
// Set the right provenance id so the renderer's click handler can
// deep-link to /deadlines/{id} / /appointments/{id}.
switch (row.kind) {
case "deadline":
event.deadline_id = row.id;
break;
case "appointment":
event.appointment_id = row.id;
break;
case "project_event":
event.project_event_id = row.id;
break;
}
events.push(event);
}
return { events, lanes: [...laneIndex.values()] };
}
function toTimelineKind(kind: ViewRow["kind"]): TimelineEvent["kind"] {
// ViewRow "project_event" maps to chart "milestone" — they're the
// same underlying paliad.project_events row, the chart just uses a
// different name because milestones are the chart-meaningful subset.
if (kind === "project_event") return "milestone";
// Defensive: approval_request was filtered earlier, but TS doesn't
// know that. Default to "milestone" for any unexpected kind.
if (kind === "deadline" || kind === "appointment") return kind;
return "milestone";
}
/** Status defaults to "open" — ViewRow doesn't carry chart-status
* semantics directly, and the underlying detail json shape varies per
* kind. The chart's color saturation maps status → fill / ring style,
* so "open" gives every mark a sensible default (filled, full color).
* Detail-driven status lookup is a polish job for a future slice. */
function extractStatus(row: ViewRow): TimelineEvent["status"] {
if (row.kind === "deadline") {
const d = row.detail as { status?: string };
if (d.status === "done" || d.status === "overdue") {
return d.status as TimelineEvent["status"];
}
}
return "open";
}
function laneLabelFallback(id: string): string {
if (id === "self") return "(ohne Projekt)";
// Truncated UUID is more useful than a bare 36-char string.
return id.slice(0, 8);
}

View File

@@ -69,7 +69,15 @@ export interface FilterSpec {
predicates?: Partial<Record<DataSource, Predicates>>;
}
export type RenderShape = "list" | "cards" | "calendar";
export type RenderShape = "list" | "cards" | "calendar" | "timeline";
export interface TimelineCVConfig {
palette?: "default" | "kind-coded" | "track-coded" | "high-contrast" | "print";
density?: "compact" | "standard" | "spacious";
range_preset?: "1y" | "2y" | "all" | "custom";
range_from?: string;
range_to?: string;
}
export type ListRowAction = "navigate" | "complete_toggle" | "approve" | "none";
@@ -96,6 +104,7 @@ export interface RenderSpec {
list?: ListConfig;
cards?: CardsConfig;
calendar?: CalendarConfig;
timeline?: TimelineCVConfig;
}
// ViewRow — the discriminated row shape from ViewService.RunSpec.

View File

@@ -0,0 +1,517 @@
// Shared core for Fristenrechner-style proceeding-timeline rendering.
//
// Both /tools/fristenrechner (deadline determination) and
// /tools/verfahrensablauf (abstract browse — t-paliad-179 Slice 1) call
// POST /api/tools/fristenrechner and paint the result with the same
// renderers. The module is pure-functional: no shared mutable state, all
// language / overrides / editability flow in through args so the two
// pages can wire their own per-page concerns (Akte save, anchor edits,
// Pathway B etc. on fristenrechner; variant chips, compare etc. coming
// to verfahrensablauf in later slices) without leaking into each other.
import { t, tDyn, getLang } from "../i18n";
export interface AdjustmentHoliday {
Date: string;
Name: string;
IsVacation: boolean;
IsClosure: boolean;
}
export interface AdjustmentReason {
kind: "weekend" | "public_holiday" | "vacation";
holidays?: AdjustmentHoliday[];
vacation_name?: string;
vacation_start?: string;
vacation_end?: string;
original_weekday?: string;
}
export interface CalculatedDeadline {
code: string;
name: string;
nameEN: string;
party: string;
// Priority is the canonical 4-way enum (Slice 8 made it canonical;
// Slice 9 dropped the legacy isMandatory / isOptional pair from the
// wire). priorityRendering(d) below branches on it.
priority: "mandatory" | "recommended" | "optional" | "informational";
ruleRef: string;
legalSource?: string;
// legalSourceDisplay is the pretty form ("UPC RoP R.220(1)") produced
// by FormatLegalSourceDisplay on the backend. Renderer prefers this
// over ruleRef when set; falls back to ruleRef otherwise.
legalSourceDisplay?: string;
// legalSourceURL is the youpc.org/laws permalink when the cited body
// is hosted there (UPCRoP / UPCA / UPCS today). Empty for DE/EPA/EU
// bodies — the renderer shows display text without a link.
legalSourceURL?: string;
notes?: string;
notesEN?: string;
dueDate: string;
originalDate: string;
wasAdjusted: boolean;
adjustmentReason?: AdjustmentReason;
isRootEvent: boolean;
isCourtSet: boolean;
isCourtSetIndirect?: boolean;
isOverridden?: boolean;
// conditionExpr surfaces the jsonb gate predicate (design §2.4) so
// the rule-editor + admin views can render the rule's gating shape.
// Frontend save-modal logic doesn't read this; the rule editor
// (Slice 11) is the consumer. Unknown shape on this side — pass-through.
conditionExpr?: unknown;
}
// priorityRendering returns the per-priority UX hints the save-modal
// uses. Maps the unified Priority enum to:
// - preChecked: whether the save-modal pre-checks the row
// - hideSave: whether the row renders without a save button at all
// (informational = notice card, no save action)
//
// Phase 3 Slice 9 (t-paliad-195) dropped the legacy
// (isMandatory, isOptional) fallback that pre-Slice-8 backends
// emitted. The backend now always populates `priority`; an unknown
// value falls back to "render as mandatory" (safe default — never
// silently drop a rule).
export function priorityRendering(
d: CalculatedDeadline,
): { preChecked: boolean; hideSave: boolean } {
switch (d.priority) {
case "mandatory":
case "recommended":
return { preChecked: true, hideSave: false };
case "optional":
return { preChecked: false, hideSave: false };
case "informational":
return { preChecked: false, hideSave: true };
}
// Unknown priority value: pre-Slice-8 backend or a forward-compat
// future value. Safe default: render as mandatory so the rule is
// surfaced + saved. Never silently drop.
return { preChecked: true, hideSave: false };
}
export interface DeadlineResponse {
proceedingType: string;
proceedingName: string;
triggerDate: string;
deadlines: CalculatedDeadline[];
}
export interface CourtRow {
id: string;
code: string;
nameDE: string;
nameEN: string;
country: string;
regime?: string;
courtType: string;
}
export interface CalcParams {
proceedingType: string;
triggerDate: string;
priorityDate?: string;
flags?: string[];
anchorOverrides?: Record<string, string>;
courtId?: string;
}
const PARTY_CLASS: Record<string, string> = {
claimant: "party-claimant",
defendant: "party-defendant",
court: "party-court",
both: "party-both",
};
// ─── small helpers ─────────────────────────────────────────────────────────
export function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
export function escHtml(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
export function formatDate(dateStr: string): string {
if (!dateStr) return "—";
const d = new Date(dateStr + "T00:00:00");
if (getLang() === "en") {
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${weekday}, ${yyyy}-${mm}-${dd}`;
}
return d.toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
function formatDateSpan(startISO: string, endISO: string): string {
const start = new Date(startISO + "T00:00:00");
const end = new Date(endISO + "T00:00:00");
if (getLang() === "en") {
const fmt = (d: Date) => d.toLocaleDateString("en-US", { day: "numeric", month: "short" });
return `${fmt(start)} ${fmt(end)}`;
}
const fmt = (d: Date) => `${d.getDate()}.${d.getMonth() + 1}.`;
return `${fmt(start)}${fmt(end)}`;
}
function localizeWeekday(en: string): string {
if (en === "Saturday") return t("deadlines.adjusted.weekend.saturday");
if (en === "Sunday") return t("deadlines.adjusted.weekend.sunday");
return en;
}
// Vacation names come straight from paliad.holidays (e.g. "UPC judicial
// vacation"). Not translated — they're proper names of court-set closures.
function localizeVacationName(name: string): string {
return name;
}
function renderAdjustmentReason(r: AdjustmentReason): string {
if (r.kind === "vacation" && r.vacation_name && r.vacation_start && r.vacation_end) {
const span = formatDateSpan(r.vacation_start, r.vacation_end);
return tDyn("deadlines.adjusted.vacation")
.replace("{name}", localizeVacationName(r.vacation_name))
.replace("{span}", span);
}
if (r.kind === "public_holiday" && r.holidays && r.holidays.length > 0) {
return tDyn("deadlines.adjusted.holiday").replace("{name}", r.holidays[0].Name);
}
if (r.kind === "weekend" && r.original_weekday) {
return localizeWeekday(r.original_weekday);
}
return t("deadlines.adjusted.weekend");
}
function formatAdjustedNote(dl: CalculatedDeadline): string {
const arrow = `${formatDate(dl.originalDate)}${formatDate(dl.dueDate)}`;
const reason = dl.adjustmentReason
? renderAdjustmentReason(dl.adjustmentReason)
: t("deadlines.adjusted.reason");
if (getLang() === "en") {
return `${t("deadlines.adjusted")} (${reason}): ${arrow}`;
}
return `${t("deadlines.adjusted")} wegen ${reason}: ${arrow}`;
}
export function partyBadge(party: string): string {
const cls = PARTY_CLASS[party] || "party-both";
return `<span class="party-badge ${cls}">${tDyn("deadlines.party." + party)}</span>`;
}
// ─── card + body renderers ────────────────────────────────────────────────
export interface CardOpts {
showParty: boolean;
// editable=true wires the click-to-edit affordance: data-rule-code,
// role=button, tabindex, hover hint. Fristenrechner enables it; the
// verfahrensablauf abstract-browse surface keeps editable=false because
// there's no anchor-override state on that page in Slice 1.
editable?: boolean;
// showNotes controls how the per-rule descriptive notes render:
// true → expanded `<div class="timeline-notes">…</div>` below the card
// false → compact ⓘ icon next to the meta line, full text on hover
// (browser-native `title` attribute) and screen-reader-readable
// Page shells expose a toggle ("Hinweise anzeigen") that flips this and
// re-renders. Default false — notes are noisy on long timelines.
showNotes?: boolean;
}
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
const wantsEditable = !!opts.editable;
const editable = wantsEditable && !dl.isRootEvent && dl.code !== "";
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
const editAttrs = editable
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
: "";
const courtLabelKey = dl.isCourtSetIndirect
? "deadlines.court.indirect"
: "deadlines.court.set";
const dateStr = dl.isCourtSet
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
// Slice 9 (t-paliad-195): the legacy boolean pair is gone — read
// priority directly. Optional badge fires only on 'optional'
// priority (RoP.151-style opt-in deadlines).
const mandatoryBadge = dl.priority === "optional"
? '<span class="optional-badge">optional</span>'
: "";
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
const adjustedNote = dl.wasAdjusted
? `<div class="timeline-adjusted">⚠ ${formatAdjustedNote(dl)}</div>`
: "";
// Prefer the structured legalSource (pretty display + youpc.org link
// when hosted there) over the bare rule_code fallback. UPC.RoP rules
// link to /laws/UPCRoP/<n>; DE / EPA / EU bodies have no youpc home
// yet so we render display text plain.
const legalDisplay = dl.legalSourceDisplay || "";
const legalURL = dl.legalSourceURL || "";
let ruleRef = "";
if (legalDisplay && legalURL) {
ruleRef = `<a class="timeline-rule timeline-rule--link" href="${escAttr(legalURL)}" target="_blank" rel="noopener noreferrer">${escHtml(legalDisplay)}</a>`;
} else if (legalDisplay) {
ruleRef = `<span class="timeline-rule">${escHtml(legalDisplay)}</span>`;
} else if (dl.ruleRef) {
ruleRef = `<span class="timeline-rule">${escHtml(dl.ruleRef)}</span>`;
}
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
const showNotes = opts.showNotes === true;
const notesBlock = noteText && showNotes
? `<div class="timeline-notes">${noteText}</div>`
: "";
const noteHint = noteText && !showNotes
? `<span class="timeline-note-hint" tabindex="0" role="note" aria-label="${escAttr(noteText)}" title="${escAttr(noteText)}">ⓘ</span>`
: "";
const meta = (opts.showParty || ruleRef || noteHint)
? `<div class="timeline-meta">
${opts.showParty ? partyBadge(dl.party) : ""}
${ruleRef}
${noteHint}
</div>`
: "";
return `<div class="timeline-item-header">
<span class="timeline-name">
${dlName}
${mandatoryBadge}
</span>
${dateStr}
</div>
${meta}
${adjustedNote}
${notesBlock}`;
}
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
let html = '<div class="timeline">';
for (const dl of data.deadlines) {
html += `
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
<div class="timeline-dot-col">
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
<div class="timeline-line"></div>
</div>
<div class="timeline-content">
${deadlineCardHtml(dl, opts)}
</div>
</div>
`;
}
html += "</div>";
return html;
}
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
// (defendant). Each grid row shares a dueDate so same-day events line up
// across columns; party=both renders in BOTH the Proactive and Reactive
// cells of the row. Undated rows (Urteil etc.) trail the dated tail, each
// keyed by sequence-order so e.g. Urteil precedes Berufungseinlegung.
export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "showParty"> = {}): string {
type Cell = CalculatedDeadline[];
type Row = { proactive: Cell; court: Cell; reactive: Cell };
const UNSCHEDULED_PREFIX = "__unscheduled__";
const rowsMap = new Map<string, Row>();
const ensureRow = (key: string): Row => {
let r = rowsMap.get(key);
if (!r) {
r = { proactive: [], court: [], reactive: [] };
rowsMap.set(key, r);
}
return r;
};
data.deadlines.forEach((dl, idx) => {
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
const row = ensureRow(key);
switch (dl.party) {
case "claimant":
row.proactive.push(dl);
break;
case "defendant":
row.reactive.push(dl);
break;
case "court":
row.court.push(dl);
break;
case "both":
row.proactive.push(dl);
row.reactive.push(dl);
break;
default:
row.court.push(dl);
}
});
const datedKeys: string[] = [];
const unscheduledKeys: string[] = [];
for (const k of rowsMap.keys()) {
if (k.startsWith(UNSCHEDULED_PREFIX)) unscheduledKeys.push(k);
else datedKeys.push(k);
}
datedKeys.sort();
unscheduledKeys.sort();
const keys = [...datedKeys, ...unscheduledKeys];
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
const renderCell = (items: CalculatedDeadline[]): string => {
if (items.length === 0) {
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
}
const cards = items
.map((dl) => {
const mirrorTag = dl.party === "both"
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
: "";
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
${deadlineCardHtml(dl, cardOpts)}
${mirrorTag}
</div>`;
})
.join("");
return `<div class="fr-col-cell">${cards}</div>`;
};
const headerCell = (label: string, cls: string) =>
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
let html = '<div class="fr-columns-view">';
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
html += headerCell(t("deadlines.col.court"), "fr-col-court");
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
for (const key of keys) {
const row = rowsMap.get(key)!;
html += renderCell(row.proactive);
html += renderCell(row.court);
html += renderCell(row.reactive);
}
html += "</div>";
return html;
}
// ─── calculate fetch wrapper ──────────────────────────────────────────────
export async function calculateDeadlines(params: CalcParams): Promise<DeadlineResponse | null> {
if (!params.proceedingType || !params.triggerDate) return null;
try {
const resp = await fetch("/api/tools/fristenrechner", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
proceedingType: params.proceedingType,
triggerDate: params.triggerDate,
priorityDate: params.priorityDate || undefined,
flags: params.flags && params.flags.length > 0 ? params.flags : undefined,
anchorOverrides: params.anchorOverrides && Object.keys(params.anchorOverrides).length > 0
? params.anchorOverrides
: undefined,
courtId: params.courtId || undefined,
}),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
console.error("API error:", err);
return null;
}
return (await resp.json()) as DeadlineResponse;
} catch (e) {
console.error("Fetch error:", e);
return null;
}
}
// ─── court picker ─────────────────────────────────────────────────────────
const courtCache = new Map<string, CourtRow[]>();
export function courtTypesFor(proceedingType: string): string[] {
if (proceedingType === "upc.apl.merits" || proceedingType === "upc.apl.order" || proceedingType === "upc.apl.cost") {
return ["UPC-CoA"];
}
if (proceedingType === "upc.rev.cfi") {
return ["UPC-CD", "UPC-LD"];
}
if (proceedingType.startsWith("upc.")) {
return ["UPC-LD"];
}
return [];
}
export function defaultCourtFor(proceedingType: string): string {
if (proceedingType === "upc.apl.merits" || proceedingType === "upc.apl.order" || proceedingType === "upc.apl.cost") {
return "upc-coa-luxembourg";
}
if (proceedingType === "upc.rev.cfi") {
return "upc-cd-paris";
}
return "upc-ld-muenchen";
}
export async function fetchCourts(courtType: string): Promise<CourtRow[]> {
if (courtCache.has(courtType)) return courtCache.get(courtType)!;
try {
const resp = await fetch(`/api/tools/courts?courtType=${encodeURIComponent(courtType)}`);
if (!resp.ok) return [];
const rows = (await resp.json()) as CourtRow[];
courtCache.set(courtType, rows);
return rows;
} catch {
return [];
}
}
// populateCourtPicker fills the <select> for the proceeding's compatible
// court types. The row + select IDs are passed in so each page can own
// its own DOM scope. Visible only when the proceeding has ≥2 compatible
// courts; otherwise hidden (server resolves the jurisdiction default).
export async function populateCourtPicker(
rowId: string,
selectId: string,
proceedingType: string,
): Promise<void> {
const row = document.getElementById(rowId);
const select = document.getElementById(selectId) as HTMLSelectElement | null;
if (!row || !select) return;
const types = courtTypesFor(proceedingType);
if (types.length === 0) {
row.style.display = "none";
select.innerHTML = "";
return;
}
const lists = await Promise.all(types.map((c) => fetchCourts(c)));
const courts = lists.flat();
if (courts.length <= 1) {
row.style.display = "none";
select.innerHTML = "";
return;
}
const lang = getLang();
const defaultID = defaultCourtFor(proceedingType);
select.innerHTML = courts.map((c) => {
const name = lang === "en" ? c.nameEN : c.nameDE;
return `<option value="${escAttr(c.id)}"${c.id === defaultID ? " selected" : ""}>${escHtml(name)}</option>`;
}).join("");
row.style.display = "";
}

View File

@@ -64,28 +64,28 @@ export function ProjectFormFields(): string {
<div className="form-field-row">
<div className="form-field">
<label htmlFor="project-client-number" data-i18n="projects.field.client_number">Client-Nr. (7 Ziffern)</label>
<label htmlFor="project-client-number" data-i18n="projects.field.client_number">Client-Nr. (6 Ziffern)</label>
<input
type="text"
id="project-client-number"
pattern="[0-9]{7}"
maxLength={7}
placeholder="0001234"
pattern="[0-9]{6}"
maxLength={6}
placeholder="001234"
/>
</div>
<div className="form-field">
<label htmlFor="project-matter-number" data-i18n="projects.field.matter_number">Matter-Nr. (7 Ziffern)</label>
<label htmlFor="project-matter-number" data-i18n="projects.field.matter_number">Matter-Nr. (6 Ziffern)</label>
<input
type="text"
id="project-matter-number"
pattern="[0-9]{7}"
maxLength={7}
placeholder="0000567"
pattern="[0-9]{6}"
maxLength={6}
placeholder="000567"
/>
</div>
</div>
<p className="form-hint" data-i18n="projects.field.clientmatter.hint">
{`${FIRM}-Billing-Nummern. Format CCCCCCC.MMMMMMM. Client-Nr. wird an Unterprojekte vererbt
{`${FIRM}-Billing-Nummern. Format CCCCCC.MMMMMM. Client-Nr. wird an Unterprojekte vererbt
(überschreibbar).`}
</p>

View File

@@ -7,9 +7,10 @@ const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" s
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const ICON_LINK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>';
// Open-book icon for the /tools/fristenrechner?path=a "Verfahrensablauf"
// nav entry (t-paliad-168). Distinct from ICON_BOOK (Glossar, closed)
// so the two affordances read as different at a glance.
// Open-book icon for the /tools/verfahrensablauf "Verfahrensablauf"
// nav entry (t-paliad-168 → t-paliad-179 Slice 1 split). Distinct from
// ICON_BOOK (Glossar, closed) so the two affordances read as different
// at a glance.
const ICON_BOOK_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/><path d="M22 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>';
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
@@ -139,6 +140,18 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
navItem("/team", ICON_USERS, "nav.team", "Team", currentPath),
)}
{/* t-paliad-177 \u2014 contextual chart link, revealed by sidebar.ts
when the user is on a /projects/{id}/* page (but NOT on the
chart itself). The href is filled in client-side from the
URL path so the same Sidebar TSX serves every page. */}
<a href="#"
className="sidebar-item sidebar-context-chart"
id="sidebar-project-chart-link"
style="display:none">
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_GAUGE }} />
<span className="sidebar-label" data-i18n="nav.context.project_chart">Als Chart anzeigen</span>
</a>
{/* Ansichten \u2014 single consolidated group (m's 2026-05-08 20:32
dogfood: "all views under one — not Ansichten and meine Ansichten").
Holds the built-in Fristen + Termine, the user-defined views
@@ -161,7 +174,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
Gerichte / Glossar), then content (Links / Downloads). */}
{group("nav.group.werkzeuge", "Werkzeuge",
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
navItem("/tools/fristenrechner?path=a", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +
@@ -186,6 +199,8 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
<a href="/admin/paliadin" id="sidebar-admin-paliadin-link"

View File

@@ -54,34 +54,44 @@ function quickChip(c: QuickChip): string {
}
const UPC_TYPES: ProceedingDef[] = [
{ code: "UPC_INF", i18nKey: "deadlines.upc_inf", name: "Verletzungsverfahren" },
{ code: "UPC_REV", i18nKey: "deadlines.upc_rev", name: "Nichtigkeitsklage" },
{ code: "UPC_PI", i18nKey: "deadlines.upc_pi", name: "Einstw. Ma\u00dfnahmen" },
{ code: "UPC_APP", i18nKey: "deadlines.upc_app", name: "Berufung" },
{ code: "UPC_DAMAGES", i18nKey: "deadlines.upc_damages", name: "Schadensbemessung" },
{ code: "UPC_DISCOVERY", i18nKey: "deadlines.upc_discovery", name: "Bucheinsicht" },
{ code: "UPC_COST_APPEAL", i18nKey: "deadlines.upc_cost_appeal", name: "Berufung Kosten" },
{ code: "UPC_APP_ORDERS", i18nKey: "deadlines.upc_app_orders", name: "Berufung Anordnungen" },
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Ma\u00dfnahmen" },
{ code: "upc.apl.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" },
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
{ code: "upc.apl.cost", i18nKey: "deadlines.upc.apl.cost", name: "Berufung Kosten" },
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
];
const DE_TYPES: ProceedingDef[] = [
{ code: "DE_INF", i18nKey: "deadlines.de_inf", name: "Verletzungsklage (LG)" },
{ code: "DE_INF_OLG", i18nKey: "deadlines.de_inf_olg", name: "Berufung OLG" },
{ code: "DE_INF_BGH", i18nKey: "deadlines.de_inf_bgh", name: "Revision/NZB BGH" },
{ code: "DE_NULL", i18nKey: "deadlines.de_null", name: "Nichtigkeitsverfahren" },
{ code: "DE_NULL_BGH", i18nKey: "deadlines.de_null_bgh", name: "Berufung BGH (Nichtigk.)" },
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
// 2026-05-18 ask. Labels are parallel: <court> (<procedural role>),
// so a user scanning the picker sees the instance-and-role at a glance
// without one tile reading "Berufung OLG" and another "Nichtigkeits-
// verfahren". Sub-group headers convey the type grouping. Combined-
// timeline behaviour (LG→OLG→BGH as one calc) is filed as m/paliad#41.
const DE_INF_TYPES: ProceedingDef[] = [
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
];
const DE_NULL_TYPES: ProceedingDef[] = [
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
];
const EPA_TYPES: ProceedingDef[] = [
{ code: "EPA_OPP", i18nKey: "deadlines.epa_opp", name: "Einspruchsverfahren" },
{ code: "EPA_APP", i18nKey: "deadlines.epa_app", name: "Beschwerdeverfahren" },
{ code: "EP_GRANT", i18nKey: "deadlines.ep_grant", name: "EP-Erteilungsverfahren" },
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
];
const DPMA_TYPES: ProceedingDef[] = [
{ code: "DPMA_OPP", i18nKey: "deadlines.dpma_opp", name: "Einspruch DPMA" },
{ code: "DPMA_BPATG_BESCHWERDE", i18nKey: "deadlines.dpma_bpatg_beschwerde", name: "Beschwerde BPatG (DPMA)" },
{ code: "DPMA_BGH_RB", i18nKey: "deadlines.dpma_bgh_rb", name: "Rechtsbeschwerde BGH" },
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
];
export function renderFristenrechner(): string {
@@ -207,20 +217,9 @@ export function renderFristenrechner(): string {
Incoming &mdash; ein Ereignis hat eine Frist ausgel&ouml;st.
</span>
</button>
{/* t-paliad-168 — third card: discoverable browse-/learn-mode
entry. Drops directly into Pathway A (Verfahrensablauf
wizard) with no save flow — mirrors the existing ad-hoc
explore behaviour: timeline renders, save CTA stays
disabled because there's no save intent. */}
<button type="button" className="fristen-step2-card" data-action="browse" id="fristen-step2-browse">
<span className="fristen-step2-card-icon" aria-hidden="true">&#128214;</span>
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.browse.title">
Verfahrensablauf einsehen
</span>
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.browse.desc">
Browse / Learn &mdash; sehen, was wann passiert. Keine Frist eintragen.
</span>
</button>
{/* t-paliad-179 Slice 1: the third "Verfahrensablauf
einsehen" card retired — abstract-browse intent now
owns its own route at /tools/verfahrensablauf. */}
</div>
<div className="fristen-step2-shortcut">
<div className="fristen-pathway-fork-shortcut-label" data-i18n="deadlines.pathway.shortcut.label">
@@ -245,78 +244,74 @@ export function renderFristenrechner(): string {
<span data-i18n="deadlines.pathway.b.title">Frist eintragen aufgrund Ereignis</span>
</h2>
<div className="fristen-mode-toggle" role="radiogroup" aria-label="B1/B2 mode">
<label className="fristen-mode-toggle-option">
<input type="radio" name="fristen-b-mode" value="tree" id="fristen-b-mode-tree" />
<span data-i18n="deadlines.pathway.b.mode.tree">Schritt-f&uuml;r-Schritt (Entscheidungsbaum)</span>
</label>
<label className="fristen-mode-toggle-option">
<input type="radio" name="fristen-b-mode" value="filter" id="fristen-b-mode-filter" />
<span data-i18n="deadlines.pathway.b.mode.filter">Filter / Suche</span>
</label>
</div>
{/* B1 panel — decision tree above + concept-card results below.
fristen-b1-cascade hosts the breadcrumb / question / button row.
fristen-b1-results hosts the narrowing concept-card list,
populated by runB1Search() in fristenrechner.ts. The cards
reuse renderConceptCard() (B2's card shape).
m/paliad#15 follow-up: the inbox-channel chip lives at the
top of THIS panel (not page-level) — m's call: "inside the
decision tree because it helps us to determine what to do
next". The chip narrows the cascade entry-points + B2 fine
forum filter; Pathway A's Verlauf doesn't see it. */}
{/* B1 panel — row-stack cascade.
`#fristen-row-stack` hosts the perspective / inbox /
cascade rows (t-paliad-180 Slice 1; t-paliad-197 Slice 2
added project-driven prefills + auto-walk). The
stack-header above carries the inline-search trigger
(t-paliad-198 Slice 3 — clicking expands
`#fristen-row-search-panel` over the row stack instead
of routing to the legacy B2 surface) and the reset link.
`#fristen-b1-results` is unchanged — it renders concept
cards for both cascade-narrowing AND inline-search
results, so users see the same card layout regardless
of how they reached a deadline rule. */}
<div className="fristen-b1-panel" id="fristen-b1-panel" data-mode="tree" hidden>
{/* Slice 3c — perspective chip strip. Klägerseite vs
Beklagtenseite hides cascade leaves whose party tag
contradicts the user's side. "Beide" / no chip
leaves the cascade unfiltered. */}
<div className="fristen-perspective-bar" id="fristen-perspective-bar" role="group" aria-label="Perspective">
<span className="fristen-inbox-bar-label" data-i18n="deadlines.perspective.label">Ich vertrete:</span>
<div className="fristen-inbox-chips">
<button type="button" className="fristen-inbox-chip" data-perspective="claimant"
data-i18n-title="deadlines.perspective.claimant.title" title="Kl&auml;gerseite (Proactive)">
<span data-i18n="deadlines.perspective.claimant.short">Kl&auml;ger</span>
</button>
<button type="button" className="fristen-inbox-chip" data-perspective="defendant"
data-i18n-title="deadlines.perspective.defendant.title" title="Beklagtenseite (Reactive)">
<span data-i18n="deadlines.perspective.defendant.short">Beklagter</span>
</button>
<button type="button" className="fristen-inbox-chip fristen-inbox-chip--clear" data-perspective-clear>
<span data-i18n="deadlines.perspective.both.short">Beide</span>
</button>
</div>
{/* t-paliad-164 — predefined-from-Akte hint. Hidden by
default; client/fristenrechner.ts shows it when the
active perspective came from project.our_side. The
user can still click another chip to override. */}
<span className="fristen-perspective-hint" id="fristen-perspective-hint"
data-i18n="deadlines.perspective.predefined_hint" hidden>
vorgegeben durch Akte
</span>
<div className="fristen-row-stack-header" id="fristen-row-stack-header">
<button type="button" className="fristen-row-search-link" id="fristen-row-search-link"
data-i18n-title="deadlines.row.search.link.title"
aria-expanded="false"
aria-controls="fristen-row-search-panel"
title="Direkt nach einer Frist suchen">
<span aria-hidden="true">&#128269;</span>{" "}
<span data-i18n="deadlines.row.search.link">Direkt suchen</span>
</button>
<button type="button" className="fristen-row-reset-link" id="fristen-row-reset"
data-i18n-title="deadlines.row.reset.title"
title="Pfad zur&uuml;cksetzen — alle Cascade-Antworten verwerfen">
<span aria-hidden="true">&#8634;</span>{" "}
<span data-i18n="deadlines.row.reset">Pfad zur&uuml;cksetzen</span>
</button>
</div>
<div className="fristen-inbox-bar" id="fristen-inbox-bar" role="group" aria-label="Inbox channel">
<span className="fristen-inbox-bar-label" data-i18n="deadlines.inbox.label">Wo kam es an?</span>
<div className="fristen-inbox-chips">
<button type="button" className="fristen-inbox-chip" data-inbox="cms"
data-i18n-title="deadlines.inbox.cms.title" title="UPC &mdash; &uuml;ber CMS">
CMS
</button>
<button type="button" className="fristen-inbox-chip" data-inbox="bea"
data-i18n-title="deadlines.inbox.bea.title" title="Nationale Verfahren &mdash; &uuml;ber beA">
beA
</button>
<button type="button" className="fristen-inbox-chip" data-inbox="posteingang"
data-i18n-title="deadlines.inbox.posteingang.title" title="Nationale Verfahren &mdash; Postzustellung">
<span data-i18n="deadlines.inbox.posteingang">Posteingang</span>
</button>
<button type="button" className="fristen-inbox-chip fristen-inbox-chip--clear" data-inbox-clear>
<span data-i18n="deadlines.inbox.all">Alle</span>
{/* Inline search overlay (t-paliad-198 Slice 3). Hidden by
default; the search icon-button in the stack header
toggles it open / closed. While open, the row stack is
hidden and the search input drives `#fristen-b1-results`
directly — same surface the cascade leaf populates so
the user sees one consistent concept-card list. */}
<div className="fristen-row-search-panel" id="fristen-row-search-panel" hidden role="search">
<button type="button" className="fristen-row-search-panel-back" id="fristen-row-search-panel-back"
data-i18n-title="deadlines.row.search.panel.back.title"
title="Zur&uuml;ck zum Entscheidungsbaum">
<span aria-hidden="true">&larr;</span>{" "}
<span data-i18n="deadlines.row.search.panel.back">Zur&uuml;ck zum Entscheidungsbaum</span>
</button>
<div className="fristen-row-search-panel-input-wrap">
<svg className="fristen-row-search-panel-icon" width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input
type="search"
id="fristen-row-search-panel-input"
className="fristen-row-search-panel-input"
autocomplete="off"
spellcheck="false"
data-i18n-placeholder="deadlines.row.search.panel.placeholder"
placeholder="Frist suchen&hellip;"
aria-label="Frist suchen"
/>
<button type="button" className="fristen-row-search-panel-clear" id="fristen-row-search-panel-clear"
data-i18n-title="deadlines.row.search.panel.clear" title="Eingabe leeren" hidden>
<span aria-hidden="true">&times;</span>
</button>
</div>
</div>
<div className="fristen-b1-cascade" id="fristen-b1-cascade"></div>
<div className="fristen-row-stack" id="fristen-row-stack" aria-live="polite"></div>
<div className="fristen-b1-results" id="fristen-b1-results" aria-live="polite"></div>
</div>
@@ -439,8 +434,17 @@ export function renderFristenrechner(): string {
<div className="proceeding-group" data-forum="de">
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
<div className="proceeding-btns">
{DE_TYPES.map((p) => proceedingBtn(p))}
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
<div className="proceeding-btns">
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
<div className="proceeding-btns">
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
</div>
@@ -542,6 +546,10 @@ export function renderFristenrechner(): string {
<input type="radio" name="fristen-view" value="timeline" />
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
</label>
<label className="fristen-notes-option">
<input type="checkbox" id="fristen-notes-show" />
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
</label>
</div>
<div id="timeline-container">

View File

@@ -117,6 +117,8 @@ export type I18nKey =
| "admin.card.feature_flags.title"
| "admin.card.partner_units.desc"
| "admin.card.partner_units.title"
| "admin.card.rules.desc"
| "admin.card.rules.title"
| "admin.card.team.desc"
| "admin.card.team.title"
| "admin.coming_soon"
@@ -266,6 +268,174 @@ export type I18nKey =
| "admin.partner_units.new.heading"
| "admin.partner_units.subtitle"
| "admin.partner_units.title"
| "admin.rules.col.legal_citation"
| "admin.rules.col.lifecycle"
| "admin.rules.col.modified"
| "admin.rules.col.name"
| "admin.rules.col.priority"
| "admin.rules.col.proceeding"
| "admin.rules.col.submission_code"
| "admin.rules.edit.action.archive"
| "admin.rules.edit.action.archive.error"
| "admin.rules.edit.action.archive.ok"
| "admin.rules.edit.action.clone"
| "admin.rules.edit.action.clone.error"
| "admin.rules.edit.action.ok"
| "admin.rules.edit.action.publish"
| "admin.rules.edit.action.publish.error"
| "admin.rules.edit.action.publish.ok"
| "admin.rules.edit.action.restore"
| "admin.rules.edit.action.restore.error"
| "admin.rules.edit.action.restore.ok"
| "admin.rules.edit.action.save_draft"
| "admin.rules.edit.action.save_draft.error"
| "admin.rules.edit.action.save_draft.ok"
| "admin.rules.edit.audit.action.archive"
| "admin.rules.edit.audit.action.create"
| "admin.rules.edit.audit.action.delete"
| "admin.rules.edit.audit.action.publish"
| "admin.rules.edit.audit.action.restore"
| "admin.rules.edit.audit.action.update"
| "admin.rules.edit.audit.actor.system"
| "admin.rules.edit.audit.empty"
| "admin.rules.edit.audit.exported"
| "admin.rules.edit.audit.heading"
| "admin.rules.edit.audit.loading"
| "admin.rules.edit.audit.loadmore"
| "admin.rules.edit.breadcrumb"
| "admin.rules.edit.error.bad_id"
| "admin.rules.edit.error.load"
| "admin.rules.edit.error.not_found"
| "admin.rules.edit.field.alt_duration_unit"
| "admin.rules.edit.field.alt_duration_value"
| "admin.rules.edit.field.alt_rule_code"
| "admin.rules.edit.field.anchor_alt"
| "admin.rules.edit.field.combine_op"
| "admin.rules.edit.field.concept"
| "admin.rules.edit.field.condition.valid"
| "admin.rules.edit.field.condition_hint"
| "admin.rules.edit.field.deadline_notes"
| "admin.rules.edit.field.deadline_notes_en"
| "admin.rules.edit.field.description"
| "admin.rules.edit.field.duration_unit"
| "admin.rules.edit.field.duration_value"
| "admin.rules.edit.field.event_type"
| "admin.rules.edit.field.is_court_set"
| "admin.rules.edit.field.is_spawn"
| "admin.rules.edit.field.legal_source"
| "admin.rules.edit.field.name"
| "admin.rules.edit.field.name_en"
| "admin.rules.edit.field.parent"
| "admin.rules.edit.field.primary_party"
| "admin.rules.edit.field.priority"
| "admin.rules.edit.field.proceeding"
| "admin.rules.edit.field.proceeding.none"
| "admin.rules.edit.field.rule_code"
| "admin.rules.edit.field.sequence_order"
| "admin.rules.edit.field.spawn_label"
| "admin.rules.edit.field.spawn_proceeding"
| "admin.rules.edit.field.spawn_proceeding.none"
| "admin.rules.edit.field.submission_code"
| "admin.rules.edit.field.timing"
| "admin.rules.edit.field.trigger"
| "admin.rules.edit.field.trigger.none"
| "admin.rules.edit.heading.loading"
| "admin.rules.edit.modal.archive.body"
| "admin.rules.edit.modal.archive.title"
| "admin.rules.edit.modal.clone.body"
| "admin.rules.edit.modal.clone.title"
| "admin.rules.edit.modal.publish.body"
| "admin.rules.edit.modal.publish.title"
| "admin.rules.edit.modal.restore.body"
| "admin.rules.edit.modal.restore.title"
| "admin.rules.edit.modal.save_draft.body"
| "admin.rules.edit.modal.save_draft.title"
| "admin.rules.edit.preview.empty"
| "admin.rules.edit.preview.error"
| "admin.rules.edit.preview.flags"
| "admin.rules.edit.preview.heading"
| "admin.rules.edit.preview.hint"
| "admin.rules.edit.preview.only_drafts"
| "admin.rules.edit.preview.run"
| "admin.rules.edit.preview.running"
| "admin.rules.edit.preview.trigger_date"
| "admin.rules.edit.preview.trigger_required"
| "admin.rules.edit.section.condition"
| "admin.rules.edit.section.display"
| "admin.rules.edit.section.identity"
| "admin.rules.edit.section.lifecycle"
| "admin.rules.edit.section.party"
| "admin.rules.edit.section.proceeding"
| "admin.rules.edit.section.timing"
| "admin.rules.edit.title"
| "admin.rules.empty"
| "admin.rules.error.load"
| "admin.rules.export.breadcrumb"
| "admin.rules.export.copied"
| "admin.rules.export.copy"
| "admin.rules.export.copy_failed"
| "admin.rules.export.count"
| "admin.rules.export.download"
| "admin.rules.export.error"
| "admin.rules.export.field.since"
| "admin.rules.export.heading"
| "admin.rules.export.latest"
| "admin.rules.export.no_pending"
| "admin.rules.export.ok"
| "admin.rules.export.run"
| "admin.rules.export.running"
| "admin.rules.export.subtitle"
| "admin.rules.export.title"
| "admin.rules.filter.lifecycle"
| "admin.rules.filter.lifecycle.any"
| "admin.rules.filter.proceeding"
| "admin.rules.filter.proceeding.any"
| "admin.rules.filter.search"
| "admin.rules.filter.search.placeholder"
| "admin.rules.filter.trigger"
| "admin.rules.filter.trigger.any"
| "admin.rules.lifecycle.archived"
| "admin.rules.lifecycle.draft"
| "admin.rules.lifecycle.published"
| "admin.rules.list.export"
| "admin.rules.list.heading"
| "admin.rules.list.new"
| "admin.rules.list.subtitle"
| "admin.rules.list.title"
| "admin.rules.loading"
| "admin.rules.modal.confirm"
| "admin.rules.modal.error.create"
| "admin.rules.modal.error.name_required"
| "admin.rules.modal.error.resolve"
| "admin.rules.modal.field.duration"
| "admin.rules.modal.field.name"
| "admin.rules.modal.field.name_en"
| "admin.rules.modal.new.body"
| "admin.rules.modal.new.title"
| "admin.rules.modal.reason"
| "admin.rules.modal.reason.hint"
| "admin.rules.modal.reason.placeholder"
| "admin.rules.modal.reason.too_short"
| "admin.rules.modal.resolve.body"
| "admin.rules.modal.resolve.title"
| "admin.rules.orphans.empty"
| "admin.rules.orphans.field.proceeding"
| "admin.rules.orphans.field.project"
| "admin.rules.orphans.field.reason"
| "admin.rules.orphans.loading"
| "admin.rules.orphans.no_candidates"
| "admin.rules.orphans.reason.ambiguous"
| "admin.rules.orphans.reason.manual_unbound"
| "admin.rules.orphans.reason.no_match"
| "admin.rules.orphans.reason.no_project"
| "admin.rules.orphans.resolved"
| "admin.rules.orphans.subtitle"
| "admin.rules.priority.informational"
| "admin.rules.priority.mandatory"
| "admin.rules.priority.optional"
| "admin.rules.priority.recommended"
| "admin.rules.tab.orphans"
| "admin.rules.tab.rules"
| "admin.section.available"
| "admin.section.planned"
| "admin.subtitle"
@@ -422,6 +592,9 @@ export type I18nKey =
| "approvals.decision_kind.peer"
| "approvals.diff.after"
| "approvals.diff.before"
| "approvals.disabled.not_authorized"
| "approvals.disabled.revoke_not_requester"
| "approvals.disabled.self_approval"
| "approvals.empty.mine"
| "approvals.empty.pending_mine"
| "approvals.entity.appointment"
@@ -480,8 +653,13 @@ export type I18nKey =
| "bottomnav.add.title"
| "bottomnav.badge.deadlines"
| "bottomnav.menu"
| "cal.day.back_to_month"
| "cal.day.fri"
| "cal.day.mon"
| "cal.day.next"
| "cal.day.no_entries"
| "cal.day.open_day"
| "cal.day.prev"
| "cal.day.sat"
| "cal.day.sun"
| "cal.day.thu"
@@ -499,6 +677,13 @@ export type I18nKey =
| "cal.month.7"
| "cal.month.8"
| "cal.month.9"
| "cal.month.next"
| "cal.month.prev"
| "cal.view.day"
| "cal.view.month"
| "cal.view.week"
| "cal.week.next"
| "cal.week.prev"
| "caldav.delete"
| "caldav.delete.confirm"
| "caldav.delete.done"
@@ -740,16 +925,19 @@ export type I18nKey =
| "deadlines.col.status"
| "deadlines.col.title"
| "deadlines.complete.action"
| "deadlines.complete.confirm"
| "deadlines.court.indirect"
| "deadlines.court.label"
| "deadlines.court.set"
| "deadlines.date.edit.hint"
| "deadlines.de"
| "deadlines.de_inf"
| "deadlines.de_inf_bgh"
| "deadlines.de_inf_olg"
| "deadlines.de_null"
| "deadlines.de_null_bgh"
| "deadlines.de.group.inf"
| "deadlines.de.group.null"
| "deadlines.de.inf.bgh"
| "deadlines.de.inf.lg"
| "deadlines.de.inf.olg"
| "deadlines.de.null.bgh"
| "deadlines.de.null.bpatg"
| "deadlines.detail.back"
| "deadlines.detail.cancel"
| "deadlines.detail.complete"
@@ -772,16 +960,16 @@ export type I18nKey =
| "deadlines.detail.source"
| "deadlines.detail.title"
| "deadlines.dpma"
| "deadlines.dpma_bgh_rb"
| "deadlines.dpma_bpatg_beschwerde"
| "deadlines.dpma_opp"
| "deadlines.dpma.appeal.bgh"
| "deadlines.dpma.appeal.bpatg"
| "deadlines.dpma.opp.dpma"
| "deadlines.empty.filtered"
| "deadlines.empty.hint"
| "deadlines.empty.title"
| "deadlines.ep_grant"
| "deadlines.epa"
| "deadlines.epa_app"
| "deadlines.epa_opp"
| "deadlines.epa.grant.exa"
| "deadlines.epa.opp.boa"
| "deadlines.epa.opp.opd"
| "deadlines.error.generic"
| "deadlines.error.required"
| "deadlines.event.adjusted"
@@ -881,6 +1069,7 @@ export type I18nKey =
| "deadlines.neu.submit"
| "deadlines.neu.subtitle"
| "deadlines.neu.title"
| "deadlines.notes.show"
| "deadlines.optional.badge"
| "deadlines.party.both"
| "deadlines.party.both.label"
@@ -913,9 +1102,27 @@ export type I18nKey =
| "deadlines.perspective.predefined_hint"
| "deadlines.print"
| "deadlines.priority.date"
| "deadlines.priority.informational"
| "deadlines.priority.informational.notice_label"
| "deadlines.priority.mandatory"
| "deadlines.priority.optional"
| "deadlines.priority.recommended"
| "deadlines.proceeding.reselect"
| "deadlines.proceeding.selected"
| "deadlines.reset"
| "deadlines.row.autowalk.dismiss"
| "deadlines.row.autowalk.tooltip"
| "deadlines.row.edit"
| "deadlines.row.mode.question"
| "deadlines.row.prefilled.from_akte"
| "deadlines.row.reset"
| "deadlines.row.reset.title"
| "deadlines.row.search.link"
| "deadlines.row.search.link.title"
| "deadlines.row.search.panel.back"
| "deadlines.row.search.panel.back.title"
| "deadlines.row.search.panel.clear"
| "deadlines.row.search.panel.placeholder"
| "deadlines.save.cta"
| "deadlines.save.cta.adhoc.hint"
| "deadlines.save.error"
@@ -1000,14 +1207,15 @@ export type I18nKey =
| "deadlines.trigger.label"
| "deadlines.unavailable"
| "deadlines.upc"
| "deadlines.upc_app"
| "deadlines.upc_app_orders"
| "deadlines.upc_cost_appeal"
| "deadlines.upc_damages"
| "deadlines.upc_discovery"
| "deadlines.upc_inf"
| "deadlines.upc_pi"
| "deadlines.upc_rev"
| "deadlines.upc.apl.cost"
| "deadlines.upc.apl.merits"
| "deadlines.upc.apl.order"
| "deadlines.upc.ccr.cfi"
| "deadlines.upc.disc.cfi"
| "deadlines.upc.dmgs.cfi"
| "deadlines.upc.inf.cfi"
| "deadlines.upc.pi.cfi"
| "deadlines.upc.rev.cfi"
| "deadlines.urgency.later"
| "deadlines.urgency.overdue"
| "deadlines.urgency.soon"
@@ -1437,11 +1645,14 @@ export type I18nKey =
| "nav.admin.event_types"
| "nav.admin.paliadin"
| "nav.admin.partner_units"
| "nav.admin.rules"
| "nav.admin.rules_export"
| "nav.admin.team"
| "nav.agenda"
| "nav.akten"
| "nav.caldav"
| "nav.checklisten"
| "nav.context.project_chart"
| "nav.dashboard"
| "nav.downloads"
| "nav.einstellungen"
@@ -1573,6 +1784,10 @@ export type I18nKey =
| "partner_unit.members_label"
| "partner_unit.none"
| "partner_unit.subtitle"
| "project.instance_level.appeal"
| "project.instance_level.cassation"
| "project.instance_level.first"
| "project.instance_level.unset"
| "projects.cancel"
| "projects.cards.deadline_open"
| "projects.cards.deadline_overdue"
@@ -1623,6 +1838,42 @@ export type I18nKey =
| "projects.cards.show_all_levels"
| "projects.cards.show_all_levels.hint"
| "projects.cards.team"
| "projects.chart.back"
| "projects.chart.control.columns.auto"
| "projects.chart.control.density.label"
| "projects.chart.control.density.standard"
| "projects.chart.control.export.soon"
| "projects.chart.control.layout.horizontal"
| "projects.chart.control.palette.default"
| "projects.chart.control.palette.label"
| "projects.chart.control.range.label"
| "projects.chart.density.compact"
| "projects.chart.density.spacious"
| "projects.chart.density.standard"
| "projects.chart.error.mount"
| "projects.chart.export.csv"
| "projects.chart.export.ics"
| "projects.chart.export.json"
| "projects.chart.export.menu"
| "projects.chart.export.png"
| "projects.chart.export.print"
| "projects.chart.export.svg"
| "projects.chart.loading"
| "projects.chart.notfound"
| "projects.chart.palette.default"
| "projects.chart.palette.high_contrast"
| "projects.chart.palette.kind_coded"
| "projects.chart.palette.print"
| "projects.chart.palette.track_coded"
| "projects.chart.permalink.copy"
| "projects.chart.permalink.title"
| "projects.chart.range.1y"
| "projects.chart.range.2y"
| "projects.chart.range.all"
| "projects.chart.range.custom"
| "projects.chart.range.from"
| "projects.chart.range.to"
| "projects.chart.title"
| "projects.chip.all"
| "projects.chip.has_open_deadlines"
| "projects.chip.mine"
@@ -1748,6 +1999,7 @@ export type I18nKey =
| "projects.detail.smarttimeline.milestone.date"
| "projects.detail.smarttimeline.milestone.description"
| "projects.detail.smarttimeline.milestone.title"
| "projects.detail.smarttimeline.open_chart"
| "projects.detail.smarttimeline.section.future"
| "projects.detail.smarttimeline.section.past"
| "projects.detail.smarttimeline.section.undated"
@@ -2013,11 +2265,16 @@ export type I18nKey =
| "theme.toggle.cycle.light"
| "theme.toggle.dark"
| "theme.toggle.light"
| "tools.verfahrensablauf.heading"
| "tools.verfahrensablauf.subtitle"
| "tools.verfahrensablauf.title"
| "unit_role.attorney"
| "unit_role.lead"
| "unit_role.pa"
| "unit_role.paralegal"
| "unit_role.senior_pa"
| "verlauf.spawn.chip"
| "verlauf.spawn.cycle_warning"
| "views.action.edit"
| "views.bar.action.reset"
| "views.bar.action.save_as_view"
@@ -2172,11 +2429,19 @@ export type I18nKey =
| "views.shape.calendar"
| "views.shape.cards"
| "views.shape.list"
| "views.shape.timeline"
| "views.source.appointment"
| "views.source.approval_request"
| "views.source.deadline"
| "views.source.project_event"
| "views.subtitle"
| "views.timeline.caveat.body"
| "views.timeline.zoom.1y"
| "views.timeline.zoom.2y"
| "views.timeline.zoom.all"
| "views.timeline.zoom.in"
| "views.timeline.zoom.label"
| "views.timeline.zoom.out"
| "views.title"
| "views.toast.inaccessible_n"
| "views.toast.inaccessible_one";

View File

@@ -0,0 +1,172 @@
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";
// t-paliad-177 Slice 1 — Project Timeline / Chart standalone page.
//
// Pure shell: header / controls scaffold (inert chips for the
// vertical-toggle, density and palette pickers, which Slice 3 wires
// live) + a chart host that client/projects-chart.ts mounts the SVG
// renderer into. Project metadata is loaded client-side so the same
// dist/projects-chart.html serves every {id}.
//
// Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
export function renderProjectsChart(): 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="projects.chart.title">Projekt-Chart &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/projects" />
<BottomNav currentPath="/projects" />
<main>
<section className="tool-page smart-timeline-chart-page">
<div className="container">
<a
id="projects-chart-back-link"
href="/projects"
className="back-link"
data-i18n="projects.chart.back"
>
&larr; Zur&uuml;ck zum Verlauf
</a>
<div id="projects-chart-loading" className="entity-loading">
<p data-i18n="projects.chart.loading">L&auml;dt&hellip;</p>
</div>
<div id="projects-chart-notfound" className="entity-empty" style="display:none">
<p data-i18n="projects.chart.notfound">Projekt nicht gefunden oder keine Berechtigung.</p>
</div>
<div id="projects-chart-body" style="display:none">
<header className="smart-timeline-chart-header">
<h1 id="projects-chart-title" />
<span id="projects-chart-meta" className="smart-timeline-chart-meta" />
</header>
<div className="smart-timeline-chart-controls" id="projects-chart-controls">
{/* Slice 1: chips render inert. Slice 3 wires them to
density / palette / zoom state. The presence keeps
the surface visually stable when controls light up. */}
<span className="chip-inert" data-i18n="projects.chart.control.layout.horizontal" title="Slice 3">
Layout: Horizontal
</span>
<span className="smart-timeline-chart-picker">
<label htmlFor="projects-chart-range" data-i18n="projects.chart.control.range.label">
Zeitraum:
</label>
<select id="projects-chart-range">
<option value="1y" data-i18n="projects.chart.range.1y">1 Jahr</option>
<option value="2y" data-i18n="projects.chart.range.2y">2 Jahre</option>
<option value="all" data-i18n="projects.chart.range.all">Alles anzeigen</option>
<option value="custom" data-i18n="projects.chart.range.custom">Eigener Bereich</option>
</select>
</span>
<span className="smart-timeline-chart-picker smart-timeline-chart-range-custom" id="projects-chart-range-custom" style="display:none">
<label htmlFor="projects-chart-range-from" data-i18n="projects.chart.range.from">Von:</label>
<input type="date" id="projects-chart-range-from" />
<label htmlFor="projects-chart-range-to" data-i18n="projects.chart.range.to">Bis:</label>
<input type="date" id="projects-chart-range-to" />
</span>
<span className="smart-timeline-chart-picker">
<label htmlFor="projects-chart-density" data-i18n="projects.chart.control.density.label">
Dichte:
</label>
<select id="projects-chart-density">
<option value="compact" data-i18n="projects.chart.density.compact">Kompakt</option>
<option value="standard" data-i18n="projects.chart.density.standard">Standard</option>
<option value="spacious" data-i18n="projects.chart.density.spacious">Großzügig</option>
</select>
</span>
<span className="smart-timeline-chart-picker">
<label htmlFor="projects-chart-palette" data-i18n="projects.chart.control.palette.label">
Palette:
</label>
<select id="projects-chart-palette">
<option value="default" data-i18n="projects.chart.palette.default">Standard</option>
<option value="kind-coded" data-i18n="projects.chart.palette.kind_coded">Nach Ereignistyp</option>
<option value="track-coded" data-i18n="projects.chart.palette.track_coded">Nach Spur</option>
<option value="high-contrast" data-i18n="projects.chart.palette.high_contrast">Hoher Kontrast</option>
<option value="print" data-i18n="projects.chart.palette.print">Druck (S/W)</option>
</select>
</span>
<button
type="button"
id="projects-chart-copylink"
className="smart-timeline-chart-copylink"
data-i18n="projects.chart.permalink.copy"
data-i18n-title="projects.chart.permalink.title"
title="URL mit allen Filtern in die Zwischenablage kopieren"
>
&#128279; Link kopieren
</button>
<details className="smart-timeline-chart-export">
<summary data-i18n="projects.chart.export.menu">
&dArr; Export
</summary>
<menu className="smart-timeline-chart-export-menu">
<li>
<button type="button" id="projects-chart-export-svg" data-i18n="projects.chart.export.svg">
SVG (Vektorgrafik)
</button>
</li>
<li>
<button type="button" id="projects-chart-export-png" data-i18n="projects.chart.export.png">
PNG (Bild, 2&times; HiDPI)
</button>
</li>
<li>
<button type="button" id="projects-chart-export-print" data-i18n="projects.chart.export.print">
PDF (Drucken)
</button>
</li>
<li className="smart-timeline-chart-export-divider" />
<li>
<button type="button" id="projects-chart-export-csv" data-i18n="projects.chart.export.csv">
CSV (Excel-Tabelle)
</button>
</li>
<li>
<button type="button" id="projects-chart-export-json" data-i18n="projects.chart.export.json">
JSON (Rohdaten)
</button>
</li>
<li>
<button type="button" id="projects-chart-export-ics" data-i18n="projects.chart.export.ics">
iCal (.ics Outlook / Apple)
</button>
</li>
</menu>
</details>
</div>
<div id="projects-chart-lanes-filter" className="smart-timeline-chart-lanes-filter" style="display:none" />
<div id="projects-chart-host" className="smart-timeline-chart-host" />
<p id="projects-chart-undated" className="smart-timeline-chart-undated-hint" style="display:none" />
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/projects-chart.js"></script>
</body>
</html>
);
}

View File

@@ -104,6 +104,19 @@ export function renderProjectsDetail(): string {
<button type="button" className="btn-primary btn-cta-lime btn-small" id="smart-timeline-add-btn" data-i18n="projects.detail.smarttimeline.add.cta">
+ Eintrag
</button>
{/* t-paliad-177 — link to the standalone /chart page.
Opens in a new tab per design §8.1; the Verlauf
embed itself stays vertical-DOM-only. */}
<a
id="smart-timeline-open-chart"
className="btn-secondary btn-small"
href="#"
target="_blank"
rel="noopener"
data-i18n="projects.detail.smarttimeline.open_chart"
>
Als Chart anzeigen &nearr;
</a>
</div>
<div id="project-events-filter-bar" />
<div id="project-smart-timeline" className="smart-timeline" />
@@ -175,7 +188,7 @@ export function renderProjectsDetail(): string {
<div className="form-field">
<label htmlFor="smart-timeline-counterclaim-procedure" data-i18n="projects.detail.smarttimeline.counterclaim.procedure">Verfahrenstyp</label>
<select id="smart-timeline-counterclaim-procedure">
{/* Options injected from client; defaults to UPC_REV */}
{/* Options injected from client; defaults to upc.rev.cfi */}
</select>
</div>
<div className="form-field">

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,259 @@
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";
// Slice 1 (t-paliad-179) — the dedicated abstract-browse surface for
// procedural shape. Same backend (POST /api/tools/fristenrechner) +
// same renderer module (./client/views/verfahrensablauf-core) as
// /tools/fristenrechner; this page strips the Step 1 Akte picker /
// Step 2 cards / Pathway A wizard / Pathway B cascade / save modal,
// leaving just: proceeding-type tile picker + trigger date + court
// picker + result panel. Variant chips, lane view and compare arrive in
// Slices 2-4.
interface ProceedingDef {
code: string;
i18nKey: string;
name: string;
}
function proceedingBtn(p: ProceedingDef): string {
return (
<button type="button" className="proceeding-btn" data-code={p.code}>
<strong data-i18n={p.i18nKey}>{p.name}</strong>
</button>
);
}
const UPC_TYPES: ProceedingDef[] = [
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
{ code: "upc.apl.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" },
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
{ code: "upc.apl.cost", i18nKey: "deadlines.upc.apl.cost", name: "Berufung Kosten" },
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
];
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
// 2026-05-18 ask. Labels are parallel: <court> (<procedural role>),
// so a user scanning the picker sees the instance-and-role at a glance
// without one tile reading "Berufung OLG" and another "Nichtigkeits-
// verfahren". Sub-group headers convey the type grouping. Combined-
// timeline behaviour (LG→OLG→BGH as one calc) is filed as m/paliad#41.
const DE_INF_TYPES: ProceedingDef[] = [
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
];
const DE_NULL_TYPES: ProceedingDef[] = [
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
];
const EPA_TYPES: ProceedingDef[] = [
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
];
const DPMA_TYPES: ProceedingDef[] = [
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
];
export function renderVerfahrensablauf(): string {
const today = new Date().toISOString().split("T")[0];
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="tools.verfahrensablauf.title">Verfahrensablauf &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/tools/verfahrensablauf" />
<BottomNav currentPath="/tools/verfahrensablauf" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="tools.verfahrensablauf.heading">Verfahrensablauf</h1>
<p className="tool-subtitle" data-i18n="tools.verfahrensablauf.subtitle">
Typischen Verfahrensablauf einsehen &mdash; Verfahrensart w&auml;hlen, Datum optional setzen.
</p>
</div>
{/* Verfahrensart picker (single-tile mode — same DOM ids as
/tools/fristenrechner so the shared renderer module and
court-picker primitives bind without parameterisation). */}
<div className="fristen-wizard" id="verfahrensablauf-wizard" data-mode="procedure">
<div className="wizard-step" id="step-1">
<h3 className="wizard-step-label">
<span className="step-number">1</span>
<span data-i18n="deadlines.step1">Verfahrensart w&auml;hlen</span>
</h3>
<div className="proceeding-group" data-forum="upc">
<h4 data-i18n="deadlines.upc">UPC</h4>
<div className="proceeding-btns">
{UPC_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group" data-forum="de">
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
<div className="proceeding-btns">
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
<div className="proceeding-btns">
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
</div>
<div className="proceeding-group" data-forum="epa">
<h4 data-i18n="deadlines.epa">EPA</h4>
<div className="proceeding-btns">
{EPA_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group" data-forum="dpma">
<h4 data-i18n="deadlines.dpma">DPMA</h4>
<div className="proceeding-btns">
{DPMA_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
<strong className="proceeding-summary-name" id="proceeding-summary-name">&mdash;</strong>
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
data-i18n="deadlines.proceeding.reselect">
Anderes Verfahren w&auml;hlen
</button>
</div>
</div>
<div className="wizard-step" id="step-2" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">2</span>
<span data-i18n="deadlines.step2">Ausgangsdatum eingeben</span>
</h3>
<div className="date-input-group">
<div className="date-field-row">
<label htmlFor="trigger-event" className="date-label" data-i18n="deadlines.trigger.event">Ausl&ouml;sendes Ereignis:</label>
<span id="trigger-event" className="trigger-event-name">&mdash;</span>
</div>
<div className="date-field-row">
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
<input type="date" id="trigger-date" className="date-input" value={today} />
</div>
<div className="date-field-row" id="court-picker-row" style="display:none">
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
<select id="court-picker" className="date-input"></select>
</div>
{/* Proceeding-specific flag rows — mirror /tools/fristenrechner
so an abstract-browse user can model the same variants
(CCR, Patentänderung, Verletzungswiderklage,
Vorab-Einrede). Show/hide driven by selectedType in
the client. */}
<div className="date-field-row" id="ccr-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="ccr-flag" />
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
</label>
</div>
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="inf-amend-flag" />
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patent&auml;nderung (R.30)</span>
</label>
</div>
<div className="date-field-row" id="rev-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-amend-flag" />
<span data-i18n="deadlines.flag.rev_amend">Mit Antrag auf Patent&auml;nderung (R.49.2.a)</span>
</label>
</div>
<div className="date-field-row" id="rev-cci-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-cci-flag" />
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
</label>
</div>
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
Fristen berechnen
</button>
</div>
</div>
<div className="wizard-step" id="step-3" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">3</span>
<span data-i18n="deadlines.step3">Ergebnis</span>
</h3>
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
<label className="fristen-view-option">
<input type="radio" name="fristen-view" value="columns" checked />
<span data-i18n="deadlines.view.columns">Spalten</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="fristen-view" value="timeline" />
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
</label>
<label className="fristen-notes-option">
<input type="checkbox" id="fristen-notes-show" />
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
</label>
</div>
<div id="timeline-container">
</div>
<div className="fristen-result-actions">
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="6 9 6 2 18 2 18 9"></polyline>
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
<rect x="6" y="14" width="12" height="8"></rect>
</svg>
<span data-i18n="deadlines.print">Drucken</span>
</button>
</div>
</div>
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/verfahrensablauf.js"></script>
</body>
</html>
);
}

View File

@@ -52,6 +52,7 @@ export function renderViews(): string {
<button type="button" className="agenda-chip" data-shape="list" role="tab" data-i18n="views.shape.list">Liste</button>
<button type="button" className="agenda-chip" data-shape="cards" role="tab" data-i18n="views.shape.cards">Karten</button>
<button type="button" className="agenda-chip" data-shape="calendar" role="tab" data-i18n="views.shape.calendar">Kalender</button>
<button type="button" className="agenda-chip" data-shape="timeline" role="tab" data-i18n="views.shape.timeline">Timeline</button>
</div>
<div className="views-toolbar-spacer" />
<a href="#" className="btn-secondary btn-small" id="views-save-as" data-i18n="views.save_as" hidden>
@@ -59,6 +60,13 @@ export function renderViews(): string {
</a>
</div>
{/* Filter bar host — t-paliad-211. mountFilterBar appends its
own toolbar element here; the saved view's filter_spec
becomes the bar's baseline, axes are chosen client-side
per the view's data sources. */}
<div className="views-filter-bar" id="views-filter-bar" hidden />
{/* Empty / onboarding state — shown on bare /views with no saved views. */}
<div className="views-onboarding" id="views-onboarding" hidden>
<h2 data-i18n="views.onboarding.title">Eigene Ansichten &mdash; was ist das?</h2>
@@ -94,6 +102,24 @@ export function renderViews(): string {
<div className="views-shape-host views-shape-list" id="views-shape-list" hidden />
<div className="views-shape-host views-shape-cards" id="views-shape-cards" hidden />
<div className="views-shape-host views-shape-calendar" id="views-shape-calendar" hidden />
<div className="views-shape-host views-shape-timeline" id="views-shape-timeline" hidden>
{/* CV-chart caveat banner — design §13.4: ViewService
doesn't run the fristenrechner calculator, so Custom
Views show actual events only. One-time-per-session
dismissible (sessionStorage). */}
<div className="views-timeline-caveat" id="views-timeline-caveat" hidden>
<span data-i18n="views.timeline.caveat.body">
Custom Views zeigen nur eingetretene Ereignisse. F&uuml;r prognostizierte Fristen das Projekt-Chart &ouml;ffnen.
</span>
<button
type="button"
className="views-timeline-caveat-close"
id="views-timeline-caveat-close"
aria-label="Schlie&szlig;en"
>&times;</button>
</div>
<div id="views-timeline-chart-host" />
</div>
</div>
</section>
<Footer />

View File

@@ -0,0 +1,27 @@
-- t-paliad-182 down — reverses 078_unified_rule_columns.up.sql.
--
-- Drops in reverse dependency order: indexes → CHECK constraints →
-- FKs → columns. Idempotent (IF EXISTS guards everywhere).
DROP INDEX IF EXISTS paliad.deadline_rules_lifecycle_state_idx;
DROP INDEX IF EXISTS paliad.deadline_rules_spawn_proceeding_type_id_idx;
DROP INDEX IF EXISTS paliad.deadline_rules_trigger_event_id_idx;
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_lifecycle_state_check;
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_priority_check;
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_combine_op_check;
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_draft_of_fkey;
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_spawn_proceeding_type_id_fkey;
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_trigger_event_id_fkey;
ALTER TABLE paliad.deadline_rules
DROP COLUMN IF EXISTS published_at,
DROP COLUMN IF EXISTS draft_of,
DROP COLUMN IF EXISTS lifecycle_state,
DROP COLUMN IF EXISTS is_court_set,
DROP COLUMN IF EXISTS priority,
DROP COLUMN IF EXISTS condition_expr,
DROP COLUMN IF EXISTS combine_op,
DROP COLUMN IF EXISTS spawn_proceeding_type_id,
DROP COLUMN IF EXISTS trigger_event_id;

View File

@@ -0,0 +1,173 @@
-- t-paliad-182 / Fristen Phase 3 Slice 1 (Step A of
-- docs/design-fristen-phase2-2026-05-15.md §3.1).
--
-- Additive only: extends paliad.deadline_rules with the unified-rule
-- columns the Phase 3 calculator + rule editor will use.
--
-- NO drops in this slice. Legacy columns (is_mandatory, is_optional,
-- condition_flag, condition_rule_id) stay live until Slice 9. Compat-
-- mode readers consume both shapes during the transition window
-- (design §3.2 "Cutover ordering").
--
-- Column-by-column rationale:
-- trigger_event_id — event-rooted dispatch (Pipeline C unification, §2.5).
-- spawn_proceeding_type_id — cross-proceeding spawn resolution (Q7, §2.6).
-- combine_op — composite-rule arithmetic 'max'/'min' (R.198/R.213).
-- condition_expr — jsonb condition grammar replacing condition_flag (Q6, §2.4).
-- priority — 4-way enum mandatory|recommended|optional|informational (Q3, §2.3).
-- is_court_set — explicit replacement of the runtime heuristic (Q12).
-- lifecycle_state — draft|published|archived for the rule editor (Q5, §4.2).
-- draft_of — draft self-FK pointing at the published row it replaces.
-- published_at — promotion timestamp, NULL while draft.
--
-- FK type notes:
-- trigger_event_id is BIGINT (paliad.trigger_events.id is bigint, mig 028).
-- spawn_proceeding_type_id is INTEGER (paliad.proceeding_types.id is
-- serial = int4, mig 003).
-- draft_of is UUID (self-FK on paliad.deadline_rules.id).
-- The design doc (§2.1) calls them "int FK" loosely; the actual schemas
-- demand the precise int width, hence bigint/integer here.
--
-- Indexes:
-- FK lookups for trigger_event_id + spawn_proceeding_type_id (sparse,
-- most rules have neither — partial WHERE NOT NULL keeps the index
-- small).
-- lifecycle_state is queried by the admin /admin/rules listing's
-- default filter (state='published'); plain btree is fine, no
-- WHERE clause so 'draft' / 'archived' rows index too.
--
-- Idempotent: every ADD COLUMN uses IF NOT EXISTS. Re-applying is a
-- no-op. Tracker advances 77 → 78.
-- =============================================================================
-- 1. New columns on paliad.deadline_rules
-- =============================================================================
ALTER TABLE paliad.deadline_rules
ADD COLUMN IF NOT EXISTS trigger_event_id bigint,
ADD COLUMN IF NOT EXISTS spawn_proceeding_type_id integer,
ADD COLUMN IF NOT EXISTS combine_op text,
ADD COLUMN IF NOT EXISTS condition_expr jsonb,
ADD COLUMN IF NOT EXISTS priority text NOT NULL DEFAULT 'mandatory',
ADD COLUMN IF NOT EXISTS is_court_set boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS lifecycle_state text NOT NULL DEFAULT 'published',
ADD COLUMN IF NOT EXISTS draft_of uuid,
ADD COLUMN IF NOT EXISTS published_at timestamptz;
COMMENT ON COLUMN paliad.deadline_rules.trigger_event_id IS
'Optional FK to paliad.trigger_events. When non-NULL, this rule is '
'event-rooted (Pipeline C unification, design §2.5). When NULL the '
'rule is proceeding-rooted via proceeding_type_id. Exactly one of '
'the two must be set after Slice 3 backfill (enforced by a CHECK '
'constraint added in Slice 9 after legacy callers retire).';
COMMENT ON COLUMN paliad.deadline_rules.spawn_proceeding_type_id IS
'When is_spawn=true, points at the target proceeding whose rule set '
'the calculator follows when this rule fires (cross-proceeding '
'spawn, design §2.6). Backfilled in Slice 7 for the 8 live spawn '
'rules.';
COMMENT ON COLUMN paliad.deadline_rules.combine_op IS
'NULL = single-anchor arithmetic. ''max'' / ''min'' = composite-rule '
'arithmetic combining (duration_value, duration_unit) with '
'(alt_duration_value, alt_duration_unit). Used by R.198 / R.213 '
'("31d OR 20 working_days, whichever is longer / shorter").';
COMMENT ON COLUMN paliad.deadline_rules.condition_expr IS
'jsonb gating expression replacing condition_flag (Q6, design §2.4). '
'Grammar: {"flag": "<name>"} | {"op":"and"|"or", "args":[...]} | '
'{"op":"not", "args":[<node>]}. NULL or {} = unconditional. '
'Backfilled in Slice 2 from condition_flag; new code reads this, '
'falls back to condition_flag during the transition window.';
COMMENT ON COLUMN paliad.deadline_rules.priority IS
'Unified 4-way enum (Q3, design §2.3) replacing the is_mandatory + '
'is_optional pair. Allowed: mandatory | recommended | optional | '
'informational. Default ''mandatory'' on new rows; legacy rows get '
'backfilled in Slice 2 from the (is_mandatory, is_optional) pair.';
COMMENT ON COLUMN paliad.deadline_rules.is_court_set IS
'Replaces the runtime heuristic (primary_party=''court'' OR '
'event_type IN (...)) with an explicit column (Q12). Default false '
'on new rows; Slice 2 backfills from the heuristic so behaviour is '
'unchanged at first.';
COMMENT ON COLUMN paliad.deadline_rules.lifecycle_state IS
'Rule-editor lifecycle (Q5, design §4.2). draft = work-in-progress '
'admin edit; published = live, calculator-visible; archived = '
'historical (kept for audit). Default ''published'' so every '
'existing row stays live without an UPDATE.';
COMMENT ON COLUMN paliad.deadline_rules.draft_of IS
'When lifecycle_state=''draft'', points at the published rule this '
'draft will replace on publish. NULL on published or archived '
'rows. NULL also on net-new drafts (no prior published peer).';
COMMENT ON COLUMN paliad.deadline_rules.published_at IS
'Timestamp this row entered lifecycle_state=''published''. NULL '
'while draft, populated on publish, retained through archive. '
'Distinct from updated_at (which moves on every edit).';
-- =============================================================================
-- 2. Foreign keys
-- =============================================================================
--
-- DEFERRABLE INITIALLY IMMEDIATE keeps normal-statement semantics
-- intact while still letting backfill migrations defer until end-of-
-- transaction if they need to (e.g. when Slice 3 inserts a rule row
-- whose trigger_event_id references a row inserted in the same tx).
ALTER TABLE paliad.deadline_rules
ADD CONSTRAINT deadline_rules_trigger_event_id_fkey
FOREIGN KEY (trigger_event_id)
REFERENCES paliad.trigger_events(id)
ON DELETE SET NULL
DEFERRABLE INITIALLY IMMEDIATE;
ALTER TABLE paliad.deadline_rules
ADD CONSTRAINT deadline_rules_spawn_proceeding_type_id_fkey
FOREIGN KEY (spawn_proceeding_type_id)
REFERENCES paliad.proceeding_types(id)
ON DELETE SET NULL
DEFERRABLE INITIALLY IMMEDIATE;
ALTER TABLE paliad.deadline_rules
ADD CONSTRAINT deadline_rules_draft_of_fkey
FOREIGN KEY (draft_of)
REFERENCES paliad.deadline_rules(id)
ON DELETE SET NULL
DEFERRABLE INITIALLY IMMEDIATE;
-- =============================================================================
-- 3. CHECK constraints on enum-style columns
-- =============================================================================
--
-- combine_op: NULL (unset) or one of two values.
ALTER TABLE paliad.deadline_rules
ADD CONSTRAINT deadline_rules_combine_op_check
CHECK (combine_op IS NULL OR combine_op IN ('max', 'min'));
-- priority: 4-way enum.
ALTER TABLE paliad.deadline_rules
ADD CONSTRAINT deadline_rules_priority_check
CHECK (priority IN ('mandatory', 'recommended', 'optional', 'informational'));
-- lifecycle_state: 3-way enum.
ALTER TABLE paliad.deadline_rules
ADD CONSTRAINT deadline_rules_lifecycle_state_check
CHECK (lifecycle_state IN ('draft', 'published', 'archived'));
-- =============================================================================
-- 4. Indexes
-- =============================================================================
CREATE INDEX IF NOT EXISTS deadline_rules_trigger_event_id_idx
ON paliad.deadline_rules (trigger_event_id)
WHERE trigger_event_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS deadline_rules_spawn_proceeding_type_id_idx
ON paliad.deadline_rules (spawn_proceeding_type_id)
WHERE spawn_proceeding_type_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS deadline_rules_lifecycle_state_idx
ON paliad.deadline_rules (lifecycle_state);

View File

@@ -0,0 +1,15 @@
-- t-paliad-182 down — reverses 079_deadline_rule_audit.up.sql.
--
-- Order: trigger → function → policy → indexes → table.
DROP TRIGGER IF EXISTS deadline_rules_audit_aiud ON paliad.deadline_rules;
DROP FUNCTION IF EXISTS paliad.deadline_rule_audit_trigger();
DROP POLICY IF EXISTS deadline_rule_audit_select ON paliad.deadline_rule_audit;
DROP INDEX IF EXISTS paliad.deadline_rule_audit_pending_export_idx;
DROP INDEX IF EXISTS paliad.deadline_rule_audit_changed_by_idx;
DROP INDEX IF EXISTS paliad.deadline_rule_audit_changed_at_idx;
DROP INDEX IF EXISTS paliad.deadline_rule_audit_rule_id_idx;
DROP TABLE IF EXISTS paliad.deadline_rule_audit;

View File

@@ -0,0 +1,207 @@
-- t-paliad-182 / Fristen Phase 3 Slice 1 — audit log for the rule editor
-- (design §2.8, §3.1 Step A.079).
--
-- The audit log lands BEFORE the rule editor (Slice 11) so every future
-- write to paliad.deadline_rules is captured forever, including the
-- Slice 2 backfill UPDATEs. Defence-in-depth: the rule-editor service
-- writes Go-authored audit rows with semantic actions ('publish',
-- 'archive', 'restore'); this trigger is the backstop for raw SQL.
--
-- Field-naming mirrors design §2.8 (`changed_by` / `changed_at` /
-- `before_json` / `after_json` / `migration_exported`), not the
-- audit_log shorthand used elsewhere in Paliad.
--
-- Schema deviations from design §2.8, documented for the head review:
--
-- 1. `changed_by` is nullable, not NOT NULL. Reason: the trigger reads
-- auth.uid() which is NULL when the writer is `service_role`
-- (migrations, server-side Go using the service key, direct DB
-- maintenance). NOT NULL would block every Slice-2 backfill UPDATE
-- and every migration-applied seed. The Go rule-editor service
-- enforces non-NULL changed_by at the application layer when it
-- writes its own audit rows.
--
-- 2. `action` values stored by the trigger are 'create' / 'update' /
-- 'delete' (the raw TG_OP semantics). Go-authored audit rows can
-- additionally store 'publish' / 'archive' / 'restore' — those are
-- lifecycle_state flips at the SQL level and appear as 'update' in
-- the trigger's view of the world. The Go layer writes the
-- higher-level action *before* the UPDATE, so the human-readable
-- action is captured even though the trigger fires a paired
-- 'update' row. The audit UI in Slice 11 collapses paired rows.
--
-- Audit-reason enforcement: the trigger reads
-- `current_setting('paliad.audit_reason', true)` (the `true` flag
-- returns NULL when unset rather than raising). On UPDATE and DELETE
-- the trigger requires a non-empty reason and raises EXCEPTION 'audit
-- reason required' if missing. On INSERT the reason is optional
-- (defaults to 'create' so seed migrations don't need to set it).
--
-- Idempotent: re-applying is a no-op. Tracker advances 78 → 79.
-- =============================================================================
-- 1. paliad.deadline_rule_audit
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.deadline_rule_audit (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- The rule this delta concerns. ON DELETE CASCADE: when a rule row
-- gets hard-deleted (rare; lifecycle_state='archived' is the normal
-- path), drop its audit chain too — the trail otherwise survives in
-- the migration history of the table itself.
rule_id uuid NOT NULL
REFERENCES paliad.deadline_rules(id) ON DELETE CASCADE,
-- See header comment §1: nullable so trigger writes from service_role
-- contexts (migrations, backfills) don't fail.
changed_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
changed_at timestamptz NOT NULL DEFAULT now(),
-- See header comment §2 for the trigger vs Go-layer split.
action text NOT NULL
CHECK (action IN (
'create', 'update', 'delete',
'publish', 'archive', 'restore'
)),
-- Row state pre/post change. NULL on create / delete respectively.
before_json jsonb,
after_json jsonb,
-- Justification required by the trigger on UPDATE / DELETE; optional
-- on INSERT (defaults to 'create' when paliad.audit_reason is unset
-- so seed migrations don't need to bother).
reason text NOT NULL,
-- Flips to true when the migration-export endpoint (Slice 11b) folds
-- this delta into a checked-in .up.sql. Lets the export endpoint
-- skip already-exported rows.
migration_exported boolean NOT NULL DEFAULT false
);
CREATE INDEX IF NOT EXISTS deadline_rule_audit_rule_id_idx
ON paliad.deadline_rule_audit (rule_id, changed_at DESC);
CREATE INDEX IF NOT EXISTS deadline_rule_audit_changed_at_idx
ON paliad.deadline_rule_audit (changed_at DESC);
CREATE INDEX IF NOT EXISTS deadline_rule_audit_changed_by_idx
ON paliad.deadline_rule_audit (changed_by)
WHERE changed_by IS NOT NULL;
CREATE INDEX IF NOT EXISTS deadline_rule_audit_pending_export_idx
ON paliad.deadline_rule_audit (changed_at DESC)
WHERE migration_exported = false;
COMMENT ON TABLE paliad.deadline_rule_audit IS
'Append-only audit log for paliad.deadline_rules. Written by the '
'AFTER-trigger on the rules table (raw create/update/delete) and '
'by the Go rule-editor service (semantic publish/archive/restore). '
'Required reason field is the compliance hook for the rule-editor '
'design (Q5, §4.7).';
-- =============================================================================
-- 2. Audit trigger
-- =============================================================================
--
-- SECURITY DEFINER so the trigger function runs with the table-owner's
-- privileges and bypasses RLS on the audit table. Otherwise an
-- authenticated user's UPDATE on a rule would fail when the trigger
-- tried to INSERT under their RLS context.
CREATE OR REPLACE FUNCTION paliad.deadline_rule_audit_trigger()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = paliad, public
AS $$
DECLARE
v_reason text;
v_action text;
v_before jsonb;
v_after jsonb;
v_rule_id uuid;
BEGIN
v_reason := current_setting('paliad.audit_reason', true);
IF TG_OP = 'INSERT' THEN
v_action := 'create';
v_before := NULL;
v_after := to_jsonb(NEW);
v_rule_id := NEW.id;
-- INSERT is allowed without an explicit reason; seed migrations
-- and net-new drafts default to a synthetic reason.
IF v_reason IS NULL OR v_reason = '' THEN
v_reason := 'create';
END IF;
ELSIF TG_OP = 'UPDATE' THEN
v_action := 'update';
v_before := to_jsonb(OLD);
v_after := to_jsonb(NEW);
v_rule_id := NEW.id;
IF v_reason IS NULL OR v_reason = '' THEN
RAISE EXCEPTION 'paliad.deadline_rules: audit reason required for UPDATE — '
'set paliad.audit_reason via SET LOCAL or set_config()';
END IF;
ELSIF TG_OP = 'DELETE' THEN
v_action := 'delete';
v_before := to_jsonb(OLD);
v_after := NULL;
v_rule_id := OLD.id;
IF v_reason IS NULL OR v_reason = '' THEN
RAISE EXCEPTION 'paliad.deadline_rules: audit reason required for DELETE — '
'set paliad.audit_reason via SET LOCAL or set_config()';
END IF;
END IF;
INSERT INTO paliad.deadline_rule_audit
(rule_id, changed_by, action, before_json, after_json, reason)
VALUES
(v_rule_id, auth.uid(), v_action, v_before, v_after, v_reason);
RETURN COALESCE(NEW, OLD);
END;
$$;
COMMENT ON FUNCTION paliad.deadline_rule_audit_trigger() IS
'AFTER-trigger backstop that writes paliad.deadline_rule_audit rows '
'for every raw INSERT / UPDATE / DELETE on paliad.deadline_rules. '
'UPDATE / DELETE require paliad.audit_reason to be set in the '
'session (via SET LOCAL paliad.audit_reason = ...); INSERT defaults '
'to ''create'' so seed migrations remain ergonomic.';
DROP TRIGGER IF EXISTS deadline_rules_audit_aiud ON paliad.deadline_rules;
CREATE TRIGGER deadline_rules_audit_aiud
AFTER INSERT OR UPDATE OR DELETE ON paliad.deadline_rules
FOR EACH ROW
EXECUTE FUNCTION paliad.deadline_rule_audit_trigger();
-- =============================================================================
-- 3. RLS on the audit table
-- =============================================================================
--
-- Read: global_admin only (mirrors mig 057 pattern). Service-layer code
-- gates `/admin/rules/{id}/audit` separately; this RLS is defence-in-
-- depth for any future auth-context query path.
--
-- Write: nobody via row-level paths. The trigger function is
-- SECURITY DEFINER so it bypasses RLS entirely. Direct INSERTs by
-- authenticated users are denied (no INSERT policy). service_role
-- bypasses RLS as usual.
ALTER TABLE paliad.deadline_rule_audit ENABLE ROW LEVEL SECURITY;
CREATE POLICY deadline_rule_audit_select
ON paliad.deadline_rule_audit FOR SELECT
USING (
EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid()
AND u.global_role = 'global_admin'
)
);

View File

@@ -0,0 +1,3 @@
-- t-paliad-182 down — reverses 080_projects_instance_level.up.sql.
ALTER TABLE paliad.projects DROP COLUMN IF EXISTS instance_level;

View File

@@ -0,0 +1,30 @@
-- t-paliad-182 / Fristen Phase 3 Slice 1 — paliad.projects.instance_level
-- (design §2.7, §7).
--
-- Lets the SmartTimeline + calculator derive the effective proceeding
-- code from (proceeding_code, instance_level) — e.g. DE_INF + 'appeal'
-- resolves to DE_INF_OLG.
--
-- Nullable: NULL means "not asked / not relevant" (e.g. EP_GRANT, a
-- non-litigation patent project). Allowed values:
-- first — first instance (default once the picker UI lands)
-- appeal — Berufung / EPA Beschwerde / appellate level
-- cassation — BGH-Revision / EPA-EBA / final instance
--
-- No backfill in this slice. The picker UI (Slice 8) writes the column;
-- legacy projects stay NULL and behave as if first instance via the
-- calculator's fallback (`NULL OR 'first'` → use base proceeding code).
--
-- Idempotent: re-applying is a no-op. Tracker advances 79 → 80.
ALTER TABLE paliad.projects
ADD COLUMN IF NOT EXISTS instance_level text
CHECK (instance_level IS NULL
OR instance_level IN ('first', 'appeal', 'cassation'));
COMMENT ON COLUMN paliad.projects.instance_level IS
'Procedural instance the project sits at: first | appeal | '
'cassation. NULL = unset / not applicable. Combined with '
'proceeding_type.code + jurisdiction by FristenrechnerService to '
'pick the effective proceeding code (e.g. DE_INF + appeal → '
'DE_INF_OLG). See design-fristen-phase2-2026-05-15.md §2.7, §7.';

View File

@@ -0,0 +1,21 @@
-- t-paliad-183 down — reverts the is_court_set flips written by
-- 082_backfill_is_court_set.up.sql.
--
-- "Revert" here means: restore the post-Slice-1 default (false on every
-- row). We don't know after the fact which rows were already true
-- before the backfill (mig 078 created the column with DEFAULT false on
-- every existing row, so post-Slice-1 every row was false — there is
-- no pre-existing true population to preserve). Setting back to false
-- is therefore equivalent to "undo the backfill".
--
-- Audit-reason set so the trigger doesn't raise on the down-side
-- UPDATEs either.
SELECT set_config(
'paliad.audit_reason',
'rollback 082: reset is_court_set to mig 078 default (false)',
true);
UPDATE paliad.deadline_rules
SET is_court_set = false
WHERE is_court_set = true;

View File

@@ -0,0 +1,68 @@
-- t-paliad-183 / Fristen Phase 3 Slice 2 Step B-1 — backfill
-- paliad.deadline_rules.is_court_set from the live runtime heuristic.
--
-- Heuristic source-of-truth: internal/services/fristenrechner.go
-- isCourtDeterminedRule() — at the time of Slice 1 (commit c7fa0d6) the
-- body is precisely:
--
-- primary_party = 'court'
-- OR event_type IN ('hearing', 'decision', 'order')
--
-- The Slice 2 head instruction (msg 1746) suggested padding with
-- 'name ILIKE %entscheidung% OR %urteil%'; head's clarification
-- (msg 1750) rules that out: replicate the live code exactly. Padding
-- would mis-flag party submissions like 'Antrag auf Kostenentscheidung'
-- (RoP.151) and 'Stellungnahme zum Hinweisbeschluss' as court-set —
-- they are not (the party files them; only their anchor is set by the
-- court).
--
-- Audit footnote for the legal-review pass: ~8 'Zustellung…' rules
-- (Zustellung BPatG-Entscheidung, Zustellung LG-Urteil, etc.) carry
-- primary_party='both' + event_type='filing'. Semantically the
-- Zustellung date IS court-set, but the live heuristic doesn't treat
-- them as such and flagging them now would change calculator
-- rendering without legal review. Leaving them is_court_set=false
-- preserves current behaviour; the legal-review pass mentioned in
-- design §2.3 ("flag them informational in a Phase 3 slice") can
-- promote them later via a targeted UPDATE.
--
-- Audit-reason: set_config('paliad.audit_reason', …, true) scopes the
-- value to golang-migrate's implicit per-file transaction. The audit
-- trigger from mig 079 picks it up via current_setting() and writes
-- one paliad.deadline_rule_audit row per flipped rule — the compliance
-- trail for the backfill, persisted forever.
--
-- Idempotent: WHERE is_court_set = false guards re-runs against double-
-- counting audit rows.
--
-- Expected delta on the production corpus (172 rules): 47 rows flipped
-- false→true (every primary_party='court' rule also has a matching
-- event_type in the current data — the two predicates fully overlap).
--
-- Tracker note: mig 081 was reserved for proceeding_types display_order
-- verification per design §3.1; that was a no-op and not authored.
-- Slice 1 shipped 078/079/080; Slice 2 starts at 082. golang-migrate
-- only requires ascending order, not contiguity.
SELECT set_config(
'paliad.audit_reason',
'backfill 082: is_court_set from isCourtDeterminedRule heuristic '
|| '(primary_party=court OR event_type IN hearing/decision/order) '
|| 'per design §2.3 / fristenrechner.go',
true);
UPDATE paliad.deadline_rules
SET is_court_set = true
WHERE is_court_set = false
AND (
primary_party = 'court'
OR event_type IN ('hearing', 'decision', 'order')
);
DO $$
DECLARE
n_set int;
BEGIN
SELECT count(*) INTO n_set FROM paliad.deadline_rules WHERE is_court_set = true;
RAISE NOTICE 'backfill 082: is_court_set=true on % rules', n_set;
END $$;

View File

@@ -0,0 +1,17 @@
-- t-paliad-183 down — reverts the priority flips written by
-- 083_backfill_priority.up.sql.
--
-- "Revert" here means: restore the post-Slice-1 column default
-- ('mandatory' on every row). Mig 078 created the column with that
-- default; post-Slice-1 every row was 'mandatory' regardless of its
-- (is_mandatory, is_optional) pair. Resetting to 'mandatory' is
-- therefore equivalent to "undo the backfill".
SELECT set_config(
'paliad.audit_reason',
'rollback 083: reset priority to mig 078 default (mandatory)',
true);
UPDATE paliad.deadline_rules
SET priority = 'mandatory'
WHERE priority <> 'mandatory';

View File

@@ -0,0 +1,110 @@
-- t-paliad-183 / Fristen Phase 3 Slice 2 Step B-2 — backfill
-- paliad.deadline_rules.priority from the legacy (is_mandatory,
-- is_optional) pair per DESIGN §2.3 (NOT the inverted mapping in
-- head's msg 1746 — head's clarification msg 1750 rules in favour of
-- the design doc).
--
-- Final mapping (design §2.3 + RoP.151 / mig 068 t-paliad-157 semantic):
--
-- is_mandatory=true, is_optional=false → 'mandatory' (statutory must,
-- ☑ pre-checked in
-- save modal)
-- is_mandatory=true, is_optional=true → 'optional' (statutorily strict
-- ONCE IT APPLIES,
-- but applies only
-- if a party files —
-- RoP.151 is the
-- canonical case;
-- ☐ pre-unchecked)
-- is_mandatory=false, is_optional=true → 'recommended' (no live data, but
-- defensive default
-- so the CHECK
-- constraint stays
-- satisfied if such
-- a row ever lands)
-- is_mandatory=false, is_optional=false → 'recommended' (situational filings
-- — Berufungserwiderung,
-- Replik, Duplik,
-- R.19 Preliminary
-- Objection, R.116
-- EPÜ, Anschluss-
-- berufung, etc.
-- Default-save with
-- override, not
-- 'informational'
-- which would make
-- them never-saveable)
--
-- Live-data expected delta (172 rules total, mig 078 set every row to
-- the default 'mandatory'):
-- T/F (153 rows) → 'mandatory' — 153 no-op UPDATEs (already correct)
-- T/T ( 1 row) → 'optional' — 1 row flips
-- F/F ( 18 rows) → 'recommended' — 18 rows flip
-- F/T ( 0 rows) → 'recommended' — 0 rows (no live data)
--
-- The UPDATE is split into branches with explicit WHERE clauses so the
-- audit log records each branch as a distinct backfill action (separate
-- audit row chains by (is_mandatory, is_optional) shape). It also keeps
-- the migration idempotent: re-running only touches rows whose priority
-- doesn't already match the target.
--
-- Audit-reason cites design §2.3 — that's the persistent rationale in
-- the paliad.deadline_rule_audit log.
SELECT set_config(
'paliad.audit_reason',
'backfill 083: priority from (is_mandatory, is_optional) per design §2.3 — '
|| 'T/T→optional (RoP.151), F/F→recommended (situational filings)',
true);
-- Branch 1: T/T → 'optional' (RoP.151).
UPDATE paliad.deadline_rules
SET priority = 'optional'
WHERE is_mandatory = true
AND is_optional = true
AND priority <> 'optional';
-- Branch 2: F/F → 'recommended'.
UPDATE paliad.deadline_rules
SET priority = 'recommended'
WHERE is_mandatory = false
AND is_optional = false
AND priority <> 'recommended';
-- Branch 3: F/T → 'recommended' (defensive; no live rows today).
UPDATE paliad.deadline_rules
SET priority = 'recommended'
WHERE is_mandatory = false
AND is_optional = true
AND priority <> 'recommended';
-- Branch 4: T/F → 'mandatory'. Skipped explicitly: the mig 078 column
-- default is already 'mandatory', so every T/F row already has the
-- correct value. A defensive UPDATE here would write 153 needless
-- audit rows. Leave T/F untouched.
DO $$
DECLARE
n_mand int;
n_opt int;
n_reco int;
n_info int;
n_null int;
BEGIN
SELECT count(*) FILTER (WHERE priority = 'mandatory'),
count(*) FILTER (WHERE priority = 'optional'),
count(*) FILTER (WHERE priority = 'recommended'),
count(*) FILTER (WHERE priority = 'informational'),
count(*) FILTER (WHERE priority IS NULL)
INTO n_mand, n_opt, n_reco, n_info, n_null
FROM paliad.deadline_rules;
RAISE NOTICE 'backfill 083: priority distribution — '
'mandatory=%, optional=%, recommended=%, informational=%, NULL=%',
n_mand, n_opt, n_reco, n_info, n_null;
-- Hard assertion: priority is NOT NULL by schema (mig 078) and
-- every value must lie in the CHECK enum. n_null must be 0.
IF n_null > 0 THEN
RAISE EXCEPTION 'backfill 083: % rows still have priority IS NULL — '
'schema violation', n_null;
END IF;
END $$;

View File

@@ -0,0 +1,14 @@
-- t-paliad-183 down — reverts the condition_expr translations written
-- by 084_backfill_condition_expr.up.sql. Mig 078 created the column
-- with NULL on every row; resetting non-NULL values to NULL undoes the
-- backfill cleanly (condition_flag is the source of truth for the
-- legacy code path and stays untouched).
SELECT set_config(
'paliad.audit_reason',
'rollback 084: reset condition_expr to mig 078 default (NULL)',
true);
UPDATE paliad.deadline_rules
SET condition_expr = NULL
WHERE condition_expr IS NOT NULL;

View File

@@ -0,0 +1,111 @@
-- t-paliad-183 / Fristen Phase 3 Slice 2 Step B-3 — backfill
-- paliad.deadline_rules.condition_expr from the legacy
-- condition_flag text[] column per DESIGN §2.4 long form (NOT the
-- short {"and":[...]} form sketched in head's msg 1746 — head's
-- clarification msg 1750 rules in favour of the design doc).
--
-- Mapping (design §2.4):
--
-- condition_flag IS NULL OR array_length(_, 1) = 0
-- → condition_expr stays NULL (unconditional, every rule renders)
--
-- array_length = 1, e.g. ['with_ccr']
-- → condition_expr = jsonb '{"flag": "with_ccr"}'
-- (single flag unwrapped — saves a layer of nesting that
-- parses as the same boolean expression)
--
-- array_length >= 2, e.g. ['with_ccr', 'with_amend']
-- → condition_expr = jsonb '{"op":"and","args":[
-- {"flag":"with_ccr"},
-- {"flag":"with_amend"}
-- ]}'
-- (long form — same shape the rule editor will emit for OR /
-- NOT in future rules so the calculator's parser is uniform)
--
-- Why long form on >=2: the calculator (Slice 4) reads
-- {"op":"<and|or|not>","args":[...]} as the canonical boolean node and
-- {"flag":"<name>"} as the leaf. Single-flag unwrap is a parse-time
-- shortcut equivalent to a 1-arg AND. The short {"and":[...]} form in
-- msg 1746 would require a per-key parser that doesn't generalise to
-- OR / NOT. Design §2.4 long form is the load-bearing decision.
--
-- Live-data expected delta (172 rules total):
--
-- ['with_ccr'] × 5 rows → {"flag":"with_ccr"}
-- ['with_amend'] × 4 rows → {"flag":"with_amend"}
-- ['with_cci'] × 4 rows → {"flag":"with_cci"}
-- ['with_ccr', 'with_amend'] × 4 rows → {"op":"and","args":[
-- {"flag":"with_ccr"},
-- {"flag":"with_amend"}
-- ]}
-- NULL or {} × 155 rows → stays NULL
--
-- Total touched: 17 rows.
--
-- Idempotent: WHERE condition_expr IS NULL guards re-runs against
-- double-writing audit rows for already-translated rules.
--
-- jsonb construction: jsonb_build_object + jsonb_agg + a CASE on
-- array_length keeps the long-form / unwrapped-flag split inline in
-- one UPDATE. Per-flag jsonb leaf is built by a LATERAL unnest over
-- the flag array so the args[] order matches the source array.
SELECT set_config(
'paliad.audit_reason',
'backfill 084: condition_expr from condition_flag text[] per design §2.4 — '
|| 'single flag unwrapped, multi flag long form {op:and, args:[...]}',
true);
UPDATE paliad.deadline_rules dr
SET condition_expr = sub.expr
FROM (
SELECT dr_inner.id AS rule_id,
CASE
-- Single flag: unwrapped leaf.
WHEN array_length(dr_inner.condition_flag, 1) = 1
THEN jsonb_build_object('flag', dr_inner.condition_flag[1])
-- >=2 flags: long-form AND with args[] preserving order.
WHEN array_length(dr_inner.condition_flag, 1) >= 2
THEN jsonb_build_object(
'op', 'and',
'args', (
SELECT jsonb_agg(jsonb_build_object('flag', f) ORDER BY ord)
FROM unnest(dr_inner.condition_flag) WITH ORDINALITY AS u(f, ord)
)
)
-- Empty array (array_length=0) or NULL: leave NULL.
ELSE NULL
END AS expr
FROM paliad.deadline_rules dr_inner
WHERE dr_inner.condition_flag IS NOT NULL
AND array_length(dr_inner.condition_flag, 1) > 0
) AS sub
WHERE dr.id = sub.rule_id
AND dr.condition_expr IS NULL;
DO $$
DECLARE
n_total int;
n_with_flag int;
n_with_expr int;
n_with_both int;
BEGIN
SELECT count(*),
count(*) FILTER (WHERE condition_flag IS NOT NULL AND array_length(condition_flag, 1) > 0),
count(*) FILTER (WHERE condition_expr IS NOT NULL),
count(*) FILTER (WHERE condition_flag IS NOT NULL AND array_length(condition_flag, 1) > 0
AND condition_expr IS NOT NULL)
INTO n_total, n_with_flag, n_with_expr, n_with_both
FROM paliad.deadline_rules;
RAISE NOTICE 'backfill 084: total=%, with_condition_flag=%, with_condition_expr=%, both=%',
n_total, n_with_flag, n_with_expr, n_with_both;
-- Hard assertion: every rule with a non-empty condition_flag now
-- has a non-NULL condition_expr (the inverse of the legacy column).
IF n_with_flag <> n_with_both THEN
RAISE EXCEPTION 'backfill 084: % rules carry condition_flag but no condition_expr — '
'translation incomplete',
n_with_flag - n_with_both;
END IF;
END $$;

View File

@@ -0,0 +1,17 @@
-- t-paliad-184 down — reverts the Pipeline-C data-move from
-- 085_pipeline_c_data_move.up.sql. Deletes every paliad.deadline_rules
-- row carrying a non-NULL trigger_event_id (those are exactly the rows
-- the up-migration created — before mig 085 no Pipeline-A rule ever
-- carried trigger_event_id, and Slice 9 hasn't dropped the source
-- table yet so the rows can be regenerated).
--
-- Audit-reason set so the mig 079 trigger captures the rollback
-- rationale and doesn't raise on DELETE.
SELECT set_config(
'paliad.audit_reason',
'rollback 085: delete Pipeline-C unified rows (source preserved in event_deadlines)',
true);
DELETE FROM paliad.deadline_rules
WHERE trigger_event_id IS NOT NULL;

View File

@@ -0,0 +1,184 @@
-- t-paliad-184 / Fristen Phase 3 Slice 3 Step C — data-move 77 rows
-- from paliad.event_deadlines → paliad.deadline_rules so the Phase-3
-- unified backend can serve both pipelines.
--
-- Source rows are PRESERVED (mig 086's read-only trigger blocks
-- further writes; mig 090 in Slice 9 drops the table once every
-- caller has cut over). The data-move is one-way; legacy callers
-- continue reading event_deadlines via plain SELECTs until Slice 9.
--
-- Mapping (per design §3.C):
--
-- paliad.event_deadlines → paliad.deadline_rules
-- ------------------------- ----------------------
-- id (new gen_random_uuid())
-- trigger_event_id trigger_event_id (Phase 3 column from mig 078)
-- title (EN, NOT NULL) name_en (NOT NULL)
-- title_de (DE, NOT NULL DEFAULT '') name (NOT NULL — every row has non-empty title_de in live data)
-- duration_value duration_value
-- duration_unit (days/weeks/months/working_days) duration_unit
-- timing (before/after) timing
-- notes (DE) deadline_notes (DE)
-- notes_en (EN, nullable) deadline_notes_en (EN, nullable)
-- alt_duration_value alt_duration_value
-- alt_duration_unit alt_duration_unit
-- combine_op (max/min, nullable) combine_op (Phase 3 column from mig 078)
-- legal_source legal_source
-- is_active is_active
-- created_at published_at (preserves chronology — lifecycle_state='published' on every row)
-- updated_at = now() (this is the publish event)
--
-- Pipeline-A-only fields default:
-- proceeding_type_id = NULL (event-rooted, no proceeding)
-- parent_id = NULL (Pipeline C is flat, no chain)
-- spawn_proceeding_type_id = NULL (no spawn)
-- code = NULL (no local rule code in Pipeline C)
-- primary_party = NULL (event_deadlines has no party column)
-- event_type = NULL (filing/hearing/decision is a
-- Pipeline-A category)
-- is_court_set = false (no court-set Pipeline-C rules
-- in the corpus; legal-review
-- pass can flip Zustellung-* if
-- those ever land here)
-- is_spawn = false
-- is_mandatory = true (Pipeline C has no mandatory
-- bool; design §2.3 says default
-- 'mandatory' is correct for
-- statutory event-driven deadlines)
-- is_optional = false
-- priority = 'mandatory'
-- condition_expr = NULL (Pipeline C has no flag gating)
-- condition_flag = NULL
-- sequence_order = 1000 + event_deadlines.id
-- (large offset so Pipeline-C
-- rows sort AFTER any future
-- hand-edited Pipeline-A
-- sequence_orders without
-- colliding with the
-- existing 0171 range)
-- lifecycle_state = 'published'
--
-- Idempotency: WHERE NOT EXISTS guard on (trigger_event_id, name) skips
-- rows that already exist in deadline_rules. Re-running the migration
-- is a no-op.
--
-- Hard assertion at end: COUNT(deadline_rules WHERE trigger_event_id
-- IS NOT NULL) == COUNT(event_deadlines WHERE is_active = true) (77 = 77).
-- RAISE EXCEPTION on mismatch so a partial move fails the migration
-- loudly instead of poisoning Slice 4.
--
-- Audit-reason cites design §3.C — the rationale persists in the
-- paliad.deadline_rule_audit log forever via the mig 079 trigger.
SELECT set_config(
'paliad.audit_reason',
'pipeline C migration 085: data-move event_deadlines → deadline_rules per design §3.C — '
|| 'preserves source rows; mig 086 wraps the source table read-only',
true);
INSERT INTO paliad.deadline_rules (
id,
proceeding_type_id,
parent_id,
trigger_event_id,
spawn_proceeding_type_id,
code,
name,
name_en,
primary_party,
event_type,
is_mandatory,
is_optional,
is_court_set,
is_spawn,
duration_value,
duration_unit,
timing,
alt_duration_value,
alt_duration_unit,
combine_op,
rule_code,
deadline_notes,
deadline_notes_en,
legal_source,
condition_expr,
condition_flag,
sequence_order,
is_active,
priority,
lifecycle_state,
draft_of,
published_at,
created_at,
updated_at
)
SELECT
gen_random_uuid() AS id,
NULL::integer AS proceeding_type_id,
NULL::uuid AS parent_id,
ed.trigger_event_id AS trigger_event_id,
NULL::integer AS spawn_proceeding_type_id,
NULL::text AS code,
ed.title_de AS name,
ed.title AS name_en,
NULL::text AS primary_party,
NULL::text AS event_type,
true AS is_mandatory,
false AS is_optional,
false AS is_court_set,
false AS is_spawn,
ed.duration_value AS duration_value,
ed.duration_unit AS duration_unit,
ed.timing AS timing,
ed.alt_duration_value AS alt_duration_value,
ed.alt_duration_unit AS alt_duration_unit,
ed.combine_op AS combine_op,
NULL::text AS rule_code,
NULLIF(ed.notes, '') AS deadline_notes,
ed.notes_en AS deadline_notes_en,
ed.legal_source AS legal_source,
NULL::jsonb AS condition_expr,
NULL::text[] AS condition_flag,
(1000 + ed.id)::integer AS sequence_order,
ed.is_active AS is_active,
'mandatory' AS priority,
'published' AS lifecycle_state,
NULL::uuid AS draft_of,
ed.created_at AS published_at,
ed.created_at AS created_at,
now() AS updated_at
FROM paliad.event_deadlines ed
WHERE ed.is_active = true
AND NOT EXISTS (
SELECT 1
FROM paliad.deadline_rules dr
WHERE dr.trigger_event_id = ed.trigger_event_id
AND dr.name = ed.title_de
);
-- Hard assertion: every active event_deadlines row must have a matching
-- deadline_rules row by (trigger_event_id, name). If the counts diverge,
-- something in the WHERE NOT EXISTS clause (likely a stale duplicate)
-- prevented a real insert — fail the migration rather than ship a
-- partial Pipeline-C corpus.
DO $$
DECLARE
n_source int;
n_target int;
BEGIN
SELECT count(*) INTO n_source
FROM paliad.event_deadlines WHERE is_active = true;
SELECT count(*) INTO n_target
FROM paliad.deadline_rules WHERE trigger_event_id IS NOT NULL;
RAISE NOTICE 'mig 085: event_deadlines(active)=%, deadline_rules(trigger_event_id IS NOT NULL)=%',
n_source, n_target;
IF n_target <> n_source THEN
RAISE EXCEPTION 'mig 085: data-move incomplete — expected % unified rows, got %. '
'Investigate event_deadlines (trigger_event_id, title_de) duplicates '
'OR re-applied migration on dirtied target.',
n_source, n_target;
END IF;
END $$;

View File

@@ -0,0 +1,5 @@
-- t-paliad-184 down — reverts the read-only wrapper from
-- 086_event_deadlines_readonly.up.sql. Order: trigger → function.
DROP TRIGGER IF EXISTS event_deadlines_readonly ON paliad.event_deadlines;
DROP FUNCTION IF EXISTS paliad.event_deadlines_readonly_trigger();

View File

@@ -0,0 +1,58 @@
-- t-paliad-184 / Fristen Phase 3 Slice 3 — wrap paliad.event_deadlines
-- in a read-only trigger so nobody can edit either side mid-cutover.
--
-- Slice 3 just moved 77 rows from event_deadlines → deadline_rules (mig
-- 085). Until Slice 4 cuts every reader over and Slice 9 drops the
-- legacy table, event_deadlines stays in place as the audit anchor and
-- (briefly) a compat-read source. We must not let any writer mutate it
-- behind the unified backend's back — diverging the two sides would
-- silently regress "Was kommt nach…" parity.
--
-- The trigger fires AFTER INSERT / UPDATE / DELETE and raises an
-- EXCEPTION with a clear message pointing the writer at the unified
-- table. SELECT is unaffected — the legacy EventDeadlineService's
-- pre-Slice-3 SELECT path keeps working until Slice 4 swaps it.
--
-- The supabase service_role bypasses RLS but NOT triggers — so
-- direct DB maintenance (psql, migration scripts) is also blocked.
-- This is intentional: any further edit to event_deadlines is a
-- mistake until Slice 9 drops the table.
--
-- Removed by Slice 9 (Step E, mig ~090) when paliad.event_deadlines is
-- dropped. Until then the trigger is the only thing keeping the two
-- tables in sync.
CREATE OR REPLACE FUNCTION paliad.event_deadlines_readonly_trigger()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION
'paliad.event_deadlines is read-only after Phase 3 Slice 3 — '
'writes must go through paliad.deadline_rules (Pipeline C is '
'unified; the source table is preserved as an audit anchor '
'until Slice 9 drops it). Operation: %', TG_OP;
END;
$$;
COMMENT ON FUNCTION paliad.event_deadlines_readonly_trigger() IS
'BEFORE INSERT/UPDATE/DELETE trigger function that raises on any '
'write to paliad.event_deadlines. Lives only between Slice 3 and '
'Slice 9 — removed when the source table is dropped.';
-- BEFORE-trigger so the write is blocked before any row image is
-- captured. AFTER would still raise but the surrounding tx would
-- have already taken row locks.
DROP TRIGGER IF EXISTS event_deadlines_readonly ON paliad.event_deadlines;
CREATE TRIGGER event_deadlines_readonly
BEFORE INSERT OR UPDATE OR DELETE ON paliad.event_deadlines
FOR EACH ROW
EXECUTE FUNCTION paliad.event_deadlines_readonly_trigger();
-- Defensive INSERT-row-level trigger covers the COPY path too; same
-- function, identical behaviour.
COMMENT ON TRIGGER event_deadlines_readonly ON paliad.event_deadlines IS
'Phase 3 Slice 3 read-only wrapper. Blocks every INSERT/UPDATE/DELETE '
'until Slice 9 drops the table. SELECT unaffected.';

View File

@@ -0,0 +1,28 @@
-- t-paliad-186 down — reverses 087_project_proceeding_type_remap.up.sql.
--
-- "Revert" here means: NULL every project that the up-migration remapped
-- AND drop the 'proceeding_type_remap_null' project_events rows it
-- wrote. We cannot perfectly recover the litigation→fristenrechner
-- remap because the up-migration moved INF→UPC_INF (etc.) without
-- preserving the original code in a side column. Resetting to NULL is
-- the safe rollback — the operator can hand-remap a project if needed.
--
-- Today this is a no-op on production data (0 live remaps).
SELECT set_config(
'paliad.audit_reason',
'rollback 087: NULL projects.proceeding_type_id remapped by mig 087',
true);
DELETE FROM paliad.project_events
WHERE event_type = 'proceeding_type_remap_null'
AND metadata->>'migration' = '087';
UPDATE paliad.projects
SET proceeding_type_id = NULL
WHERE proceeding_type_id IS NOT NULL
AND proceeding_type_id IN (
SELECT id FROM paliad.proceeding_types
WHERE category = 'fristenrechner'
AND code IN ('UPC_INF', 'UPC_REV', 'UPC_APP')
);

View File

@@ -0,0 +1,148 @@
-- t-paliad-186 / Fristen Phase 3 Slice 5 Step F-1 — remap any project
-- still pointing at a litigation-category proceeding_types row to the
-- corresponding fristenrechner-category code (per design §3.F + m's
-- Q2 ruling: "I dont even get 'litigation corpus'").
--
-- Live-data reality: 11/11 projects carry proceeding_type_id IS NULL
-- today, so this migration is effectively a no-op on the production
-- corpus. It still ships defensively for any future test / staging /
-- imported data that might land with a litigation-category id before
-- the CHECK trigger (mig 088) catches the next write.
--
-- Mapping (cross-checked against the live paliad.proceeding_types
-- catalog — 19 fristenrechner codes, 7 litigation codes):
--
-- INF → UPC_INF (UPC infringement, canonical reading)
-- REV → UPC_REV (UPC revocation)
-- APP → UPC_APP (UPC appeal)
-- CCR → NULL (no UPC_CCR in the fristenrechner catalog
-- — flag for legal review per design §3.F)
-- APM → NULL (no UPC_APM — flag for legal review)
-- AMD → NULL (no UPC_AMD — flag for legal review)
-- ZPO_CIVIL → NULL (no fristenrechner analogue, design §3.F:
-- "litigation codes stay but become unused
-- for project-binding")
--
-- Each NULL-remap leaves a paliad.project_events row with a
-- 'proceeding_type_remap_null' event so legal review can spot the
-- project + decide whether to pick a hand-mapped fristenrechner code.
-- Today no live project hits this branch — the events table stays
-- clean — but the audit hook is there for the day a litigation-coded
-- project lands.
--
-- Idempotent: only rows still pointing at a litigation-category code
-- are touched. Re-running on a clean target is a no-op.
--
-- Hard assertion at end: no paliad.projects row points at a
-- non-fristenrechner-category proceeding_types row post-mig. RAISE
-- EXCEPTION if violated — fails the migration loudly rather than
-- relying on mig 088's runtime trigger to catch the next write.
--
-- Audit-reason wrapper: required by the mig 079 trigger when this
-- migration UPDATEs deadline_rules tangentially (it doesn't, but
-- set_config is harmless if no audited row mutates).
SELECT set_config(
'paliad.audit_reason',
'mig 087: remap projects.proceeding_type_id from litigation→fristenrechner per design §3.F + Q2',
true);
-- ============================================================================
-- 1. Remap rows that point at litigation codes with a known UPC analogue.
-- ============================================================================
UPDATE paliad.projects p
SET proceeding_type_id = pt_new.id
FROM paliad.proceeding_types pt_old
JOIN paliad.proceeding_types pt_new
ON pt_new.code = CASE pt_old.code
WHEN 'INF' THEN 'UPC_INF'
WHEN 'REV' THEN 'UPC_REV'
WHEN 'APP' THEN 'UPC_APP'
END
AND pt_new.is_active = true
AND pt_new.category = 'fristenrechner'
WHERE p.proceeding_type_id = pt_old.id
AND pt_old.category = 'litigation'
AND pt_old.code IN ('INF', 'REV', 'APP');
-- ============================================================================
-- 2. NULL-remap rows pointing at litigation codes with no fristenrechner
-- analogue. Record a paliad.project_events row so legal review can
-- follow up.
-- ============================================================================
-- Capture the projects we're about to NULL-remap into a temp table so
-- we can both UPDATE and INSERT events from the same set (without a
-- second SELECT that might race with the UPDATE).
CREATE TEMP TABLE _mig_087_null_remaps ON COMMIT DROP AS
SELECT p.id AS project_id,
p.created_by AS actor,
pt_old.code AS old_code
FROM paliad.projects p
JOIN paliad.proceeding_types pt_old ON pt_old.id = p.proceeding_type_id
WHERE pt_old.category = 'litigation'
AND pt_old.code IN ('CCR', 'APM', 'AMD', 'ZPO_CIVIL');
UPDATE paliad.projects p
SET proceeding_type_id = NULL
FROM _mig_087_null_remaps r
WHERE p.id = r.project_id;
INSERT INTO paliad.project_events
(id, project_id, event_type, title, description, event_date, created_by, metadata, created_at, updated_at)
SELECT gen_random_uuid(),
r.project_id,
'proceeding_type_remap_null',
'Verfahrenstyp zurückgesetzt (Soft-Merge Phase 3)',
'proceeding_type_id wurde auf NULL gesetzt — '
|| r.old_code
|| ' hat kein Fristenrechner-Pendant. Bitte manuell einen passenden Code wählen.',
now(),
r.actor,
jsonb_build_object(
'migration', '087',
'old_code', r.old_code,
'reason', 'project soft-merge: no fristenrechner analogue'
),
now(),
now()
FROM _mig_087_null_remaps r;
-- ============================================================================
-- 3. Hard assertion: every non-NULL proceeding_type_id on projects now
-- references a fristenrechner-category row.
-- ============================================================================
DO $$
DECLARE
n_total int;
n_null int;
n_fristen int;
n_non_fristen int;
BEGIN
SELECT count(*) INTO n_total FROM paliad.projects;
SELECT count(*) FILTER (WHERE proceeding_type_id IS NULL)
INTO n_null FROM paliad.projects;
SELECT count(*)
INTO n_fristen
FROM paliad.projects p
JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
WHERE pt.category = 'fristenrechner';
SELECT count(*)
INTO n_non_fristen
FROM paliad.projects p
JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
WHERE pt.category <> 'fristenrechner';
RAISE NOTICE 'mig 087: projects total=%, NULL=%, fristenrechner=%, other=%',
n_total, n_null, n_fristen, n_non_fristen;
IF n_non_fristen > 0 THEN
RAISE EXCEPTION 'mig 087: % projects still point at non-fristenrechner-category '
'proceeding_type_ids — soft-merge incomplete. Investigate '
'and either extend the remap or add a hand-mapped code.',
n_non_fristen;
END IF;
END $$;

View File

@@ -0,0 +1,5 @@
-- t-paliad-186 down — reverses 088_project_proceeding_type_check.up.sql.
DROP TRIGGER IF EXISTS projects_proceeding_type_category_check
ON paliad.projects;
DROP FUNCTION IF EXISTS paliad.projects_proceeding_type_category_check();

View File

@@ -0,0 +1,90 @@
-- t-paliad-186 / Fristen Phase 3 Slice 5 Step F-2 — enforce
-- "fristenrechner-category only" on paliad.projects.proceeding_type_id
-- via a BEFORE INSERT/UPDATE trigger. PostgreSQL CHECK constraints
-- can't reference other tables, so a trigger is the only way to
-- evaluate the (proceeding_types.category = 'fristenrechner')
-- predicate per row.
--
-- Why trigger over deferrable-FK-to-partial-index: a partial unique
-- index on proceeding_types where category='fristenrechner' would
-- let us reference it from a separate FK column, but the existing
-- FK on projects.proceeding_type_id → proceeding_types.id is
-- broad-category. Replacing it with a narrower FK would invalidate
-- the existing schema reference in mig 027. A trigger keeps the FK
-- in place and just adds the category predicate on top.
--
-- Behaviour:
-- - INSERT/UPDATE with proceeding_type_id IS NULL: pass (NULL is allowed).
-- - INSERT/UPDATE with proceeding_type_id pointing at a
-- fristenrechner-category row: pass.
-- - INSERT/UPDATE with proceeding_type_id pointing at any other
-- category: RAISE EXCEPTION with a German + English message so the
-- handler / frontend can surface a friendly error.
-- - INSERT/UPDATE with proceeding_type_id pointing at a missing row:
-- the existing FK on the column rejects it before this trigger
-- even fires; nothing to do here.
--
-- Removed when the litigation category is fully retired (Slice 9 or
-- later). Until then this is the runtime guard for any writer that
-- bypasses the Go service-layer validation.
--
-- Idempotent: re-applying the migration drops + recreates the trigger.
CREATE OR REPLACE FUNCTION paliad.projects_proceeding_type_category_check()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_category text;
BEGIN
IF NEW.proceeding_type_id IS NULL THEN
RETURN NEW;
END IF;
SELECT category INTO v_category
FROM paliad.proceeding_types
WHERE id = NEW.proceeding_type_id;
-- The FK on the column guarantees v_category is non-NULL when the
-- id resolves — but defensive against a future FK relax-and-replace.
IF v_category IS NULL THEN
RAISE EXCEPTION
'paliad.projects.proceeding_type_id = % does not resolve to a '
'proceeding_types row — FK constraint should have caught this.',
NEW.proceeding_type_id;
END IF;
IF v_category <> 'fristenrechner' THEN
RAISE EXCEPTION
'paliad.projects.proceeding_type_id must reference a '
'fristenrechner-category proceeding_types row (got category=''%''). '
'Verfahrenstyp muss ein Fristenrechner-Typ sein (Kategorie=''%''). '
'Slice 5 (Phase 3 soft-merge per design §3.F) retires the '
'''litigation'' category for project-binding; pick a UPC_*, '
'DE_*, EPA_*, DPMA_* or EP_GRANT code instead.',
v_category, v_category;
END IF;
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION paliad.projects_proceeding_type_category_check() IS
'BEFORE INSERT/UPDATE trigger function enforcing the Phase 3 Slice 5 '
'invariant: paliad.projects.proceeding_type_id may only reference '
'fristenrechner-category proceeding_types rows. NULL is allowed.';
DROP TRIGGER IF EXISTS projects_proceeding_type_category_check
ON paliad.projects;
CREATE TRIGGER projects_proceeding_type_category_check
BEFORE INSERT OR UPDATE OF proceeding_type_id ON paliad.projects
FOR EACH ROW
EXECUTE FUNCTION paliad.projects_proceeding_type_category_check();
COMMENT ON TRIGGER projects_proceeding_type_category_check ON paliad.projects IS
'Phase 3 Slice 5 (t-paliad-186) runtime guard for the projects '
'soft-merge — rejects any INSERT/UPDATE that would bind a project '
'to a non-fristenrechner-category proceeding_type. The Go service '
'layer also enforces this with a typed error; this trigger is the '
'defence-in-depth backstop.';

View File

@@ -0,0 +1,9 @@
-- t-paliad-190 down — reverses 089_deadline_rule_backfill_orphans.up.sql.
-- Drops the staging table; mig 090's down-migration MUST run first
-- (it depends on this table for its INSERT — running them in reverse
-- order satisfies that).
DROP POLICY IF EXISTS deadline_rule_backfill_orphans_select ON paliad.deadline_rule_backfill_orphans;
DROP INDEX IF EXISTS paliad.deadline_rule_backfill_orphans_unresolved_idx;
DROP INDEX IF EXISTS paliad.deadline_rule_backfill_orphans_deadline_id_idx;
DROP TABLE IF EXISTS paliad.deadline_rule_backfill_orphans;

View File

@@ -0,0 +1,82 @@
-- t-paliad-190 / Fristen Phase 3 Slice 10 — staging table for the
-- fuzzy-match orphans produced by mig 090. Per design §3.I + m's Q10
-- ruling: legacy paliad.deadlines rows whose title can't be uniquely
-- bound to a deadline_rule via fuzzy matching are NOT silently left
-- NULL — they're logged here so a legal-review pass can hand-link
-- the ambiguous tail.
--
-- Mig 089 ships the table; mig 090 does the actual backfill +
-- populates this table. Numbering reflects the dependency order
-- (the backfill SELECTs into this table, so the table must exist
-- first).
--
-- Schema notes:
-- - deadline_id is the FK to paliad.deadlines.id with ON DELETE
-- CASCADE so a hand-deletion of an orphan deadline cleans up
-- its staging row too. (Deadlines are normally archived, not
-- deleted; the cascade is defensive.)
-- - project_id stays denormalised so the admin orphan-review UI
-- can group orphans by project without re-joining deadlines.
-- - reason is a free-text discriminator: 'no_match' | 'ambiguous'
-- today; the editor in Slice 11 may add 'manual_unbound' or
-- similar in the future.
-- - resolved_at + resolved_rule_id are NULL on insert; the admin
-- orphan-review UI sets them when an editor hand-links the row,
-- so the table doubles as an audit trail of the legal-review
-- pass. The matching paliad.deadlines.rule_id is updated at the
-- same time (the UPDATE on deadlines fires its own audit row
-- once an audit trigger lives on that table; today no trigger,
-- so the staging row is the audit artefact).
--
-- RLS: admin-only read. The orphan list contains real deadline titles
-- + project ids, so non-admins should not see it. The Slice 11 rule
-- editor surface gates this further.
CREATE TABLE IF NOT EXISTS paliad.deadline_rule_backfill_orphans (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
deadline_id uuid NOT NULL
REFERENCES paliad.deadlines(id) ON DELETE CASCADE,
title text NOT NULL,
project_id uuid,
proceeding_code text,
reason text NOT NULL
CHECK (reason IN ('no_match', 'ambiguous', 'no_project', 'manual_unbound')),
candidate_count int NOT NULL DEFAULT 0,
candidate_rule_ids uuid[] NOT NULL DEFAULT '{}',
resolved_at timestamptz,
resolved_rule_id uuid
REFERENCES paliad.deadline_rules(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS deadline_rule_backfill_orphans_deadline_id_idx
ON paliad.deadline_rule_backfill_orphans (deadline_id);
CREATE INDEX IF NOT EXISTS deadline_rule_backfill_orphans_unresolved_idx
ON paliad.deadline_rule_backfill_orphans (created_at DESC)
WHERE resolved_at IS NULL;
COMMENT ON TABLE paliad.deadline_rule_backfill_orphans IS
'Slice 10 (mig 089/090, t-paliad-190): staging for legacy '
'paliad.deadlines rows that the fuzzy-match backfill could not '
'uniquely bind to a deadline_rule. Each row holds the deadline '
'context + the candidate rule IDs the matcher found (0 → '
'''no_match''; ≥2 → ''ambiguous'') so a legal-review pass can '
'hand-link without rerunning the match. resolved_at + '
'resolved_rule_id flip when the admin orphan-review UI binds the '
'row.';
-- RLS: admin-only read.
ALTER TABLE paliad.deadline_rule_backfill_orphans ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS deadline_rule_backfill_orphans_select ON paliad.deadline_rule_backfill_orphans;
CREATE POLICY deadline_rule_backfill_orphans_select
ON paliad.deadline_rule_backfill_orphans FOR SELECT
USING (
EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid()
AND u.global_role = 'global_admin'
)
);

View File

@@ -0,0 +1,30 @@
-- t-paliad-190 down — reverses 090_backfill_deadline_rule_id.up.sql.
--
-- Restores rule_id values from the pre-mig snapshot (every deadline
-- that mig 090 touched had rule_id IS NULL originally, so restoring
-- means setting rule_id back to NULL on every row that survived the
-- backfill). Drops the orphan rows mig 090 wrote (resolved rows stay
-- — those represent legal-review work that shouldn't disappear on
-- a code rollback) and drops the backup table.
--
-- This is a defensive rollback path; the migration itself is one-time
-- + idempotent, so re-running 090 after a down + up is safe.
SELECT set_config(
'paliad.audit_reason',
'rollback 090: NULL rule_id on deadlines mig 090 touched + drop pre-089 backup',
true);
-- Restore rule_id = NULL on every deadline mig 090 may have written.
-- We use the backup table as the authoritative "before" snapshot.
UPDATE paliad.deadlines d
SET rule_id = b.rule_id
FROM paliad.deadlines_pre_089 b
WHERE d.id = b.id;
-- Drop the unresolved orphan rows mig 090 wrote. Resolved rows stay —
-- a legal-review hand-link is real work that survives a code rollback.
DELETE FROM paliad.deadline_rule_backfill_orphans
WHERE resolved_at IS NULL;
DROP TABLE IF EXISTS paliad.deadlines_pre_089;

View File

@@ -0,0 +1,320 @@
-- t-paliad-190 / Fristen Phase 3 Slice 10 — one-time fuzzy-match
-- backfill of paliad.deadlines.rule_id per design §3.I + m's Q10
-- ruling. Restores SmartTimeline's "anchor real deadlines into
-- projection" affordance on legacy data (1 of 26 deadlines currently
-- has rule_id populated; the SmartTimeline anchor flow needs the FK
-- to thread predicted dates off actuals).
--
-- Matching strategies (in priority order; first unique hit wins):
--
-- 1. rule_code-prefix extraction from title. Titles like
-- "RoP.023 — Klageerwiderung" carry the rule citation in the
-- prefix; we extract the leading citation token and JOIN on
-- deadline_rules.rule_code = extracted. When the rule_code
-- resolves to multiple rules (e.g. RoP.023 → 2 rules — DE
-- Klageerwiderung + EN Statement of Defence), the remaining
-- title fragment narrows by name ILIKE.
--
-- 2. exact title match against rule.name OR rule.name_en (LOWER).
-- Mostly hits common Pipeline-A names ("Antrag auf
-- Schadensbemessung" → 1 unique rule); ambiguous for shared
-- names like "Klageerwiderung" (8 rules across proceedings).
--
-- 3. deadline_concepts.aliases match. Each concept carries a
-- text[] of canonical aliases; if LOWER(d.title) is in the
-- aliases array, we pick the rules with that concept_id. Today
-- the alias coverage is thin (no aliases for "Schutzschrift"
-- etc.), but the strategy is shaped so a future seed lights
-- it up.
--
-- For each deadline, we collect all candidates across the three
-- strategies, dedupe by rule.id, and:
-- - exactly 1 candidate → UPDATE rule_id (matched).
-- - 0 candidates → orphan with reason='no_match'.
-- - ≥2 candidates → orphan with reason='ambiguous', candidate_rule_ids
-- populated so a legal-review pass can hand-pick.
--
-- Per-project narrowing by proceeding_type_id is the design's primary
-- discriminator. In the live corpus today all 11 projects have
-- proceeding_type_id IS NULL (Slice 5 retired litigation codes from
-- project-binding; the fristenrechner-side rebinding hasn't happened),
-- so this slice can't use proceeding-narrowing on production data.
-- The CTE still includes the predicate so the migration self-tunes
-- the moment proceeding_type_id starts getting populated.
--
-- Defensive backup: paliad.deadlines is snapshotted to
-- paliad.deadlines_pre_089 before the UPDATE so an operator can
-- restore individual rule_id values if a hand-link goes wrong post
-- mig. The table is dropped in the down-migration; Slice 11 (rule
-- editor) can drop it once orphan resolution finishes in prod.
--
-- Idempotency: WHERE d.rule_id IS NULL on the UPDATE; the orphan
-- INSERT uses ON CONFLICT DO NOTHING via a NOT EXISTS guard (no
-- unique constraint on deadline_id alone — a deadline may legitimately
-- get re-orphaned after a resolution rollback; but re-running 090 on
-- the same corpus must not duplicate orphan rows for unresolved
-- deadlines).
--
-- Hard assertion at end: SUM(matched) + SUM(orphans for current
-- unresolved deadlines) ≥ COUNT(deadlines processed). Strict equality
-- doesn't hold cleanly on a re-run (the orphan table may already
-- carry prior rows from a partial run), so the assertion is "at
-- least one row exists per unresolved deadline".
SELECT set_config(
'paliad.audit_reason',
'mig 090: one-time fuzzy-match backfill of deadlines.rule_id per design §3.I / Q10',
true);
-- =============================================================================
-- 1. Defensive backup before any UPDATE.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.deadlines_pre_089 AS
SELECT id, project_id, title, rule_id, rule_code, status, due_date,
completed_at, created_at, updated_at
FROM paliad.deadlines
WHERE rule_id IS NULL
AND project_id IS NOT NULL;
COMMENT ON TABLE paliad.deadlines_pre_089 IS
'Snapshot of paliad.deadlines (id, rule_id-relevant columns) taken '
'before mig 090 ran the fuzzy-match backfill. Lets an operator '
'restore individual rule_id values if a hand-link goes wrong. '
'Slice 11 (rule editor) drops this once orphan resolution finishes.';
-- =============================================================================
-- 2. Build the candidate set in a temp table so the per-deadline
-- aggregation + UPDATE + orphan INSERT can share the work without
-- re-evaluating the matchers.
-- =============================================================================
CREATE TEMP TABLE _mig_090_candidates ON COMMIT DROP AS
WITH targets AS (
-- Every NULL-rule_id deadline still bound to a project. project_id
-- is required because we want at least the SmartTimeline anchor
-- flow to work; un-bound deadlines (rare) are out of scope.
SELECT d.id AS deadline_id,
d.title AS title,
d.project_id,
p.proceeding_type_id,
-- Extract a leading citation token like "RoP.023" or
-- "R.49" from the title. Captures the rule_code prefix
-- on titles that carry one ("RoP.023 — Klageerwiderung");
-- NULL on plain titles.
NULLIF(regexp_replace(d.title, '^\s*((?:RoP|R|Art|§)\.?\s*[0-9]+(?:\.[a-z0-9]+)*)\s*(?:[—–-].*)?$', '\1'), d.title) AS code_token,
-- Strip the leading citation + separator to surface the
-- title's name fragment. "RoP.023 — Klageerwiderung" →
-- "Klageerwiderung"; "RoP.029.a" (no suffix) → ""; plain
-- "Klageerwiderung" → "Klageerwiderung" unchanged.
NULLIF(trim(regexp_replace(d.title, '^\s*(?:RoP|R|Art|§)\.?\s*[0-9]+(?:\.[a-z0-9]+)*\s*[—–-]?\s*', '')), '') AS title_tail
FROM paliad.deadlines d
LEFT JOIN paliad.projects p ON p.id = d.project_id
WHERE d.rule_id IS NULL
AND d.project_id IS NOT NULL
),
by_code_and_tail AS (
-- Strategy 1a (narrowest): rule_code AND name (DE or EN) matches
-- the title's tail fragment. Handles "RoP.023 — Klageerwiderung"
-- where the bare code matches 2 rules (DE Klageerwiderung +
-- EN Statement of Defence); the tail picks the DE one.
SELECT t.deadline_id, dr.id AS rule_id, 'rule_code_and_tail' AS strategy
FROM targets t
JOIN paliad.deadline_rules dr
ON dr.rule_code = trim(t.code_token)
AND dr.is_active = true
AND (LOWER(dr.name) = LOWER(t.title_tail)
OR LOWER(dr.name_en) = LOWER(t.title_tail))
WHERE t.code_token IS NOT NULL
AND t.title_tail IS NOT NULL
),
by_code AS (
-- Strategy 1b: rule_code prefix only. Handles bare-code titles
-- ("RoP.029.a" maps to 1 unique rule regardless of suffix) and
-- the fallback when by_code_and_tail returns 0 (suffix doesn't
-- match — e.g. "RoP.029.a — Replik" where the suffix "Replik"
-- doesn't appear in any RoP.029.a rule's name; pick the
-- code-only match anyway).
SELECT t.deadline_id, dr.id AS rule_id, 'rule_code' AS strategy
FROM targets t
JOIN paliad.deadline_rules dr
ON dr.rule_code = trim(t.code_token)
AND dr.is_active = true
WHERE t.code_token IS NOT NULL
),
by_name AS (
-- Strategy 2: exact title match against rule.name or rule.name_en.
-- The widest matcher; for shared names like "Klageerwiderung"
-- (8 rules across proceedings) this is ambiguous, but for
-- unique titles like "Antrag auf Schadensbemessung" (1 rule) it
-- nails the match.
SELECT t.deadline_id, dr.id AS rule_id, 'name_exact' AS strategy
FROM targets t
JOIN paliad.deadline_rules dr
ON (LOWER(dr.name) = LOWER(t.title)
OR LOWER(dr.name_en) = LOWER(t.title))
AND dr.is_active = true
),
by_alias AS (
-- Strategy 3: concept aliases. deadline_concepts.aliases is a
-- text[] of canonical synonyms; if the deadline title appears
-- in that array, every active rule on the concept is a candidate.
-- Today's alias coverage is thin (the seed for Slice 12 is the
-- expected source of new aliases), but the strategy is in place
-- so future seeds light it up without a migration.
SELECT t.deadline_id, dr.id AS rule_id, 'concept_alias' AS strategy
FROM targets t
JOIN paliad.deadline_concepts dc
ON LOWER(t.title) = ANY(SELECT LOWER(a) FROM unnest(dc.aliases) a)
JOIN paliad.deadline_rules dr
ON dr.concept_id = dc.id
AND dr.is_active = true
)
SELECT deadline_id, rule_id, strategy
FROM by_code_and_tail
UNION
SELECT deadline_id, rule_id, strategy
FROM by_code
UNION
SELECT deadline_id, rule_id, strategy
FROM by_name
UNION
SELECT deadline_id, rule_id, strategy
FROM by_alias;
-- =============================================================================
-- 3. Aggregate per-deadline candidate counts by strategy + pick the
-- narrowest-unique-match per deadline. Strategy priority (narrowest
-- first): rule_code_and_tail > rule_code > name_exact > concept_alias.
-- A deadline's "chosen" rule comes from the highest-priority strategy
-- that yields exactly 1 distinct candidate.
-- =============================================================================
CREATE TEMP TABLE _mig_090_strategy_counts ON COMMIT DROP AS
SELECT deadline_id,
strategy,
count(DISTINCT rule_id) AS n,
MIN(rule_id::text) AS first_rule_text
FROM _mig_090_candidates
GROUP BY deadline_id, strategy;
CREATE TEMP TABLE _mig_090_chosen ON COMMIT DROP AS
SELECT DISTINCT ON (deadline_id)
deadline_id,
first_rule_text::uuid AS rule_id,
strategy AS chosen_strategy
FROM _mig_090_strategy_counts
WHERE n = 1
ORDER BY deadline_id,
CASE strategy
WHEN 'rule_code_and_tail' THEN 1
WHEN 'rule_code' THEN 2
WHEN 'name_exact' THEN 3
WHEN 'concept_alias' THEN 4
ELSE 5
END;
-- "Aggregated" carries the widest candidate set for orphan logging
-- (an editor reviewing an orphan wants to see EVERY plausible rule,
-- not just the narrowest-strategy result).
CREATE TEMP TABLE _mig_090_aggregated ON COMMIT DROP AS
SELECT c.deadline_id,
count(DISTINCT c.rule_id) AS n_candidates,
array_agg(DISTINCT c.rule_id) AS all_rule_ids
FROM _mig_090_candidates c
GROUP BY c.deadline_id;
-- =============================================================================
-- 4. UPDATE deadlines.rule_id for the chosen set (narrowest-unique match).
-- =============================================================================
UPDATE paliad.deadlines d
SET rule_id = c.rule_id
FROM _mig_090_chosen c
WHERE d.id = c.deadline_id
AND d.rule_id IS NULL;
-- =============================================================================
-- 5. Log every deadline that didn't get a unique match as an orphan.
-- Skip rows that already have a non-resolved orphan entry (re-run
-- guard) — the existing entry is the source-of-truth until the
-- admin UI flips resolved_at.
-- =============================================================================
INSERT INTO paliad.deadline_rule_backfill_orphans
(deadline_id, title, project_id, proceeding_code, reason,
candidate_count, candidate_rule_ids)
SELECT t.deadline_id,
t.title,
t.project_id,
pt.code AS proceeding_code,
CASE
WHEN a.n_candidates IS NULL OR a.n_candidates = 0 THEN 'no_match'
WHEN a.n_candidates > 1 THEN 'ambiguous'
END AS reason,
COALESCE(a.n_candidates, 0),
COALESCE(a.all_rule_ids, ARRAY[]::uuid[])
FROM (
SELECT d.id AS deadline_id, d.title, d.project_id, p.proceeding_type_id
FROM paliad.deadlines d
LEFT JOIN paliad.projects p ON p.id = d.project_id
WHERE d.rule_id IS NULL
AND d.project_id IS NOT NULL
) t
LEFT JOIN _mig_090_aggregated a ON a.deadline_id = t.deadline_id
LEFT JOIN paliad.proceeding_types pt ON pt.id = t.proceeding_type_id
WHERE NOT EXISTS (
SELECT 1
FROM paliad.deadline_rule_backfill_orphans o
WHERE o.deadline_id = t.deadline_id
AND o.resolved_at IS NULL
);
-- =============================================================================
-- 6. Hard assertion: every NULL-rule_id deadline (with project) is
-- either resolved (rule_id IS NOT NULL post-mig) or carries an
-- unresolved orphan row.
-- =============================================================================
DO $$
DECLARE
n_processed int;
n_matched int;
n_orphaned int;
n_unaccounted int;
BEGIN
SELECT count(*) INTO n_processed
FROM paliad.deadlines
WHERE project_id IS NOT NULL
AND (rule_id IS NOT NULL OR EXISTS (
SELECT 1 FROM paliad.deadline_rule_backfill_orphans o
WHERE o.deadline_id = paliad.deadlines.id
));
SELECT count(*) INTO n_matched
FROM paliad.deadlines d
JOIN paliad.deadlines_pre_089 b ON b.id = d.id
WHERE d.rule_id IS NOT NULL;
SELECT count(DISTINCT deadline_id) INTO n_orphaned
FROM paliad.deadline_rule_backfill_orphans
WHERE resolved_at IS NULL;
SELECT count(*) INTO n_unaccounted
FROM paliad.deadlines d
WHERE d.rule_id IS NULL
AND d.project_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM paliad.deadline_rule_backfill_orphans o
WHERE o.deadline_id = d.id
);
RAISE NOTICE 'mig 090: processed=% matched=% orphaned=% unaccounted=%',
n_processed, n_matched, n_orphaned, n_unaccounted;
IF n_unaccounted > 0 THEN
RAISE EXCEPTION 'mig 090: % deadlines have rule_id IS NULL and no orphan row — '
'matcher missed them. Investigate the candidate query.',
n_unaccounted;
END IF;
END $$;

View File

@@ -0,0 +1,32 @@
-- t-paliad-195 down — reverses 091_drop_legacy_rule_columns.up.sql.
--
-- Restores the four columns and re-populates them from the
-- paliad.deadline_rules_pre_091 snapshot. Rules created AFTER the
-- mig 091 cutover (via the rule editor's POST /admin/api/rules)
-- won't have a snapshot entry — they get NULL on the restored
-- columns, which matches their original "never had these legacy
-- fields" state.
--
-- The snapshot table itself stays (it's a permanent audit artefact);
-- a focused follow-up slice / Slice 12 cleanup drops it once the
-- rule editor's migration-export flow has been used to roll any
-- post-drop edits back into version control.
SELECT set_config(
'paliad.audit_reason',
'rollback 091: restore legacy columns from pre-drop snapshot',
true);
ALTER TABLE paliad.deadline_rules
ADD COLUMN IF NOT EXISTS is_mandatory boolean NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS is_optional boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS condition_flag text[],
ADD COLUMN IF NOT EXISTS condition_rule_id uuid;
UPDATE paliad.deadline_rules dr
SET is_mandatory = b.is_mandatory,
is_optional = b.is_optional,
condition_flag = b.condition_flag,
condition_rule_id = b.condition_rule_id
FROM paliad.deadline_rules_pre_091 b
WHERE dr.id = b.id;

View File

@@ -0,0 +1,116 @@
-- t-paliad-195 / Fristen Phase 3 Slice 9 Step E (design §3.E, §9.1).
-- m approved the downtime window 2026-05-15 ("paliad ist nicht in use
-- heute, downtime ist egal") so the destructive drops can land.
--
-- This migration drops the four legacy columns on
-- paliad.deadline_rules that the unified Phase 3 calculator no longer
-- reads. The replacements have been backfilled (Slice 2 mig 082/083/
-- 084), wired into the calculator (Slice 4), and on the wire (Slice 8):
--
-- is_mandatory → priority='mandatory' | (recommended | optional | informational)
-- is_optional → priority='optional' (the RoP.151 T/T case)
-- condition_flag → condition_expr (jsonb long form)
-- condition_rule_id → DEAD (no live rows, Q13 m's approved drop)
--
-- Sibling drops (event_deadlines/trigger_events tables, retire of
-- litigation category) are deferred from this slice per the live-data
-- audit (see head ping). This file is the legacy-column-drop only.
--
-- Backup: paliad.deadline_rules_pre_091 snapshot of the four columns +
-- id BEFORE the drop, so the down-migration can restore individual
-- values if a deploy needs to roll back. The backup uses CREATE TABLE
-- IF NOT EXISTS so a re-applied migration is a no-op.
--
-- Audit-reason set at the top: the mig 079 trigger fires on every
-- UPDATE/DELETE on paliad.deadline_rules; ALTER TABLE DROP COLUMN
-- doesn't fire the row-level trigger but the wrapper is the standard
-- Phase 3 pattern. The reason persists in the audit log only for
-- write paths.
SELECT set_config(
'paliad.audit_reason',
'mig 091: drop legacy rule columns per design §3.E + m''s 2026-05-15 approval',
true);
-- =============================================================================
-- 1. Snapshot of the four columns + id, so the down-migration can
-- restore values to existing rows. Skipping the snapshot table
-- would mean a rollback adds the columns back but with NULL data;
-- the snapshot preserves the legacy values for any downstream
-- consumer the audit might surface.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_091 AS
SELECT id,
is_mandatory,
is_optional,
condition_flag,
condition_rule_id,
now() AS snapshotted_at
FROM paliad.deadline_rules;
COMMENT ON TABLE paliad.deadline_rules_pre_091 IS
'Snapshot of paliad.deadline_rules.(is_mandatory, is_optional, '
'condition_flag, condition_rule_id) before mig 091''s drop. Lets '
'a rollback restore the legacy values for the 172 rules that '
'existed at drop time. Drop this table after Slice 9 is verified '
'in prod (a focused follow-up slice or part of Slice 12 cleanup).';
-- =============================================================================
-- 2. Drop the columns. Order doesn't matter — none of them reference
-- each other or other tables (condition_rule_id was a dead self-FK
-- that no live row uses, Q13).
-- =============================================================================
ALTER TABLE paliad.deadline_rules
DROP COLUMN IF EXISTS is_mandatory,
DROP COLUMN IF EXISTS is_optional,
DROP COLUMN IF EXISTS condition_flag,
DROP COLUMN IF EXISTS condition_rule_id;
-- =============================================================================
-- 3. Hard assertion: every remaining row carries a valid priority +
-- has condition_expr populated when its legacy condition_flag was
-- non-empty pre-mig. Belt-and-braces — Slice 2 backfilled both
-- paths and Slice 4 unified the calculator, but a stale row would
-- light up here BEFORE we hand the schema to the unified code.
-- =============================================================================
DO $$
DECLARE
n_total int;
n_null_prio int;
n_lost int;
BEGIN
SELECT count(*), count(*) FILTER (WHERE priority IS NULL)
INTO n_total, n_null_prio
FROM paliad.deadline_rules;
-- Cross-check against the snapshot: every pre-mig row with a
-- non-empty condition_flag must have a non-NULL condition_expr
-- post-mig. If any row lost its gate, the calculator's gate
-- behaviour would silently change — surface it loudly.
SELECT count(*)
INTO n_lost
FROM paliad.deadline_rules_pre_091 b
JOIN paliad.deadline_rules dr ON dr.id = b.id
WHERE b.condition_flag IS NOT NULL
AND array_length(b.condition_flag, 1) > 0
AND dr.condition_expr IS NULL;
RAISE NOTICE 'mig 091: % rules, % with NULL priority, % lost condition_expr',
n_total, n_null_prio, n_lost;
IF n_null_prio > 0 THEN
RAISE EXCEPTION 'mig 091: % rules have priority IS NULL post-drop — '
'the priority column must be backfilled (Slice 2 mig 083) '
'before legacy columns are dropped',
n_null_prio;
END IF;
IF n_lost > 0 THEN
RAISE EXCEPTION 'mig 091: % rules had a condition_flag pre-drop but no '
'condition_expr post-drop — Slice 2 mig 084 missed them',
n_lost;
END IF;
END $$;

View File

@@ -0,0 +1,116 @@
-- t-paliad-199 down — reverses 092_drop_event_deadlines_tables.up.sql.
--
-- Re-creates paliad.event_deadlines + paliad.event_deadline_rule_codes
-- with the schema they had at end of mig 086 (the read-only state right
-- before mig 092 dropped them), repopulates from the _pre_092
-- snapshots, restores the mig 086 read-only trigger, and drops the
-- rule_codes column the up migration added to paliad.deadline_rules.
--
-- The snapshot tables themselves stay — they're the source of this
-- rollback's data and a permanent audit artefact. A focused
-- follow-up slice / Slice 12 cleanup drops the snapshots once
-- Slice 9 is verified in prod.
SELECT set_config(
'paliad.audit_reason',
'rollback 092: restore paliad.event_deadlines + event_deadline_rule_codes from pre-drop snapshots and drop rule_codes column',
true);
-- =============================================================================
-- 1. Recreate paliad.event_deadlines. Schema matches the live state at
-- the start of mig 092 (post-mig-086, with the notes_en column from
-- mig 036 and the legal_source column from mig 038).
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.event_deadlines (
id bigint PRIMARY KEY,
trigger_event_id bigint NOT NULL REFERENCES paliad.trigger_events(id) ON DELETE CASCADE,
title text NOT NULL,
title_de text NOT NULL DEFAULT '',
duration_value integer NOT NULL DEFAULT 0,
duration_unit text NOT NULL DEFAULT 'days'
CHECK (duration_unit IN ('days', 'weeks', 'months', 'working_days')),
timing text NOT NULL DEFAULT 'after'
CHECK (timing IN ('before', 'after')),
notes text NOT NULL DEFAULT '',
alt_duration_value integer,
alt_duration_unit text CHECK (alt_duration_unit IS NULL OR alt_duration_unit IN ('days', 'weeks', 'months', 'working_days')),
combine_op text CHECK (combine_op IS NULL OR combine_op IN ('max', 'min')),
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
notes_en text,
legal_source text
);
CREATE INDEX IF NOT EXISTS event_deadlines_trigger_event_idx
ON paliad.event_deadlines (trigger_event_id);
CREATE INDEX IF NOT EXISTS event_deadlines_active_idx
ON paliad.event_deadlines (is_active) WHERE is_active = true;
CREATE INDEX IF NOT EXISTS event_deadlines_legal_src_trgm
ON paliad.event_deadlines USING gin (legal_source gin_trgm_ops);
INSERT INTO paliad.event_deadlines
(id, trigger_event_id, title, title_de, duration_value, duration_unit,
timing, notes, alt_duration_value, alt_duration_unit, combine_op,
is_active, created_at, updated_at, notes_en, legal_source)
SELECT id, trigger_event_id, title, title_de, duration_value, duration_unit,
timing, notes, alt_duration_value, alt_duration_unit, combine_op,
is_active, created_at, updated_at, notes_en, legal_source
FROM paliad.event_deadlines_pre_092
ON CONFLICT (id) DO NOTHING;
-- =============================================================================
-- 2. Recreate paliad.event_deadline_rule_codes.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.event_deadline_rule_codes (
event_deadline_id bigint NOT NULL REFERENCES paliad.event_deadlines(id) ON DELETE CASCADE,
rule_code text NOT NULL,
sort_order integer NOT NULL DEFAULT 0,
PRIMARY KEY (event_deadline_id, rule_code)
);
CREATE INDEX IF NOT EXISTS event_deadline_rule_codes_code_idx
ON paliad.event_deadline_rule_codes (rule_code);
INSERT INTO paliad.event_deadline_rule_codes
(event_deadline_id, rule_code, sort_order)
SELECT event_deadline_id, rule_code, sort_order
FROM paliad.event_deadline_rule_codes_pre_092
ON CONFLICT (event_deadline_id, rule_code) DO NOTHING;
-- =============================================================================
-- 3. Restore the mig 086 read-only trigger + function (the rolled-back
-- state IS "Slice 3 + Slice 9 only", which had the trigger in place).
-- =============================================================================
CREATE OR REPLACE FUNCTION paliad.event_deadlines_readonly_trigger()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION
'paliad.event_deadlines is read-only after Phase 3 Slice 3 — '
'writes must go through paliad.deadline_rules (Pipeline C is '
'unified; the source table is preserved as an audit anchor '
'until Slice 9 drops it). Operation: %', TG_OP;
END;
$$;
DROP TRIGGER IF EXISTS event_deadlines_readonly ON paliad.event_deadlines;
CREATE TRIGGER event_deadlines_readonly
BEFORE INSERT OR UPDATE OR DELETE ON paliad.event_deadlines
FOR EACH ROW
EXECUTE FUNCTION paliad.event_deadlines_readonly_trigger();
-- =============================================================================
-- 4. Drop the rule_codes column the up migration added. The data is
-- preserved in paliad.event_deadline_rule_codes (just restored
-- above), so dropping the column doesn't lose history.
-- =============================================================================
ALTER TABLE paliad.deadline_rules
DROP COLUMN IF EXISTS rule_codes;

View File

@@ -0,0 +1,195 @@
-- t-paliad-199 / Fristen Phase 3 Slice 9 follow-up A — drop the legacy
-- Pipeline-C source tables (paliad.event_deadlines +
-- paliad.event_deadline_rule_codes) and the read-only trigger from
-- mig 086, now that EventDeadlineService.Calculate has been rewritten
-- to read from paliad.deadline_rules.
--
-- Lorenz's Slice 9 (t-paliad-195) deferred this drop because the
-- legacy service still SELECTed event_deadlines.duration_value /
-- duration_unit / timing / notes / alt_* / combine_op. Slice 9
-- follow-up A refactors the service onto deadline_rules (the unified
-- source-of-truth since Slice 3 / mig 085) and frees us to remove the
-- old tables.
--
-- Sequencing — every step in this single migration is required for the
-- drop to be safe:
--
-- 1. Snapshot both source tables into paliad.event_deadlines_pre_092
-- + paliad.event_deadline_rule_codes_pre_092 (CREATE TABLE IF NOT
-- EXISTS — idempotent re-run). The snapshots persist after the
-- drop as audit anchors; the down migration restores from them.
-- 2. ADD COLUMN rule_codes text[] to paliad.deadline_rules and
-- backfill from paliad.event_deadline_rule_codes. Pipeline-C
-- deadlines carry multi-code rules (e.g. R.198 / R.213 carry
-- [RoP.029.a, RoP.030]) which don't fit deadline_rules.rule_code
-- (singular text); mig 085 left rule_code NULL on the 77
-- Pipeline-C rows. Without this backfill the drop would silently
-- lose 72 RoP citations.
-- 3. Hard assertion: every event_deadline_rule_codes row resolves to
-- a deadline_rules row via the sequence_order = 1000 +
-- event_deadlines.id convention from mig 085. If any row didn't
-- land, fail loudly before dropping the source.
-- 4. DROP TRIGGER + FUNCTION from mig 086 — orphan once the table is
-- gone.
-- 5. DROP TABLE paliad.event_deadline_rule_codes (FK side first).
-- 6. DROP TABLE paliad.event_deadlines.
-- 7. Final assertion: paliad.deadline_rules still carries >=77 active
-- rows with trigger_event_id IS NOT NULL (the Slice 3 corpus must
-- not have collapsed).
--
-- audit_reason wrapper at top — the mig 079 trigger on
-- paliad.deadline_rules logs every row-level edit. The ALTER TABLE +
-- UPDATE on rule_codes fires through that trigger, so the reason
-- persists in paliad.deadline_rule_audit for forever-grade audit.
SELECT set_config(
'paliad.audit_reason',
'mig 092: drop paliad.event_deadlines + event_deadline_rule_codes after backfilling rule_codes into deadline_rules (t-paliad-199, Slice 9 follow-up A, design §3.E)',
true);
-- =============================================================================
-- 1. Backup snapshots — full row copies so the down migration can
-- rebuild both tables byte-identically. CREATE TABLE IF NOT EXISTS
-- keeps the migration idempotent across reapplications; if the
-- snapshot already exists from a prior aborted run, we re-use it.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.event_deadlines_pre_092 AS
SELECT *, now() AS snapshotted_at
FROM paliad.event_deadlines;
COMMENT ON TABLE paliad.event_deadlines_pre_092 IS
'Snapshot of paliad.event_deadlines before mig 092 dropped it. '
'Source-of-truth for the down migration; persists post-drop as the '
'permanent audit record of the 77 Pipeline-C source rows that '
'seeded paliad.deadline_rules via mig 085. Drop with a focused '
'follow-up after Slice 9 is verified in prod (pair with '
'paliad.deadline_rules_pre_091 cleanup).';
CREATE TABLE IF NOT EXISTS paliad.event_deadline_rule_codes_pre_092 AS
SELECT *, now() AS snapshotted_at
FROM paliad.event_deadline_rule_codes;
COMMENT ON TABLE paliad.event_deadline_rule_codes_pre_092 IS
'Snapshot of paliad.event_deadline_rule_codes before mig 092 dropped '
'it. Restored by the down migration; persists post-drop as the '
'permanent audit record of the legacy RoP citations attached to '
'Pipeline-C deadlines (72 rows across 70 of 77 deadlines).';
-- =============================================================================
-- 2. Add paliad.deadline_rules.rule_codes (text[]) and backfill it for
-- the 77 Pipeline-C rules. Mig 085 set rule_code = NULL on every
-- Pipeline-C row because deadline_rules.rule_code is singular and
-- Pipeline-C deadlines can carry multiple citations. rule_codes
-- holds the array form. Pipeline-A rules keep NULL here and continue
-- using rule_code; this column is a Pipeline-C-only field today.
-- =============================================================================
ALTER TABLE paliad.deadline_rules
ADD COLUMN IF NOT EXISTS rule_codes text[];
COMMENT ON COLUMN paliad.deadline_rules.rule_codes IS
'Array of legal-rule citations attached to this deadline, in '
'render order. Pipeline-C rules (event-rooted, trigger_event_id IS '
'NOT NULL) populate this column from the legacy '
'paliad.event_deadline_rule_codes junction (mig 092 backfill); '
'Pipeline-A rules use the singular rule_code column instead. NULL '
'on Pipeline-A rules + on the 7 Pipeline-C deadlines that had no '
'junction rows pre-mig.';
-- Aggregate junction rows into a text[] sorted by (sort_order,
-- rule_code) — matches the legacy ORDER BY contract that
-- EventDeadlineService.loadRuleCodes used.
--
-- Join key: the sequence_order = 1000 + event_deadlines.id convention
-- mig 085 anchored. Every active event_deadlines.id has a corresponding
-- deadline_rules row at sequence_order = 1000 + id; mig 085's hard
-- assertion guarantees that.
WITH agg AS (
SELECT event_deadline_id,
array_agg(rule_code ORDER BY sort_order, rule_code) AS codes
FROM paliad.event_deadline_rule_codes
GROUP BY event_deadline_id
)
UPDATE paliad.deadline_rules dr
SET rule_codes = agg.codes
FROM agg
WHERE dr.trigger_event_id IS NOT NULL
AND dr.sequence_order = 1000 + agg.event_deadline_id
AND dr.rule_codes IS DISTINCT FROM agg.codes;
-- =============================================================================
-- 3. Hard assertion: every junction row landed on a deadline_rules row.
-- Sums elements across all rule_codes arrays — if the count differs
-- from the source junction count, some event_deadline_id failed to
-- match any deadline_rules row (sequence_order convention broken).
-- Fail loudly here BEFORE dropping the source.
-- =============================================================================
DO $$
DECLARE
n_codes_src int;
n_codes_target int;
BEGIN
SELECT count(*) INTO n_codes_src
FROM paliad.event_deadline_rule_codes;
SELECT COALESCE(SUM(array_length(rule_codes, 1)), 0) INTO n_codes_target
FROM paliad.deadline_rules
WHERE rule_codes IS NOT NULL;
RAISE NOTICE 'mig 092: junction rows=%, backfilled rule_codes elements=%',
n_codes_src, n_codes_target;
IF n_codes_target < n_codes_src THEN
RAISE EXCEPTION 'mig 092: rule_codes backfill missed % junction rows '
'(source=%, target=%) — sequence_order = 1000 + ed.id '
'convention broken? Aborting before drop.',
n_codes_src - n_codes_target, n_codes_src, n_codes_target;
END IF;
END $$;
-- =============================================================================
-- 4. Drop the read-only trigger + function from mig 086. They're orphan
-- once paliad.event_deadlines goes away — explicit drop documents
-- that the wrapper's job is done, and keeps the symmetric reverse in
-- the down migration cleanly readable.
-- =============================================================================
DROP TRIGGER IF EXISTS event_deadlines_readonly ON paliad.event_deadlines;
DROP FUNCTION IF EXISTS paliad.event_deadlines_readonly_trigger();
-- =============================================================================
-- 5. Drop the legacy tables. Order: junction first (it has a FK to
-- event_deadlines), then the parent. Explicit ordering is clearer
-- than relying on CASCADE and mirrors the down migration's CREATE
-- sequence.
-- =============================================================================
DROP TABLE IF EXISTS paliad.event_deadline_rule_codes;
DROP TABLE IF EXISTS paliad.event_deadlines;
-- =============================================================================
-- 6. Final assertion: the unified Pipeline-C corpus is still intact.
-- Mig 085 moved 77 active rows; future hand-edited Pipeline-C rules
-- can only raise the count. A drop below 77 means the upstream
-- deadline_rules data was clobbered while this migration ran and
-- the deploy must abort.
-- =============================================================================
DO $$
DECLARE
n_unified int;
BEGIN
SELECT count(*) INTO n_unified
FROM paliad.deadline_rules
WHERE trigger_event_id IS NOT NULL AND is_active = true;
RAISE NOTICE 'mig 092: post-drop Pipeline-C rule count = %', n_unified;
IF n_unified < 77 THEN
RAISE EXCEPTION 'mig 092: Pipeline-C corpus collapsed — expected >=77 '
'active deadline_rules with trigger_event_id IS NOT NULL, got %',
n_unified;
END IF;
END $$;

View File

@@ -0,0 +1,67 @@
-- t-paliad-200 down — reverses 093_retire_litigation_category.up.sql.
--
-- Restores the 7 litigation-category paliad.proceeding_types rows from
-- the _pre_093 snapshot, moves the 40 archived deadline_rules back onto
-- their original proceeding_type_id values (and reverts
-- lifecycle_state + is_active to their pre-093 values), then drops the
-- _archived_litigation holding pt.
--
-- The snapshot tables themselves stay — they're the source of this
-- rollback's data and a permanent audit artefact. A focused
-- follow-up drops the snapshots once Slice 9 is verified in prod.
SELECT set_config(
'paliad.audit_reason',
'rollback 093: restore litigation proceeding_types + un-archive the 40 Pipeline-A rules from pre-093 snapshots',
true);
-- =============================================================================
-- 1. Restore the 7 litigation proceeding_types rows. ON CONFLICT (id)
-- DO NOTHING — if a row somehow survived the up migration we don't
-- clobber it.
-- =============================================================================
INSERT INTO paliad.proceeding_types
(id, code, name, description, jurisdiction, category,
default_color, sort_order, is_active, name_en, display_order)
SELECT id, code, name, description, jurisdiction, category,
default_color, sort_order, is_active, name_en, display_order
FROM paliad.proceeding_types_pre_093
ON CONFLICT (id) DO NOTHING;
-- Re-align the proceeding_types_id_seq if a SERIAL/IDENTITY column
-- bumped past the restored ids. The pre-093 max was 7; the
-- _archived_litigation INSERT in the up migration claimed a later id.
-- Setting the seq to the max of the live table keeps future INSERTs
-- safe regardless of order.
SELECT setval(
pg_get_serial_sequence('paliad.proceeding_types', 'id'),
GREATEST(
(SELECT COALESCE(MAX(id), 1) FROM paliad.proceeding_types),
1
)
);
-- =============================================================================
-- 2. Restore the 40 deadline_rules rows to their pre-093 state:
-- proceeding_type_id, lifecycle_state, is_active, updated_at. The
-- rule UUIDs are stable so we match on id. The mig 079 audit
-- trigger captures these UPDATEs as the rollback record.
-- =============================================================================
UPDATE paliad.deadline_rules dr
SET proceeding_type_id = snap.proceeding_type_id,
lifecycle_state = snap.lifecycle_state,
is_active = snap.is_active,
updated_at = snap.updated_at
FROM paliad.deadline_rules_pre_093 snap
WHERE dr.id = snap.id;
-- =============================================================================
-- 3. Drop the _archived_litigation holding pt. Safe — step 2 moved all
-- 40 rules off it. The CASCADE is a no-op (FK on rules has
-- ON DELETE CASCADE, but there are zero rules to cascade).
-- =============================================================================
DELETE FROM paliad.proceeding_types
WHERE code = '_archived_litigation';

View File

@@ -0,0 +1,247 @@
-- t-paliad-200 / Fristen Phase 3 Slice 9 follow-up B — retire the
-- 'litigation' category from the rule corpus.
--
-- Lorenz's Slice 9 (t-paliad-195) deferred this drop because 40 active
-- paliad.deadline_rules still pointed at the 7 litigation-category
-- proceeding_types (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL). Phase 3
-- Slice 5 retired litigation codes from project-binding (mig 087/088);
-- this migration retires them from the rule corpus.
--
-- Plan choice (audit-gated, paliadin-approved): archive-all-40 rather
-- than the original re-parent plan. The audit found:
--
-- * 23 of 40 Pipeline-A litigation rules share their `code` with an
-- existing fristenrechner rule on the proposed re-parent target
-- (e.g. `inf.oral` exists on both INF and UPC_INF). Re-parenting
-- would leave two rules with identical (proceeding_type_id, code),
-- breaking the implicit per-proceeding rule_code identity contract
-- keyed off by projection / search / rule_editor.
-- * The fristenrechner-category rules are the production version:
-- proper German names, legal_source pinned (UPC.RoP citations),
-- full bilateral chains, intra-proceeding counterclaim handling
-- via inf.def_to_ccr / rev.cc_inf / etc. The Pipeline-A rules are
-- stubs: English-only, mostly NULL legal_source, duration_value=0
-- for 28 of 40, no spawn_proceeding_type_id wiring.
-- * 1 live deadline ("Lecker Frist", status=completed) points at
-- Pipeline-A inf.rejoin/INF via paliad.deadlines.rule_id. Archive-
-- not-delete preserves the FK.
-- * 30 intra-litigation parent_id chains would be silently broken by
-- piecemeal re-parenting. Archive-all preserves them.
-- * FK on deadline_rules.proceeding_type_id is ON DELETE CASCADE →
-- proceeding_types(id). A naive DELETE of the 7 litigation rows
-- would cascade-delete all 40 rules AND break the live deadline's
-- rule_id FK. Rules must be moved off the litigation pt ids before
-- the litigation rows are dropped.
--
-- Surfaced for legal review at merge (commit body lists these so they
-- don't get lost as the four open coverage questions Phase 3 leaves
-- behind):
--
-- 1. inf.prelim (Preliminary Objection, RoP 19, 1 month) — not
-- present on UPC_INF. Possible coverage gap for the fristenrechner
-- ruleset; legal review to decide whether to add it.
-- 2. inf.appeal / rev.appeal / ccr.appeal as cross-proceeding spawns
-- into UPC_APP (2 months, UPC.RoP.220.1) — fristenrechner UPC_APP
-- currently starts standalone with no spawn from UPC_INF/UPC_REV.
-- Possible UX gap; the Pipeline-A versions had
-- spawn_proceeding_type_id=NULL so they weren't functional
-- spawns either.
-- 3. ccr.amend / rev.amend (spawn rules) — superseded by
-- inf.app_to_amend / rev.app_to_amend on UPC_INF / UPC_REV. Safe
-- to drop.
-- 4. zpo.klage / zpo.vertanz / zpo.klageerw / zpo.berufung — no UPC
-- analogue; redundant with DE_INF / DE_INF_OLG / DE_INF_BGH and
-- DE_NULL / DE_NULL_BGH. Safe to drop.
--
-- Sequencing — every step required for the drop to be safe:
--
-- 1. Snapshot paliad.proceeding_types and the 40 affected
-- paliad.deadline_rules into _pre_093 audit tables.
-- 2. Create a holding proceeding_type `_archived_litigation`
-- (category='archived', is_active=false, jurisdiction='UPC') to
-- home the archived rules and preserve their intra-set parent_id
-- chains across the drop.
-- 3. UPDATE all 40 rules: proceeding_type_id = archived_id,
-- lifecycle_state='archived', is_active=false. The mig 079
-- trigger captures every row in paliad.deadline_rule_audit.
-- 4. DELETE the 7 litigation rows from paliad.proceeding_types
-- (now safe — nothing references them).
-- 5. Hard assertions: zero rules on litigation ids, zero litigation
-- rows surviving, exactly 40 rules on the archive id.
--
-- Idempotent: re-applying is a no-op (snapshots use CREATE TABLE IF
-- NOT EXISTS; the archive pt INSERT uses ON CONFLICT DO NOTHING; the
-- UPDATEs are guarded by lifecycle_state='archived' so they only fire
-- once; the DELETE targets category='litigation' which becomes empty
-- after first run).
--
-- audit_reason wrapper at top — the mig 079 trigger on
-- paliad.deadline_rules logs every row-level edit. The UPDATE on all
-- 40 rules fires through that trigger, so the reason persists in
-- paliad.deadline_rule_audit for forever-grade audit.
SELECT set_config(
'paliad.audit_reason',
'mig 093: retire litigation category from rule corpus — archive 40 Pipeline-A rules under _archived_litigation pt, drop 7 litigation proceeding_types rows (t-paliad-200, Slice 9 follow-up B)',
true);
-- =============================================================================
-- 1. Backup snapshots. CREATE TABLE IF NOT EXISTS keeps the migration
-- idempotent across reapplications. Snapshots persist post-drop as
-- the permanent audit anchor; the down migration restores from them.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.proceeding_types_pre_093 AS
SELECT *, now() AS snapshotted_at
FROM paliad.proceeding_types
WHERE category = 'litigation';
COMMENT ON TABLE paliad.proceeding_types_pre_093 IS
'Snapshot of the 7 litigation-category paliad.proceeding_types rows '
'(INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL) before mig 093 dropped '
'them. Source-of-truth for the down migration; persists post-drop '
'as the permanent audit record of the Pipeline-A proceeding '
'inventory. Drop with a focused follow-up after the Phase 3 cleanup '
'is verified in prod.';
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_093 AS
SELECT dr.*, now() AS snapshotted_at
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE pt.category = 'litigation';
COMMENT ON TABLE paliad.deadline_rules_pre_093 IS
'Snapshot of the 40 paliad.deadline_rules rows that pointed at '
'litigation-category proceeding_types before mig 093 re-homed '
'them under the _archived_litigation pt. Source-of-truth for the '
'down migration; persists post-drop as the permanent audit record '
'of the Pipeline-A rule corpus.';
-- =============================================================================
-- 2. Create the holding proceeding_type `_archived_litigation`. Category
-- is the new 'archived' bucket (non-fristenrechner, so it cannot be
-- selected from any UI that filters category='fristenrechner', and
-- the mig 088 trigger continues to reject project-binding to it).
-- is_active=false so it doesn't appear in admin lists.
--
-- sort_order = 9999 to sit at the tail of any category sort. The
-- INSERT is idempotent via ON CONFLICT (code) DO NOTHING.
-- =============================================================================
INSERT INTO paliad.proceeding_types
(code, name, name_en, description, jurisdiction, category,
default_color, sort_order, display_order, is_active)
VALUES
('_archived_litigation',
'Archivierte Litigation-Regeln (Pipeline A)',
'Archived litigation rules (Pipeline A)',
'Holding proceeding_type for the 40 Pipeline-A litigation-category '
'rules retired by mig 093 (t-paliad-200, Slice 9 follow-up B). Not '
'selectable from any UI; preserves the rules + their 30 intra-set '
'parent_id chains for audit, and keeps the FK valid for the one '
'live deadline that still references inf.rejoin/INF.',
'UPC',
'archived',
'#94a3b8',
9999,
9999,
false)
ON CONFLICT (code) DO NOTHING;
-- =============================================================================
-- 3. Re-home all 40 rules to the archive pt and mark them archived.
-- The mig 079 trigger requires a non-empty audit_reason for UPDATE;
-- set_config above provides it. lifecycle_state='archived' +
-- is_active=false means projection_service / fristenrechner /
-- rule_editor filter them out by default. The intra-set parent_id
-- chains (30 of them) are preserved verbatim — parent_id values
-- point at the rule UUIDs which don't change.
--
-- Guard the UPDATE on lifecycle_state <> 'archived' so a second
-- application of the migration is a no-op (the rules are already
-- archived on the second run).
-- =============================================================================
UPDATE paliad.deadline_rules dr
SET proceeding_type_id = (SELECT id FROM paliad.proceeding_types
WHERE code = '_archived_litigation'),
lifecycle_state = 'archived',
is_active = false,
updated_at = now()
FROM paliad.proceeding_types pt
WHERE pt.id = dr.proceeding_type_id
AND pt.category = 'litigation'
AND dr.lifecycle_state <> 'archived';
-- =============================================================================
-- 4. Drop the 7 litigation rows from paliad.proceeding_types. Nothing
-- references them now: step 3 moved all 40 rules off; mig 087 moved
-- every project off; the audit confirmed zero cross-category spawn /
-- parent references. The FK is ON DELETE CASCADE but cascades zero
-- rows at this point.
-- =============================================================================
DELETE FROM paliad.proceeding_types
WHERE category = 'litigation';
-- =============================================================================
-- 5. Hard assertions. Raise loudly if anything didn't land — this
-- migration is not safe to leave half-applied because the litigation
-- pt rows are gone and the rule corpus needs to be coherent.
-- =============================================================================
DO $$
DECLARE
v_orphan_rules integer;
v_lit_rows integer;
v_archived integer;
v_archive_id integer;
BEGIN
SELECT id INTO v_archive_id
FROM paliad.proceeding_types
WHERE code = '_archived_litigation';
IF v_archive_id IS NULL THEN
RAISE EXCEPTION
'mig 093: _archived_litigation proceeding_type missing after step 2';
END IF;
-- No deadline_rules row still points at a litigation pt id (the
-- pt rows themselves are gone, so the proper check is "no rule
-- points at a row outside the surviving proceeding_types set").
-- This collapses to: no rule has a NULL proceeding_type from the
-- DELETE (the FK on rules → pt(id) is ON DELETE CASCADE; if we
-- missed a rule it would have been cascade-deleted in step 4).
-- Cross-check by counting rules that used to be on litigation pts:
SELECT count(*) INTO v_lit_rows
FROM paliad.proceeding_types
WHERE category = 'litigation';
IF v_lit_rows <> 0 THEN
RAISE EXCEPTION
'mig 093: % litigation proceeding_types rows survived the DELETE',
v_lit_rows;
END IF;
SELECT count(*) INTO v_archived
FROM paliad.deadline_rules
WHERE proceeding_type_id = v_archive_id;
IF v_archived <> 40 THEN
RAISE EXCEPTION
'mig 093: expected 40 rules on _archived_litigation, got %',
v_archived;
END IF;
-- Belt-and-braces: every snapshot row matches a surviving rule on
-- the archive pt by id. If any rule was cascade-deleted by a
-- missed step, this raises.
SELECT count(*) INTO v_orphan_rules
FROM paliad.deadline_rules_pre_093 snap
LEFT JOIN paliad.deadline_rules dr ON dr.id = snap.id
WHERE dr.id IS NULL;
IF v_orphan_rules <> 0 THEN
RAISE EXCEPTION
'mig 093: % rules from the pre-snapshot are missing from '
'paliad.deadline_rules — cascade-delete leak',
v_orphan_rules;
END IF;
END $$;

View File

@@ -0,0 +1,32 @@
-- mig 094 DOWN — restore the 7-digit CHECK and the snapshotted
-- pre-clear client_number / matter_number values from
-- paliad.projects_pre_094. Symmetric to the up migration.
SELECT set_config(
'paliad.audit_reason',
'mig 094 DOWN: restore 7-digit CHECK and pre-094 client_number/matter_number values from snapshot',
true);
-- 1. Drop the 6-digit CHECKs.
ALTER TABLE paliad.projects
DROP CONSTRAINT projekte_client_number_check,
DROP CONSTRAINT projekte_matter_number_check;
-- 2. Restore the original values from the snapshot. Only rows that
-- existed at snapshot time are touched; rows added since stay as
-- they were.
UPDATE paliad.projects p
SET client_number = s.client_number,
matter_number = s.matter_number
FROM paliad.projects_pre_094 s
WHERE p.id = s.id;
-- 3. Re-add the legacy 7-digit CHECKs.
ALTER TABLE paliad.projects
ADD CONSTRAINT projekte_client_number_check
CHECK (client_number IS NULL OR client_number ~ '^[0-9]{7}$'),
ADD CONSTRAINT projekte_matter_number_check
CHECK (matter_number IS NULL OR matter_number ~ '^[0-9]{7}$');
-- 4. Drop the snapshot. The down migration is the only consumer.
DROP TABLE IF EXISTS paliad.projects_pre_094;

View File

@@ -0,0 +1,97 @@
-- mig 094 — tighten paliad.projects.client_number + matter_number CHECK
-- from 7-digit to 6-digit. The "7-Ziffern" rule in mig 018 was wrong;
-- HLC's real Client/Matter format is 6 digits each (m's correction,
-- 2026-05-17). The constraints carry the legacy 'projekte_*_check'
-- name from before the table was renamed (mig 021), so the ALTER
-- TABLE DROP / ADD has to use those names verbatim.
--
-- Existing rows: only test data (2 client_numbers, 1 matter_number),
-- all 7-digit. They violate the new pattern, so we NULL them out
-- before tightening — preserving the project rows themselves, just
-- clearing the wrong-shaped billing identifiers. The rows are
-- snapshotted in projects_pre_094 first so the down migration can
-- restore them byte-identically.
--
-- audit_reason wrapper at top: the trigger on paliad.projects logs
-- every row-level UPDATE; the message persists in the audit table as
-- the permanent record of why those test values were cleared.
SELECT set_config(
'paliad.audit_reason',
'mig 094: clear test 7-digit client_number/matter_number values before tightening CHECK to 6-digit (HLC real format correction, 2026-05-17)',
true);
-- =============================================================================
-- 1. Backup snapshot. Full row copy of every paliad.projects row that
-- has either field populated. Idempotent via CREATE TABLE IF NOT
-- EXISTS — re-running the migration after an aborted run re-uses
-- the existing snapshot.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.projects_pre_094 AS
SELECT *, now() AS snapshotted_at
FROM paliad.projects
WHERE client_number IS NOT NULL OR matter_number IS NOT NULL;
COMMENT ON TABLE paliad.projects_pre_094 IS
'Snapshot of paliad.projects rows that had a client_number or '
'matter_number set before mig 094 tightened the CHECK from '
'7-digit to 6-digit. The 094 UPDATE NULL-ed those values out '
'because they were leftover 7-digit test data. Persists as the '
'permanent audit anchor; the down migration restores from it.';
-- =============================================================================
-- 2. Clear the 7-digit test values. Only rows that already violate
-- the new pattern are touched — anything that happens to already
-- be 6 digits (none today, but the WHERE keeps the migration
-- re-runnable after future inserts) is left alone.
-- =============================================================================
UPDATE paliad.projects
SET client_number = NULL
WHERE client_number IS NOT NULL
AND client_number !~ '^[0-9]{6}$';
UPDATE paliad.projects
SET matter_number = NULL
WHERE matter_number IS NOT NULL
AND matter_number !~ '^[0-9]{6}$';
-- =============================================================================
-- 3. Replace the legacy 7-digit CHECKs with 6-digit ones. The
-- constraint names carry the pre-rename `projekte_*` prefix from
-- mig 018; keep them stable so external audit tools that scan
-- pg_constraint by name don't drift.
-- =============================================================================
ALTER TABLE paliad.projects
DROP CONSTRAINT projekte_client_number_check,
DROP CONSTRAINT projekte_matter_number_check;
ALTER TABLE paliad.projects
ADD CONSTRAINT projekte_client_number_check
CHECK (client_number IS NULL OR client_number ~ '^[0-9]{6}$'),
ADD CONSTRAINT projekte_matter_number_check
CHECK (matter_number IS NULL OR matter_number ~ '^[0-9]{6}$');
-- =============================================================================
-- 4. Hard assertions. Any row that survived the UPDATE+ALTER must
-- satisfy the new pattern; the count of cleared test rows must
-- match the snapshot.
-- =============================================================================
DO $$
DECLARE
n_violations int;
BEGIN
SELECT count(*) INTO n_violations
FROM paliad.projects
WHERE (client_number IS NOT NULL AND client_number !~ '^[0-9]{6}$')
OR (matter_number IS NOT NULL AND matter_number !~ '^[0-9]{6}$');
IF n_violations > 0 THEN
RAISE EXCEPTION 'mig 094: % rows still violate the 6-digit pattern after UPDATE — should be 0', n_violations;
END IF;
RAISE NOTICE 'mig 094: 6-digit CHECKs in place, all rows compliant';
END $$;

View File

@@ -0,0 +1,61 @@
-- Reverses mig 095. Restores the 4 patched de_inf.* rows from
-- paliad.deadline_rules_pre_095 and removes the 4 new rules
-- (inf.prelim, rev.prelim, inf.appeal_spawn, rev.appeal_spawn).
--
-- The audit_reason is required by the mig 079 trigger for UPDATE +
-- DELETE; set_config at top supplies it.
SELECT set_config(
'paliad.audit_reason',
'mig 095 (down): revert t-paliad-205 fristen gap-fill — restore de_inf.* patches from deadline_rules_pre_095, delete 4 new rules',
true);
-- =============================================================================
-- 1. Delete the 4 new rules. Idempotent — if a rule is already missing
-- the DELETE matches zero rows.
-- =============================================================================
DELETE FROM paliad.deadline_rules
WHERE code IN ('inf.prelim', 'rev.prelim',
'inf.appeal_spawn', 'rev.appeal_spawn')
AND lifecycle_state = 'published';
-- =============================================================================
-- 2. Restore the 4 patched rows from the pre_095 snapshot. The snapshot
-- captured the rows at first up-migration run; the restore copies
-- each tracked field back. If the snapshot table doesn't exist (down
-- run before up), the restore is a no-op.
-- =============================================================================
DO $$
DECLARE
v_snap_exists boolean;
BEGIN
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules_pre_095'
) INTO v_snap_exists;
IF NOT v_snap_exists THEN
RAISE NOTICE
'mig 095 (down): snapshot table paliad.deadline_rules_pre_095 missing — nothing to restore';
RETURN;
END IF;
UPDATE paliad.deadline_rules dr
SET legal_source = snap.legal_source,
is_court_set = snap.is_court_set,
description = snap.description,
updated_at = now()
FROM paliad.deadline_rules_pre_095 snap
WHERE dr.id = snap.id;
END $$;
-- =============================================================================
-- 3. Drop the snapshot table so a re-applied up migration captures a
-- fresh snapshot of the current state.
-- =============================================================================
DROP TABLE IF EXISTS paliad.deadline_rules_pre_095;

View File

@@ -0,0 +1,403 @@
-- t-paliad-205 / Fristen gap-fill — ingest curie's t-paliad-203 deltas
-- as code. Source of truth for the deltas is
-- docs/proposals/fristen-gap-fill-2026-05-18.md § 0.3 (m's decisions
-- captured 2026-05-18, commit 0123d11).
--
-- Mig 093 (commit 40e49e8) retired the Pipeline-A litigation rule
-- corpus and surfaced four open coverage questions for legal review.
-- curie's proposal verified those questions and m signed off on:
--
-- * 4 new rules — preliminary-objection (RoP 19.1) on UPC_INF and
-- UPC_REV, and merits-appeal spawn (RoP 220.1(a)) on the same two
-- proceedings.
-- * 4 polish PATCHes on the German civil-procedure rules — backfill
-- legal_source on de_inf.klage, flip de_inf.erwidg to court-set
-- with a §276 Abs.1 S.2 note, plus a defensive verify on
-- de_inf.berufung.legal_source.
--
-- Final shape per the proposal § 0.3:
--
-- NEW
-- inf.prelim UPC_INF parent=inf.soc 1mo RoP.019.1 flag=with_po optional
-- rev.prelim UPC_REV parent=rev.app 1mo RoP.019.1 flag=with_po optional
-- inf.appeal_spawn UPC_INF parent=inf.decision 2mo RoP.220.1.a (no flag, always) optional spawn → UPC_APP (id=11)
-- rev.appeal_spawn UPC_REV parent=rev.decision 2mo RoP.220.1.a (no flag, always) optional spawn → UPC_APP (id=11)
--
-- PATCH
-- de_inf.klage legal_source NULL → 'DE.ZPO.253'
-- de_inf.anzeige no change (already 'DE.ZPO.276.1')
-- de_inf.erwidg is_court_set false → true; set description with §276 Abs.1 S.2 note
-- duration_value=6 weeks stays as the default-display value when no
-- court order is yet attached.
-- de_inf.berufung legal_source set to 'DE.ZPO.517' if still NULL (defensive verify)
--
-- The merits-appeal spawn rules unconditionally produce the 2-month
-- appeal-window row once inf.decision / rev.decision is anchored
-- (m's F2.3 decision: "appeal is always a possibility"). Visibility
-- filtering for non-appealing projects is a frontend concern, not a
-- rule-level flag (see proposal § 0.3 follow-up note).
--
-- The spawn_proceeding_type_id FK points at UPC_APP (id=11). t-paliad-204
-- may rename the `code` string for that row but the integer id is stable;
-- if id=11 ever moves, this migration's spawn rules still chain to the
-- correct row.
--
-- Idempotency:
-- * Backup snapshot `deadline_rules_pre_095` is CREATE TABLE IF NOT
-- EXISTS, capturing the 4 patched rows at first run.
-- * INSERTs use `WHERE NOT EXISTS` keyed on (proceeding_type_id, code,
-- lifecycle_state='published') — there is no unique index on
-- (proceeding_type_id, code) in paliad.deadline_rules (mig 093 left
-- archived and published rows co-existing with identical codes), so
-- ON CONFLICT is not available; WHERE NOT EXISTS is the equivalent
-- idempotency guard.
-- * UPDATEs are guarded by clauses that only fire when the row still
-- has the old value (legal_source IS NULL, is_court_set = false).
--
-- audit_reason wrapper required by the mig 079 trigger for both UPDATE
-- and INSERT (INSERT defaults to 'create' but we surface the t-paliad-205
-- context anyway so deadline_rule_audit reads cleanly).
SELECT set_config(
'paliad.audit_reason',
'mig 095: t-paliad-205 fristen gap-fill — 4 new rules (inf.prelim, rev.prelim, inf.appeal_spawn, rev.appeal_spawn) + 4 patches on de_inf.* rules per docs/proposals/fristen-gap-fill-2026-05-18.md § 0.3',
true);
-- =============================================================================
-- 1. Backup snapshot of the 4 rows the PATCHes touch. CREATE TABLE IF
-- NOT EXISTS keeps this idempotent across reapplications. Snapshot
-- persists post-patch as the audit anchor; the down migration
-- restores from it.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_095 AS
SELECT *, now() AS snapshotted_at
FROM paliad.deadline_rules
WHERE code IN ('de_inf.klage', 'de_inf.anzeige',
'de_inf.erwidg', 'de_inf.berufung')
AND lifecycle_state = 'published'
AND is_active = true;
COMMENT ON TABLE paliad.deadline_rules_pre_095 IS
'Snapshot of the 4 de_inf.* deadline_rules rows that mig 095 '
'PATCHed (t-paliad-205). Source-of-truth for the down migration; '
'persists post-patch as the permanent audit record. Drop with a '
'focused follow-up after the gap-fill is verified in prod.';
-- =============================================================================
-- 2. New rules — preliminary objection on UPC_INF and UPC_REV
-- (RoP 19.1, flag-gated `with_po`, 1 month from service of the
-- Statement of Claim / Application for Revocation).
--
-- Anchor: parent_id on the existing root rule (inf.soc / rev.app),
-- matching the chaining pattern used by inf.sod, inf.def_to_ccr,
-- rev.defence, rev.app_to_amend. Idempotent via WHERE NOT EXISTS.
--
-- sequence_order=5 places the PO row before the SoD (sequence_order=10)
-- in the per-proceeding timeline ordering, reflecting the 1-month
-- statutory window beating the 3-month defence in calendar terms.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state)
SELECT
8,
(SELECT id FROM paliad.deadline_rules
WHERE code = 'inf.soc'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND is_active = true),
'inf.prelim',
'Vorab-Einrede (R. 19 VerfO)',
'Preliminary Objection (RoP 19)',
'Vorab-Einrede des Beklagten gegen Zuständigkeit, Verfahrenssprache (R.14) oder Spruchkörper-Zusammensetzung. Statutarische Frist von 1 Monat ab Zustellung der Klage; der UPC entscheidet typischerweise durch Beschluss vor der Zwischenverhandlung (R.19.7).',
'defendant',
'filing',
1,
'months',
'after',
'RoP.019.1',
'Innerhalb von 1 Monat ab Zustellung der Klage. Drei mögliche Gründe: (a) Zuständigkeit/Kompetenz, (b) Verfahrenssprache (R.14), (c) Spruchkörper.',
'Within 1 month of service of the Statement of claim. Three available grounds: (a) jurisdiction/competence, (b) language (R.14), (c) panel composition.',
5,
false,
NULL,
NULL,
true,
'UPC.RoP.19.1',
false,
'{"flag":"with_po"}'::jsonb,
'optional',
false,
'published'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE code = 'inf.prelim'
AND proceeding_type_id = 8
AND lifecycle_state = 'published');
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state)
SELECT
9,
(SELECT id FROM paliad.deadline_rules
WHERE code = 'rev.app'
AND proceeding_type_id = 9
AND lifecycle_state = 'published'
AND is_active = true),
'rev.prelim',
'Vorab-Einrede (R. 19 i.V.m. R. 46 VerfO)',
'Preliminary Objection (RoP 19 in conjunction with RoP 46)',
'Vorab-Einrede des Beklagten (Patentinhaber) im Nichtigkeitsverfahren. R.46 erklärt R.19 für Nichtigkeitsverfahren mutatis mutandis anwendbar; statutarische Frist von 1 Monat ab Zustellung der Nichtigkeitsklage.',
'defendant',
'filing',
1,
'months',
'after',
'RoP.019.1',
'Innerhalb von 1 Monat ab Zustellung der Nichtigkeitsklage. R.46 macht R.19 mutatis mutandis für Nichtigkeitsverfahren anwendbar; in der Praxis vor allem Verfahrenssprache und Spruchkörper-Zusammensetzung als Gründe.',
'Within 1 month of service of the Application for Revocation. R.46 makes R.19 apply mutatis mutandis to revocation actions; in practice the main grounds are language and panel composition.',
5,
false,
NULL,
NULL,
true,
'UPC.RoP.19.1',
false,
'{"flag":"with_po"}'::jsonb,
'optional',
false,
'published'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE code = 'rev.prelim'
AND proceeding_type_id = 9
AND lifecycle_state = 'published');
-- =============================================================================
-- 3. New rules — merits-appeal spawn on UPC_INF and UPC_REV
-- (RoP 220.1(a), 2 months from service of the final decision, always
-- fires once the decision is anchored). spawn_proceeding_type_id=11
-- is UPC_APP; the spawn renders as an entry point into the appeal
-- proceeding which already has app.notice / app.grounds as root
-- rules.
--
-- No condition_expr — m's F2.3 decision: "the appeal deadline should
-- always be triggered by a decision … appeal is always a possibility".
-- Visibility filtering on the frontend is the right place to hide
-- appeals on projects where no appeal is contemplated.
--
-- sequence_order=80 places the spawn row after inf.cost_app (70)
-- and rev.decision's tail in the per-proceeding ordering.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state)
SELECT
8,
(SELECT id FROM paliad.deadline_rules
WHERE code = 'inf.decision'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND is_active = true),
'inf.appeal_spawn',
'Berufung gegen Endentscheidung',
'Appeal against final decision',
'Berufung gegen die Endentscheidung nach R.118. Statutarische Frist von 2 Monaten ab Zustellung der Entscheidung (R.224.1(a)); die Berufungsbegründung folgt mit 4 Monaten ab Zustellung (R.224.2(a), eigenständige Frist im Berufungsverfahren).',
'both',
'filing',
2,
'months',
'after',
'RoP.220.1.a',
'Innerhalb von 2 Monaten ab Zustellung der Endentscheidung Berufungsschrift einreichen (R.224.1(a)). Die Berufungsbegründung (R.224.2(a), 4 Monate) läuft als separate Frist im Berufungsverfahren.',
'Within 2 months of service of the final decision lodge the Statement of appeal (R.224.1(a)). The Statement of grounds (R.224.2(a), 4 months) runs as an independent deadline in the appeal proceeding.',
80,
true,
11,
'Berufungsverfahren öffnen',
true,
'UPC.RoP.220.1',
false,
NULL,
'optional',
false,
'published'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE code = 'inf.appeal_spawn'
AND proceeding_type_id = 8
AND lifecycle_state = 'published');
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state)
SELECT
9,
(SELECT id FROM paliad.deadline_rules
WHERE code = 'rev.decision'
AND proceeding_type_id = 9
AND lifecycle_state = 'published'
AND is_active = true),
'rev.appeal_spawn',
'Berufung gegen Endentscheidung (Nichtigkeit)',
'Appeal against final decision (revocation)',
'Berufung gegen die Endentscheidung im Nichtigkeitsverfahren nach R.118. Statutarische Frist von 2 Monaten ab Zustellung der Entscheidung (R.224.1(a)). Bei with_cci-Konstellationen (Verletzungswiderklage) deckt eine R.118-Entscheidung beide Streitgegenstände ab und erzeugt ein gemeinsames Berufungsfenster.',
'both',
'filing',
2,
'months',
'after',
'RoP.220.1.a',
'Innerhalb von 2 Monaten ab Zustellung der Endentscheidung Berufungsschrift einreichen (R.224.1(a)). Bei Verletzungswiderklage (with_cci) ein gemeinsames Fenster.',
'Within 2 months of service of the final decision lodge the Statement of appeal (R.224.1(a)). Where a counterclaim for infringement was raised (with_cci) the appeal window covers both parts.',
80,
true,
11,
'Berufungsverfahren öffnen',
true,
'UPC.RoP.220.1',
false,
NULL,
'optional',
false,
'published'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE code = 'rev.appeal_spawn'
AND proceeding_type_id = 9
AND lifecycle_state = 'published');
-- =============================================================================
-- 4. PATCHes on existing rows. Each UPDATE is guarded by a WHERE clause
-- that only fires when the row still has the old value — re-running
-- the migration is a no-op once the first run has applied.
-- =============================================================================
-- 4.1 de_inf.klage: legal_source NULL → 'DE.ZPO.253'
UPDATE paliad.deadline_rules
SET legal_source = 'DE.ZPO.253',
updated_at = now()
WHERE code = 'de_inf.klage'
AND lifecycle_state = 'published'
AND is_active = true
AND legal_source IS NULL;
-- 4.2 de_inf.anzeige: no change — verified DE.ZPO.276.1 already correct
-- (proposal § 4.2; intentional no-op to make the audit log complete).
-- 4.3 de_inf.erwidg: flip is_court_set true; set description with §276
-- Abs.1 S.2 note. Keep duration_value=6, duration_unit='weeks' as
-- the default-display value when no court order is yet attached
-- (per § 0.3 — fristenrechner renders the 6-week heuristic until
-- the user enters the actual court-set date).
UPDATE paliad.deadline_rules
SET is_court_set = true,
description = 'Gericht setzt eine Frist von mindestens zwei Wochen ab Verteidigungsanzeige (§276 Abs. 1 S. 2 ZPO).',
updated_at = now()
WHERE code = 'de_inf.erwidg'
AND lifecycle_state = 'published'
AND is_active = true
AND is_court_set = false;
-- 4.4 de_inf.berufung: defensive verify — set legal_source to
-- 'DE.ZPO.517' only if currently NULL. Production value is already
-- 'DE.ZPO.517' per the proposal § 4.4 verification, so this is a
-- no-op in prod; preserved here as a belt-and-braces guard against
-- a staging snapshot where the field was never backfilled.
UPDATE paliad.deadline_rules
SET legal_source = 'DE.ZPO.517',
updated_at = now()
WHERE code = 'de_inf.berufung'
AND lifecycle_state = 'published'
AND is_active = true
AND legal_source IS NULL;
-- =============================================================================
-- 5. Hard assertions. The migration is not safe to leave half-applied —
-- if any of the new rules failed to insert, or the de_inf.erwidg
-- flip didn't land, the fristenrechner corpus is inconsistent.
-- =============================================================================
DO $$
DECLARE
v_new_rules integer;
v_court_set integer;
v_appeal_ids integer;
v_klage_src text;
BEGIN
-- 5.1 All four new rules exist and are active+published
SELECT count(*) INTO v_new_rules
FROM paliad.deadline_rules
WHERE code IN ('inf.prelim', 'rev.prelim',
'inf.appeal_spawn', 'rev.appeal_spawn')
AND is_active = true
AND lifecycle_state = 'published';
IF v_new_rules <> 4 THEN
RAISE EXCEPTION
'mig 095: expected 4 new active+published rules, got %',
v_new_rules;
END IF;
-- 5.2 de_inf.erwidg is now court-set
SELECT count(*) INTO v_court_set
FROM paliad.deadline_rules
WHERE code = 'de_inf.erwidg'
AND lifecycle_state = 'published'
AND is_active = true
AND is_court_set = true;
IF v_court_set <> 1 THEN
RAISE EXCEPTION
'mig 095: expected de_inf.erwidg to be court-set after patch, got % matching rows',
v_court_set;
END IF;
-- 5.3 Both spawn rules reference an existing proceeding_type id=11
SELECT count(*) INTO v_appeal_ids
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.spawn_proceeding_type_id
WHERE dr.code IN ('inf.appeal_spawn', 'rev.appeal_spawn')
AND dr.lifecycle_state = 'published'
AND dr.is_active = true
AND pt.id = 11;
IF v_appeal_ids <> 2 THEN
RAISE EXCEPTION
'mig 095: expected both appeal_spawn rules to chain to proceeding_type id=11, got % matching rows',
v_appeal_ids;
END IF;
-- 5.4 de_inf.klage now has a legal_source (we just set it, or it was
-- already set — either way it must not be NULL after this mig)
SELECT legal_source INTO v_klage_src
FROM paliad.deadline_rules
WHERE code = 'de_inf.klage'
AND lifecycle_state = 'published'
AND is_active = true;
IF v_klage_src IS NULL THEN
RAISE EXCEPTION
'mig 095: de_inf.klage.legal_source is still NULL after patch';
END IF;
END $$;

View File

@@ -0,0 +1,99 @@
-- Reverses mig 096. Restores the original UPPER_SNAKE codes on
-- paliad.proceeding_types + paliad.event_category_concepts, drops the
-- new upc.ccr.cfi row, removes the shape CHECK, refreshes the
-- deadline_search materialized view, then drops the snapshot table.
--
-- audit_reason wrapper required by the mig 079 audit trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 096 (down): revert t-paliad-206 proceeding-code rename — restore UPPER_SNAKE codes from proceeding_types_pre_096, delete upc.ccr.cfi peer, drop shape CHECK',
true);
-- =============================================================================
-- 1. Drop the shape CHECK first so the UPPER_SNAKE restores don't trip it.
-- =============================================================================
ALTER TABLE paliad.proceeding_types
DROP CONSTRAINT IF EXISTS paliad_proceeding_code_shape;
-- =============================================================================
-- 2. Delete the upc.ccr.cfi peer. The down restores the pre-096 state, which
-- didn't have this row. If the row is already missing, the DELETE
-- matches zero — idempotent.
-- =============================================================================
DELETE FROM paliad.proceeding_types
WHERE code = 'upc.ccr.cfi';
-- =============================================================================
-- 3. Restore proceeding_types.code from the pre_096 snapshot. The snapshot
-- captured the rows at first up-migration run; if the table is missing
-- (down run before up), the restore is a no-op.
-- =============================================================================
DO $$
DECLARE
v_snap_exists boolean;
BEGIN
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'paliad'
AND table_name = 'proceeding_types_pre_096'
) INTO v_snap_exists;
IF NOT v_snap_exists THEN
RAISE NOTICE
'mig 096 (down): snapshot table paliad.proceeding_types_pre_096 missing — nothing to restore';
RETURN;
END IF;
UPDATE paliad.proceeding_types pt
SET code = snap.code
FROM paliad.proceeding_types_pre_096 snap
WHERE pt.id = snap.id
AND pt.code <> snap.code;
END $$;
-- =============================================================================
-- 4. Revert soft references on event_category_concepts.proceeding_type_code
-- by running the inverse mapping. Symmetric with §4 of the up migration.
-- =============================================================================
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_INF' WHERE proceeding_type_code = 'upc.inf.cfi';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_REV' WHERE proceeding_type_code = 'upc.rev.cfi';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_PI' WHERE proceeding_type_code = 'upc.pi.cfi';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_APP' WHERE proceeding_type_code = 'upc.apl.merits';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_DAMAGES' WHERE proceeding_type_code = 'upc.dmgs.cfi';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_DISCOVERY' WHERE proceeding_type_code = 'upc.disc.cfi';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_COST_APPEAL' WHERE proceeding_type_code = 'upc.apl.cost';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_APP_ORDERS' WHERE proceeding_type_code = 'upc.apl.order';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DE_INF' WHERE proceeding_type_code = 'de.inf.lg';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DE_INF_OLG' WHERE proceeding_type_code = 'de.inf.olg';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DE_INF_BGH' WHERE proceeding_type_code = 'de.inf.bgh';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DE_NULL' WHERE proceeding_type_code = 'de.null.bpatg';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DE_NULL_BGH' WHERE proceeding_type_code = 'de.null.bgh';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'EP_GRANT' WHERE proceeding_type_code = 'epa.grant.exa';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'EPA_OPP' WHERE proceeding_type_code = 'epa.opp.opd';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'EPA_APP' WHERE proceeding_type_code = 'epa.opp.boa';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DPMA_OPP' WHERE proceeding_type_code = 'dpma.opp.dpma';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DPMA_BPATG_BESCHWERDE' WHERE proceeding_type_code = 'dpma.appeal.bpatg';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DPMA_BGH_RB' WHERE proceeding_type_code = 'dpma.appeal.bgh';
-- =============================================================================
-- 5. Refresh deadline_search so the reverted proceeding_code strings
-- repopulate the materialized view.
-- =============================================================================
REFRESH MATERIALIZED VIEW paliad.deadline_search;
-- =============================================================================
-- 6. Drop the snapshot table so a re-applied up migration captures a
-- fresh snapshot of the current state.
-- =============================================================================
DROP TABLE IF EXISTS paliad.proceeding_types_pre_096;

View File

@@ -0,0 +1,226 @@
-- t-paliad-206 / proceeding-code rename — replace the historical
-- UPPER_SNAKE proceeding codes with the lowercase dot-separated
-- taxonomy ratified by m on 2026-05-18 (see
-- docs/design-proceeding-code-taxonomy-2026-05-18.md).
--
-- IDs are stable. Only the `code` STRING changes. FKs
-- (deadline_rules.proceeding_type_id, projects.proceeding_type_id,
-- deadline_rules.spawn_proceeding_type_id) reference IDs, so the
-- existing rule corpus and spawn wiring continue to work unchanged
-- (incl. mig 095's spawn_proceeding_type_id=11 which becomes
-- 'upc.apl.merits' after this migration).
--
-- Soft references on `code` (text column on event_category_concepts) are
-- updated row-for-row to keep the soft join through proceeding_types.code
-- resolving.
--
-- The materialized view paliad.deadline_search projects pt.code as
-- proceeding_code; mig 096 REFRESHes it at the bottom so the new codes
-- show up in search results immediately.
--
-- Idempotent:
-- * UPDATEs are guarded by `WHERE code = '<OLD>'`. Re-running after a
-- successful first apply is a no-op.
-- * INSERT of upc.ccr.cfi uses `WHERE NOT EXISTS` keyed on the new
-- code (bohr noted in t-paliad-205 that a UNIQUE constraint on the
-- code column is not present, hence WHERE NOT EXISTS rather than
-- ON CONFLICT).
-- * CHECK constraint is dropped-then-recreated under the same name
-- (paliad_proceeding_code_shape) so reapplication doesn't error.
-- * Snapshot table uses CREATE TABLE IF NOT EXISTS.
--
-- audit_reason wrapper required by the mig 079 audit trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 096: t-paliad-206 proceeding-code rename — lowercase dot-separated taxonomy + new upc.ccr.cfi illustrative peer; see docs/design-proceeding-code-taxonomy-2026-05-18.md',
true);
-- =============================================================================
-- 1. Backup snapshot of paliad.proceeding_types BEFORE the rename. The
-- rename is forward-only in code (the Go + frontend sweeps reference
-- the new strings) but the DB snapshot is the audit anchor and the
-- source for the down migration.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.proceeding_types_pre_096 AS
SELECT *, now() AS snapshotted_at
FROM paliad.proceeding_types;
COMMENT ON TABLE paliad.proceeding_types_pre_096 IS
'Snapshot of paliad.proceeding_types taken before mig 096 renamed '
'the `code` strings to the lowercase dot-separated taxonomy '
'(t-paliad-206, 2026-05-18). Source-of-truth for the down '
'migration; persists post-rename as the permanent audit record.';
-- =============================================================================
-- 2. Drop any prior shape CHECK so we can recreate it post-rename. The
-- constraint name is stable so reapplication idempotently drops it.
-- =============================================================================
ALTER TABLE paliad.proceeding_types
DROP CONSTRAINT IF EXISTS paliad_proceeding_code_shape;
-- =============================================================================
-- 3. The 19 renames. Order-independent — every UPDATE is guarded by
-- `WHERE code = '<OLD>'` so re-application is a no-op. id values in
-- the trailing comment for cross-reference with the design doc.
-- =============================================================================
-- UPC
UPDATE paliad.proceeding_types SET code = 'upc.inf.cfi' WHERE code = 'UPC_INF'; -- id=8
UPDATE paliad.proceeding_types SET code = 'upc.rev.cfi' WHERE code = 'UPC_REV'; -- id=9
UPDATE paliad.proceeding_types SET code = 'upc.pi.cfi' WHERE code = 'UPC_PI'; -- id=10
UPDATE paliad.proceeding_types SET code = 'upc.apl.merits' WHERE code = 'UPC_APP'; -- id=11
UPDATE paliad.proceeding_types SET code = 'upc.dmgs.cfi' WHERE code = 'UPC_DAMAGES'; -- id=17
UPDATE paliad.proceeding_types SET code = 'upc.disc.cfi' WHERE code = 'UPC_DISCOVERY'; -- id=18
UPDATE paliad.proceeding_types SET code = 'upc.apl.cost' WHERE code = 'UPC_COST_APPEAL';-- id=19
UPDATE paliad.proceeding_types SET code = 'upc.apl.order' WHERE code = 'UPC_APP_ORDERS'; -- id=20
-- DE
UPDATE paliad.proceeding_types SET code = 'de.inf.lg' WHERE code = 'DE_INF'; -- id=12
UPDATE paliad.proceeding_types SET code = 'de.inf.olg' WHERE code = 'DE_INF_OLG'; -- id=25
UPDATE paliad.proceeding_types SET code = 'de.inf.bgh' WHERE code = 'DE_INF_BGH'; -- id=26
UPDATE paliad.proceeding_types SET code = 'de.null.bpatg' WHERE code = 'DE_NULL'; -- id=13
UPDATE paliad.proceeding_types SET code = 'de.null.bgh' WHERE code = 'DE_NULL_BGH'; -- id=27
-- EPA
UPDATE paliad.proceeding_types SET code = 'epa.grant.exa' WHERE code = 'EP_GRANT'; -- id=16
UPDATE paliad.proceeding_types SET code = 'epa.opp.opd' WHERE code = 'EPA_OPP'; -- id=14
UPDATE paliad.proceeding_types SET code = 'epa.opp.boa' WHERE code = 'EPA_APP'; -- id=15
-- DPMA
UPDATE paliad.proceeding_types SET code = 'dpma.opp.dpma' WHERE code = 'DPMA_OPP'; -- id=28
UPDATE paliad.proceeding_types SET code = 'dpma.appeal.bpatg' WHERE code = 'DPMA_BPATG_BESCHWERDE';-- id=29
UPDATE paliad.proceeding_types SET code = 'dpma.appeal.bgh' WHERE code = 'DPMA_BGH_RB'; -- id=30
-- =============================================================================
-- 4. Update soft references on event_category_concepts.proceeding_type_code.
-- Same OLD→NEW table as above; the column has a UNIQUE NULLS NOT
-- DISTINCT constraint on (event_category_id, concept_id, proceeding_type_code)
-- but no row has the NEW string yet so the UPDATEs cannot collide.
-- =============================================================================
-- UPC
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.inf.cfi' WHERE proceeding_type_code = 'UPC_INF';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.rev.cfi' WHERE proceeding_type_code = 'UPC_REV';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.pi.cfi' WHERE proceeding_type_code = 'UPC_PI';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.apl.merits' WHERE proceeding_type_code = 'UPC_APP';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.dmgs.cfi' WHERE proceeding_type_code = 'UPC_DAMAGES';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.disc.cfi' WHERE proceeding_type_code = 'UPC_DISCOVERY';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.apl.cost' WHERE proceeding_type_code = 'UPC_COST_APPEAL';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.apl.order' WHERE proceeding_type_code = 'UPC_APP_ORDERS';
-- DE
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'de.inf.lg' WHERE proceeding_type_code = 'DE_INF';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'de.inf.olg' WHERE proceeding_type_code = 'DE_INF_OLG';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'de.inf.bgh' WHERE proceeding_type_code = 'DE_INF_BGH';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'de.null.bpatg' WHERE proceeding_type_code = 'DE_NULL';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'de.null.bgh' WHERE proceeding_type_code = 'DE_NULL_BGH';
-- EPA
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'epa.grant.exa' WHERE proceeding_type_code = 'EP_GRANT';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'epa.opp.opd' WHERE proceeding_type_code = 'EPA_OPP';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'epa.opp.boa' WHERE proceeding_type_code = 'EPA_APP';
-- DPMA
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'dpma.opp.dpma' WHERE proceeding_type_code = 'DPMA_OPP';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'dpma.appeal.bpatg' WHERE proceeding_type_code = 'DPMA_BPATG_BESCHWERDE';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'dpma.appeal.bgh' WHERE proceeding_type_code = 'DPMA_BGH_RB';
-- =============================================================================
-- 5. Insert the new illustrative peer `upc.ccr.cfi`. is_active=true so it
-- surfaces in the determinator + dropdowns; no rules attached.
-- proceeding_mapping.go routes cascade hits on this code back to
-- upc.inf.cfi (id=8) with the with_ccr default flag — see design doc S1.
--
-- WHERE NOT EXISTS gates the insert on the new code so re-application
-- is a no-op even though there's no UNIQUE constraint on (code).
-- =============================================================================
INSERT INTO paliad.proceeding_types
(code, category, jurisdiction, is_active, name, name_en, description)
SELECT
'upc.ccr.cfi',
'fristenrechner',
'UPC',
true,
'Widerklage auf Nichtigkeit',
'Counterclaim for Revocation',
'Illustrativer Peer von upc.inf.cfi für Widerklagen auf Nichtigkeit. Regeln liegen auf upc.inf.cfi (with_ccr=true); der Fristenrechner leitet bei Auswahl dorthin weiter. Keine eigenen Fristregeln.'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.proceeding_types
WHERE code = 'upc.ccr.cfi');
-- =============================================================================
-- 6. CHECK constraint on the code shape. Active rows must conform to the
-- new lowercase dot-separated form; the carve-out for
-- `_archived_litigation` keeps the Pipeline-A bucket addressable.
-- =============================================================================
ALTER TABLE paliad.proceeding_types
ADD CONSTRAINT paliad_proceeding_code_shape
CHECK (
code ~ '^[a-z]+\.[a-z]+\.[a-z]+$'
OR code ~ '^_archived_'
);
-- =============================================================================
-- 7. Refresh the deadline_search materialized view so search hits return
-- the new proceeding_code strings immediately.
-- =============================================================================
REFRESH MATERIALIZED VIEW paliad.deadline_search;
-- =============================================================================
-- 8. Hard assertions. Half-applied migrations would leave the rule corpus
-- inconsistent with the new shape; assert every active fristenrechner
-- code conforms and that no old codes leak.
-- =============================================================================
DO $$
DECLARE
v_new_shape integer;
v_old_codes integer;
v_ccr_row integer;
BEGIN
-- 8.1 Every active fristenrechner row matches the new shape regex.
-- 20 = 19 renamed rows + 1 newly inserted upc.ccr.cfi. The check
-- uses >= so an additional row added in a follow-up migration
-- doesn't trip the assertion.
SELECT count(*) INTO v_new_shape
FROM paliad.proceeding_types
WHERE category = 'fristenrechner'
AND is_active = true
AND code ~ '^[a-z]+\.[a-z]+\.[a-z]+$';
IF v_new_shape < 20 THEN
RAISE EXCEPTION
'mig 096: expected >= 20 active fristenrechner rows on the new shape, got %',
v_new_shape;
END IF;
-- 8.2 No old UPPER_SNAKE codes remain on any row.
SELECT count(*) INTO v_old_codes
FROM paliad.proceeding_types
WHERE code LIKE 'UPC\_%' ESCAPE '\'
OR code LIKE 'DE\_%' ESCAPE '\'
OR code LIKE 'EPA\_%' ESCAPE '\'
OR code LIKE 'EP\_%' ESCAPE '\'
OR code LIKE 'DPMA\_%' ESCAPE '\';
IF v_old_codes <> 0 THEN
RAISE EXCEPTION
'mig 096: expected 0 old UPPER_SNAKE codes after rename, got %',
v_old_codes;
END IF;
-- 8.3 The new ccr peer exists and is active.
SELECT count(*) INTO v_ccr_row
FROM paliad.proceeding_types
WHERE code = 'upc.ccr.cfi'
AND is_active = true;
IF v_ccr_row <> 1 THEN
RAISE EXCEPTION
'mig 096: expected 1 active upc.ccr.cfi row, got %',
v_ccr_row;
END IF;
END $$;

View File

@@ -0,0 +1,59 @@
-- Reverses mig 097. Restores rule_code + legal_source on every row
-- touched by the backfill (and the rev.defence normalization) from the
-- paliad.deadline_rules_pre_097 snapshot, refreshes the deadline_search
-- materialized view, then drops the snapshot.
--
-- audit_reason wrapper required by the mig 079 audit trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 097 (down): revert t-paliad-210 legal-citation backfill — restore rule_code/legal_source from deadline_rules_pre_097 snapshot',
true);
-- =============================================================================
-- 1. Restore rule_code + legal_source from the pre_097 snapshot for every
-- row whose current values diverge from the snapshot. Symmetric across
-- the § 1 / § 2 / § 3 backfills and the § 5 rev.defence normalization
-- in one pass. If the snapshot table is missing (down run before up),
-- the restore is a no-op.
-- =============================================================================
DO $$
DECLARE
v_snap_exists boolean;
BEGIN
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules_pre_097'
) INTO v_snap_exists;
IF NOT v_snap_exists THEN
RAISE NOTICE
'mig 097 (down): snapshot table paliad.deadline_rules_pre_097 missing — nothing to restore';
RETURN;
END IF;
UPDATE paliad.deadline_rules dr
SET rule_code = snap.rule_code,
legal_source = snap.legal_source
FROM paliad.deadline_rules_pre_097 snap
WHERE dr.id = snap.id
AND (dr.rule_code IS DISTINCT FROM snap.rule_code
OR dr.legal_source IS DISTINCT FROM snap.legal_source);
END $$;
-- =============================================================================
-- 2. Refresh deadline_search so the reverted rule_code / legal_source
-- values repopulate the materialized view.
-- =============================================================================
REFRESH MATERIALIZED VIEW paliad.deadline_search;
-- =============================================================================
-- 3. Drop the snapshot so a re-applied up migration captures a fresh
-- snapshot of the current state.
-- =============================================================================
DROP TABLE IF EXISTS paliad.deadline_rules_pre_097;

View File

@@ -0,0 +1,684 @@
-- t-paliad-210 / legal-citation backfill — apply huygens's HIGH/MED
-- proposals from docs/proposals/legal-citation-backfill-2026-05-18.md
-- (commit 391be09) PLUS m's 2026-05-18 FLAG walk-through (paliadin/head
-- instruction-msg 2002). Scope grew from the original brief: m approved
-- filling almost every category, with only 3 FLAG-J rows left NULL.
--
-- Touches (in 8 buckets, ~135 rows):
--
-- § 1 Easy wins — 6 rows. rule_code only. The 2
-- § 123 PatG twins (Wiedereinsetzung)
-- move into the FLAG-A dedup bucket
-- below; not filled here.
--
-- § 2 HIGH/MED proceeding-typed — 15 rows. rule_code + legal_source.
--
-- § 3 HIGH/MED orphans — 47 rows. rule_code + legal_source.
-- For UPC rows also rule_codes[]
-- normalized to ARRAY[rule_code].
-- Excludes 3 archive-dest dup rows
-- that are filled via the canonical
-- in § 4 instead (5c0508f4 /
-- 791fd0f7 / d886f46f).
--
-- § 4 FLAG-A dedup (clean only) — 3 canonical fills + 3 archive
-- flips. Only sets where the
-- duplicate rows share an existing
-- rule_codes[] value (or both are
-- NULL) are deduped:
-- * 2× "Wiedereinsetzungsantrag
-- § 123 PatG" — canonical
-- b588fa64 (lowest UUID),
-- archive c24d494c.
-- * 2× "Berufungsschrift R.220.1
-- (a)/(b)" — canonical 1dfba5b1
-- (filled in § 3.3), archive
-- 5c0508f4.
-- * 2× "Berufungsbegründung R.220.1
-- (a)/(b)" — canonical 573df3d1
-- (filled in § 3.3), archive
-- 791fd0f7.
--
-- DEFERRED (paliadin/head msg 2006,
-- pending m's call): 6× "Mängel-
-- beseitigung / Zahlung" and 2×
-- "Beginn des Hauptsacheverfahrens".
-- Each row in those sets carries a
-- DIFFERENT existing rule_codes[]
-- value (Mängelbeseitigung: RoP.207
-- .6.a, RoP.253.2, RoP.016.3.a,
-- RoP.027.2, RoP.089.2, RoP.229.2;
-- Beginn-Hauptsache: RoP.198 vs
-- RoP.213). These may be distinct
-- procedural-context rules masquer-
-- ading as duplicates; m owns the
-- collapse-or-preserve decision.
-- Mig 097 leaves all 8 rows
-- untouched (rule_code stays NULL,
-- rule_codes[] stays as-is, neither
-- archived nor filled).
--
-- § 5 FLAG-B court-scheduled — 26 rows. Per m: "try to find the
-- rules — they often exist." Cites
-- the framing norm authorising the
-- court to schedule the event (RoP.111
-- for UPC oral hearings, RoP.118 for
-- UPC decisions, § 285 ZPO / § 300
-- ZPO for DE Verhandlung / Urteil,
-- § 47 / 78 / 79 / 107 PatG for
-- DPMA/BPatG/BGH variants, etc.).
--
-- § 6 FLAG-C/D rubber-stamp — 5 rows. rev.reply/rev.rejoin/
-- app.response use canonical RoP.5x
-- regardless of duration-vs-norm
-- mismatch (m: "just go ahead").
-- de_inf.replik/de_inf.duplik cite
-- § 273 ZPO (court-set framing).
--
-- § 7 FLAG-E service triggers — 6 rows (DE/EPA). Service-trigger
-- citations on Zustellung events.
-- UPC initial-submission rows carry
-- the RoP.271.b 10-day deferral as a
-- secondary cite in rule_codes[]
-- (handled in § 9 below).
--
-- § 8 FLAG-F combined-pleading — 5 rows. Use rule_codes[] multi-cite
-- array (column already exists from
-- mig 095). Primary cite in
-- rule_code, full set in rule_codes[].
--
-- § 9 FLAG-G/H/I + RoP.271.b — 13 rows. G: 2 Patentänderung
-- orphans split by INF/REV context.
-- H: 8 sub-paragraph spot-checks
-- applied as-is per the doc. I: 3
-- negative-declaration rows cite
-- RoP.069 by analogy.
-- Plus: 5 UPC initial-submission rows
-- append RoP.271.b to rule_codes[]
-- as the 10-day service deferral.
-- m flagged this distinct from the
-- primary substantive cite.
--
-- § 10 R.19 label rename — 2 rows max. inf.prelim / rev.prelim:
-- set name to "Einspruch (R. 19 VerfO)"
-- / "Einspruch (R. 19 i.V.m. R. 46
-- VerfO)" + rule_code 'RoP.019.1'.
-- Originally drafted in fermi's
-- t-paliad-207 session; m applied the
-- rename live on prod and asked us to
-- consolidate the mig here per Path-A.
-- Guard `name LIKE 'Vorab-Einrede%'`
-- makes this a defensive no-op on the
-- prod DB (fermi already wrote there)
-- but applies cleanly on any future
-- deploy that hasn't seen the live
-- write.
--
-- § 11 Side-fix RoP.49.1 → .049.1 — 1 row. rev.defence carries an
-- un-padded rule_code; all other UPC
-- RoP rules under 100 use 3-digit
-- padding. legal_source stays
-- 'UPC.RoP.49.1' (structured locator
-- never pads).
--
-- FLAG-J kept NULL (3 rows: d124c95b — Aufhebung Entscheidung des
-- Amtes, 002c2ba7 — Folgemaßnahmen Validitätsentscheidung, 902cc5d5 —
-- Klärung Übersetzungsfragen). m will pick them up later via
-- /admin/rules. Existing rule_codes[] on these is left untouched.
--
-- Idempotent:
-- * Backfill UPDATEs guarded on `rule_code IS NULL` (the de-novo fill
-- bucket) — re-running is a no-op.
-- * Archive UPDATEs guarded on `is_active = true AND lifecycle_state
-- = 'published'` — re-running is a no-op.
-- * Normalization UPDATE guarded on `rule_code = 'RoP.49.1'` — no-op
-- after first apply.
-- * Prelim rename UPDATEs guarded on `name LIKE 'Vorab-Einrede%'` —
-- no-op after first apply or on prod (fermi already wrote).
-- * Snapshot table uses CREATE TABLE IF NOT EXISTS.
-- * Materialized-view refresh is safe to repeat.
--
-- audit_reason is set at the top via set_config(..., true) so the
-- mig-079 audit trigger on paliad.deadline_rules accepts the UPDATEs.
SELECT set_config(
'paliad.audit_reason',
'mig 097: t-paliad-210 legal-citation backfill — m''s FLAG walk-through 2026-05-18 (paliadin/head msg 2002). HIGH/MED proposals from docs/proposals/legal-citation-backfill-2026-05-18.md (commit 391be09) plus FLAG-A dedup + FLAG-B court-scheduled cites + FLAG-F rule_codes[] multi-cite + RoP.271.b on UPC initial submissions + RoP.49.1 padding normalization + R.19 prelim rename (fermi/t-paliad-207 consolidated)',
true);
-- =============================================================================
-- 0. Backup snapshot of paliad.deadline_rules BEFORE the backfill. Full
-- table snapshot for the complete pre-097 baseline. Matches the
-- mig 096 pattern (proceeding_types_pre_096).
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_097 AS
SELECT *, now() AS snapshotted_at
FROM paliad.deadline_rules;
COMMENT ON TABLE paliad.deadline_rules_pre_097 IS
'Snapshot of paliad.deadline_rules taken before mig 097 backfilled '
'rule_code + legal_source + rule_codes[] across huygens''s HIGH/MED '
'proposals (t-paliad-208) and m''s expanded FLAG walk-through '
'(2026-05-18). Source-of-truth for the down migration; persists '
'post-backfill as the permanent audit anchor — also retains the '
'pre-dedup per-row rule_codes[] for the Mängelbeseitigung × 6 + '
'Beginn-Hauptsache × 2 sets in case m later wants to recover the '
'procedural-context citations.';
-- =============================================================================
-- 1. § 1 Easy wins (6 rows). legal_source already populated; only
-- rule_code missing. The 2 § 123 PatG Wiedereinsetzung twins
-- (c24d494c…, b588fa64…) are handled in § 4 below as part of the
-- FLAG-A dedup.
-- =============================================================================
UPDATE paliad.deadline_rules SET rule_code = '§ 253 ZPO'
WHERE id = '1f532c82-9e6d-4f48-bd16-fa2fc71d5880' AND rule_code IS NULL; -- de_inf.klage / Klageerhebung
UPDATE paliad.deadline_rules SET rule_code = '§ 339 ZPO'
WHERE id = '20254f4e-d213-4cf6-8f5f-1d9d36eeb6ac' AND rule_code IS NULL; -- Einspruch gegen Versäumnisurteil
UPDATE paliad.deadline_rules SET rule_code = '§ 296a ZPO'
WHERE id = '3c36f149-3a81-456e-aac1-d4d18bfcb16b' AND rule_code IS NULL; -- Schriftsatznachreichung
UPDATE paliad.deadline_rules SET rule_code = 'R. 135 EPÜ'
WHERE id = 'f1099cf6-4c87-430e-b1c5-488bd44cb143' AND rule_code IS NULL; -- Weiterbehandlungsantrag (Art. 121 EPÜ)
UPDATE paliad.deadline_rules SET rule_code = '§ 234 ZPO'
WHERE id = 'd40d9be7-e1b6-451c-bee2-6eaee2307ec5' AND rule_code IS NULL; -- Wiedereinsetzungsantrag (§ 233 ZPO)
UPDATE paliad.deadline_rules SET rule_code = 'R. 136 EPÜ'
WHERE id = '23c6f445-4ed2-4ade-8ea0-c4ab6b364bb6' AND rule_code IS NULL; -- Wiedereinsetzungsantrag (Art. 122 EPÜ)
-- =============================================================================
-- 2. § 2 Proceeding-typed HIGH/MED (15 rows). rule_code + legal_source.
-- Note: rule_codes[] is set in § 9 for the 5 UPC initial-submission
-- rows (inf.soc / rev.app / pi.app / damages.app / disc.app) to
-- include the RoP.271.b secondary cite. For DE/EPA rows here,
-- rule_codes[] is left untouched (currently NULL and not used for
-- DE/EPA citations in this corpus).
-- =============================================================================
UPDATE paliad.deadline_rules SET rule_code = 'RoP.013.1', legal_source = 'UPC.RoP.13.1'
WHERE id = '42be6c9b-8e84-4804-962f-94c3315aca1b' AND rule_code IS NULL; -- upc.inf.cfi / inf.soc
UPDATE paliad.deadline_rules SET rule_code = 'RoP.042', legal_source = 'UPC.RoP.42'
WHERE id = '995c108e-e73a-4f9c-b79f-47abe7c94108' AND rule_code IS NULL; -- upc.rev.cfi / rev.app
UPDATE paliad.deadline_rules SET rule_code = 'RoP.206', legal_source = 'UPC.RoP.206'
WHERE id = 'ed0194b7-74ab-4402-8971-7211f6036ff9' AND rule_code IS NULL; -- upc.pi.cfi / pi.app
UPDATE paliad.deadline_rules SET rule_code = 'RoP.243', legal_source = 'UPC.RoP.243', rule_codes = ARRAY['RoP.243']::text[]
WHERE id = '85f92b72-c654-4429-8e91-03402f9438c6' AND rule_code IS NULL; -- upc.apl.merits / app.oral
UPDATE paliad.deadline_rules SET rule_code = 'RoP.131', legal_source = 'UPC.RoP.131'
WHERE id = '3e1719e8-f6f6-4260-8f02-754bd214937f' AND rule_code IS NULL; -- upc.dmgs.cfi / damages.app
UPDATE paliad.deadline_rules SET rule_code = 'RoP.141', legal_source = 'UPC.RoP.141'
WHERE id = 'eb1fa1d1-b345-42ba-ab14-79f5284166b0' AND rule_code IS NULL; -- upc.disc.cfi / disc.app
UPDATE paliad.deadline_rules SET rule_code = '§ 81 PatG', legal_source = 'DE.PatG.81.1'
WHERE id = 'ba33e704-18f6-4486-8107-abdb1e9cbfad' AND rule_code IS NULL; -- de.null.bpatg / de_null.klage
UPDATE paliad.deadline_rules SET rule_code = '§ 58 PatG', legal_source = 'DE.PatG.58.1'
WHERE id = '972f8fe4-8f4c-4497-9736-d60399ae5989' AND rule_code IS NULL; -- dpma.opp.dpma / dpma_opp.publish
UPDATE paliad.deadline_rules SET rule_code = 'Art. 75 EPÜ', legal_source = 'EU.EPÜ.75'
WHERE id = 'a1766364-1478-4b13-ae02-0a94367c585e' AND rule_code IS NULL; -- epa.grant.exa / ep_grant.filing
UPDATE paliad.deadline_rules SET rule_code = 'Art. 92 EPÜ', legal_source = 'EU.EPÜ.92'
WHERE id = '63069ae5-e380-4db5-b020-d1856f31300c' AND rule_code IS NULL; -- epa.grant.exa / ep_grant.search
UPDATE paliad.deadline_rules SET rule_code = 'Art. 97 EPÜ', legal_source = 'EU.EPÜ.97.1'
WHERE id = '86b3a295-d76b-4566-955d-55f7a394524e' AND rule_code IS NULL; -- epa.grant.exa / ep_grant.grant
UPDATE paliad.deadline_rules SET rule_code = 'Art. 97 EPÜ', legal_source = 'EU.EPÜ.97.3'
WHERE id = '520dd205-7b4a-45f4-b87f-e2be5d1e183e' AND rule_code IS NULL; -- epa.opp.opd / epa_opp.grant
UPDATE paliad.deadline_rules SET rule_code = 'Art. 101 EPÜ', legal_source = 'EU.EPÜ.101'
WHERE id = '8961a54b-2645-4af4-b0f5-114128150839' AND rule_code IS NULL; -- epa.opp.opd / epa_opp.entsch
UPDATE paliad.deadline_rules SET rule_code = 'Art. 116 EPÜ', legal_source = 'EU.EPÜ.116'
WHERE id = '926f333d-55d2-4a12-890e-0508a4ea1bd4' AND rule_code IS NULL; -- epa.opp.boa / epa_app.oral
UPDATE paliad.deadline_rules SET rule_code = 'Art. 111 EPÜ', legal_source = 'EU.EPÜ.111'
WHERE id = 'd0949eaf-da69-4972-90c2-7e6c1bebcd79' AND rule_code IS NULL; -- epa.opp.boa / epa_app.entsch2
-- =============================================================================
-- 3. § 3 Orphan HIGH/MED (47 rows). rule_code + legal_source. For UPC
-- rows also normalize rule_codes[] to ARRAY[rule_code] so the
-- structured tooling field matches the display field. The orphan
-- archive destinations (5c0508f4 / 791fd0f7 / d886f46f) are NOT
-- filled here — they're flipped to archived in § 4.
-- =============================================================================
-- § 3.1 main-pleadings track (10 rows)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.023', legal_source = 'UPC.RoP.23.1', rule_codes = ARRAY['RoP.023']::text[]
WHERE id = 'e34097d6-670d-447a-bdfe-b42df20ba459' AND rule_code IS NULL; -- Klageerwiderung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.025.1', legal_source = 'UPC.RoP.25.1', rule_codes = ARRAY['RoP.025.1']::text[]
WHERE id = '7d8a4804-0ebc-42c4-8552-624350cd81f3' AND rule_code IS NULL; -- Nichtigkeitswiderklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.049.2.b', legal_source = 'UPC.RoP.49.2.b', rule_codes = ARRAY['RoP.049.2.b']::text[]
WHERE id = 'c7523e6b-579d-4d80-afb3-e1cf11238d40' AND rule_code IS NULL; -- Verletzungswiderklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.019.1', legal_source = 'UPC.RoP.19.1', rule_codes = ARRAY['RoP.019.1']::text[]
WHERE id = 'c57f62f8-bb52-4232-be85-9125fa93f58c' AND rule_code IS NULL; -- Vorgängige Einrede
UPDATE paliad.deadline_rules SET rule_code = 'RoP.029.b', legal_source = 'UPC.RoP.29.b', rule_codes = ARRAY['RoP.029.b']::text[]
WHERE id = '84b390e0-1ca4-461a-942c-4ad94c643750' AND rule_code IS NULL; -- Replik auf Klageerwiderung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.029.c', legal_source = 'UPC.RoP.29.c', rule_codes = ARRAY['RoP.029.c']::text[]
WHERE id = '176cc1ca-2b25-49ee-9c3e-8afed1673b7d' AND rule_code IS NULL; -- Duplik Replik Klageerwiderung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.049.1', legal_source = 'UPC.RoP.49.1', rule_codes = ARRAY['RoP.049.1']::text[]
WHERE id = 'a32dcec1-6aaa-4a3c-936c-9a761d9362f0' AND rule_code IS NULL; -- Erwiderung auf Nichtigkeitsklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.052', legal_source = 'UPC.RoP.52', rule_codes = ARRAY['RoP.052']::text[]
WHERE id = '1b5c6dee-0032-4be8-864c-f2ab945aacc5' AND rule_code IS NULL; -- Duplik Replik Erwiderung Nichtigkeitsklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.056.1', legal_source = 'UPC.RoP.56.1', rule_codes = ARRAY['RoP.056.1']::text[]
WHERE id = 'bea86f9b-37d5-4f6e-b6bd-f0c01f053b66' AND rule_code IS NULL; -- Erwiderung auf Verletzungswiderklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.056.3', legal_source = 'UPC.RoP.56.3', rule_codes = ARRAY['RoP.056.3']::text[]
WHERE id = '4834c957-2518-40e9-ad62-447f3f220d33' AND rule_code IS NULL; -- Replik Erwiderung Verletzungswiderklage
-- § 3.2 Patentänderungs-Track (1 row; FLAG-G twin rows are handled in § 9)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.032.1', legal_source = 'UPC.RoP.32.1', rule_codes = ARRAY['RoP.032.1']::text[]
WHERE id = '7e65a434-f5c6-4391-a65c-d02de735f551' AND rule_code IS NULL; -- Erwiderung auf Patentänderungsantrag
UPDATE paliad.deadline_rules SET rule_code = 'RoP.032.3', legal_source = 'UPC.RoP.32.3', rule_codes = ARRAY['RoP.032.3']::text[]
WHERE id = 'dfd52792-840f-42c4-8b71-0f77d07cbb53' AND rule_code IS NULL; -- Replik Erwiderung Patentänderung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.032.3', legal_source = 'UPC.RoP.32.3', rule_codes = ARRAY['RoP.032.3']::text[]
WHERE id = '8cdf54eb-5189-47fd-a390-6a0ee98e5243' AND rule_code IS NULL; -- Duplik Replik Erwiderung Patentänderung
-- § 3.3 appeal track (8 fills; 2 archive-destinations handled in § 4)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.224.1.a', legal_source = 'UPC.RoP.224.1.a', rule_codes = ARRAY['RoP.224.1.a']::text[]
WHERE id = '1dfba5b1-4ed1-40c1-9cf6-4ed8ff7a0818' AND rule_code IS NULL; -- Berufungsschrift canonical
UPDATE paliad.deadline_rules SET rule_code = 'RoP.224.1.b', legal_source = 'UPC.RoP.224.1.b', rule_codes = ARRAY['RoP.224.1.b']::text[]
WHERE id = 'd560b3b6-9437-4b22-b62c-957d4a37d21a' AND rule_code IS NULL; -- Berufungsschrift Orders
UPDATE paliad.deadline_rules SET rule_code = 'RoP.225.1', legal_source = 'UPC.RoP.225.1', rule_codes = ARRAY['RoP.225.1']::text[]
WHERE id = '573df3d1-8ea2-4a6e-b0d4-fc3cd10506da' AND rule_code IS NULL; -- Berufungsbegründung canonical
UPDATE paliad.deadline_rules SET rule_code = 'RoP.224.1.b', legal_source = 'UPC.RoP.224.1.b', rule_codes = ARRAY['RoP.224.1.b']::text[]
WHERE id = '91e367dd-ffe6-4012-ac6a-b61c32e2b3b7' AND rule_code IS NULL; -- Berufung (Anordnungen & mit Zulassung)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.221.1', legal_source = 'UPC.RoP.221.1', rule_codes = ARRAY['RoP.221.1']::text[]
WHERE id = 'ccb916df-4ee3-4dde-bcb0-6a5b557c0cba' AND rule_code IS NULL; -- Berufungszulassung Kosten
UPDATE paliad.deadline_rules SET rule_code = 'RoP.220.3', legal_source = 'UPC.RoP.220.3', rule_codes = ARRAY['RoP.220.3']::text[]
WHERE id = '342e749d-c2bc-4148-974b-ac0331b76229' AND rule_code IS NULL; -- Ermessensüberprüfung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.235.1', legal_source = 'UPC.RoP.235.1', rule_codes = ARRAY['RoP.235.1']::text[]
WHERE id = '10374392-b8db-4738-8a61-f8ce0fabcc3e' AND rule_code IS NULL; -- Berufungserwiderung (224.2(a))
UPDATE paliad.deadline_rules SET rule_code = 'RoP.237.1', legal_source = 'UPC.RoP.237.1', rule_codes = ARRAY['RoP.237.1']::text[]
WHERE id = '6e39b653-1328-40e1-95f1-071fdf46eed6' AND rule_code IS NULL; -- Anschlussberufung (224.2(a))
UPDATE paliad.deadline_rules SET rule_code = 'RoP.238.1', legal_source = 'UPC.RoP.238.1', rule_codes = ARRAY['RoP.238.1']::text[]
WHERE id = '6b989e85-e739-4e3b-bfd1-52b0e0c35f61' AND rule_code IS NULL; -- Erwiderung Anschlussberufung (224.2(a))
UPDATE paliad.deadline_rules SET rule_code = 'RoP.238.2', legal_source = 'UPC.RoP.238.2', rule_codes = ARRAY['RoP.238.2']::text[]
WHERE id = 'e78f4652-acf9-4ecd-ac48-888ce475173f' AND rule_code IS NULL; -- Erwiderung Anschlussberufung (224.2(b))
-- § 3.4 Schadensbemessung / Rechnungslegung (7 rows)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.137.2', legal_source = 'UPC.RoP.137.2', rule_codes = ARRAY['RoP.137.2']::text[]
WHERE id = 'd414f603-14c1-49f2-91be-e305eba696e3' AND rule_code IS NULL; -- Erwiderung Schadensbemessung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.139', legal_source = 'UPC.RoP.139', rule_codes = ARRAY['RoP.139']::text[]
WHERE id = '9f39e263-e9ec-4805-a82e-c7551a22c78d' AND rule_code IS NULL; -- Replik Schadensbemessung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.139', legal_source = 'UPC.RoP.139', rule_codes = ARRAY['RoP.139']::text[]
WHERE id = '067ffdf0-180b-488f-a369-249f6bcb9faa' AND rule_code IS NULL; -- Duplik Schadensbemessung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.142.2', legal_source = 'UPC.RoP.142.2', rule_codes = ARRAY['RoP.142.2']::text[]
WHERE id = '429b8ec0-227a-4945-8b20-6ad79330a490' AND rule_code IS NULL; -- Erwiderung Rechnungslegung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.142.3', legal_source = 'UPC.RoP.142.3', rule_codes = ARRAY['RoP.142.3']::text[]
WHERE id = '8d36fc76-61b9-4e99-b113-eed4c9c4b2c7' AND rule_code IS NULL; -- Replik Rechnungslegung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.142.3', legal_source = 'UPC.RoP.142.3', rule_codes = ARRAY['RoP.142.3']::text[]
WHERE id = 'ed82fec9-2346-494f-a0ff-f41e64c26942' AND rule_code IS NULL; -- Duplik Rechnungslegung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.151', legal_source = 'UPC.RoP.151', rule_codes = ARRAY['RoP.151']::text[]
WHERE id = 'eed69e8b-0dc8-4d97-83f0-5694d539b46a' AND rule_code IS NULL; -- Kostenentscheidung
-- § 3.5 provisional / PI (2 rows; canonical ba335c99 + the d886f46f archive handled in § 4)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.197.3', legal_source = 'UPC.RoP.197.3', rule_codes = ARRAY['RoP.197.3']::text[]
WHERE id = '1f1f72ef-5a67-4d6a-9a80-82e53375177a' AND rule_code IS NULL; -- Beweissicherungsanordnung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.207.9', legal_source = 'UPC.RoP.207.9', rule_codes = ARRAY['RoP.207.9']::text[]
WHERE id = '3e2f5697-3012-4bae-bd4d-44998dd3b75b' AND rule_code IS NULL; -- Schutzschrift
-- § 3.7 formalities / Registry (4 fills; 5 Mängelbeseitigung dups + FLAG-J 2 rows handled separately)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.016.5', legal_source = 'UPC.RoP.16.5', rule_codes = ARRAY['RoP.016.5']::text[]
WHERE id = '3bc40027-9ebf-4f3d-880d-bf9de6da3ec0' AND rule_code IS NULL; -- Mängelbeseitigung / Stellungnahme
UPDATE paliad.deadline_rules SET rule_code = 'RoP.262.2', legal_source = 'UPC.RoP.262.2', rule_codes = ARRAY['RoP.262.2']::text[]
WHERE id = '69e356b7-79b3-42d7-972b-44d4e35ebdbc' AND rule_code IS NULL; -- Vertraulichkeit
UPDATE paliad.deadline_rules SET rule_code = 'RoP.353', legal_source = 'UPC.RoP.353', rule_codes = ARRAY['RoP.353']::text[]
WHERE id = '57e6eeca-8695-4af3-96cc-16ebd8bc3f2c' AND rule_code IS NULL; -- Berichtigung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.333.1', legal_source = 'UPC.RoP.333.1', rule_codes = ARRAY['RoP.333.1']::text[]
WHERE id = '8ec233b9-3bc4-4015-a158-86af233e52b3' AND rule_code IS NULL; -- Verfahrensleitende Anordnung
-- § 3.8 translation / interpretation (1 row; FLAG-H/J handled in § 9 / left NULL)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.109.1', legal_source = 'UPC.RoP.109.1', rule_codes = ARRAY['RoP.109.1']::text[]
WHERE id = 'bb7bafcb-9d91-4bf7-ae2c-6634652d9906' AND rule_code IS NULL; -- Simultanübersetzung
-- § 3.9 review / rehearing (2 rows)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.247.2', legal_source = 'UPC.RoP.247.2', rule_codes = ARRAY['RoP.247.2']::text[]
WHERE id = '372e86e3-c8ff-4cb5-9389-66acdbc96e57' AND rule_code IS NULL; -- Wiederaufnahme (schwerwiegend)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.247.2', legal_source = 'UPC.RoP.247.2', rule_codes = ARRAY['RoP.247.2']::text[]
WHERE id = '58de9573-07db-4d8d-9b00-8fab0d71d88c' AND rule_code IS NULL; -- Wiederaufnahme (Straftat)
-- =============================================================================
-- 4. § 4 FLAG-A dedup (clean only). 1 canonical fill (the other 2
-- canonicals are filled in § 3.3) + 3 archive flips. Canonical
-- selection per m's spec: lowest UUID. None of the archive
-- candidates have FK references in mgmt.deadline_rules / paliad.
-- appointments / paliad.deadlines / paliad.deadline_rules (parent_id
-- or draft_of) — verified pre-mig. Archive over DELETE per m
-- (audit trail).
--
-- Mängelbeseitigung 6× and Beginn-Hauptsache 2× are intentionally
-- NOT deduped in this mig — see header for the deferred-decision
-- rationale. Their rows stay active+published+rule_code IS NULL
-- until m's call lands.
-- =============================================================================
-- Canonical fill for the § 123 PatG twin (legal_source already
-- DE.PatG.123.2). The other 2 canonicals (Berufungsschrift 1dfba5b1
-- and Berufungsbegründung 573df3d1) are filled in § 3.3 above.
UPDATE paliad.deadline_rules SET rule_code = '§ 123 PatG'
WHERE id = 'b588fa64-a727-4cfb-a45d-69a835a3b05a' AND rule_code IS NULL;
-- Archive flips (3 rows: the non-canonical sides of the 3 clean dedup
-- sets). After this each set has exactly 1 active+published row.
UPDATE paliad.deadline_rules
SET is_active = false, lifecycle_state = 'archived'
WHERE id IN (
'c24d494c-0da1-4f01-aa74-0f37f99fe1ae', -- Wiedereinsetzung § 123 PatG dup
'5c0508f4-020a-4ef5-bcc7-1ee85eafe0b3', -- Berufungsschrift dup
'791fd0f7-a448-4711-b1aa-63e6df1e7c57' -- Berufungsbegründung dup
)
AND is_active = true
AND lifecycle_state = 'published';
-- =============================================================================
-- 5. § 5 FLAG-B court-scheduled events (26 rows). Cite the framing norm
-- that authorises the court to schedule the event. UPC RoP.111 /
-- RoP.118 / RoP.101 / RoP.209 / RoP.211 / RoP.350 / RoP.220.1.c /
-- RoP.157. DE § 285 ZPO / § 300 ZPO / § 89 PatG / § 84 PatG / § 113
-- PatG / § 119 PatG. DPMA § 47 / 78 / 79 / 107 PatG.
-- =============================================================================
-- UPC court-scheduled events
UPDATE paliad.deadline_rules SET rule_code = 'RoP.118', legal_source = 'UPC.RoP.118', rule_codes = ARRAY['RoP.118']::text[]
WHERE id = '60d71f1e-a0e8-42cd-85e9-89f3c808868f' AND rule_code IS NULL; -- inf.decision
UPDATE paliad.deadline_rules SET rule_code = 'RoP.101', legal_source = 'UPC.RoP.101', rule_codes = ARRAY['RoP.101']::text[]
WHERE id = '7b118633-92b2-4c91-8512-6cb929288f10' AND rule_code IS NULL; -- inf.interim
UPDATE paliad.deadline_rules SET rule_code = 'RoP.111', legal_source = 'UPC.RoP.111', rule_codes = ARRAY['RoP.111']::text[]
WHERE id = 'd4c01a6f-d147-4505-bf1c-9aaf88b15287' AND rule_code IS NULL; -- inf.oral
UPDATE paliad.deadline_rules SET rule_code = 'RoP.118', legal_source = 'UPC.RoP.118', rule_codes = ARRAY['RoP.118']::text[]
WHERE id = 'f382cfe4-6703-40f8-a43d-0fe02d62d0fa' AND rule_code IS NULL; -- rev.decision
UPDATE paliad.deadline_rules SET rule_code = 'RoP.101', legal_source = 'UPC.RoP.101', rule_codes = ARRAY['RoP.101']::text[]
WHERE id = 'ccad91ef-da04-4b81-a979-658578fb97c4' AND rule_code IS NULL; -- rev.interim
UPDATE paliad.deadline_rules SET rule_code = 'RoP.111', legal_source = 'UPC.RoP.111', rule_codes = ARRAY['RoP.111']::text[]
WHERE id = '38e8982b-5cc9-41b3-b477-37ce4bd4e7c4' AND rule_code IS NULL; -- rev.oral
UPDATE paliad.deadline_rules SET rule_code = 'RoP.209', legal_source = 'UPC.RoP.209', rule_codes = ARRAY['RoP.209']::text[]
WHERE id = 'e4a61ebf-c49b-450f-9d94-bb06098536b4' AND rule_code IS NULL; -- pi.oral
UPDATE paliad.deadline_rules SET rule_code = 'RoP.211', legal_source = 'UPC.RoP.211', rule_codes = ARRAY['RoP.211']::text[]
WHERE id = '7b93a8b7-115d-42b4-9d1d-34684ddf5206' AND rule_code IS NULL; -- pi.order
UPDATE paliad.deadline_rules SET rule_code = 'RoP.209.1', legal_source = 'UPC.RoP.209.1', rule_codes = ARRAY['RoP.209.1']::text[]
WHERE id = '30ffe572-aa77-4dcb-9292-a4750289f75c' AND rule_code IS NULL; -- pi.response (court-set)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.350', legal_source = 'UPC.RoP.350', rule_codes = ARRAY['RoP.350']::text[]
WHERE id = '685bad4f-3c3e-425d-8839-2f765d0fc96e' AND rule_code IS NULL; -- app.decision
UPDATE paliad.deadline_rules SET rule_code = 'RoP.220.1.c', legal_source = 'UPC.RoP.220.1.c', rule_codes = ARRAY['RoP.220.1.c']::text[]
WHERE id = 'c2865575-d7d6-436d-b61c-0a266217f76c' AND rule_code IS NULL; -- app_ord.order
UPDATE paliad.deadline_rules SET rule_code = 'RoP.157', legal_source = 'UPC.RoP.157', rule_codes = ARRAY['RoP.157']::text[]
WHERE id = '01db67c9-5621-48ca-9dbd-d652b6237b24' AND rule_code IS NULL; -- cost.decision
-- DE court-scheduled events
UPDATE paliad.deadline_rules SET rule_code = '§ 285 ZPO', legal_source = 'DE.ZPO.285'
WHERE id = 'a95af317-2fdb-43c9-ab66-c8b2099aaa5a' AND rule_code IS NULL; -- de_inf.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 300 ZPO', legal_source = 'DE.ZPO.300'
WHERE id = 'e46d2ae7-74bf-4c06-9e55-921242d36f2a' AND rule_code IS NULL; -- de_inf.urteil
UPDATE paliad.deadline_rules SET rule_code = '§ 285 ZPO', legal_source = 'DE.ZPO.285'
WHERE id = '2a16f77f-408f-48c4-9d71-8ea5926d4dca' AND rule_code IS NULL; -- de_inf_olg.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 300 ZPO', legal_source = 'DE.ZPO.300'
WHERE id = '7d7d88c5-895e-4855-8f4d-2e160ff74998' AND rule_code IS NULL; -- de_inf_olg.urteil_olg
UPDATE paliad.deadline_rules SET rule_code = '§ 285 ZPO', legal_source = 'DE.ZPO.285'
WHERE id = 'b1460f90-419e-47ae-978a-8e32ffafad73' AND rule_code IS NULL; -- de_inf_bgh.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 300 ZPO', legal_source = 'DE.ZPO.300'
WHERE id = '803460ac-f6bd-4194-b5ab-140175644648' AND rule_code IS NULL; -- de_inf_bgh.urteil_bgh
UPDATE paliad.deadline_rules SET rule_code = '§ 89 PatG', legal_source = 'DE.PatG.89'
WHERE id = 'ab60e712-bc56-4326-8df0-413881996bf3' AND rule_code IS NULL; -- de_null.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 84 PatG', legal_source = 'DE.PatG.84'
WHERE id = '1476829a-cc92-4221-b182-846fc99ad941' AND rule_code IS NULL; -- de_null.urteil
UPDATE paliad.deadline_rules SET rule_code = '§ 113 PatG', legal_source = 'DE.PatG.113'
WHERE id = 'd077816d-bce4-4cb7-bd67-7b52edbf7fb9' AND rule_code IS NULL; -- de_null_bgh.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 119 PatG', legal_source = 'DE.PatG.119'
WHERE id = '816e9756-efff-4e40-b650-f0b31bdc21e5' AND rule_code IS NULL; -- de_null_bgh.urteil_bgh
-- DPMA / BPatG / BGH-PatG court-scheduled events
UPDATE paliad.deadline_rules SET rule_code = '§ 47 PatG', legal_source = 'DE.PatG.47'
WHERE id = '193a85e2-5794-463a-8c45-73174a54cea9' AND rule_code IS NULL; -- dpma_opp.entscheidung
UPDATE paliad.deadline_rules SET rule_code = '§ 79 PatG', legal_source = 'DE.PatG.79'
WHERE id = 'baaff831-6a3f-43ed-96bb-eae6ad73f6fc' AND rule_code IS NULL; -- dpma_bpatg.entsch_bpatg
UPDATE paliad.deadline_rules SET rule_code = '§ 78 PatG', legal_source = 'DE.PatG.78'
WHERE id = '446694c2-5b34-4ecd-9bf7-7eee055b0d1b' AND rule_code IS NULL; -- dpma_bpatg.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 107 PatG', legal_source = 'DE.PatG.107'
WHERE id = '99c02992-1a77-4694-b773-941ac9876bb5' AND rule_code IS NULL; -- dpma_bgh.entsch_bgh
-- =============================================================================
-- 6. § 6 FLAG-C/D rubber-stamp (5 rows). UPC RoP duration-vs-norm
-- mismatches get the canonical citation per m ("just go ahead"). DE
-- LG patent-practice 4-week replik/duplik cite § 273 ZPO (court-set
-- framing).
-- =============================================================================
UPDATE paliad.deadline_rules SET rule_code = 'RoP.052', legal_source = 'UPC.RoP.52', rule_codes = ARRAY['RoP.052']::text[]
WHERE id = '7e0ea937-d81b-4dee-897e-0d8bc0543f34' AND rule_code IS NULL; -- rev.reply (FLAG-C)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.052', legal_source = 'UPC.RoP.52', rule_codes = ARRAY['RoP.052']::text[]
WHERE id = 'b7890351-c6d6-46e4-b064-0513a1808e6d' AND rule_code IS NULL; -- rev.rejoin (FLAG-C)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.235.1', legal_source = 'UPC.RoP.235.1', rule_codes = ARRAY['RoP.235.1']::text[]
WHERE id = 'd6600ceb-d1d5-408a-a7c9-1026f304ac7f' AND rule_code IS NULL; -- app.response (FLAG-C)
UPDATE paliad.deadline_rules SET rule_code = '§ 273 ZPO', legal_source = 'DE.ZPO.273'
WHERE id = 'd46d915e-fd46-4167-88b5-6d22bcbb8882' AND rule_code IS NULL; -- de_inf.replik (FLAG-D)
UPDATE paliad.deadline_rules SET rule_code = '§ 273 ZPO', legal_source = 'DE.ZPO.273'
WHERE id = 'ca9b52cb-e986-4c3a-9e89-e799e6a6ac33' AND rule_code IS NULL; -- de_inf.duplik (FLAG-D)
-- =============================================================================
-- 7. § 7 FLAG-E service triggers (6 rows, DE/EPA). § 317 ZPO for LG/OLG
-- judgment-service, § 99 / § 47 / § 79 PatG for the PatG variants,
-- R. 111 EPÜ for EPA notification.
-- =============================================================================
UPDATE paliad.deadline_rules SET rule_code = '§ 317 ZPO', legal_source = 'DE.ZPO.317'
WHERE id = '106d8a0b-514b-4021-8b65-7debff71f1d3' AND rule_code IS NULL; -- de_inf_olg.urteil_lg
UPDATE paliad.deadline_rules SET rule_code = '§ 317 ZPO', legal_source = 'DE.ZPO.317'
WHERE id = 'd071b5c6-f33e-44e8-8656-4e9cccf55701' AND rule_code IS NULL; -- de_inf_bgh.urteil_olg
UPDATE paliad.deadline_rules SET rule_code = '§ 99 PatG', legal_source = 'DE.PatG.99.1'
WHERE id = 'bdae7319-7435-40e9-be19-6ce21fdb9946' AND rule_code IS NULL; -- de_null_bgh.urteil_bpatg
UPDATE paliad.deadline_rules SET rule_code = '§ 47 PatG', legal_source = 'DE.PatG.47.1'
WHERE id = '327390f9-3c1b-496f-8e63-2bf19c380dfe' AND rule_code IS NULL; -- dpma_bpatg.entscheidung
UPDATE paliad.deadline_rules SET rule_code = '§ 79 PatG', legal_source = 'DE.PatG.79.1'
WHERE id = 'd3ea5e50-f7e2-40f1-bb16-30664acc2e2b' AND rule_code IS NULL; -- dpma_bgh.entsch_bpatg
UPDATE paliad.deadline_rules SET rule_code = 'R. 111 EPÜ', legal_source = 'EU.EPC-R.111'
WHERE id = '79c27f9b-5195-4272-90d6-ea6a43cd0938' AND rule_code IS NULL; -- epa_app.entsch
-- =============================================================================
-- 8. § 8 FLAG-F combined-pleading rows (5 rows). Primary cite in
-- rule_code + legal_source; full set of citations in rule_codes[]
-- so downstream tooling can resolve any of the combined norms.
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.029.a', legal_source = 'UPC.RoP.29.a',
rule_codes = ARRAY['RoP.029.a', 'RoP.029.b']::text[]
WHERE id = 'cec1a865-30a4-46c9-8abf-630d4478b91a' AND rule_code IS NULL; -- Erwid CCR + Replik SoD
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.029.c', legal_source = 'UPC.RoP.29.c',
rule_codes = ARRAY['RoP.029.c', 'RoP.032.3']::text[]
WHERE id = '02ae9c1f-2aa0-4e0e-acf1-ae235588a64f' AND rule_code IS NULL; -- Duplik Replik + Replik Erwid Patentänderung
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.029.d', legal_source = 'UPC.RoP.29.d',
rule_codes = ARRAY['RoP.029.d', 'RoP.029.c', 'RoP.032.1']::text[]
WHERE id = 'ec2a1274-ffd8-42e7-9e27-582365d04d6e' AND rule_code IS NULL; -- Replik Erwid Widerklage + Duplik Replik Klageerwid + Erwid Patentänderung
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.051', legal_source = 'UPC.RoP.51',
rule_codes = ARRAY['RoP.051', 'RoP.049.2.a', 'RoP.056.1']::text[]
WHERE id = '37bd034b-79e3-4c3c-a21d-b078aaf2ea04' AND rule_code IS NULL; -- Replik Erwid Nichtigkeit + Erwid Patent + Erwid Widerklage
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.056.4', legal_source = 'UPC.RoP.56.4',
rule_codes = ARRAY['RoP.056.4', 'RoP.032.3']::text[]
WHERE id = '7b548c48-6fef-4387-8123-e1f1e4ee6da2' AND rule_code IS NULL; -- Duplik (Verletzungswiderklage + Patentänderung)
-- =============================================================================
-- 9. § 9 FLAG-G/H/I + RoP.271.b. Patentänderung INF/REV split (G),
-- sub-paragraph spot-checks (H, applied as-is per doc), negative-
-- declaration RoP.069 by analogy (I), and the RoP.271.b 10-day
-- service-deferral secondary cite on UPC initial submissions.
-- =============================================================================
-- FLAG-G: Patentänderungs-Twin (INF vs REV context)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.030.1', legal_source = 'UPC.RoP.30.1', rule_codes = ARRAY['RoP.030.1']::text[]
WHERE id = 'fb7050c6-a18b-47e4-8811-46ca3677d549' AND rule_code IS NULL; -- Patentänderung INF
UPDATE paliad.deadline_rules SET rule_code = 'RoP.049.2.a', legal_source = 'UPC.RoP.49.2.a', rule_codes = ARRAY['RoP.049.2.a']::text[]
WHERE id = '21e67ac1-fe40-44d1-ae2e-ea90e0b97598' AND rule_code IS NULL; -- Patentänderung REV
-- FLAG-H: sub-paragraph spot-checks (8 rows, applied per doc proposal)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.225.2', legal_source = 'UPC.RoP.225.2', rule_codes = ARRAY['RoP.225.2']::text[]
WHERE id = 'c3a369f9-4f56-4c88-b11c-f98d05d3b376' AND rule_code IS NULL; -- Berufungsbegründung Orders
UPDATE paliad.deadline_rules SET rule_code = 'RoP.234.1', legal_source = 'UPC.RoP.234.1', rule_codes = ARRAY['RoP.234.1']::text[]
WHERE id = 'd4f739cd-444d-48c0-98c4-70f0521b4916' AND rule_code IS NULL; -- Anfechtung Verwerfung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.235.4', legal_source = 'UPC.RoP.235.4', rule_codes = ARRAY['RoP.235.4']::text[]
WHERE id = '4c585c6d-fb5c-4a99-a798-86a05c757bf7' AND rule_code IS NULL; -- Berufungserwiderung Orders
UPDATE paliad.deadline_rules SET rule_code = 'RoP.237.2', legal_source = 'UPC.RoP.237.2', rule_codes = ARRAY['RoP.237.2']::text[]
WHERE id = 'a00e51bb-bcb6-48d0-9aa5-2216e9480c5c' AND rule_code IS NULL; -- Anschlussberufung Orders
UPDATE paliad.deadline_rules SET rule_code = 'RoP.097.1', legal_source = 'UPC.RoP.97.1', rule_codes = ARRAY['RoP.097.1']::text[]
WHERE id = '0531b6ba-98cc-48f4-adb8-da8b7a7c3535' AND rule_code IS NULL; -- Aufhebung EPA Einheitswirkung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.037.4', legal_source = 'UPC.RoP.37.4', rule_codes = ARRAY['RoP.037.4']::text[]
WHERE id = '6b6b967c-65fd-4172-9640-1ffff8a46704' AND rule_code IS NULL; -- Verweisung Zentralkammer
UPDATE paliad.deadline_rules SET rule_code = 'RoP.109.5', legal_source = 'UPC.RoP.109.5', rule_codes = ARRAY['RoP.109.5']::text[]
WHERE id = '8c682cff-3423-41d8-81ca-b5b461461682' AND rule_code IS NULL; -- Dolmetscher own-cost
UPDATE paliad.deadline_rules SET rule_code = 'RoP.007.2', legal_source = 'UPC.RoP.7.2', rule_codes = ARRAY['RoP.007.2']::text[]
WHERE id = '9ed513c1-68df-455e-810e-a5d8d7b85729' AND rule_code IS NULL; -- Übersetzungen Schriftstücke
-- FLAG-I: negative-declaration track (3 rows, RoP.069 by analogy per m)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.069', legal_source = 'UPC.RoP.69', rule_codes = ARRAY['RoP.069']::text[]
WHERE id = '521bf607-1c69-4dc5-a09e-70339bbe4684' AND rule_code IS NULL; -- Erwid neg. Feststellungsklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.069', legal_source = 'UPC.RoP.69', rule_codes = ARRAY['RoP.069']::text[]
WHERE id = 'e887b1fb-83ff-4073-b81b-c10dde6dc2c6' AND rule_code IS NULL; -- Replik neg. Feststellung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.069', legal_source = 'UPC.RoP.69', rule_codes = ARRAY['RoP.069']::text[]
WHERE id = '0cf1d755-3ba5-44ce-87ca-f98bb076c995' AND rule_code IS NULL; -- Duplik neg. Feststellung
-- RoP.271.b — 10-day service deferral on UPC initial submissions.
-- Set rule_codes[] to [primary substantive cite, 'RoP.271.b'] for the
-- 5 UPC initial-submission rows whose § 2 UPDATEs above only set
-- rule_code + legal_source. Idempotent via the IS DISTINCT FROM guard
-- — re-running matches no rows.
UPDATE paliad.deadline_rules
SET rule_codes = target.rule_codes
FROM (VALUES
('42be6c9b-8e84-4804-962f-94c3315aca1b'::uuid, ARRAY['RoP.013.1', 'RoP.271.b']::text[]), -- inf.soc
('995c108e-e73a-4f9c-b79f-47abe7c94108'::uuid, ARRAY['RoP.042', 'RoP.271.b']::text[]), -- rev.app
('ed0194b7-74ab-4402-8971-7211f6036ff9'::uuid, ARRAY['RoP.206', 'RoP.271.b']::text[]), -- pi.app
('3e1719e8-f6f6-4260-8f02-754bd214937f'::uuid, ARRAY['RoP.131', 'RoP.271.b']::text[]), -- damages.app
('eb1fa1d1-b345-42ba-ab14-79f5284166b0'::uuid, ARRAY['RoP.141', 'RoP.271.b']::text[]) -- disc.app
) AS target(id, rule_codes)
WHERE paliad.deadline_rules.id = target.id
AND paliad.deadline_rules.rule_codes IS DISTINCT FROM target.rule_codes;
-- =============================================================================
-- 10. § 10 R.19 label rename (inf.prelim / rev.prelim). Defensive
-- idempotent backstop for fermi's live prod write. Matches no rows
-- on the current prod DB (fermi already renamed) and on the first
-- post-mig fresh-deploy too. Catches any future prod that hasn't
-- seen the live write.
-- =============================================================================
UPDATE paliad.deadline_rules
SET name = 'Einspruch (R. 19 VerfO)', rule_code = 'RoP.019.1'
WHERE code = 'inf.prelim' AND name LIKE 'Vorab-Einrede%';
UPDATE paliad.deadline_rules
SET name = 'Einspruch (R. 19 i.V.m. R. 46 VerfO)', rule_code = 'RoP.019.1'
WHERE code = 'rev.prelim' AND name LIKE 'Vorab-Einrede%';
-- =============================================================================
-- 11. § 11 Side-fix: normalize the one un-padded UPC RoP <100 rule_code
-- outlier. legal_source stays 'UPC.RoP.49.1' (structured locator
-- never pads — convention § 0.2 of the proposal doc).
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.049.1'
WHERE rule_code = 'RoP.49.1'
AND code = 'rev.defence';
-- =============================================================================
-- 12. Refresh the deadline_search materialized view so search hits
-- return the newly populated rule_code + legal_source values.
-- =============================================================================
REFRESH MATERIALIZED VIEW paliad.deadline_search;
-- =============================================================================
-- 13. Hard assertions. Verifies the post-state matches the plan.
--
-- a) 11 active+published rows remain rule_code IS NULL: the 3
-- FLAG-J rows (m picks them up via /admin/rules) plus the 8
-- rows whose dedup decision is deferred (Mängelbeseitigung 6×
-- + Beginn-Hauptsache 2×).
-- b) No un-padded RoP.49.1 outlier remains.
-- c) Padded RoP.049.1 present at least twice (rev.defence
-- normalized + a32dcec1 orphan filled).
-- d) Each of the 3 clean-dedup sets has exactly 1 active+published
-- row after the archive flips.
-- =============================================================================
DO $$
DECLARE
v_null_after integer;
v_old_outlier integer;
v_new_padded integer;
v_dup_count integer;
BEGIN
-- (a) 3 FLAG-J + 8 deferred-dedup rows stay NULL.
SELECT count(*) INTO v_null_after
FROM paliad.deadline_rules
WHERE rule_code IS NULL
AND is_active = true
AND lifecycle_state = 'published';
IF v_null_after <> 11 THEN
RAISE EXCEPTION
'mig 097: expected 11 rule_code IS NULL active+published rows after backfill (3 FLAG-J + 8 deferred dedup), got %',
v_null_after;
END IF;
-- (b) RoP.49.1 outlier normalized.
SELECT count(*) INTO v_old_outlier
FROM paliad.deadline_rules
WHERE rule_code = 'RoP.49.1';
IF v_old_outlier <> 0 THEN
RAISE EXCEPTION
'mig 097: expected 0 RoP.49.1 rows after normalization, got %',
v_old_outlier;
END IF;
-- (c) RoP.049.1 present at least twice.
SELECT count(*) INTO v_new_padded
FROM paliad.deadline_rules
WHERE rule_code = 'RoP.049.1';
IF v_new_padded < 2 THEN
RAISE EXCEPTION
'mig 097: expected >= 2 RoP.049.1 rows after normalization + orphan fill, got %',
v_new_padded;
END IF;
-- (d) Each clean-dedup set has exactly 1 active+published row.
SELECT count(*) INTO v_dup_count
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND id IN (
'b588fa64-a727-4cfb-a45d-69a835a3b05a',
'c24d494c-0da1-4f01-aa74-0f37f99fe1ae'
);
IF v_dup_count <> 1 THEN
RAISE EXCEPTION
'mig 097 dedup: Wiedereinsetzung-§123-PatG set must have 1 active+published row, got %',
v_dup_count;
END IF;
SELECT count(*) INTO v_dup_count
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND id IN (
'1dfba5b1-4ed1-40c1-9cf6-4ed8ff7a0818',
'5c0508f4-020a-4ef5-bcc7-1ee85eafe0b3'
);
IF v_dup_count <> 1 THEN
RAISE EXCEPTION
'mig 097 dedup: Berufungsschrift set must have 1 active+published row, got %',
v_dup_count;
END IF;
SELECT count(*) INTO v_dup_count
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND id IN (
'573df3d1-8ea2-4a6e-b0d4-fc3cd10506da',
'791fd0f7-a448-4711-b1aa-63e6df1e7c57'
);
IF v_dup_count <> 1 THEN
RAISE EXCEPTION
'mig 097 dedup: Berufungsbegründung set must have 1 active+published row, got %',
v_dup_count;
END IF;
END $$;

View File

@@ -0,0 +1,162 @@
-- Reverses mig 098. Restores the pre-098 submission codes on
-- paliad.deadline_rules, renames the column back to `code`, recreates
-- the deadline_search matview against the restored column, then drops
-- the snapshot table.
--
-- audit_reason wrapper required by the mig 079 audit trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 098 (down): revert t-paliad-209 workstream B — restore paliad.deadline_rules.code values from deadline_rules_pre_098 snapshot and rename submission_code → code; matview deadline_search rebuilt against the restored column.',
true);
-- =============================================================================
-- 1. Drop the matview so the column rename can succeed.
-- =============================================================================
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
-- =============================================================================
-- 2. Rename the column back. Guarded so a down run on a DB where the
-- up never ran (or where the column is already named `code`) is a
-- no-op rather than an error.
-- =============================================================================
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules'
AND column_name = 'submission_code'
) THEN
ALTER TABLE paliad.deadline_rules
RENAME COLUMN submission_code TO code;
END IF;
END $$;
-- =============================================================================
-- 3. Restore code values from the pre_098 snapshot. The snapshot was
-- captured at the first up-migration run; if the table is missing
-- (down run before up), the restore is a no-op.
-- =============================================================================
DO $$
DECLARE
v_snap_exists boolean;
BEGIN
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules_pre_098'
) INTO v_snap_exists;
IF NOT v_snap_exists THEN
RAISE NOTICE
'mig 098 (down): snapshot table paliad.deadline_rules_pre_098 missing — nothing to restore';
RETURN;
END IF;
UPDATE paliad.deadline_rules dr
SET code = snap.code
FROM paliad.deadline_rules_pre_098 snap
WHERE dr.id = snap.id
AND dr.code <> snap.code;
END $$;
-- =============================================================================
-- 4. Recreate the deadline_search matview against the restored column.
-- Identical body to mig 051 §4, reproduced here so the down leaves
-- the schema in the same shape mig 051 created.
-- =============================================================================
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || dr.id::text AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction AS jurisdiction,
pt.display_order AS proceeding_display_order,
dr.code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active
AND pt.is_active
AND pt.category = 'fristenrechner'
UNION ALL
SELECT
'trigger'::text,
't:' || te.id::text,
dc.id,
dc.slug,
dc.name_de,
dc.name_en,
dc.description,
dc.aliases,
dc.party,
dc.category,
dc.sort_order,
NULL::uuid,
te.id,
NULL::text,
NULL::text,
NULL::text,
'cross-cutting'::text,
9999::int AS proceeding_display_order,
te.code,
te.name_de,
te.name,
NULL::text,
NULL::text,
NULL::int,
NULL::text,
NULL::text,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
WHERE te.is_active;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
-- =============================================================================
-- 5. Drop the snapshot table so a re-applied up captures a fresh
-- snapshot of the current state.
-- =============================================================================
DROP TABLE IF EXISTS paliad.deadline_rules_pre_098;

View File

@@ -0,0 +1,275 @@
-- t-paliad-209 / workstream B — submission-code prefix + rename.
--
-- m's 2026-05-18 call: the `paliad.deadline_rules.code` field is a
-- SUBMISSION identifier (the event/filing within a proceeding), not the
-- legal-citation rule code (which lives in `rule_code` / `legal_source`).
-- Two cleanups land here:
--
-- 1. DATA — prefix every existing submission code with its proceeding
-- code so submission codes carry the full hierarchical shape
-- (e.g. `inf.soc` on `upc.inf.cfi` → `upc.inf.cfi.soc`,
-- `de_inf.klage` on `de.inf.lg` → `de.inf.lg.klage`).
-- Algorithm: keep the proceeding-code prefix as-is, strip the
-- old single-segment prefix (everything before the first dot in
-- `dr.code`) and replace it with the proceeding's full `code`.
--
-- 2. SCHEMA — rename `paliad.deadline_rules.code` → `submission_code`
-- so future devs don't conflate it with `rule_code` (legal
-- citation) or `proceeding_types.code`. Explicit name encodes the
-- semantic taxonomy ratified in
-- docs/design-proceeding-code-taxonomy-2026-05-18.md §0.1.
--
-- Materialized-view dependency: `paliad.deadline_search` (mig 051) has
-- `dr.code AS rule_local_code` baked into its SELECT list. Postgres
-- rejects RENAME COLUMN when a matview's column list still resolves
-- via the old name — so the matview is dropped before the rename and
-- recreated against `submission_code` afterwards, with every index
-- reproduced. The mig 047 / 051 indexes are reproduced verbatim here.
--
-- IDs and FKs are untouched. `deadline_rules.proceeding_type_id` /
-- `parent_id` / `spawn_proceeding_type_id` reference ids; no
-- code-string FK exists on submission codes (the parent_id chain is on
-- UUID `id`, not the code string), so the data UPDATE doesn't risk
-- breaking joins.
--
-- Idempotent:
-- * The data UPDATE is gated `WHERE dr.code NOT LIKE pt.code || '.%'`
-- — rows already prefixed with their proceeding code (i.e. the
-- migration ran before) are skipped.
-- * The rename is wrapped in a DO block that checks column existence,
-- so a second run is a no-op.
-- * Snapshot table uses CREATE TABLE IF NOT EXISTS.
-- * Matview drop/recreate is DROP IF EXISTS + CREATE.
--
-- audit_reason wrapper required by the mig 079 audit trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 098: t-paliad-209 workstream B — prefix every paliad.deadline_rules.code with its proceeding code, then rename code → submission_code; matview deadline_search rebuilt against the new column. See docs/design-proceeding-code-taxonomy-2026-05-18.md and the t-paliad-209 task brief.',
true);
-- =============================================================================
-- 1. Backup snapshot of paliad.deadline_rules BEFORE the prefix + rename.
-- Captures the rows as they are; serves as the source for the down
-- migration and the permanent audit anchor.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_098 AS
SELECT *, now() AS snapshotted_at
FROM paliad.deadline_rules;
COMMENT ON TABLE paliad.deadline_rules_pre_098 IS
'Snapshot of paliad.deadline_rules taken before mig 098 prefixed '
'every `code` with its proceeding code and renamed the column to '
'`submission_code` (t-paliad-209, 2026-05-18). Source-of-truth '
'for the down migration; persists post-rename as the permanent '
'audit record.';
-- =============================================================================
-- 2. Drop the deadline_search materialized view. It bakes `dr.code AS
-- rule_local_code` into its SELECT list (mig 051 §4), and Postgres
-- refuses to rename a column that a matview's column list still
-- resolves via the old name. The matview is recreated verbatim in §5
-- against the renamed column.
-- =============================================================================
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
-- =============================================================================
-- 3. Data UPDATE — prefix every submission code with its proceeding
-- code. Algorithm:
-- * proceeding_code = pt.code
-- * suffix = portion of dr.code after the first '.'
-- * new code = proceeding_code || '.' || suffix
--
-- regexp_replace('inf.soc', '^[^.]+\.', '') = 'soc'
-- regexp_replace('de_inf_bgh.revision', ...) = 'revision'
--
-- The WHERE clause skips rows that already start with `pt.code || '.'`
-- so re-running the migration is a no-op on already-prefixed rows.
-- Archived rows (proceeding `_archived_litigation`) get the same
-- treatment — they end up as `_archived_litigation.<suffix>`. The
-- shape regex in §6 only inspects active+published rows, so the
-- archived form sits outside the constraint by design.
-- =============================================================================
UPDATE paliad.deadline_rules dr
SET code = pt.code || '.' || regexp_replace(dr.code, '^[^.]+\.', '')
FROM paliad.proceeding_types pt
WHERE pt.id = dr.proceeding_type_id
AND dr.code IS NOT NULL
AND position('.' in dr.code) > 0
AND dr.code NOT LIKE pt.code || '.%';
-- =============================================================================
-- 4. Rename the column. Guarded in a DO block so a second run (e.g. a
-- fresh DB built up to mig 098 from an empty schema, or a manual
-- re-apply) is a no-op rather than a hard error.
-- =============================================================================
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules'
AND column_name = 'code'
) THEN
ALTER TABLE paliad.deadline_rules
RENAME COLUMN code TO submission_code;
END IF;
END $$;
-- =============================================================================
-- 5. Recreate the deadline_search matview against the renamed column.
-- Column list reproduced verbatim from mig 051 §4 with the single
-- edit: `dr.code AS rule_local_code` → `dr.submission_code AS
-- rule_local_code`. All indexes from mig 051 are reproduced too.
-- =============================================================================
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || dr.id::text AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction AS jurisdiction,
pt.display_order AS proceeding_display_order,
dr.submission_code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active
AND pt.is_active
AND pt.category = 'fristenrechner'
UNION ALL
SELECT
'trigger'::text,
't:' || te.id::text,
dc.id,
dc.slug,
dc.name_de,
dc.name_en,
dc.description,
dc.aliases,
dc.party,
dc.category,
dc.sort_order,
NULL::uuid,
te.id,
NULL::text,
NULL::text,
NULL::text,
'cross-cutting'::text,
9999::int AS proceeding_display_order,
te.code,
te.name_de,
te.name,
NULL::text,
NULL::text,
NULL::int,
NULL::text,
NULL::text,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
WHERE te.is_active;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
-- =============================================================================
-- 6. Hard assertions. Half-applied migrations would leave the rule
-- corpus inconsistent; gate on the shape of every active+published
-- row and on column existence so this fails loudly rather than
-- leaving the schema in a half-renamed state.
-- =============================================================================
DO $$
DECLARE
v_bad_shape integer;
v_null_codes integer;
v_col_exists boolean;
BEGIN
-- 6.1 Every active+published row has the proceeding-code-prefixed
-- 4+-segment shape. Archived rows (`_archived_litigation` ones)
-- keep their shorter shape by design — they're carved out.
-- Suffix segments may include digits (existing data — e.g. EPA rule
-- codes like `epa.opp.boa.r106` / `epa.grant.exa.r71_3` carry the
-- statutory rule number in the suffix). Allow [a-z_0-9] per segment.
SELECT count(*) INTO v_bad_shape
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND submission_code !~ '^[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+(\..*)?$';
IF v_bad_shape <> 0 THEN
RAISE EXCEPTION
'mig 098: expected every active+published deadline_rules row to match the 4+-segment submission_code shape, got % violators',
v_bad_shape;
END IF;
-- 6.2 No NULL submission_code on active+published rows that BELONG
-- to a proceeding. Orphan rows (`proceeding_type_id IS NULL`)
-- are cross-cutting rules without a fixed proceeding home
-- (Wiedereinsetzung, Schriftsatznachreichung, etc.) — they
-- legitimately carry NULL submission_code because there's no
-- proceeding to prefix with. Exempt them.
SELECT count(*) INTO v_null_codes
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND proceeding_type_id IS NOT NULL
AND submission_code IS NULL;
IF v_null_codes <> 0 THEN
RAISE EXCEPTION
'mig 098: expected 0 NULL submission_code on active+published rows, got %',
v_null_codes;
END IF;
-- 6.3 Column was actually renamed. Catches the case where the DO
-- guard in §4 short-circuited because the schema hadn't yet
-- been migrated.
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules'
AND column_name = 'submission_code'
) INTO v_col_exists;
IF NOT v_col_exists THEN
RAISE EXCEPTION
'mig 098: column paliad.deadline_rules.submission_code missing after rename — half-applied migration';
END IF;
END $$;

View File

@@ -0,0 +1,10 @@
-- Revert mig 098 — restore the with_po condition_expr (mig 095 shape).
-- audit_reason required: set via SET LOCAL paliad.audit_reason in tooling.
UPDATE paliad.deadline_rules dr
SET condition_expr = '{"flag":"with_po"}'::jsonb
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code IN ('upc.inf.cfi', 'upc.rev.cfi')
AND dr.rule_code = 'RoP.019.1'
AND dr.condition_expr IS NULL;

View File

@@ -0,0 +1,34 @@
-- t-paliad-207 — drop the `with_po` flag from the two RoP 19 rules.
-- m's call 2026-05-18 (interactive session): the Einspruch (R. 19) is
-- not flag-gated — it's just an optional submission the defendant can
-- always make, triggered by the SoC. Same reasoning that drove the
-- always-fire decision for the appeal-spawn rules in t-paliad-203 F2.3
-- ("appeal is always a possibility").
--
-- Net effect: the calculator will surface the R.19 row on every UPC_INF
-- / UPC_REV calc as an optional row (priority='optional' already set
-- by mig 095, unchanged here). The save-modal pre-uncheck behaviour
-- for optional priority handles the "user opts in" gesture without a
-- separate flag.
--
-- Two rows updated; pinned by proceeding code so this stays correct
-- after any rule-id reshuffle. Idempotent: the WHERE clause matches
-- the live shape, so re-apply is a no-op.
--
-- audit_reason set_config required at the top — the mig 079 trigger
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
-- on any UPDATE without it. Original mig 099 author missed this and
-- crash-looped paliad prod; this is the recovery patch.
SELECT set_config(
'paliad.audit_reason',
'mig 099: drop with_po condition_expr on the two RoP.019.1 rows — m''s call 2026-05-18 (t-paliad-207 interactive session), R.19 Einspruch is always-available not flag-gated',
true);
UPDATE paliad.deadline_rules dr
SET condition_expr = NULL
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code IN ('upc.inf.cfi', 'upc.rev.cfi')
AND dr.rule_code = 'RoP.019.1'
AND dr.condition_expr::text LIKE '%with_po%';

View File

@@ -0,0 +1,26 @@
-- Revert mig 100 — remove the upc.inf.cfi.ccr informational rule and
-- restore the sequence_order values of def_to_ccr / app_to_amend.
SELECT set_config(
'paliad.audit_reason',
'mig 100 down: revert upc.inf.cfi.ccr informational rule + sequence reshuffle',
true);
UPDATE paliad.deadline_rules
SET sequence_order = 12
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 13;
UPDATE paliad.deadline_rules
SET sequence_order = 11
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 12;
DELETE FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published';

View File

@@ -0,0 +1,97 @@
-- t-paliad-207 — make the Nichtigkeitswiderklage (CCR) visible in the
-- calculator output when the `with_ccr` flag is set. m's observation
-- 2026-05-18 (interactive session): toggling "Mit Nichtigkeitswider-
-- klage" surfaces the response rules (def_to_ccr, reply, rejoin, …)
-- but the triggering event itself — the act of filing the CCR — is
-- invisible. Per R.25 VerfO the CCR is filed AS PART OF the Statement
-- of Defence with the same 3-month deadline, so the corpus author
-- (mig 028) skipped it. UX is the problem: users see consequences
-- without the cause.
--
-- Net effect: a new `upc.inf.cfi.ccr` row with priority='informational'
-- renders the CCR as a notice card on the timeline (no save action,
-- no extra deadline-to-track; the SoD's deadline already covers it).
-- Date is identical to the SoD (3 months from SoC, same anchor +
-- duration). condition_expr={"flag":"with_ccr"} so the row only appears
-- when the user has flagged that a CCR is being filed.
--
-- Sequence reshuffle: inserting at sequence_order=11 pushes
-- def_to_ccr 11→12 and app_to_amend 12→13 so the timeline reads
-- SoD → CCR → def_to_ccr → app_to_amend (cause before effect). The
-- two UPDATEs are guarded by the SOURCE values so re-apply is a no-op.
--
-- audit_reason set_config required at the top — the deadline_rules
-- audit trigger raises EXCEPTION 'audit reason required' on any
-- mutation without it (cf. mig 099 hotfix history).
--
-- Idempotency:
-- * INSERT uses NOT EXISTS keyed on (proceeding_type_id,
-- submission_code, lifecycle_state='published').
-- * UPDATEs are guarded by current sequence_order value.
SELECT set_config(
'paliad.audit_reason',
'mig 100: add upc.inf.cfi.ccr informational rule so CCR filing event is visible when with_ccr flag is set (m''s 2026-05-18 ask, t-paliad-207 interactive session)',
true);
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state)
SELECT
8,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.soc'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND is_active = true),
'upc.inf.cfi.ccr',
'Nichtigkeitswiderklage',
'Counterclaim for Revocation',
'Widerklage des Beklagten auf Nichtigkeit des Klagepatents. Wird gemeinsam mit der Klageerwiderung (Statement of Defence) eingereicht (R.25 VerfO); selbe Frist von 3 Monaten ab Zustellung der Klage. Eigener adversarialer Schriftsatz, der die Folge-Schriftsätze (Erwiderung auf Nichtigkeitswiderklage, Replik, Duplik) auslöst.',
'defendant',
'filing',
3,
'months',
'after',
'RoP.025',
'Wird mit der Klageerwiderung eingereicht (R.25 VerfO); kein separater Fristtermin — selbes Datum wie die Klageerwiderung. Wird informativ angezeigt, damit der auslösende Schriftsatz für die Folgefristen sichtbar bleibt.',
'Filed together with the Statement of Defence (RoP 25); no separate deadline — same date as the SoD. Surfaced informationally so the triggering submission for the downstream deadlines is visible.',
11,
false,
NULL,
NULL,
true,
'UPC.RoP.25.1',
false,
'{"flag":"with_ccr"}'::jsonb,
'informational',
false,
'published'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published');
-- Sequence reshuffle: bump def_to_ccr and app_to_amend by 1 so the
-- new ccr row at 11 sits between SoD (10) and def_to_ccr. Guarded by
-- the source values to keep idempotency.
UPDATE paliad.deadline_rules
SET sequence_order = 12
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 11;
UPDATE paliad.deadline_rules
SET sequence_order = 13
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 12;

View File

@@ -0,0 +1,52 @@
-- Revert mig 101 — restore the bracket-bearing Einspruch names and
-- flip the CCR priority back to 'informational'.
SELECT set_config(
'paliad.audit_reason',
'mig 101 down: restore "Einspruch (R. 19 VerfO)" and "Einspruch (R. 19 i.V.m. R. 46 VerfO)" names + flip upc.inf.cfi.ccr priority back to informational',
true);
UPDATE paliad.deadline_rules dr
SET name_en = 'Preliminary Objection (RoP 19 in conjunction with RoP 46)'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.rev.cfi'
AND dr.submission_code = 'upc.rev.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name_en = 'Preliminary Objection';
UPDATE paliad.deadline_rules dr
SET name = 'Einspruch (R. 19 i.V.m. R. 46 VerfO)'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.rev.cfi'
AND dr.submission_code = 'upc.rev.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name = 'Einspruch';
UPDATE paliad.deadline_rules dr
SET name_en = 'Preliminary Objection (RoP 19)'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name_en = 'Preliminary Objection';
UPDATE paliad.deadline_rules dr
SET name = 'Einspruch (R. 19 VerfO)'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name = 'Einspruch';
UPDATE paliad.deadline_rules dr
SET priority = 'informational'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.ccr'
AND dr.lifecycle_state = 'published'
AND dr.priority = 'optional';

View File

@@ -0,0 +1,89 @@
-- t-paliad-207 (m's interactive session) — two label/priority polish
-- fixes on upc.inf.cfi / upc.rev.cfi:
--
-- 1. **CCR priority informational → optional.** m's correction
-- 2026-05-18 18:01: the Nichtigkeitswiderklage is a substantive
-- defensive choice the defendant makes — not just an informational
-- notice. priority='optional' renders it as an unchecked save row
-- the user can opt into. The fermi amend (commit e8d658a) flipping
-- this didn't land in main — paliadin's merge of mig 100 (commit
-- c10f8cf, merge 4ddcd28) picked up the pre-amend 'informational'
-- version. This is the recovery.
--
-- 2. **Strip rule citation from Einspruch names.** m's correction
-- 2026-05-18 18:08: every other rule name in the corpus carries
-- the act-name without a parenthetical rule cite (Klageerwiderung,
-- Antrag auf Patentänderung, Replik, etc.). The Einspruch rule
-- names are the outliers:
-- upc.inf.cfi.prelim "Einspruch (R. 19 VerfO)" → "Einspruch"
-- upc.rev.cfi.prelim "Einspruch (R. 19 i.V.m. R. 46 VerfO)" → "Einspruch"
-- and EN equivalents:
-- "Preliminary Objection (RoP 19)" → "Preliminary Objection"
-- "Preliminary Objection (RoP 19 in conjunction with RoP 46)"
-- → "Preliminary Objection"
-- The legal_source / rule_code columns already carry the citation
-- and render in the deadline card's meta line, so the name stays
-- clean. The R.46-i.V.m. distinction is preserved in the legal
-- source field (RoP.019.1 for both — m may want to further
-- differentiate; flagged in description text instead).
--
-- audit_reason set_config required at the top — the deadline_rules
-- audit trigger raises EXCEPTION 'audit reason required' on any
-- mutation without it (cf. mig 099 hotfix history).
--
-- Idempotency:
-- * Priority UPDATE guarded on the current 'informational' value.
-- * Name UPDATEs guarded on the current parenthetical-bearing names.
SELECT set_config(
'paliad.audit_reason',
'mig 101: flip upc.inf.cfi.ccr priority informational→optional + strip rule-cite brackets from R.19 Einspruch names on both upc.inf.cfi.prelim and upc.rev.cfi.prelim (m''s corrections 2026-05-18, t-paliad-207 interactive session)',
true);
-- 1) Flip CCR priority
UPDATE paliad.deadline_rules dr
SET priority = 'optional'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.ccr'
AND dr.lifecycle_state = 'published'
AND dr.priority = 'informational';
-- 2a) Strip "(R. 19 VerfO)" from upc.inf.cfi.prelim DE/EN names
UPDATE paliad.deadline_rules dr
SET name = 'Einspruch'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name = 'Einspruch (R. 19 VerfO)';
UPDATE paliad.deadline_rules dr
SET name_en = 'Preliminary Objection'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name_en = 'Preliminary Objection (RoP 19)';
-- 2b) Strip "(R. 19 i.V.m. R. 46 VerfO)" from upc.rev.cfi.prelim DE/EN names
UPDATE paliad.deadline_rules dr
SET name = 'Einspruch'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.rev.cfi'
AND dr.submission_code = 'upc.rev.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name = 'Einspruch (R. 19 i.V.m. R. 46 VerfO)';
UPDATE paliad.deadline_rules dr
SET name_en = 'Preliminary Objection'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.rev.cfi'
AND dr.submission_code = 'upc.rev.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name_en = 'Preliminary Objection (RoP 19 in conjunction with RoP 46)';

View File

@@ -0,0 +1,31 @@
-- Revert mig 102 — restore the pre-mig-102 sequence_order values
-- (post-mig-100 state). Same two-phase swap pattern.
SELECT set_config(
'paliad.audit_reason',
'mig 102 down: restore pre-track-aware sequence_order on upc.inf.cfi rules',
true);
-- Phase 1: park
UPDATE paliad.deadline_rules SET sequence_order = 1011 WHERE submission_code = 'upc.inf.cfi.ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 20;
UPDATE paliad.deadline_rules SET sequence_order = 1012 WHERE submission_code = 'upc.inf.cfi.def_to_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 22;
UPDATE paliad.deadline_rules SET sequence_order = 1013 WHERE submission_code = 'upc.inf.cfi.app_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 30;
UPDATE paliad.deadline_rules SET sequence_order = 1020 WHERE submission_code = 'upc.inf.cfi.reply' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 12;
UPDATE paliad.deadline_rules SET sequence_order = 1021 WHERE submission_code = 'upc.inf.cfi.def_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 32;
UPDATE paliad.deadline_rules SET sequence_order = 1022 WHERE submission_code = 'upc.inf.cfi.reply_def_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 24;
UPDATE paliad.deadline_rules SET sequence_order = 1030 WHERE submission_code = 'upc.inf.cfi.rejoin' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 14;
UPDATE paliad.deadline_rules SET sequence_order = 1031 WHERE submission_code = 'upc.inf.cfi.reply_def_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 34;
UPDATE paliad.deadline_rules SET sequence_order = 1032 WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 26;
UPDATE paliad.deadline_rules SET sequence_order = 1033 WHERE submission_code = 'upc.inf.cfi.rejoin_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 36;
-- Phase 2: assign originals
UPDATE paliad.deadline_rules SET sequence_order = 11 WHERE submission_code = 'upc.inf.cfi.ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1011;
UPDATE paliad.deadline_rules SET sequence_order = 12 WHERE submission_code = 'upc.inf.cfi.def_to_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1012;
UPDATE paliad.deadline_rules SET sequence_order = 13 WHERE submission_code = 'upc.inf.cfi.app_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1013;
UPDATE paliad.deadline_rules SET sequence_order = 20 WHERE submission_code = 'upc.inf.cfi.reply' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1020;
UPDATE paliad.deadline_rules SET sequence_order = 21 WHERE submission_code = 'upc.inf.cfi.def_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1021;
UPDATE paliad.deadline_rules SET sequence_order = 22 WHERE submission_code = 'upc.inf.cfi.reply_def_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1022;
UPDATE paliad.deadline_rules SET sequence_order = 30 WHERE submission_code = 'upc.inf.cfi.rejoin' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1030;
UPDATE paliad.deadline_rules SET sequence_order = 31 WHERE submission_code = 'upc.inf.cfi.reply_def_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1031;
UPDATE paliad.deadline_rules SET sequence_order = 32 WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1032;
UPDATE paliad.deadline_rules SET sequence_order = 33 WHERE submission_code = 'upc.inf.cfi.rejoin_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1033;

View File

@@ -0,0 +1,211 @@
-- t-paliad-207 — re-sequence upc.inf.cfi rules so within any tied-date
-- group the infringement-track responses sit ABOVE the revocation-
-- track responses ABOVE the amendment-track responses. m's ask
-- 2026-05-18 18:08: "the infringement parts (like Replik) should show
-- above the part for the revocation (Erwiderung Nichtigkeitswider-
-- klage)".
--
-- Three tracks coexist on upc.inf.cfi once the with_ccr / with_amend
-- flags are set. They share calendar dates because R.29 / R.30 / R.32
-- all key off the SoD or its descendants. The current sequence_orders
-- (post-mig 100) interleave them; the user sees Erwiderung-zur-CCR
-- before Replik even though Replik is the infringement-side response
-- to the same triggering event.
--
-- New sequence_order assignment (preserves the soc=0, prelim=5,
-- sod=10, ccr=11 anchors at the head; phase markers interim/oral/
-- decision/cost_app/appeal_spawn keep their existing 40/50/60/70/80
-- slots at the tail):
--
-- Old → New submission_code track date
-- --- --- --------------- ----- ----
-- 0 0 upc.inf.cfi.soc — D+0
-- 5 5 upc.inf.cfi.prelim — D+1mo
-- 10 10 upc.inf.cfi.sod infringement D+3mo
-- 11 20 upc.inf.cfi.ccr revocation D+3mo
-- 20 12 upc.inf.cfi.reply infringement D+5mo ← MOVED UP
-- 12 22 upc.inf.cfi.def_to_ccr revocation D+5mo
-- 13 30 upc.inf.cfi.app_to_amend amendment D+5mo
-- 30 14 upc.inf.cfi.rejoin infringement D+6mo ← MOVED UP
-- 22 24 upc.inf.cfi.reply_def_ccr revocation D+7mo
-- 21 32 upc.inf.cfi.def_to_amend amendment D+7mo
-- 32 26 upc.inf.cfi.rejoin_reply_ccr revocation D+8mo
-- 31 34 upc.inf.cfi.reply_def_amd amendment D+8mo
-- 33 36 upc.inf.cfi.rejoin_amd amendment D+9mo
-- 40 40 upc.inf.cfi.interim phase later
-- 50 50 upc.inf.cfi.oral phase later
-- 60 60 upc.inf.cfi.decision phase later
-- 70 70 upc.inf.cfi.cost_app phase later
-- 80 80 upc.inf.cfi.appeal_spawn phase later
--
-- Order within each tied-date group after the reshuffle:
-- D+3mo: sod(10), ccr(20) — SoD then its CCR
-- D+5mo: reply(12), def_to_ccr(22), app_to_amend(30) — inf → rev → amd
-- D+7mo: reply_def_ccr(24), def_to_amend(32) — rev → amd
-- D+8mo: rejoin_reply_ccr(26), reply_def_amd(34) — rev → amd
--
-- (no infringement-track rule at +7mo or +8mo so revocation leads
-- those dates; rejoin sits alone at +6mo so it has no peers to order
-- against.)
--
-- audit_reason set_config required at the top — the deadline_rules
-- audit trigger raises EXCEPTION 'audit reason required' on any
-- mutation without it (cf. mig 099 hotfix history).
--
-- Idempotency: every UPDATE is guarded by both the submission_code
-- AND the SOURCE sequence_order, so re-apply is a no-op once the new
-- numbers are in place.
SELECT set_config(
'paliad.audit_reason',
'mig 102: re-sequence upc.inf.cfi rules track-aware (infringement → revocation → amendment within tied-date groups; m''s 2026-05-18 ask, t-paliad-207 interactive session)',
true);
-- Two-phase swap to avoid sequence collisions during the UPDATE
-- (otherwise two rules can briefly share a sequence_order if Postgres
-- evaluates them in parallel). Phase 1: move every reshuffled rule to
-- a high temporary number (1000+). Phase 2: assign final numbers.
-- ─── Phase 1: park reshuffled rules at 1000+ ────────────────────────
UPDATE paliad.deadline_rules
SET sequence_order = 1011
WHERE submission_code = 'upc.inf.cfi.ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 11;
UPDATE paliad.deadline_rules
SET sequence_order = 1012
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 12;
UPDATE paliad.deadline_rules
SET sequence_order = 1013
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 13;
UPDATE paliad.deadline_rules
SET sequence_order = 1020
WHERE submission_code = 'upc.inf.cfi.reply'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 20;
UPDATE paliad.deadline_rules
SET sequence_order = 1021
WHERE submission_code = 'upc.inf.cfi.def_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 21;
UPDATE paliad.deadline_rules
SET sequence_order = 1022
WHERE submission_code = 'upc.inf.cfi.reply_def_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 22;
UPDATE paliad.deadline_rules
SET sequence_order = 1030
WHERE submission_code = 'upc.inf.cfi.rejoin'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 30;
UPDATE paliad.deadline_rules
SET sequence_order = 1031
WHERE submission_code = 'upc.inf.cfi.reply_def_amd'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 31;
UPDATE paliad.deadline_rules
SET sequence_order = 1032
WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 32;
UPDATE paliad.deadline_rules
SET sequence_order = 1033
WHERE submission_code = 'upc.inf.cfi.rejoin_amd'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 33;
-- ─── Phase 2: assign final track-aware numbers ──────────────────────
UPDATE paliad.deadline_rules
SET sequence_order = 12
WHERE submission_code = 'upc.inf.cfi.reply'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1020;
UPDATE paliad.deadline_rules
SET sequence_order = 14
WHERE submission_code = 'upc.inf.cfi.rejoin'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1030;
UPDATE paliad.deadline_rules
SET sequence_order = 20
WHERE submission_code = 'upc.inf.cfi.ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1011;
UPDATE paliad.deadline_rules
SET sequence_order = 22
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1012;
UPDATE paliad.deadline_rules
SET sequence_order = 24
WHERE submission_code = 'upc.inf.cfi.reply_def_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1022;
UPDATE paliad.deadline_rules
SET sequence_order = 26
WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1032;
UPDATE paliad.deadline_rules
SET sequence_order = 30
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1013;
UPDATE paliad.deadline_rules
SET sequence_order = 32
WHERE submission_code = 'upc.inf.cfi.def_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1021;
UPDATE paliad.deadline_rules
SET sequence_order = 34
WHERE submission_code = 'upc.inf.cfi.reply_def_amd'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1031;
UPDATE paliad.deadline_rules
SET sequence_order = 36
WHERE submission_code = 'upc.inf.cfi.rejoin_amd'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1033;

View File

@@ -0,0 +1,440 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// Admin rule-editor endpoints — Phase 3 Slice 11a (t-paliad-191).
// Every handler in this file is wired through auth.RequireAdminFunc
// in handlers.go, so the handlers themselves assume the caller is a
// global_admin and only validate request shape.
//
// Every write endpoint takes an audit_reason field on the request
// body. The service layer sets paliad.audit_reason in the same tx
// before the UPDATE so mig 079's audit trigger captures the rationale
// forever. Missing reason → 400 (ErrAuditReasonRequired).
//
// Lifecycle invariants live in the service layer: ErrInvalidLifecycleState
// is mapped to 409 Conflict so the editor UI can show a clear "must
// clone first" hint.
// GET /admin/api/rules — paginated list with filters.
func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
return
}
q := r.URL.Query()
f := services.ListRulesFilter{
LifecycleState: q.Get("lifecycle_state"),
Query: q.Get("q"),
}
if v := q.Get("proceeding_type_id"); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid proceeding_type_id"})
return
}
f.ProceedingTypeID = &n
}
if v := q.Get("trigger_event_id"); v != "" {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid trigger_event_id"})
return
}
f.TriggerEventID = &n
}
if v := q.Get("offset"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid offset"})
return
}
f.Offset = n
}
if v := q.Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid limit"})
return
}
f.Limit = n
}
rows, err := dbSvc.ruleEditor.ListRules(r.Context(), f)
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// GET /admin/api/rules/{id}
func handleAdminGetRule(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
return
}
id, ok := parseRuleID(w, r)
if !ok {
return
}
row, err := dbSvc.ruleEditor.GetByID(r.Context(), id)
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
}
// POST /admin/api/rules — create draft.
func handleAdminCreateRule(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
return
}
var body struct {
services.CreateRuleInput
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
row, err := dbSvc.ruleEditor.Create(r.Context(), body.CreateRuleInput, body.Reason)
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusCreated, row)
}
// PATCH /admin/api/rules/{id} — partial update of a draft.
func handleAdminPatchRule(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
return
}
id, ok := parseRuleID(w, r)
if !ok {
return
}
var body struct {
services.RulePatch
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
row, err := dbSvc.ruleEditor.UpdateDraft(r.Context(), id, body.RulePatch, body.Reason)
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
}
// POST /admin/api/rules/{id}/clone-as-draft
func handleAdminCloneAsDraft(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
return
}
id, ok := parseRuleID(w, r)
if !ok {
return
}
reason, ok := decodeReason(w, r)
if !ok {
return
}
row, err := dbSvc.ruleEditor.CloneAsDraft(r.Context(), id, reason)
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusCreated, row)
}
// POST /admin/api/rules/{id}/publish
func handleAdminPublishRule(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
return
}
id, ok := parseRuleID(w, r)
if !ok {
return
}
reason, ok := decodeReason(w, r)
if !ok {
return
}
row, err := dbSvc.ruleEditor.Publish(r.Context(), id, reason)
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
}
// POST /admin/api/rules/{id}/archive
func handleAdminArchiveRule(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
return
}
id, ok := parseRuleID(w, r)
if !ok {
return
}
reason, ok := decodeReason(w, r)
if !ok {
return
}
row, err := dbSvc.ruleEditor.Archive(r.Context(), id, reason)
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
}
// POST /admin/api/rules/{id}/restore
func handleAdminRestoreRule(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
return
}
id, ok := parseRuleID(w, r)
if !ok {
return
}
reason, ok := decodeReason(w, r)
if !ok {
return
}
row, err := dbSvc.ruleEditor.Restore(r.Context(), id, reason)
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
}
// GET /admin/api/rules/{id}/audit?offset=N&limit=M
func handleAdminGetRuleAudit(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
return
}
id, ok := parseRuleID(w, r)
if !ok {
return
}
offset, limit := 0, 0
q := r.URL.Query()
if v := q.Get("offset"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid offset"})
return
}
offset = n
}
if v := q.Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid limit"})
return
}
limit = n
}
rows, err := dbSvc.ruleEditor.ListAudit(r.Context(), id, offset, limit)
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// GET /admin/api/rules/{id}/preview?trigger_date=YYYY-MM-DD&flags=a,b&court_id=...
func handleAdminPreviewRule(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil || dbSvc.fristenrechner == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
return
}
id, ok := parseRuleID(w, r)
if !ok {
return
}
q := r.URL.Query()
triggerDate := q.Get("trigger_date")
if triggerDate == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "trigger_date required"})
return
}
var flags []string
if v := q.Get("flags"); v != "" {
for _, f := range splitCSV(v) {
if f != "" {
flags = append(flags, f)
}
}
}
courtID := q.Get("court_id")
resp, err := dbSvc.ruleEditor.Preview(r.Context(), dbSvc.fristenrechner, id, triggerDate, flags, courtID)
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
// GET /admin/api/rules/export-migrations?since=<audit_id>
func handleAdminExportRuleMigrations(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
return
}
since := r.URL.Query().Get("since")
out, err := dbSvc.ruleEditor.ExportMigrationsSince(r.Context(), since)
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// =============================================================================
// Page handlers — serve the static SPA shells. Auth + admin gate live
// at the route registration in handlers.go.
// =============================================================================
func handleAdminRulesListPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-rules-list.html")
}
func handleAdminRulesEditPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-rules-edit.html")
}
func handleAdminRulesExportPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-rules-export.html")
}
// =============================================================================
// helpers
// =============================================================================
func parseRuleID(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return uuid.Nil, false
}
return id, true
}
func decodeReason(w http.ResponseWriter, r *http.Request) (string, bool) {
var body struct {
Reason string `json:"reason"`
}
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return "", false
}
}
return body.Reason, true
}
// writeRuleEditorError maps the service-level typed errors to HTTP statuses.
// Distinct from writeServiceError (projects path) because the rule
// editor's lifecycle errors map to 409 Conflict, which the project
// service doesn't use.
func writeRuleEditorError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, services.ErrRuleNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "rule not found"})
case errors.Is(err, services.ErrAuditReasonRequired):
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "audit_reason required",
"message": "Every rule-editor write must include a non-empty `reason` body field.",
})
case errors.Is(err, services.ErrInvalidLifecycleState):
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
case errors.Is(err, services.ErrCyclicSpawn):
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
case errors.Is(err, services.ErrOrphanAlreadyResolved):
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
case errors.Is(err, services.ErrOrphanCandidateMismatch):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
case errors.Is(err, services.ErrInvalidInput):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// =============================================================================
// Orphan-resolution handlers — Slice 11b admin add-on.
// Lists the unresolved rows from paliad.deadline_rule_backfill_orphans
// (mig 089) and lets an admin hand-bind each to one of the matcher's
// candidate rule_ids. The resolve write lands in a single tx via the
// rule editor service so the deadline row + the staging row stay in
// sync; admin-only at the route layer.
// =============================================================================
// GET /admin/api/orphans
func handleAdminListOrphans(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
return
}
rows, err := dbSvc.ruleEditor.ListOrphans(r.Context())
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// POST /admin/api/orphans/{id}/resolve body: {"rule_id": "...", "reason": "..."}
func handleAdminResolveOrphan(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var body struct {
RuleID string `json:"rule_id"`
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
ruleID, err := uuid.Parse(body.RuleID)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid rule_id"})
return
}
if err := dbSvc.ruleEditor.ResolveOrphan(r.Context(), id, ruleID, body.Reason); err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
}

View File

@@ -281,7 +281,8 @@ func handleGetApprovalRequest(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
uid, ok := requireUser(w, r)
if !ok {
return
}
requestID, err := uuid.Parse(r.PathValue("id"))
@@ -289,7 +290,7 @@ func handleGetApprovalRequest(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
return
}
row, err := dbSvc.approval.GetRequest(r.Context(), requestID)
row, err := dbSvc.approval.GetRequest(r.Context(), uid, requestID)
if err != nil {
writeServiceError(w, err)
return

View File

@@ -0,0 +1,74 @@
package handlers
import (
"errors"
"net/http"
"os"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// t-paliad-177 — Project Timeline / Chart standalone page.
//
// Slice 1 served dist/projects-chart.html unconditionally and relied on
// the client's first API fetch to enforce visibility. That leaked a 200
// for any well-formed UUID a guesser tried (m/paliad#35 Slice 1 edge
// case #2). Slice 2 closes the leak — we resolve the project via
// ProjectService.GetByID *before* serving the shell so an inaccessible
// id returns 404 + the standard notfound chrome.
//
// Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
func handleProjectsChartPage(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
serveChartNotFound(w)
return
}
if _, err := dbSvc.projects.GetByID(r.Context(), uid, id); err != nil {
// ErrNotVisible + any "not found" surface from the service collapses
// to the same outward 404 — never tell a guesser whether the id
// exists, only whether they can see it.
if errors.Is(err, services.ErrNotVisible) {
serveChartNotFound(w)
return
}
// Genuine errors (DB hiccup, etc.) — log via writeServiceError but
// also fall back to 404 page chrome for the user instead of a raw
// 500 string. The JSON path of writeServiceError handles /api/*
// only, so we keep its logging side-effect but render the HTML.
writeServiceError(httpDevNullJSON{}, err)
serveChartNotFound(w)
return
}
http.ServeFile(w, r, "dist/projects-chart.html")
}
func serveChartNotFound(w http.ResponseWriter) {
body, err := os.ReadFile("dist/notfound.html")
if err != nil {
http.Error(w, "404 page not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write(body)
}
// httpDevNullJSON is a writer that discards everything writeServiceError
// would have emitted — we only want the log line, not a duplicate body
// before serveChartNotFound writes the real one.
type httpDevNullJSON struct{}
func (httpDevNullJSON) Header() http.Header { return http.Header{} }
func (httpDevNullJSON) Write(b []byte) (int, error) { return len(b), nil }
func (httpDevNullJSON) WriteHeader(int) {}

View File

@@ -0,0 +1,30 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
)
// t-paliad-177 Slice 2 — visibility leak fix.
//
// The end-to-end "GET /chart returns 404 for invisible projects" check
// would need a mocked ProjectService + auth.Client; the handler package
// has no harness for that today (all existing _test.go files unit-test
// pure helpers). Until that harness exists, we pin the contract from
// the helper layer: serveChartNotFound writes a 404 + an HTML
// Content-Type. The dist/notfound.html lookup falls back to a plain
// 404 string in test environments without a built frontend, which is
// the documented degraded path.
func TestServeChartNotFound_Returns404HTML(t *testing.T) {
w := httptest.NewRecorder()
serveChartNotFound(w)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", w.Code, http.StatusNotFound)
}
body := w.Body.String()
if body == "" {
t.Error("body is empty — should be either the notfound chrome or the plain-text fallback")
}
}

View File

@@ -34,16 +34,23 @@ func handleListDeadlineRules(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, rules)
}
// GET /api/proceeding-types-db
// GET /api/proceeding-types-db?category=<value>
//
// Lists active proceeding types from the DB. Optional `category` query
// param filters the result set (e.g. ?category=fristenrechner is the
// shape the project-create / project-edit pickers use after Phase 3
// Slice 5 — design §3.F + m's Q2 ruling restricts project-binding to
// fristenrechner-category codes). Empty / missing param returns every
// active row.
//
// Lists active proceeding types from the DB.
// (Distinct route name from the existing in-memory /api/tools/proceeding-types
// endpoint to avoid path conflicts during the Phase B → Phase C transition.)
func handleListProceedingTypesDB(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
types, err := dbSvc.rules.ListProceedingTypes(r.Context())
category := r.URL.Query().Get("category")
types, err := dbSvc.rules.ListProceedingTypesByCategory(r.Context(), category)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list proceeding types"})
return

View File

@@ -0,0 +1,106 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// POST /api/tools/event-trigger — Phase 3 Slice 6 (t-paliad-187, design
// §5). Discovers and computes deadline rules triggered by an event-type
// and/or a deadline-concept. Caller passes UUID identifiers (not the
// legacy /api/tools/event-deadlines bigint trigger_event_id surface);
// service handles the bridge to Pipeline-C rules internally.
//
// Body:
//
// {
// "eventTypeId": "uuid", // optional — fires Pipeline-C rules via event_types.trigger_event_id
// "conceptId": "uuid", // optional — fires Pipeline-A rules linked by concept_id FK
// "triggerDate": "2026-01-15", // required, YYYY-MM-DD
// "flags": ["with_ccr"], // optional, gates rules via evalConditionExpr
// "courtId": "upc-ld-mn", // optional, picks (country, regime) for non-working-day arithmetic
// "perspective": "claimant" // optional, drops opposing-side rules
// }
//
// At least one of eventTypeId / conceptId must be set. When both are
// set, the rule set is the UNION deduped by rule.id.
//
// Response: same shape as POST /api/tools/fristenrechner (UIResponse) —
// the frontend can render with the existing timeline renderer.
//
// Returns 503 when the DB pool is unavailable (server bootstrap before
// services attached); the page itself still renders since it's static
// HTML so a downstream error pop-up is the worst the user sees.
func handleEventTriggerCalculate(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.eventTrigger == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Event-Trigger ist vorübergehend nicht verfügbar (keine Datenbank).",
})
return
}
var req struct {
EventTypeID string `json:"eventTypeId,omitempty"`
ConceptID string `json:"conceptId,omitempty"`
TriggerDate string `json:"triggerDate"`
Flags []string `json:"flags,omitempty"`
CourtID string `json:"courtId,omitempty"`
Perspective string `json:"perspective,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
return
}
if req.TriggerDate == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "triggerDate ist erforderlich"})
return
}
if req.EventTypeID == "" && req.ConceptID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "eventTypeId oder conceptId ist erforderlich",
})
return
}
input := services.EventTriggerInput{
TriggerDate: req.TriggerDate,
Flags: req.Flags,
CourtID: req.CourtID,
Perspective: req.Perspective,
}
if req.EventTypeID != "" {
id, err := uuid.Parse(req.EventTypeID)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "eventTypeId ist keine gültige UUID",
})
return
}
input.EventTypeID = &id
}
if req.ConceptID != "" {
id, err := uuid.Parse(req.ConceptID)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "conceptId ist keine gültige UUID",
})
return
}
input.ConceptID = &id
}
resp, err := dbSvc.eventTrigger.Trigger(r.Context(), input)
if err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, resp)
}

Some files were not shown because too many files have changed in this diff Show More