Compare commits

...

27 Commits

Author SHA1 Message Date
mAi
621fe35d79 docs(test-strategy): fold m's §10 decisions addendum
m's 2026-05-19 picks via AskUserQuestion interview:
- Q1 budget: 60–90s gate, 3–4min full (inventor's call — m deferred)
- Q2 CI: Gitea Actions, gate tier only
- Q3 test DB: YouPC for devs + ephemeral docker for CI
- Q4 coverage: critical-path only, no % gate
- Q5 floor: Slices 1+4+5 before new feature work
- Q6 ownership: head decides + rotate per profile

All six matched inventor's recommendation. Slice 1 (migration
dry-run + boot smoke) starts first; Slices 4+5 in parallel after.
2026-05-19 10:30:25 +02:00
mAi
8414aa4c14 docs(test-strategy): inventor design for production-grade test pyramid
t-paliad-213 — six-layer pyramid (migration dry-run, Go/frontend unit,
frontend DOM, service live-DB, handler integration, Playwright E2E),
audit of current coverage (323 test funcs, 24 untested services, 53
untested handlers, 4/90 frontend modules), eight-slice tracer-bullet
roll-out, six open questions for m.

Read-only design phase per CLAUDE.md inventor gate — no test files,
make targets or CI configs touched. Awaiting m go/no-go on §5 slice
plan + §6 open questions before any coder shift.
2026-05-19 10:10:23 +02:00
mAi
92780cf726 fix(events): default Termine filter to 'upcoming' so past events don't show by default
m's call 2026-05-19: opening /events with type=appointment was
defaulting status='all' which surfaces every past appointment in
the corpus. The default should hide past events; 'Alle (auch
vergangene)' is opt-in for the one user who actually wants the
historical view.

Replaces the default with the existing DeadlineFilterUpcoming bucket
(already implemented backend-side at internal/services/deadline_service.go:132
as 'today + future'). New status option 'upcoming' at the top of the
appointment list; existing 'all' moves to the bottom with a clearer
label that calls out 'incl. past'.

Deadlines unaffected — they still default to 'pending'.

i18n keys added in both DE + EN slots (events.filter.status.upcoming
'Ab heute' / 'From today'; .all reframed as 'Alle (auch vergangene)'
/ 'All (incl. past)').
2026-05-19 09:56:05 +02:00
mAi
a0082d2b0d fix(index): drop Downloads section from anon landing — the dotm card was the only visible affordance for unauth visitors
m's call 2026-05-19: the /files/hl-patents-style.dotm link on the
anonymous frontpage shouldn't tempt visitors to try downloading. The
/files/{filename} route IS already auth-gated (302 to /login on
anon click), and the macro-update endpoint at /patentstyle/* stays
public for the in-Word update logic per m's note ('with knowledge
of the direct source link it needs to be available').

Authenticated users never see this page anyway — handleRootPage 302s
them to /dashboard. So removing the section costs them nothing and
removes the obvious affordance for anon visitors. ICON_DOWNLOAD
const dropped along with it.

The Downloads page itself (/downloads + Sidebar nav entry) stays —
that's auth-gated and works for logged-in users.

Leftover surface: /patentstyle/HL-Patents-Style.dotm is still anon-
downloadable (necessary for the Word macro's auto-update poll).
That's m's stated requirement — flagged as the known leak path for
anyone who knows the URL.
2026-05-19 09:05:36 +02:00
mAi
c921925c68 Merge: hlpat /patentstyle/ endpoint 2026-05-18 21:00:46 +02:00
mAi
22cfdb909f feat(handlers): serve /patentstyle/ for HL Patents Style auto-update
Hosts the manifest + .dotm that the Word ribbon's Check-for-Updates button polls. paliad.msbls.de is the primary endpoint; hihlc.msbls.de mirrors it (hihlc/main b871ded). Files live in frontend/public/patentstyle/, copied into dist/ by the frontend build. Cache-Control: no-cache via noCacheAssets so version.json never serves stale after a release.
2026-05-18 21:00:46 +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
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
31 changed files with 3343 additions and 179 deletions

View File

@@ -0,0 +1,582 @@
# Design — Paliad Test Strategy (production-grade)
**Author:** mendel (inventor)
**Date:** 2026-05-19
**Task:** t-paliad-213
**Branch:** `mai/mendel/inventor-test-strategy`
**Status:** DESIGN READY FOR REVIEW. No test files / Make targets / CI configs touched. Awaiting m go/no-go on §5 slice plan + §6 open questions before any coder shift.
---
## 0. TL;DR
Paliad has accidental test discipline today: 59 `_test.go` files / 323 test functions in Go (≈45 % of services tested, ≈12 % of handlers tested) and 4 frontend test files for 90+ client modules (≈4 %). There is no committed end-to-end suite and no CI — every smoke pass is human-driven via the manual reports in `tests/`. The `mig 098` prod crash-loop, the `t-paliad-036` triple-bug after the German→English rename, and a long tail of UX regressions (deadline-done modal, calendar column drift) would all have been caught by a 10-test boot-and-click smoke pass.
This design proposes a six-layer test pyramid with a concrete tool per layer (stdlib `testing` + bun's built-in `bun:test` + `playwright` for E2E — nothing third-party we don't already use). It pins three lessons paliad has paid for in commits:
1. **No mocks at the service↔DB boundary.** Live-DB tests against a per-developer Postgres are the floor; in-memory mocks for `paliad.*` would have hidden every rename-after-DROP-CASCADE bug. Project preference is already in this direction (27/44 service tests are live-DB-gated); we double down rather than reverse.
2. **Migrations must dry-run before they merge.** Every recent prod-down (mig 098, mig 020-after-rename, mig 099 audit_reason gap) was a migration that compiled, passed `go test ./...` (which skips without `TEST_DATABASE_URL`), and broke on first apply against the real schema. A `make verify-migrations` target that does BEGIN/apply/ROLLBACK in CI fixes the entire failure mode.
3. **Browser-shaped bugs need a browser.** The fristenrechner cascade, shape-timeline render, calendar grid, inline paliadin widget — these are JS state machines. Bun's stdlib `bun:test` covers the pure parser/codec code; Playwright covers the auth-gated DOM. Don't try to substitute one for the other.
Six slices roll the strategy out as tracer-bullet PRs, each independently shippable. Slice 1 (migration dry-run harness) and Slice 4 (Playwright golden-path smoke) buy the most outage-prevention per LoC; the rest is widening proven patterns.
Six open questions for m at §6. Most surface a coverage-vs-cost trade-off — the picks that need m's call before any code lands are CI infrastructure choice (Q2), per-PR run-time budget (Q1), and live-DB-vs-dockerised Postgres (Q3).
---
## 1. Audit — what exists today
Counts taken on `mai/mendel/inventor-test-strategy` @ HEAD (2026-05-19, 100 migrations applied).
### 1.1 Go test inventory
| Package | Source files | Test files | Test functions | Notes |
|---|---|---|---|---|
| `internal/services` | 56 | 44 | ~200 | 26 live-DB-gated (`TEST_DATABASE_URL`), 18 pure-Go. 24 services have **no test file at all** — see §1.4. |
| `internal/handlers` | 59 | 7 | ~30 | Only auth-domain check, search, audit-parse, approval-error-mapping, redirects, verfahrensablauf-redirect, chart-404 covered. **53 handlers have no test file.** |
| `internal/auth` | small | 2 | ~10 | Session middleware + require-admin. |
| `internal/branding` | small | 1 | small | Firm-name override. |
| `internal/offices` | small | 1 | small | Office enum. |
| `internal/changelog` | small | 1 | small | Pure parser. |
| `internal/calc` | small | 1 | small | Fees / fee tables. |
| `cmd/server` | 1 | 1 | small | `main_paliadin_backend_test.go` covers env-gate selection. |
| **Total** | **133** | **58** | **323** | |
`go test ./...` runs all 58 files. Without `TEST_DATABASE_URL` set, 27 of them silently skip their live-DB cases — the suite still passes, but coverage of mutation paths drops to near zero.
### 1.2 Frontend test inventory
| Path | Test files | Tested |
|---|---|---|
| `frontend/src/client/filter-bar/url-codec.test.ts` | 1 | FilterBar URL codec round-trip. |
| `frontend/src/client/views/format.test.ts` | 1 | Date/time formatters (regression for t-paliad-153). |
| `frontend/src/client/views/shape-timeline-chart.test.ts` | 1 | Chart layout pure function. |
| `frontend/src/client/views/shape-timeline-cv.test.ts` | 1 | Continuous-view shape layout. |
| **Total** | **4** | Out of ~90 client modules (`frontend/src/client/*.ts`). |
All four use bun's built-in `bun:test` (no extra dep). No DOM/jsdom tests. No Playwright. No `bun test` script in `package.json` (`bun run build` is the only script).
### 1.3 End-to-end / smoke
- `tests/smoke-2026-04-25.md`, `tests/smoke-auth-2026-04-25.md`, `tests/smoke-auth-2026-04-26-cleanup.md` — human-written reports with screenshots committed under `tests/screenshots-*`. No code. No re-runnable script.
- `mai-tester` skill uses Playwright for ad-hoc runs; nothing committed.
- No `e2e/`, no `.gitea/workflows/`, no `.github/workflows/`, no `Makefile`.
### 1.4 Critical service paths with no test file
These are `internal/services/*.go` for which no `*_test.go` sibling exists:
| Service | Risk class | Why it matters |
|---|---|---|
| `caldav_service.go`, `caldav_client.go`, `caldav_crypto.go`, `caldav_ical.go` | High | Per-user push/pull goroutines + AES-GCM at rest. One pure parser test (`caldav_ical_timeline_test.go`) exists but the service + crypto + WebDAV client are blind. |
| `agenda_service.go` | High | Dashboard agenda query; reused by `/agenda` page. Exercised transitively by visibility tests but no direct test. |
| `dashboard_service.go` | High | Traffic-light + summary counts. Same story — transitively covered via visibility, no direct test. |
| `derivation_service.go` | Medium | Project-tree derivation (the new t-paliad-194-era subtree machinery). |
| `team_service.go` | Medium | Team membership / inheritance. |
| `partner_unit_service.go` | Medium | Dezernat replacement (t-paliad-070). |
| `party_service.go`, `note_service.go`, `link_service.go`, `checklist_instance_service.go` | Medium | All do project-scoped CRUD with the same RLS+audit pattern that `t-paliad-036` proved easy to break. |
| `appointment_service.go` | High | Hot — every calendar mutation. Exercised through approval tests but has no own test file. |
| `view_service.go` | Medium | Powers the substrate (`/views/*`). |
| `paliadin_jwt.go` | Medium | Per-turn JWT mint for the aichat path (`t-paliad-194`). No call sites in tests today. |
| `markdown.go` | Low | Glossary + checklist content render. |
### 1.5 Handlers with no test file
53 of 59. Notably: **`auth.go` itself** (login / logout / session creation), **`projects.go`** (the most-mutated entity), **`deadlines.go` / `appointments.go`** (writes), **`paliadin.go` / `paliadin_suggest.go`** (m-only routes — never click-tested), **`fristenrechner.go` / `fristenrechner_search.go` / `fristenrechner_event_categories.go`** (the cascade users live in), **`dashboard.go` / `agenda.go`** (landing), **`onboarding.go` / `onboarding_gate.go`** (every new user's first three minutes), **`invite.go`** (rate-limited write path). The currently-tested handlers (search, audit-parse, approval error mapping, etc.) are the cheap pure-Go ones; every handler that touches the DB is untested at handler level.
### 1.6 Live-DB test scaffold — is it sound?
The pattern (read from `internal/services/visibility_test.go`):
```go
url := os.Getenv("TEST_DATABASE_URL")
if url == "" { t.Skip("TEST_DATABASE_URL not set — skipping live DB test") }
if err := db.ApplyMigrations(url); err != nil { t.Fatalf(...) }
pool, _ := sqlx.Connect("postgres", url)
defer pool.Close()
// per-test seed + cleanup via DELETE + defer cleanup()
```
Verdict: **sound, but has rough edges that need addressing before we widen.**
- ✅ Migrations apply at test startup against the test DB — catches every "you forgot to add a CHECK" / "you reference a column that doesn't exist" before a real-DB-touching test runs.
- ✅ Per-test cleanup via `DELETE FROM ... WHERE id IN ($1,...)` is explicit and idempotent.
- ✅ The `paliad.paliad_schema_migrations` tracker collision noted in memory `0b900afa…` is a pre-existing issue, not introduced by this design.
- ⚠️ Cleanup-via-DELETE is fragile: a test that creates a row referenced by FK from another table needs to remember to clean both. A few existing tests (see `audit_service_test.go`) already chain 5+ DELETEs.
- ⚠️ Tests can't run in parallel against the same `TEST_DATABASE_URL` because they share schema state. `go test ./...` defaults to `-parallel` per-package; same-package tests with overlapping cleanup IDs can interfere.
- ⚠️ No CI today actually exercises `TEST_DATABASE_URL` — so every live-DB test is effectively run only on the author's laptop or not at all. Half the value is paid-for but unbilled.
### 1.7 Migration tooling
- `internal/db/migrate.go` embeds `migrations/*.sql` and applies on server boot via `golang-migrate/v4` with the `paliad_schema_migrations` tracker in `public` schema.
- 100 migrations on disk (`001``100`).
- **No dry-run gate today.** A bad migration breaks `paliad.de` at boot (Dokploy crash-loops the container). Recent prod incidents: mig 098 (submission code rename), mig 099 (with_po flag drop missed audit_reason gap), mig 020 (function rename without body rewrite — see memory `49a05cfa…`).
- `down.sql` exists for every migration but no test ever exercises it.
### 1.8 CI / deploy loop
- No CI. Push-to-main → Gitea webhook → Dokploy auto-builds the Dockerfile and replaces the container. The Dockerfile runs `bun run build` then `go build`. **Neither `go test` nor `bun test` runs in the build pipeline.**
- Pre-commit hooks: none in repo. Each worker runs `go build / go vet / go test / bun run build` by convention (see memories — every shipped task report ends with "build hygiene held").
---
## 2. Test pyramid — recommended shape
```
┌─────────────────┐
│ E2E (Playwright)│ ~10 flows
│ L6 │
└─────────────────┘
┌─────────────────────────┐
│ Handler integration │ ~30 routes
│ L5 (httptest + real DB)│
└─────────────────────────┘
┌──────────────────────────────────┐
│ Service-layer (live DB) │ ~60 tests
│ L4 (BEGIN/ROLLBACK harness) │
└──────────────────────────────────┘
┌──────────────────────────────────────────┐
│ Frontend DOM / cascade (bun:test+jsdom) │ ~15 modules
│ L3 │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ Frontend unit (bun:test pure TS) │ ~30 modules
│ L2 │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Go unit (stdlib testing, table-driven, pure functions) │ ~150 tests
│ L1 │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Migration dry-run (make verify-migrations) │ 100 mig
│ L0 — gate on every PR │
└──────────────────────────────────────────────────────────────┘
```
### Layer 0 — Migration dry-run
**What:** Every `*.up.sql` in `internal/db/migrations/` is applied inside a single `BEGIN ... ROLLBACK` transaction against a scratch Postgres, in numeric order. The harness asserts each statement succeeds *and* asserts no statement leaves the schema in a `paliad_schema_migrations.dirty=true` state. A second pass applies all up-migrations end-to-end (no rollback) and then re-applies the latest up-migration to assert idempotency (every paliad migration since `t-paliad-070` has been written to be idempotent — this enforces it).
**Tool:** stdlib `testing` package, no third-party. Pattern: `internal/db/migrate_test.go` with a `TestMigrations_DryRun` driven from `TEST_DATABASE_URL`. A `make verify-migrations` target wraps it.
**Why this layer matters most:** Every recent prod-down was a migration. Catching them on a CI run before merge is the highest-leverage test investment paliad can make. Cost: one ~100-line Go file + one Postgres in CI.
**Coverage target:** 100 % of `*.up.sql` files. Hard gate on PR — no exceptions.
### Layer 1 — Go unit (pure)
**What:** `go test ./...` against pure functions — formatters, parsers, validators, calculators, fee tables, deadline calculators, projection lookahead clamping, codec round-trips. No DB, no HTTP.
**Tool:** stdlib `testing`. Table-driven `cases := []struct{...}{...}` style is already the house pattern (see `auth_test.go` / `projection_anchor_test.go`). **Do not introduce testify or any matcher library** — the current code reads cleanly without one, and 323 existing test functions don't need a rename pass.
**What's already there:** 19 pure-Go test files (calculator, mapping, codec, holiday, fees, etc.). Density is good; targeted infill rather than re-architecture.
**Coverage target:** Every pure function in `internal/services/`, `internal/handlers/`, `internal/calc/`, `internal/changelog/`. Aim for "every branch in a decision table has at least one test row." Don't chase % — chase "the obvious edge that would burn a coworker".
### Layer 2 — Frontend unit (pure)
**What:** `bun test` against pure TS modules — URL codecs (`filter-bar/url-codec`), formatters, parsers, i18n key correctness (every `data-i18n` attribute used in TSX has a key in `i18n.ts`), view-spec parsers, projection-row mapping helpers.
**Tool:** `bun:test` (built into bun, no install). Already in use in 4 files — extend the same pattern. Add `bun test` to `package.json` `scripts`.
**What to add:**
- i18n key audit (every `t("foo.bar")` and `data-i18n="foo.bar"` resolves in both `de` and `en`).
- `filter-bar/` types + render helpers (paliad has shipped 4 FilterBar slices; coverage is one codec test).
- `paliadin-context.ts` route table + entity extraction (the `[ctx …]` envelope is a stable contract paliadin's SKILL.md depends on; any drift here is a silent failure).
- `paliadin-starters.ts` registry — every route maps to ≥1 starter; every starter is bilingual.
- View-spec parsers in `views/`.
**Coverage target:** Every pure TS module in `frontend/src/client/`. Pages (TSX renderers) are E2E concern, not unit concern.
### Layer 3 — Frontend DOM (cascade / jsdom)
**What:** `bun test` with jsdom global, exercising the interactive cascade modules — the fristenrechner cascade builder, the shape-timeline render, the FilterBar UI (chips, panels), the calendar grid, the inline Paliadin widget message stream, the inbox-row click handler, the dashboard activity item navigation.
These modules contain enough state that pure-function tests miss real bugs (e.g. the t-paliad-098 `.entity-table` row-cursor lie was a CSS+DOM bug; t-paliad-099's modal close was a DOM-event bug; t-paliad-103's `::before` overlay click-swallow was a DOM bug).
**Tool:** bun + `happy-dom` is the lighter choice; if it can't handle event ordering, fall back to `jsdom`. Both are ESM-clean and bun-friendly. **Pick one and stick with it — running both means twice the dependency surface.** Default pick: `happy-dom` (smaller, paliad doesn't need legacy IE semantics).
**Pattern:** import the cascade module, build a minimal DOM (`document.body.innerHTML = …`), dispatch synthetic events, assert resulting state. Reuses the production renderers — no test-only fakes.
**Coverage target:** ~15 modules. Specifically:
- `client/filter-bar/index.ts` chip render + active-state.
- `client/fristenrechner.ts` cascade — most complex JS in the codebase; depend chains light up every UPC bug we know.
- `client/shape-timeline.ts` lane mode + track mode (envelope wire shape brittle to refactor).
- `client/projects-detail.ts` row click + Verlauf render.
- `client/paliadin-widget.ts` + `paliadin-context.ts` interaction.
- `client/inbox.ts` row-action click routing.
- `client/dashboard.ts` activity-item nav.
- `client/deadlines-calendar.ts` / `appointments-calendar.ts` column layout (the calendar-column-drift bug class).
Not unit tests; not E2E. They are the missing middle.
### Layer 4 — Service-layer (live DB)
**What:** Go service methods against a real Postgres, using the existing `TEST_DATABASE_URL` pattern. Two improvements:
1. **Replace per-test DELETE cleanup with a per-test transaction harness** — open a transaction, run the test inside it, ROLLBACK. Faster, isolating, no cleanup forgotten. Already viable because the service layer accepts `*sqlx.DB`-or-tx-shaped interfaces in many places; needs a small `internal/services/internal/testdb` package that exposes `WithTx(t *testing.T, fn func(*sqlx.Tx))`. Migration is mechanical, can happen alongside infill.
*Caveat:* some service methods open their own transactions internally (`approval_service.submit` is one). Those keep DELETE cleanup; the tx harness is a default, not a mandate.
2. **Make `TEST_DATABASE_URL` mandatory in CI.** Today these tests are skipped on every machine that doesn't `export TEST_DATABASE_URL=…` — i.e. they don't run on autoatic pipelines because there's no pipeline. Once CI exists (§3.5), it becomes a required env var.
**Tool:** stdlib `testing` + `sqlx` (already in `go.mod`). **No mocks at the service↔DB boundary.** This is m's hardest line — see global CLAUDE.md memory pattern and `t-paliad-036` (the bug that masked two other bugs would have been caught instantly by a real-DB test).
**Where to invest first:** Approval (already heavy), Projection (already heavy), Fristenrechner (already heavy), DeadlineService Create/Update/Complete/Delete with `pending_request_id` interplay, AppointmentService same, ProjectService visibility predicate, CalDAV push (the four CalDAV `*.go` files have zero direct test).
**Coverage target:** Every service method that mutates the DB has at least one happy-path live-DB test. RLS predicate (`visibilityPredicatePositional`) has one test per role (global_admin, member, non-member).
### Layer 5 — Handler integration (httptest + real DB)
**What:** Spin a real `services.DBService`, mount the protected mux, drive `httptest.NewRequest` + `ServeHTTP` against it. Auth via a fake session cookie produced by a `testauth.Login(t, userID)` helper that mints the same Supabase JWT shape `auth.UserIDFromContext` expects.
**Why:** The 53 untested handlers are where the request shape ↔ service interaction lives. Examples that would have caught real bugs:
- `t-paliad-036`'s "`/projects/{id}` 404 while `/api/projects/{id}` 200" mismatch — a 5-line handler test would have failed before the migration ran.
- mig 020's three-stacked bug — a handler test that POSTs a deadline and asserts a 200 + read-back row would have failed at submit-time, not boot-time.
- The audit-log query timezone bug — handler test asserts the JSON contains the expected `event_date`.
**Tool:** stdlib `net/http/httptest`. **No new framework.** Pattern: handler tests live next to the handler file (`internal/handlers/deadlines_test.go` next to `deadlines.go`).
**Coverage target:** Every handler that gates a state-changing route — `POST/PATCH/DELETE` flavour. Plus `GET` handlers that compose a non-trivial query (dashboard, agenda, search, audit-log).
### Layer 6 — End-to-end (Playwright)
**What:** A small Playwright suite (~10 flows) committed at `e2e/` with a `bun run e2e` entry. Targets a local `./paliad` against a scratch Postgres (the same `TEST_DATABASE_URL`). Each test logs in, drives the UI through one user journey, asserts visible state.
**Why ~10 not 100:** Per-PR budget caps at ~2 min total (§6 Q1). Playwright tests are the most expensive minute-per-confidence in this stack; they pay for themselves on the *golden path* and nothing else. The deep-coverage layer is L5; E2E is *"is the app still alive end to end?"*.
**Tool:** `playwright` (npm; bun installs cleanly). No third-party test runner — Playwright ships its own. Tests live in `e2e/*.spec.ts`. **Not bun:test.** Playwright's runner is purpose-built for browser-driving and integrates with their tracing — don't fight it.
**Cap:** 10 flows. If a new test wants in, an existing one must drop out (or we have a real reason to widen). This is the cheapest discipline available: it forces the suite to remain a smoke pass, not a regression-test dumping ground.
**Coverage target:** See §4.
---
## 3. Tooling — concrete picks per layer
| Layer | Tool | Already in deps? | Install? |
|---|---|---|---|
| L0 — migration dry-run | stdlib `testing` + `migrate/v4` | yes | no |
| L1 — Go unit | stdlib `testing` | yes | no |
| L2 — Frontend unit | `bun:test` | yes (built into bun) | no |
| L3 — Frontend DOM | `bun:test` + `happy-dom` | bun yes, happy-dom **new** | `bun add -d happy-dom` (one dep, ~200 KB) |
| L4 — Service live-DB | stdlib + sqlx | yes | no |
| L5 — Handler integration | stdlib `net/http/httptest` + sqlx | yes | no |
| L6 — E2E | `@playwright/test` | **new** | `bun add -d @playwright/test` + `npx playwright install chromium` |
Net new deps: **2** (happy-dom + playwright). Both are mainstream, both have small surface area, both align with bun's ecosystem.
Explicit rejects:
-**testify** — current tests read cleanly with stdlib; adding it forces a rename pass nobody wants.
-**vitest** — bun's built-in test runner is faster and the tests are already in `bun:test` shape.
-**dockertest / testcontainers-go** — m's preference is real-DB tests against the existing Postgres; spinning ephemeral Docker Postgres per package run adds latency and surface area for marginal isolation gain. See Q3.
-**sqlmock / gomock for DB** — banned by §0 lesson 1.
-**cypress** — Playwright is the better tool today, and the team's existing skill (`/mai-tester`) already uses it.
### 3.1 Per-PR run-time budget
Target (subject to m's call in Q1): **≤ 90 s for the gating tier (L0+L1+L2+L4 subset+L5 happy-path)**, ≤ 4 min for the full suite (add L3+L4 full+L6). The gating tier blocks merge; the full suite blocks deploy.
Indicative times (estimated, validate when slice 1 lands):
| Tier | Layers | Est. time | Blocks |
|---|---|---|---|
| **Gate (every PR)** | L0 + L1 + L2 + L5 happy-path + L4 critical | 6090 s | merge |
| **Full (every merge to main)** | + L4 full + L3 + L6 | 34 min | deploy |
### 3.2 CI — proposal, not commitment
paliad has no CI today. Two routes:
- **Gitea Actions** (m's stack already runs `mgit.msbls.de`). Self-hosted; same auth model as the rest of mAi. Adds a `.gitea/workflows/test.yml`. Postgres comes from a service container.
- **Stay click-deploy.** No CI. Workers run tests locally; Dokploy auto-deploys on green-main convention.
Recommendation: **Gitea Actions for the gate tier only** (L0 + L1 + L2), driven by a single short workflow. The L3-L6 expansion can be a follow-up once the gate tier proves stable. Deferred to Q2 for m's call.
### 3.3 Test DB — live YouPC vs ephemeral
The `paliad` schema lives on the shared YouPC Postgres (port 11833). Three options:
| Option | Pros | Cons |
|---|---|---|
| **Per-developer separate DB on YouPC** (`TEST_DATABASE_URL` per laptop) | Closest to prod; existing pattern. | Cleanup discipline matters; cross-developer contention possible. |
| **Ephemeral docker postgres per CI run** | Full isolation; parallel-safe; reset for free. | New infra; ~5 s container startup per CI invocation. |
| **Dedicated test DB on a paliad-only Postgres** | Isolated; cheap. | New infra to maintain. |
Recommendation: **option 1 for developers (no-op change), option 2 for CI** (Gitea Actions postgres service container). Deferred to Q3 for m's call.
### 3.4 Coverage targets
Don't gate on percentage. Gate on critical-path coverage (§4). Add `go test -coverprofile=` output to CI for visibility, not as a merge gate. Coverage % gating produces tests-for-tests'-sake; we want the tests that catch the bugs we've shipped.
---
## 4. Critical journeys — what MUST be covered
These are the golden-path flows. Anything not on this list is L1-L5 territory, not L6. The list is intentionally short; if it grows beyond 10, we are doing E2E wrong.
| # | Flow | Why it's critical | Layer mix |
|---|---|---|---|
| 1 | **Login → dashboard renders → traffic-light counts match** | Every user does this every day; broken auth = paliad is offline. | L6 (Playwright) + L5 handler (auth.go) |
| 2 | **Create project (Client → Litigation → Patent → Case)** | Hierarchy with team inheritance — the data model's spine. | L6 + L5 + L4 (project_service) |
| 3 | **Submit deadline → routes to /inbox → approver approves → state flips** | The 4-eye flow (t-paliad-138). Most-mutated paliad surface. | L6 + L5 (deadlines, approvals) + L4 (approval_service) |
| 4 | **Fristenrechner: pick proceeding → cascade fires → result shows** | The platform's flagship interactive tool. JS cascade. | L6 + L3 (fristenrechner cascade) + L4 (fristenrechner) |
| 5 | **SmartTimeline: anchor a projected row → predecessor-missing-error handled** | Recent Slice-2 work (t-paliad-173 / #31). High-touch surface. | L6 + L3 (shape-timeline) + L4 (projection_service) |
| 6 | **CalDAV sync: PUT a Termin → external client sees it, edits there → pull reconciles** | Owned-event semantics + foreign-UID skip rule from Phase F. Untested today. | L4 (caldav_service push/pull) — gated on Q3 (live YouPC vs ephemeral) |
| 7 | **Paliadin chat: anon visit hits 404; m's session opens widget; turn renders** | Owner-gated `/paliadin` is the only m-only surface. Quiet failures here are silent. | L6 (smoke) + L5 (paliadin_suggest) + L4 (paliadin / aichat_paliadin) |
| 8 | **/admin/rules: filter → edit one rule → lifecycle transition → audit log row** | Rules drive the cascade; bad edits break every user's fristenrechner. | L6 + L5 (admin_rules) + L4 (rule_editor_service) |
| 9 | **Onboarding: new user with allowed email → onboarding form → first project membership** | The new-user funnel; gateOnboarded middleware traps. | L6 + L5 (onboarding, invite) |
| 10 | **Migration boot smoke: spin paliad against an empty DB → server binds 8080** | Catches every mig-N crash-loop. | L0 (migration dry-run) + L4 boot-smoke variant |
Picks 1, 3, 4 and 10 are the highest-value-per-cost — they cover the routes most regressions land on (auth, mutation, cascade, boot).
---
## 5. Slice plan — tracer-bullet roll-out
Each slice is a shippable PR with a concrete deliverable, in order of expected outage-prevention payoff. Sized for a single coder shift unless flagged. No slice depends on a later one being merged. Hour estimates intentionally omitted (per global CLAUDE.md).
### Slice 1 — Migration dry-run harness + boot smoke (highest leverage)
**Branch:** `mai/<coder>/test-strategy-slice-1-migrations`
**Deliverable:**
- `internal/db/migrate_test.go``TestMigrations_DryRun` (per-mig BEGIN/ROLLBACK), `TestMigrations_EndToEnd` (full apply, then re-apply latest to assert idempotency), `TestMigrations_Down` (apply N→0).
- `Makefile` with `make verify-migrations` (the gate target), `make test` (run everything), `make test-go`, `make test-frontend`.
- `cmd/server/main_paliadin_backend_test.go` already exists; extend with a `TestMain_BindsHTTPAfterMigrate` that boots the full server against `TEST_DATABASE_URL`, asserts `:8080` is listening, then shuts down. Catches the mig-098-class crash-loop in a single test.
- README section: how to set `TEST_DATABASE_URL` locally.
**Catches:** Every mig-98-class crash-loop; every drop-cascade-with-stale-policy-name regression (t-paliad-036).
### Slice 2 — Service-layer infill: critical mutators
**Branch:** `mai/<coder>/test-strategy-slice-2-services`
**Deliverable:**
- Test files for the three highest-impact untested services:
- `internal/services/agenda_service_test.go` (live-DB, dashboard agenda query)
- `internal/services/dashboard_service_test.go` (traffic-light counts)
- `internal/services/team_service_test.go` (membership + inheritance — RLS-load-bearing)
- Tighten existing `approval_service_test.go` + `deadline_service_test.go` coverage of the create/update/complete/delete × pending-request matrix where there are demonstrable gaps.
- Add `internal/services/internal/testdb/withtx.go` — the per-test tx harness (optional adoption; existing tests stay).
**Catches:** RLS regressions, approval interplay regressions, dashboard count drift after schema renames.
### Slice 3 — Frontend bun:test setup + L2 infill
**Branch:** `mai/<coder>/test-strategy-slice-3-frontend-unit`
**Deliverable:**
- `frontend/package.json` `scripts.test = "bun test"`.
- New tests under `frontend/src/client/`:
- `paliadin-context.test.ts` (route table, entity extraction, selection truncation).
- `paliadin-starters.test.ts` (every route ≥1 starter, every starter bilingual).
- `filter-bar/index.test.ts` (chip render + active state — pure DOM-less helpers).
- i18n key audit: `frontend/scripts/i18n-audit.test.ts` parses every `data-i18n="…"` from `dist/` HTML and every `t("…")` call from `src/`, asserts both `de` and `en` resolve. Runs as part of `bun test`.
- `make test-frontend` wires `cd frontend && bun test`.
**Catches:** i18n drift (untranslated key shipped to user), context-envelope contract drift (paliadin SKILL.md depends on it), starter-registry regressions.
### Slice 4 — Playwright golden-path smoke
**Branch:** `mai/<coder>/test-strategy-slice-4-e2e`
**Deliverable:**
- `e2e/` directory at repo root.
- `playwright.config.ts` pointing at `http://localhost:8080` (paliad started by the test, not assumed).
- Five Playwright `*.spec.ts` files covering critical journeys 1, 3, 4, 7, 9 from §4.
- `make e2e` target that:
1. starts paliad against `TEST_DATABASE_URL`,
2. waits for `:8080` to be live,
3. runs `npx playwright test`,
4. tears the server down.
- `bun add -d @playwright/test` + `npx playwright install chromium`.
**Catches:** Auth regressions, deadline-mutation regressions, fristenrechner cascade regressions, owner-gated /paliadin leaks, onboarding-gate misbehaviour.
### Slice 5 — Handler integration tests for the 5 most-touched routes
**Branch:** `mai/<coder>/test-strategy-slice-5-handlers`
**Deliverable:**
- `internal/handlers/auth_test.go` extended with `TestLogin_HappyPath` + `TestLogout_ClearsCookie` (real DB).
- `internal/handlers/projects_test.go``TestProjectsCreate` (POST 200, row inserted, audit emitted), `TestProjectsGetByID_RespectsVisibility` (404 for non-member).
- `internal/handlers/deadlines_test.go``TestDeadlinesCreate_TriggersApproval` (verifies pending pill).
- `internal/handlers/appointments_test.go` — same shape.
- `internal/handlers/paliadin_test.go``TestPaliadinPage_404ForNonOwner`, `TestPaliadinPage_200ForOwner`.
- Shared `internal/handlers/testauth/testauth.go` — mints a session cookie for `userID` so handler tests don't reinvent auth seeding.
**Catches:** Handler ↔ service wiring drift, visibility-predicate handler-side bugs (t-paliad-036 bug 2 was exactly this), owner-gate bypass.
### Slice 6 — Frontend L3 (DOM) cascade tests
**Branch:** `mai/<coder>/test-strategy-slice-6-frontend-dom`
**Deliverable:**
- `bun add -d happy-dom`.
- DOM-driven tests for the three most-touched cascades:
- `client/fristenrechner.test.ts` (cascade activate → row appears → date-set fires fetch).
- `client/shape-timeline.test.ts` (lane render, track render, projected-row click).
- `client/filter-bar/index.test.ts` (chip click toggles state, URL params update).
**Catches:** The whole class of "the function exists and is unit-tested but the cascade in the browser doesn't fire it" bugs. This is the layer that catches t-paliad-098 / 099 / 102 / 103.
### Slice 7 — CI wiring (deferred — Q2 dependent)
**Branch:** `mai/<coder>/test-strategy-slice-7-ci` (gated on m's Q2 pick)
**Deliverable:**
- `.gitea/workflows/test.yml` (or stay click-deploy if m picks that).
- Gate tier runs on every PR; full suite runs on merge to main.
- Postgres service container provides `TEST_DATABASE_URL`.
- Slack/Gotify ping on red main.
**Catches:** Drift between "tests pass on my laptop" and prod reality.
### Slice 8 — Coverage reporting + dashboard (lowest priority)
**Branch:** `mai/<coder>/test-strategy-slice-8-coverage`
**Deliverable:**
- `go test -coverprofile=` aggregated into a single `coverage.html`.
- Bun's coverage output similarly.
- A `docs/coverage.md` index updated by CI.
- **Not a merge gate.** Visibility only.
**Catches:** Slow drift; nice-to-have once the floor is in.
### Slice order rationale
1, 4, 5 are the highest outage-prevention per LoC: migration dry-run kills crash-loops, E2E kills regressions, handler tests kill wiring drift. 2, 3, 6 widen the floor; 7-8 are infrastructure.
---
## 6. Open questions for m
These need m's call before any coder shift starts (or before specific slices start, where noted).
### Q1 — Per-PR test-run budget
How long is acceptable to wait on the gate tier before merge?
- 30 s — only L0 + L1 (no L2+ on the gate).
- **6090 s (recommended)** — L0 + L1 + L2 + L5 happy-path + L4 critical.
- 2 min — add L3 + L4 full.
- 4+ min — add L6 (E2E on gate).
The pick determines whether E2E gates merge or only deploy.
### Q2 — CI infrastructure
- **Gitea Actions** (self-hosted, gate tier only, recommended) — minimal new infra; aligns with m's existing stack.
- **Stay click-deploy** — workers run tests locally; merge discipline enforced by convention. Today's reality; we keep it.
- **Both:** start with click-deploy, add Gitea Actions in Slice 7 once gate tier proves stable.
### Q3 — Live-DB vs ephemeral docker Postgres for tests
- **Per-developer YouPC DB (current pattern)** — closest to prod; existing tests work unchanged.
- **Ephemeral docker postgres in CI, YouPC for devs (recommended hybrid)** — keeps local-dev simple, gives CI deterministic isolation.
- **YouPC everywhere** — simplest, but parallel CI runs would contend.
### Q4 — Coverage targets — % or critical-path?
- **Critical-path only (recommended)** — §4's 10 flows + every state-mutating service method has a test. No % gate.
- **% gate** — set a floor (e.g. 60 % lines, 50 % branches) and refuse merges below it.
- **Both** — critical-path is mandatory, % is informational.
m's prior preference (memory pattern: "tests that catch real bugs > coverage theatre") points at critical-path-only. Confirming.
### Q5 — Which slices land before paliad is "production-grade"?
paliad is already live at `paliad.de` and being used by HLC colleagues. "Production-grade" here means "next time someone ships, we don't go down."
Picks:
- **Slices 1 + 4 + 5 are the production-grade floor (recommended).** Migration dry-run + golden-path E2E + handler integration tests cover the failure modes that hit prod since the rebrand.
- Add Slice 2 + 3 + 6 as widening passes, on their own cadence.
- Slice 7-8 are nice-to-haves.
Confirming the floor pick — and whether m wants all three to land before any new feature work, or whether they roll out alongside.
### Q6 — Who owns each slice?
Recommendation: rotate coder slots so the same person isn't on every slice. Suggested assignment (head can override):
| Slice | Profile fit |
|---|---|
| 1 — migrations | Backend-heavy coder (knuth, gauss, cronus). |
| 2 — service infill | Backend-heavy coder; whoever owns approval/projection. |
| 3 — frontend unit | Frontend-heavy coder. |
| 4 — Playwright E2E | Cross-stack coder; ideally one familiar with `/mai-tester`. |
| 5 — handler integration | Backend coder. |
| 6 — frontend DOM | Frontend coder (same person as 3 makes sense). |
Inventor does **not** decide assignments; head + m do.
---
## 7. Out of scope (explicit)
- **No rewrite of any existing test.** The 323 existing test functions stay. New tests use the new patterns; old tests are migrated only when their files are touched for unrelated reasons.
- **No third-party framework where stdlib + bun:test suffice** (testify, vitest, etc. — see §3).
- **No mocks at the service↔DB boundary.** This is the lock-in. Mocks lie; the live-DB tests we already have are paliad's most useful safety net.
- **No new feature work in this strategy.** The doc proposes infra; feature scope is unchanged.
- **No retirement of the `tests/smoke-*.md` human-written reports.** Those are great for one-shot regression hunts; they coexist with the automated suite.
---
## 8. Implementation notes for the eventual coder
(For whichever coder picks up a slice. Not exhaustive.)
- **Test-name collisions in Go's flat package namespace bite when a service grows N implementations.** Memory note from `t-paliad-194` already records this. Prefix tests with the service name (e.g. `TestAichatPaliadin_RunTurn_…` not `TestRunTurn_…`).
- **`httptest.NewRequest` does not URL-encode** — use `url.QueryEscape` for any `?q=…` argument. Memory note from `t-paliad-026`.
- **sqlx v1.4.0 `Named` parser strips one colon from `::uuid[]`** — known pitfall, repro lives at `internal/services/project_service.go`. Use `CAST(... AS uuid[])` in new query strings.
- **Live-DB cleanup must DELETE FKs first.** Order matters (auth.users last). Look at `audit_service_test.go` for the chain pattern.
- **`paliad.paliad_schema_migrations` tracker collision** is documented but unresolved. Slice 1 should add a `make reset-test-db` target that drops both `public.paliad_schema_migrations` *and* `paliad.paliad_schema_migrations` to keep developers unblocked.
- **`bun:test` matchers are Jest-compatible** — `expect().toEqual()`, `expect().toHaveBeenCalled()`, etc. No deps needed.
- **happy-dom does not implement** every DOM method (notably some `<dialog>` semantics). If a cascade test fails on something missing, jsdom is the escape hatch.
---
## 9. Decision summary — pick list for m
| # | Question | Inventor recommends |
|---|---|---|
| Q1 | Per-PR budget | 6090 s gate, 34 min full |
| Q2 | CI infra | Gitea Actions, gate tier only |
| Q3 | Test DB | YouPC for devs, ephemeral docker for CI |
| Q4 | Coverage target | Critical-path only, no % gate |
| Q5 | Production-grade floor | Slices 1 + 4 + 5 before new feature work |
| Q6 | Slice ownership | Rotate per profile; head decides |
If m's calls match inventor's, the implementer's brief writes itself: Slice 1 first, then 4 + 5 in parallel, then 2/3/6 as widening passes.
---
**Status:** DESIGN READY FOR REVIEW. Awaiting m go/no-go on §5 slice plan + §6 open questions before any coder shift starts.
---
## 10. m's decisions (2026-05-19, locked)
Walked through §6 with m via the AskUserQuestion interview (per head's 2026-05-19 workflow rule: inventor questions are resolved before parking, not after). Six picks locked, all matching inventor's recommendation.
| # | Question | m's answer | Effect on plan |
|---|---|---|---|
| Q1 | Per-PR test-run budget | **Inventor's call** (m deferred). Pick: **6090 s gate, 34 min full.** | Gate tier = L0 + L1 + L2 + L5 happy-path + L4 critical. L6 E2E gates deploy, not merge. |
| Q2 | CI infrastructure | **Gitea Actions, gate tier only.** | Slice 7 adds `.gitea/workflows/test.yml` running the gate tier; full suite stays on merge-to-main. |
| Q3 | Test DB topology | **YouPC for devs + ephemeral docker for CI.** | Local dev unchanged. Slice 7 wires Postgres service container in Gitea Actions. |
| Q4 | Coverage target | **Critical-path only, no % gate.** | §4's 10 flows + every state-mutating service method gets a test. Coverage % output is informational in Slice 8, never a merge gate. |
| Q5 | Production-grade floor | **Slices 1 + 4 + 5 before new feature work.** | These three land before any new paliad feature gets a coder shift. Slices 2, 3, 6 widen the floor on their own cadence. Slices 7-8 are nice-to-haves. |
| Q6 | Slice ownership | **Head decides + rotate per profile.** | Backend slices (1, 2, 5) → backend-heavy coder. Frontend slices (3, 6) → frontend-heavy coder. E2E (4) → cross-stack. Head picks at dispatch time. |
**Implementer brief (post-m-decisions):**
1. **Slice 1 starts first** — migration dry-run harness + `make verify-migrations` + boot-smoke variant of `cmd/server/main_paliadin_backend_test.go`. Backend-heavy coder.
2. **Slice 4 + Slice 5 in parallel** once Slice 1 is merged — Playwright golden-path (cross-stack coder, 5 specs) and handler integration (backend coder, auth/projects/deadlines/appointments/paliadin).
3. Slice 7 (Gitea Actions wiring) follows once Slice 1 gate tier is proven locally.
4. Slices 2, 3, 6 enter rotation alongside feature work — not blocking.
5. Slice 8 (coverage reporting) lowest priority.
**Status:** DESIGN APPROVED — awaiting head's dispatch of Slice 1 coder shift.

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.

Binary file not shown.

View File

@@ -0,0 +1,5 @@
{
"version": "v0.260518",
"dotm_url": "https://paliad.msbls.de/patentstyle/HL-Patents-Style.dotm",
"sha256": "5CEA98A29D2FD6D9970B9A2499054DF52685A1116459E07F9290B0D0ADD521F4"
}

View File

@@ -126,11 +126,12 @@ const STATUS_OPTIONS_DEADLINE: StatusOption[] = [
];
const STATUS_OPTIONS_APPOINTMENT: StatusOption[] = [
{ value: "all", key: "events.filter.status.all" },
{ value: "upcoming", key: "events.filter.status.upcoming" },
{ value: "today", key: "deadlines.filter.today" },
{ value: "this_week", key: "deadlines.filter.thisweek" },
{ value: "next_week", key: "deadlines.filter.nextweek" },
{ value: "later", key: "deadlines.filter.later" },
{ value: "all", key: "events.filter.status.all" },
];
function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
@@ -139,7 +140,7 @@ function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
}
function defaultStatusFor(type: EventTypeChoice): string {
return type === "appointment" ? "all" : "pending";
return type === "appointment" ? "upcoming" : "pending";
}
let currentType: EventTypeChoice = "deadline";

View File

@@ -112,11 +112,14 @@ async function calculate() {
const priorityInput = document.getElementById("priority-date") as HTMLInputElement | null;
const priorityDate = selectedType === "epa.grant.exa" && priorityInput?.value ? priorityInput.value : "";
// Flags — three proceeding-specific checkboxes:
// Flags — proceeding-specific checkboxes:
// upc.inf.cfi: with_ccr (always available); with_amend (nested under
// with_ccr — R.30 application is only available with a CCR).
// upc.rev.cfi: with_amend (R.49.2.a) and with_cci (R.49.2.b) as two
// independent gates; both can be on simultaneously.
// R.19 Einspruch is NOT flag-gated (mig 098, m's 2026-05-18 call): it's
// an always-available optional submission, surfaced as priority='optional'
// without a separate checkbox.
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
const infAmendFlag = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
const revAmendFlag = document.getElementById("rev-amend-flag") as HTMLInputElement | null;

View File

@@ -235,11 +235,13 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.upc.disc.cfi": "Bucheinsicht",
"deadlines.upc.apl.cost": "Berufung Kosten",
"deadlines.upc.apl.order": "Berufung Anordnungen",
"deadlines.de.inf.lg": "Verletzungsklage (LG)",
"deadlines.de.inf.olg": "Berufung OLG",
"deadlines.de.inf.bgh": "Revision/NZB BGH",
"deadlines.de.null.bpatg": "Nichtigkeitsverfahren",
"deadlines.de.null.bgh": "Berufung BGH (Nichtigk.)",
"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",
@@ -837,6 +839,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)
@@ -1591,7 +1605,8 @@ const translations: Record<Lang, Record<string, string>> = {
"events.toggle.deadline": "Fristen",
"events.toggle.appointment": "Termine",
"events.toggle.all": "Beides",
"events.filter.status.all": "Alle",
"events.filter.status.all": "Alle (auch vergangene)",
"events.filter.status.upcoming": "Ab heute",
"events.summary.later": "Sp\u00e4ter",
"events.col.date": "Datum",
"events.col.location": "Ort",
@@ -2237,6 +2252,12 @@ const translations: Record<Lang, Record<string, string>> = {
"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.",
@@ -2802,11 +2823,13 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.upc.disc.cfi": "Lay-open Books",
"deadlines.upc.apl.cost": "Cost-Decision Appeal",
"deadlines.upc.apl.order": "Order Appeal (15-day)",
"deadlines.de.inf.lg": "Infringement (Regional Court)",
"deadlines.de.inf.olg": "Appeal OLG",
"deadlines.de.inf.bgh": "Revision / NZB BGH",
"deadlines.de.null.bpatg": "Nullity",
"deadlines.de.null.bgh": "Appeal BGH (Nullity)",
"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",
@@ -3404,6 +3427,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)
@@ -4142,7 +4177,8 @@ const translations: Record<Lang, Record<string, string>> = {
"events.toggle.deadline": "Deadlines",
"events.toggle.appointment": "Appointments",
"events.toggle.all": "Both",
"events.filter.status.all": "All",
"events.filter.status.all": "All (incl. past)",
"events.filter.status.upcoming": "From today",
"events.summary.later": "Later",
"events.col.date": "Date",
"events.col.location": "Location",
@@ -4788,6 +4824,12 @@ const translations: Record<Lang, Record<string, string>> = {
"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.",

View File

@@ -25,6 +25,35 @@ let lastResponse: DeadlineResponse | null = null;
type ProcedureView = "timeline" | "columns";
let procedureView: ProcedureView = "columns";
// 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.
@@ -46,6 +75,31 @@ function showStep(n: number) {
}
}
// 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;
@@ -61,6 +115,7 @@ async function doCalc() {
const data = await calculateDeadlines({
proceedingType: selectedType,
triggerDate,
flags: readFlags(),
courtId,
});
if (seq !== calcSeq) return;
@@ -70,13 +125,42 @@ async function doCalc() {
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");
const procName = tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
// 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>
@@ -89,6 +173,8 @@ function renderResults(data: DeadlineResponse) {
container.innerHTML = headerHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block";
if (toggle) toggle.style.display = "";
syncTriggerEventLabel();
}
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
@@ -100,18 +186,47 @@ function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string)
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 || "";
const name = btn.querySelector("strong")?.textContent || "";
const triggerEventEl = document.getElementById("trigger-event");
if (triggerEventEl) triggerEventEl.textContent = name;
// 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, name);
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
showStep(2);
scheduleCalc(0);
@@ -169,18 +284,35 @@ document.addEventListener("DOMContentLoaded", () => {
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());
initViewToggle();
onLangChange(() => {
if (lastResponse) renderResults(lastResponse);
const activeBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
// 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 name = activeBtn.querySelector("strong")?.textContent || "";
const triggerEventEl = document.getElementById("trigger-event");
if (triggerEventEl) triggerEventEl.textContent = name;
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

View File

@@ -1,16 +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();
@@ -30,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();
@@ -54,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");
}
@@ -97,57 +109,97 @@ 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 {
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);
@@ -223,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

@@ -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

@@ -467,6 +467,11 @@ export function paint(
}
// 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) {
@@ -479,13 +484,19 @@ export function paint(
}));
}
if (row.label) {
const labelEl = svg("text", {
class: "chart-lane-label",
x: 8,
y: row.y + row.height / 2 + 4,
const fo = svg("foreignObject", {
class: "chart-lane-label-fo",
x: labelPadding,
y: row.y,
width: labelMaxWidth,
height: row.height,
});
labelEl.textContent = row.label;
gGrid.appendChild(labelEl);
const div = document.createElement("div");
div.className = "chart-lane-label";
div.textContent = row.label;
div.title = row.label;
fo.appendChild(div);
gGrid.appendChild(fo);
}
}

View File

@@ -7,6 +7,7 @@ import {
} 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.
@@ -23,6 +24,12 @@ import type { RenderSpec, ViewRow } from "./types";
//
// 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>,
@@ -35,21 +42,127 @@ export function renderTimelineShape(
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.
return mount(host, {
const handle = mount(chartHost, {
projectId: "cv",
staticData: { events, lanes },
palette: (cfg.palette as Palette | undefined) ?? "default",
density: (cfg.density as Density | undefined) ?? "standard",
rangePreset: (cfg.range_preset as RangePreset | undefined) ?? "1y",
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 {

View File

@@ -38,6 +38,14 @@ export interface CalculatedDeadline {
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;
@@ -240,9 +248,20 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
? `<div class="timeline-adjusted">⚠ ${formatAdjustedNote(dl)}</div>`
: "";
const ruleRef = dl.ruleRef
? `<span class="timeline-rule">${dl.ruleRef}</span>`
: "";
// 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 notes = noteText

View File

@@ -65,12 +65,21 @@ const UPC_TYPES: ProceedingDef[] = [
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
];
const DE_TYPES: ProceedingDef[] = [
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", 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.bpatg", i18nKey: "deadlines.de.null.bpatg", 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[] = [
@@ -425,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>

View File

@@ -653,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"
@@ -672,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"
@@ -919,6 +931,8 @@ export type I18nKey =
| "deadlines.court.set"
| "deadlines.date.edit.hint"
| "deadlines.de"
| "deadlines.de.group.inf"
| "deadlines.de.group.null"
| "deadlines.de.inf.bgh"
| "deadlines.de.inf.lg"
| "deadlines.de.inf.olg"
@@ -1362,6 +1376,7 @@ export type I18nKey =
| "events.empty.hint"
| "events.empty.title"
| "events.filter.status.all"
| "events.filter.status.upcoming"
| "events.row.type.appointment"
| "events.row.type.deadline"
| "events.summary.later"
@@ -2421,6 +2436,12 @@ export type I18nKey =
| "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

@@ -9,7 +9,6 @@ const ICON_FILE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
const ICON_CALC = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="14" x2="8" y2="14.01"/><line x1="12" y1="14" x2="12" y2="14.01"/><line x1="16" y1="14" x2="16" y2="14.01"/><line x1="8" y1="18" x2="16" y2="18"/></svg>';
const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
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_GLOSSAR = '<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>';
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>';
@@ -108,19 +107,6 @@ export function renderIndex(): string {
</div>
</section>
<section className="sections">
<div className="container">
<h3 className="section-heading" data-i18n="index.downloads">Downloads</h3>
<div className="grid grid-2">
<a href="/files/hl-patents-style.dotm" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_DOWNLOAD }} />
<h2 data-i18n="index.style.title">{`${FIRM} Patents Style`}</h2>
<p data-i18n="index.style.desc">{`Word-Vorlage im ${FIRM} Patents Style. Formatierung, Schriftarten und Makros für standardisierte Schriftsätze.`}</p>
</a>
</div>
</div>
</section>
<section className="offices">
<div className="container">
<h3 data-i18n="index.offices">Standorte</h3>

View File

@@ -3075,6 +3075,25 @@ input[type="range"]::-moz-range-thumb {
margin-bottom: 0.5rem;
}
/* Sub-group inside a .proceeding-group — used today by the DE block
to split Verletzungsverfahren tiles from Nichtigkeitsverfahren tiles
under one "Deutsche Gerichte" h4. Heading is one tier below the h4
(mixed-case, no upper-tracking) so the two-level hierarchy reads at
a glance. */
.proceeding-subgroup {
margin-top: 0.6rem;
}
.proceeding-subgroup:first-child {
margin-top: 0;
}
.proceeding-subgroup-heading {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text);
margin: 0 0 0.35rem 0;
}
.proceeding-btns {
display: flex;
@@ -11773,16 +11792,58 @@ dialog.quick-add-sheet::backdrop {
color: var(--color-text-muted);
}
/* shape=calendar. */
/* shape=calendar. month / week / day views share .views-calendar wrapper;
the variant class .views-calendar--<view> drives any per-view tweaks. */
.views-calendar-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.views-calendar-view-switcher {
display: inline-flex;
gap: 4px;
}
.views-calendar-nav {
display: inline-flex;
align-items: center;
gap: 8px;
}
.views-calendar-nav-btn {
min-width: 32px;
padding: 4px 8px;
}
.views-calendar-nav-label {
font-weight: 600;
min-width: 12ch;
text-align: center;
}
.views-calendar-back-to-month {
background: transparent;
border: none;
color: var(--color-link, var(--color-accent));
cursor: pointer;
padding: 4px 8px;
font-size: 13px;
text-decoration: underline;
}
.views-calendar-back-to-month:hover {
color: var(--color-link-hover, var(--color-accent));
}
.views-calendar-month-label {
font-size: 18px;
margin: 0 0 12px 0;
}
.views-calendar-weekdays {
/* Month view — one grid contains both the weekday header row and the day
cells, so they share the same column template (no drift). */
.views-calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 4px;
}
.views-calendar-weekday {
font-size: 12px;
@@ -11790,11 +11851,7 @@ dialog.quick-add-sheet::backdrop {
letter-spacing: 0.05em;
color: var(--color-text-muted);
padding: 4px;
}
.views-calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
text-align: center;
}
.views-calendar-cell {
min-height: 80px;
@@ -11803,15 +11860,32 @@ dialog.quick-add-sheet::backdrop {
border-radius: 4px;
background: var(--color-surface);
color: var(--color-text);
display: flex;
flex-direction: column;
gap: 4px;
}
.views-calendar-cell--out {
background: transparent;
border: 1px dashed var(--color-border);
}
.views-calendar-cell--today {
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
.views-calendar-cell-day {
font-size: 12px;
color: var(--color-text-muted);
margin-bottom: 4px;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
align-self: flex-start;
font-weight: 600;
}
.views-calendar-cell-day:hover,
.views-calendar-cell-day:focus-visible {
color: var(--color-text);
text-decoration: underline;
}
.views-calendar-pills {
list-style: none;
@@ -11830,12 +11904,147 @@ dialog.quick-add-sheet::backdrop {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
text-decoration: none;
border: none;
text-align: left;
width: 100%;
cursor: pointer;
}
.views-calendar-pill:hover {
background: var(--color-surface-hover, var(--color-surface-muted));
}
.views-calendar-pill--more {
color: var(--color-text-muted);
text-align: center;
background: transparent;
}
/* Week view — 7 columns, scrollable per column when overflowing. */
.views-calendar-week-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.views-calendar-week-column {
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-surface);
display: flex;
flex-direction: column;
min-height: 200px;
max-height: 70vh;
overflow: hidden;
}
.views-calendar-week-column--today {
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
.views-calendar-week-head {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-muted);
font-size: 12px;
}
.views-calendar-week-dow {
text-transform: uppercase;
color: var(--color-text-muted);
letter-spacing: 0.05em;
}
.views-calendar-week-dnum {
font-weight: 600;
color: var(--color-text);
}
.views-calendar-week-list {
list-style: none;
margin: 0;
padding: 4px;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
flex: 1 1 auto;
}
.views-calendar-week-empty {
margin: 0;
padding: 12px 8px;
color: var(--color-text-muted);
font-size: 12px;
text-align: center;
}
/* Day view — single chronological list. */
.views-calendar-day-wrap {
display: flex;
flex-direction: column;
gap: 8px;
}
.views-calendar-day-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.views-calendar-day-empty {
margin: 0;
padding: 16px;
color: var(--color-text-muted);
font-style: italic;
text-align: center;
}
/* Row anchors used by both week and day views. */
.views-calendar-row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
border: 1px solid var(--color-border);
background: var(--color-surface);
text-decoration: none;
color: var(--color-text);
}
.views-calendar-row:hover {
background: var(--color-surface-hover, var(--color-surface-muted));
}
.views-calendar-row-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 6px;
flex: 0 0 8px;
background: var(--color-text-muted);
}
.views-calendar-row-dot--deadline { background: var(--color-accent); }
.views-calendar-row-dot--appointment { background: #3b82f6; }
.views-calendar-row-dot--project_event { background: #a855f7; }
.views-calendar-row-dot--approval_request { background: #f59e0b; }
.views-calendar-row-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.views-calendar-row-title {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.views-calendar-row-meta {
font-size: 12px;
color: var(--color-text-muted);
}
.views-calendar-row--week .views-calendar-row-title {
white-space: normal;
}
.views-calendar-mobile-notice {
margin: 0 0 12px 0;
font-size: 12px;
@@ -14603,7 +14812,14 @@ dialog.quick-add-sheet::backdrop {
.smart-timeline-chart .chart-lane-label {
font-size: 0.85rem;
font-weight: 500;
fill: var(--chart-lane-label);
color: var(--chart-lane-label);
height: 100%;
display: flex;
align-items: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
cursor: default;
}
.smart-timeline-chart .chart-today-rule {
stroke: var(--chart-today-rule);
@@ -14713,6 +14929,45 @@ dialog.quick-add-sheet::backdrop {
outline-offset: 2px;
}
/* Custom Views timeline toolbar (t-paliad-211) — zoom controls above the
chart canvas. Stays in flow so it doesn't overlap the SVG date axis. */
.views-timeline-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.views-timeline-zoom-group {
display: inline-flex;
align-items: center;
gap: 8px;
}
.views-timeline-zoom-label {
font-size: 12px;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.views-timeline-zoom-btn {
min-width: 32px;
padding: 4px 10px;
font-weight: 600;
}
.views-timeline-zoom-btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.views-timeline-zoom-chips {
display: inline-flex;
gap: 4px;
}
.views-timeline-chart-host-inner {
/* Reserve a min-height so the loading placeholder doesn't collapse
and the toolbar/chart stack stays predictable. */
min-height: 200px;
}
/* ---- Palette presets (t-paliad-177 Slice 2, design §5.1) ----
Each palette is a pure data-attribute swap of the --chart-* tokens.
Renderer code never reads palette state — it just emits classed SVG

View File

@@ -40,12 +40,21 @@ const UPC_TYPES: ProceedingDef[] = [
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
];
const DE_TYPES: ProceedingDef[] = [
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", 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.bpatg", i18nKey: "deadlines.de.null.bpatg", 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[] = [
@@ -108,8 +117,17 @@ export function renderVerfahrensablauf(): 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>
@@ -156,6 +174,35 @@ export function renderVerfahrensablauf(): string {
<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>

View File

@@ -60,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>

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

@@ -226,24 +226,31 @@ 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_]+\.[a-z_]+\.[a-z_]+\.[a-z_]+(\..*)?$';
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. The column
-- is nullable for legacy reasons, but every live row should
-- carry a code after the prefix step.
-- 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

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

@@ -159,6 +159,13 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
mux.Handle("GET /icons/", noCacheAssets(http.StripPrefix("/icons/", http.FileServer(http.Dir("dist/icons")))))
mux.HandleFunc("GET /sw.js", servePWAServiceWorker)
// HL Patents Style auto-update endpoint. version.json is the manifest
// the installed Word client polls; HL-Patents-Style.dotm is fetched on
// version mismatch. Source files live in frontend/public/patentstyle/
// (copied into dist/ at build time). noCacheAssets ensures the manifest
// is never stale after a release.
mux.Handle("GET /patentstyle/", noCacheAssets(http.StripPrefix("/patentstyle/", http.FileServer(http.Dir("dist/patentstyle")))))
// Protected routes
protected := http.NewServeMux()
protected.HandleFunc("GET /tools/kostenrechner", handleKostenrechnerPage)

View File

@@ -870,6 +870,77 @@ func FormatLegalSourceDisplay(src string) string {
return b.String()
}
// BuildLegalSourceURL maps a structured legal_source code to a
// youpc.org/laws permalink when the cited body is hosted there. Today
// youpc only carries the UPC corpus (UPCA, UPCS, UPCRoP); DE national
// codes (PatG, ZPO) and EPO bodies (EPÜ, EPC-R, RPBA) have no youpc
// home yet, so the helper returns the empty string for those and the
// caller renders the display string as plain text.
//
// Inputs mirror FormatLegalSourceDisplay — structured dot-separated
// codes like UPC.RoP.23.1, UPC.UPCA.83. Sub-paragraph segments beyond
// the law-number position are dropped; youpc resolves the page at
// <type>.<number> granularity. The law-number is zero-padded to 3
// digits to match how youpc stores law_number (laws-data.json carries
// "001" / "023" / "220" forms).
//
// URL shape uses the hash-fragment form that youpc itself emits from
// its laws-page redirect (handlers/laws.go:215+229) — the canonical
// in-app deep link target. The `/laws/:type/:number` pretty route also
// resolves the same page but redirects to the hash form anyway.
//
// UPC.RoP.23.1 → https://youpc.org/laws#UPCRoP.023
// UPC.RoP.139 → https://youpc.org/laws#UPCRoP.139
// UPC.RoP.220.1 → https://youpc.org/laws#UPCRoP.220
// UPC.RoP.29.a → https://youpc.org/laws#UPCRoP.029
// UPC.UPCA.83 → https://youpc.org/laws#UPCA.083
// DE.ZPO.276.1 → "" (no youpc home — render display text plain)
func BuildLegalSourceURL(src string) string {
src = strings.TrimSpace(src)
if src == "" {
return ""
}
parts := strings.Split(src, ".")
if len(parts) < 3 {
return ""
}
var lawType string
switch parts[0] + "." + parts[1] {
case "UPC.RoP":
lawType = "UPCRoP"
case "UPC.UPCA":
lawType = "UPCA"
case "UPC.UPCS":
lawType = "UPCS"
default:
return ""
}
number := padLawNumber(parts[2])
if number == "" {
return ""
}
return "https://youpc.org/laws#" + lawType + "." + number
}
// padLawNumber zero-pads a pure-digit law-number segment to 3 digits.
// Non-digit-only inputs (e.g. "112a" if youpc ever ingests EPÜ Art.
// 112a) pass through unchanged so the URL still resolves. Empty input
// returns the empty string.
func padLawNumber(s string) string {
if s == "" {
return ""
}
for _, c := range s {
if c < '0' || c > '9' {
return s
}
}
if len(s) >= 3 {
return s
}
return strings.Repeat("0", 3-len(s)) + s
}
// RefreshSearchView re-populates the materialised view. Safe to call on
// every server boot — it's a CONCURRENTLY refresh against a < 1k row
// view, well under 100 ms in practice. Called from cmd/server/main.go

View File

@@ -40,6 +40,38 @@ func TestFormatLegalSourceDisplay(t *testing.T) {
}
}
// TestBuildLegalSourceURL covers the structured-form → youpc.org/laws
// permalink mapping. Only the UPC corpus has a youpc home today;
// DE/EPA/EU bodies fall through to the empty string and the renderer
// shows display text without a link.
func TestBuildLegalSourceURL(t *testing.T) {
cases := []struct {
in, want string
}{
{"UPC.RoP.23.1", "https://youpc.org/laws#UPCRoP.023"},
{"UPC.RoP.139", "https://youpc.org/laws#UPCRoP.139"},
{"UPC.RoP.220.1", "https://youpc.org/laws#UPCRoP.220"},
{"UPC.RoP.29.a", "https://youpc.org/laws#UPCRoP.029"},
{"UPC.RoP.49.2.a", "https://youpc.org/laws#UPCRoP.049"},
{"UPC.RoP.19.1", "https://youpc.org/laws#UPCRoP.019"},
{"UPC.UPCA.83", "https://youpc.org/laws#UPCA.083"},
{"UPC.UPCS.40.1", "https://youpc.org/laws#UPCS.040"},
{"DE.PatG.82.1", ""},
{"DE.ZPO.276.1", ""},
{"EU.EPÜ.108", ""},
{"EU.EPC-R.79.1", ""},
{"EU.RPBA.12.1.c", ""},
{"UPC.RoP", ""},
{"", ""},
}
for _, c := range cases {
got := BuildLegalSourceURL(c.in)
if got != c.want {
t.Errorf("BuildLegalSourceURL(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestNormalizeQuery covers the input-side legal-prefix stripping that
// keeps "§ 82" / "Art. 108" findable against structured legal_source
// values that don't carry the prefix.

View File

@@ -54,6 +54,15 @@ type UIDeadline struct {
Priority string `json:"priority"`
RuleRef string `json:"ruleRef"`
LegalSource string `json:"legalSource,omitempty"`
// LegalSourceDisplay is the pretty form (e.g. "UPC RoP R.220(1)")
// of LegalSource, produced by FormatLegalSourceDisplay. Frontend
// renders this in the deadline card meta line; falls back to
// RuleRef when LegalSource is empty.
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
// 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 `json:"legalSourceURL,omitempty"`
Notes string `json:"notes,omitempty"`
NotesEN string `json:"notesEN,omitempty"`
DueDate string `json:"dueDate"`
@@ -283,6 +292,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
}
if r.LegalSource != nil {
d.LegalSource = *r.LegalSource
d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
}
if r.DeadlineNotes != nil {
d.Notes = *r.DeadlineNotes
@@ -599,6 +610,7 @@ type RuleCalculationRule struct {
RuleRef string `json:"ruleRef,omitempty"`
LegalSource string `json:"legalSource,omitempty"`
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
LegalSourceURL string `json:"legalSourceURL,omitempty"`
DurationValue int `json:"durationValue"`
DurationUnit string `json:"durationUnit"`
Party string `json:"party,omitempty"`
@@ -670,6 +682,7 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
if rule.LegalSource != nil {
out.Rule.LegalSource = *rule.LegalSource
out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource)
out.Rule.LegalSourceURL = BuildLegalSourceURL(*rule.LegalSource)
}
if rule.PrimaryParty != nil {
out.Rule.Party = *rule.PrimaryParty
@@ -1217,6 +1230,8 @@ func (s *FristenrechnerService) calculateByTriggerEvent(
}
if r.LegalSource != nil {
d.LegalSource = *r.LegalSource
d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
}
if r.DeadlineNotes != nil {
d.Notes = *r.DeadlineNotes

View File

@@ -14,13 +14,15 @@ import (
// submissionCodeShapeRegex is the proceeding-code-prefixed shape
// installed by mig 098 (t-paliad-209): the proceeding's 3-segment code
// (`^[a-z_]+\.[a-z_]+\.[a-z_]+\.`) followed by at least one suffix
// segment (and optional further dot-separated segments). The regex
// allows underscores so the legacy archived bucket (`_archived_…`) and
// hand-seeded test rules (e.g. `s11a.initial`) match alongside the
// canonical taxonomy. Mirrors the assertion in mig 098 §6.1.
// (`^[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+\.`) followed by at least one
// suffix segment (and optional further dot-separated segments). The
// regex allows digits so EPA suffixes like `r106` / `r71_3` / `r116`
// (statutory rule numbers in the suffix) pass alongside canonical
// dotted-word codes. Underscores cover the legacy archived bucket
// (`_archived_…`) and hand-seeded test rules. Mirrors the assertion in
// mig 098 §6.1.
var submissionCodeShapeRegex = regexp.MustCompile(
`^[a-z_]+\.[a-z_]+\.[a-z_]+\.[a-z_]+(\..*)?$`)
`^[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+(\..*)?$`)
// TestSubmissionCodeShape walks every active+published row in
// paliad.deadline_rules and asserts that submission_code matches the