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
d126913185 Merge: t-paliad-209 — workstream B (submission_code rename + prefix + Rechtsgrundlage column) 2026-05-18 15:11:52 +02:00
mAi
ea29165d2f feat(t-paliad-209): rename Code → Submission Code + add Rechtsgrundlage column
Workstream B frontend sweep — matches mig 098 + the Go sweep. The
/admin/rules surfaces now distinguish submission_code (the rule's
filing identifier within a proceeding, e.g. upc.inf.cfi.soc) from
rule_code (the legal citation, e.g. RoP.013.1).

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

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

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

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

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

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

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

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

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

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

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

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

Hard assertions (§6) gate the migration on:
- every active+published row matches the 4+-segment proceeding-prefixed
  shape regex
- no NULL submission_code on active+published rows
- the column was actually renamed
2026-05-18 15:05:46 +02:00
44 changed files with 2828 additions and 277 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.

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

@@ -71,16 +71,16 @@ export function renderAdminRulesEdit(): string {
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-code" data-i18n="admin.rules.edit.field.code">Code</label>
<input type="text" id="f-code" className="admin-rules-input" />
<label htmlFor="f-submission-code" data-i18n="admin.rules.edit.field.submission_code">Submission Code / Einreichung-Kennung</label>
<input type="text" id="f-submission-code" className="admin-rules-input" readonly placeholder="z. B. upc.inf.cfi.soc" />
</div>
<div className="form-field">
<label htmlFor="f-rule-code" data-i18n="admin.rules.edit.field.rule_code">Rule-Code (zit.)</label>
<label htmlFor="f-rule-code" data-i18n="admin.rules.edit.field.rule_code">Rechtsgrundlage (Kurzform)</label>
<input type="text" id="f-rule-code" className="admin-rules-input" placeholder="z. B. RoP.151" />
</div>
<div className="form-field">
<label htmlFor="f-legal-source" data-i18n="admin.rules.edit.field.legal_source">Rechtsgrundlage</label>
<input type="text" id="f-legal-source" className="admin-rules-input" />
<label htmlFor="f-legal-source" data-i18n="admin.rules.edit.field.legal_source">Rechtsgrundlage (Langform)</label>
<input type="text" id="f-legal-source" className="admin-rules-input" placeholder="z. B. UPC.RoP.151" />
</div>
</div>
</fieldset>

View File

@@ -93,7 +93,7 @@ export function renderAdminRulesList(): string {
type="text"
id="rules-filter-search"
className="admin-rules-input"
placeholder="Name, Code, rule_code..."
placeholder="Name, Submission Code, Rechtsgrundlage..."
data-i18n-placeholder="admin.rules.filter.search.placeholder"
autocomplete="off"
/>
@@ -104,7 +104,8 @@ export function renderAdminRulesList(): string {
<table className="entity-table admin-rules-table">
<thead>
<tr>
<th data-i18n="admin.rules.col.code">Code</th>
<th data-i18n="admin.rules.col.submission_code">Submission Code</th>
<th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th>
<th data-i18n="admin.rules.col.name">Name</th>
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>
<th data-i18n="admin.rules.col.priority">Priorit&auml;t</th>
@@ -113,7 +114,7 @@ export function renderAdminRulesList(): string {
</tr>
</thead>
<tbody id="rules-tbody">
<tr><td colspan={6} className="admin-rules-loading" data-i18n="admin.rules.loading">Lade...</td></tr>
<tr><td colspan={7} className="admin-rules-loading" data-i18n="admin.rules.loading">Lade...</td></tr>
</tbody>
</table>
</div>

View File

@@ -11,7 +11,10 @@ interface Rule {
id: string;
proceeding_type_id?: number | null;
parent_id?: string | null;
code?: string | null;
// submission_code is the proceeding-prefixed identifier of this rule
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
// rule_code (legal citation, e.g. `RoP.013.1`).
submission_code?: string | null;
rule_code?: string | null;
name: string;
name_en: string;
@@ -255,7 +258,7 @@ function populateForm() {
setInput("f-name", rule.name);
setInput("f-name-en", rule.name_en);
setInput("f-description", rule.description ?? "");
setInput("f-code", rule.code ?? "");
setInput("f-submission-code", rule.submission_code ?? "");
setInput("f-rule-code", rule.rule_code ?? "");
setInput("f-legal-source", rule.legal_source ?? "");
setInput("f-proceeding", rule.proceeding_type_id ?? "");

View File

@@ -11,7 +11,10 @@ import { initSidebar } from "./sidebar";
interface Rule {
id: string;
proceeding_type_id?: number | null;
code?: string | null;
// submission_code is the proceeding-prefixed identifier of this rule
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
// rule_code (the legal citation, e.g. `RoP.013.1`).
submission_code?: string | null;
rule_code?: string | null;
name: string;
name_en: string;
@@ -219,7 +222,8 @@ function renderRulesTable() {
const name = (r: Rule) => (getLang() === "en" ? r.name_en : r.name) || r.name;
tbody.innerHTML = rules.map((r) => `
<tr data-row-id="${esc(r.id)}" class="admin-rules-row">
<td class="admin-rules-col-code"><code>${esc(r.rule_code || r.code || "")}</code></td>
<td class="admin-rules-col-code"><code>${esc(r.submission_code || "")}</code></td>
<td class="admin-rules-col-legal"><code>${esc(r.rule_code || "")}</code></td>
<td>${esc(name(r))}</td>
<td>${esc(proceedingLabel(r.proceeding_type_id ?? null))}</td>
<td><span class="admin-rules-priority admin-rules-priority-${esc(r.priority)}">${esc(priorityLabel(r.priority))}</span></td>

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.",
@@ -2420,9 +2441,10 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.filter.lifecycle": "Lifecycle",
"admin.rules.filter.lifecycle.any": "Alle",
"admin.rules.filter.search": "Suche",
"admin.rules.filter.search.placeholder": "Name, Code, rule_code…",
"admin.rules.filter.search.placeholder": "Name, Submission Code, Rechtsgrundlage…",
"admin.rules.col.code": "Code",
"admin.rules.col.submission_code": "Submission Code / Einreichung-Kennung",
"admin.rules.col.legal_citation": "Rechtsgrundlage",
"admin.rules.col.name": "Name",
"admin.rules.col.proceeding": "Verfahrenstyp",
"admin.rules.col.priority": "Priorität",
@@ -2485,9 +2507,9 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.name": "Name (DE)",
"admin.rules.edit.field.name_en": "Name (EN)",
"admin.rules.edit.field.description": "Beschreibung",
"admin.rules.edit.field.code": "Code",
"admin.rules.edit.field.rule_code": "Rule-Code (zit.)",
"admin.rules.edit.field.legal_source": "Rechtsgrundlage",
"admin.rules.edit.field.submission_code": "Submission Code / Einreichung-Kennung",
"admin.rules.edit.field.rule_code": "Rechtsgrundlage (Kurzform)",
"admin.rules.edit.field.legal_source": "Rechtsgrundlage (Langform)",
"admin.rules.edit.field.proceeding": "Verfahrenstyp",
"admin.rules.edit.field.proceeding.none": "—",
"admin.rules.edit.field.trigger": "Trigger-Ereignis",
@@ -2801,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",
@@ -3403,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)
@@ -4141,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",
@@ -4787,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.",
@@ -4969,9 +5012,10 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.filter.lifecycle": "Lifecycle",
"admin.rules.filter.lifecycle.any": "Any",
"admin.rules.filter.search": "Search",
"admin.rules.filter.search.placeholder": "Name, code, rule_code…",
"admin.rules.filter.search.placeholder": "Name, submission code, legal citation…",
"admin.rules.col.code": "Code",
"admin.rules.col.submission_code": "Submission code",
"admin.rules.col.legal_citation": "Legal citation",
"admin.rules.col.name": "Name",
"admin.rules.col.proceeding": "Proceeding type",
"admin.rules.col.priority": "Priority",
@@ -5034,9 +5078,9 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.name": "Name (DE)",
"admin.rules.edit.field.name_en": "Name (EN)",
"admin.rules.edit.field.description": "Description",
"admin.rules.edit.field.code": "Code",
"admin.rules.edit.field.rule_code": "Rule code (cit.)",
"admin.rules.edit.field.legal_source": "Legal source",
"admin.rules.edit.field.submission_code": "Submission code",
"admin.rules.edit.field.rule_code": "Legal citation (short form)",
"admin.rules.edit.field.legal_source": "Legal citation (long form)",
"admin.rules.edit.field.proceeding": "Proceeding type",
"admin.rules.edit.field.proceeding.none": "—",
"admin.rules.edit.field.trigger": "Trigger event",

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -467,7 +467,7 @@ type DeadlineRule struct {
ID uuid.UUID `db:"id" json:"id"`
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
Code *string `db:"code" json:"code,omitempty"`
SubmissionCode *string `db:"submission_code" json:"submission_code,omitempty"`
Name string `db:"name" json:"name"`
NameEN string `db:"name_en" json:"name_en"`
Description *string `db:"description" json:"description,omitempty"`

View File

@@ -72,8 +72,8 @@ func (c *DeadlineCalculator) CalculateFromRules(eventDate time.Time, rules []mod
}
code := ""
if r.Code != nil {
code = *r.Code
if r.SubmissionCode != nil {
code = *r.SubmissionCode
}
results = append(results, CalculatedDeadline{

View File

@@ -120,7 +120,7 @@ func TestCalculateFromRules_BatchAndZeroDuration(t *testing.T) {
rules := []models.DeadlineRule{
{ID: uuid.New(), Name: "Filing", DurationValue: 0, DurationUnit: "months"},
{ID: uuid.New(), Name: "Defence", Code: ptr("inf.sod"), DurationValue: 3, DurationUnit: "months", Timing: ptr("after")},
{ID: uuid.New(), Name: "Defence", SubmissionCode: ptr("upc.inf.cfi.sod"), DurationValue: 3, DurationUnit: "months", Timing: ptr("after")},
}
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)
results := calc.CalculateFromRules(in, rules, "DE", "UPC")
@@ -136,8 +136,8 @@ func TestCalculateFromRules_BatchAndZeroDuration(t *testing.T) {
if results[1].DueDate != "2026-04-13" {
t.Errorf("3-month rule: got %s, want 2026-04-13", results[1].DueDate)
}
if results[1].RuleCode != "inf.sod" {
t.Errorf("rule code: got %q, want inf.sod", results[1].RuleCode)
if results[1].RuleCode != "upc.inf.cfi.sod" {
t.Errorf("rule code: got %q, want upc.inf.cfi.sod", results[1].RuleCode)
}
}

View File

@@ -27,7 +27,7 @@ func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
// condition_flag, and condition_rule_id — they were superseded by
// priority / condition_expr / is_court_set in the unified Phase 3
// shape. The SELECT now reads only the live schema.
const ruleColumns = `id, proceeding_type_id, parent_id, code, name, name_en,
const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type, duration_value,
duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
alt_duration_value, alt_duration_unit, alt_rule_code,

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

@@ -166,8 +166,8 @@ func (s *EventTriggerService) Trigger(ctx context.Context, input EventTriggerInp
WasAdjusted: wasAdj,
AdjustmentReason: reason,
}
if r.Code != nil {
d.Code = *r.Code
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
}
if r.PrimaryParty != nil {
d.Party = *r.PrimaryParty

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"`
@@ -272,8 +281,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
Priority: r.Priority,
ConditionExpr: json.RawMessage(r.ConditionExpr),
}
if r.Code != nil {
d.Code = *r.Code
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
}
if r.PrimaryParty != nil {
d.Party = *r.PrimaryParty
@@ -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
@@ -300,8 +311,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
if r.ParentID != nil && courtSet[*r.ParentID] {
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.Code != nil {
if _, ok := overrideDates[*prev.Code]; ok {
if prev.SubmissionCode != nil {
if _, ok := overrideDates[*prev.SubmissionCode]; ok {
parentOverridden = true
}
}
@@ -328,12 +339,12 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// court-set placeholder and the parent-inheritance.
if r.DurationValue == 0 {
// User override always wins.
if r.Code != nil {
if ov, ok := overrideDates[*r.Code]; ok {
if r.SubmissionCode != nil {
if ov, ok := overrideDates[*r.SubmissionCode]; ok {
d.DueDate = ov.Format("2006-01-02")
d.OriginalDate = d.DueDate
d.IsOverridden = true
computed[*r.Code] = ov
computed[*r.SubmissionCode] = ov
deadlines = append(deadlines, d)
continue
}
@@ -344,8 +355,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
d.IsRootEvent = true
d.DueDate = triggerDateStr
d.OriginalDate = triggerDateStr
if r.Code != nil {
computed[*r.Code] = triggerDate
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = triggerDate
}
} else if r.ParentID != nil && !r.IsCourtSet {
// Bucket 4: filed-with-parent. Inherit parent's date.
@@ -365,11 +376,11 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
var haveParentDate bool
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.Code != nil {
if ov, ok := overrideDates[*prev.Code]; ok {
if prev.SubmissionCode != nil {
if ov, ok := overrideDates[*prev.SubmissionCode]; ok {
parentDate = ov
haveParentDate = true
} else if ref, ok := computed[*prev.Code]; ok {
} else if ref, ok := computed[*prev.SubmissionCode]; ok {
parentDate = ref
haveParentDate = true
}
@@ -380,8 +391,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
if haveParentDate {
d.DueDate = parentDate.Format("2006-01-02")
d.OriginalDate = d.DueDate
if r.Code != nil {
computed[*r.Code] = parentDate
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = parentDate
}
} else {
// Parent not yet computed (defensive — shouldn't
@@ -442,14 +453,14 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// Linear scan is fine — rule trees are < 20 entries.
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.Code != nil {
if prev.SubmissionCode != nil {
// User override on the parent rule wins over
// the calculated date — lets the user redirect
// downstream from a real (court-extended,
// court-set) date.
if ov, ok := overrideDates[*prev.Code]; ok {
if ov, ok := overrideDates[*prev.SubmissionCode]; ok {
baseDate = ov
} else if ref, ok := computed[*prev.Code]; ok {
} else if ref, ok := computed[*prev.SubmissionCode]; ok {
baseDate = ref
}
}
@@ -484,14 +495,14 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// the user's date. Skip holiday rollover — the user's date is
// authoritative. Downstream rules that chain off this rule will
// see the override via the parent-anchor lookup above.
if r.Code != nil {
if ov, ok := overrideDates[*r.Code]; ok {
if r.SubmissionCode != nil {
if ov, ok := overrideDates[*r.SubmissionCode]; ok {
d.OriginalDate = ov.Format("2006-01-02")
d.DueDate = ov.Format("2006-01-02")
d.WasAdjusted = false
d.AdjustmentReason = nil
d.IsOverridden = true
computed[*r.Code] = ov
computed[*r.SubmissionCode] = ov
deadlines = append(deadlines, d)
continue
}
@@ -527,8 +538,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
d.DueDate = adjusted.Format("2006-01-02")
d.WasAdjusted = wasAdj
d.AdjustmentReason = reason
if r.Code != nil {
computed[*r.Code] = adjusted
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = adjusted
}
deadlines = append(deadlines, d)
}
@@ -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"`
@@ -661,8 +673,8 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
},
TriggerDate: params.TriggerDate,
}
if rule.Code != nil {
out.Rule.LocalCode = *rule.Code
if rule.SubmissionCode != nil {
out.Rule.LocalCode = *rule.SubmissionCode
}
if rule.RuleCode != nil {
out.Rule.RuleRef = *rule.RuleCode
@@ -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
@@ -797,7 +810,7 @@ func (s *FristenrechnerService) resolveRule(ctx context.Context, params CalcRule
err = s.rules.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
WHERE proceeding_type_id = $1 AND code = $2 AND is_active = true`,
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
pt.ID, params.RuleLocalCode)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, ErrUnknownRule
@@ -1206,8 +1219,8 @@ func (s *FristenrechnerService) calculateByTriggerEvent(
WasAdjusted: wasAdj,
AdjustmentReason: reason,
}
if r.Code != nil {
d.Code = *r.Code
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
}
if r.PrimaryParty != nil {
d.Party = *r.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

@@ -86,18 +86,18 @@ func TestCalculateRule(t *testing.T) {
courts := NewCourtService(pool)
svc := NewFristenrechnerService(rules, holidays, courts)
t.Run("plain rule calc — upc.inf.cfi inf.sod, R.23(1), 3 months", func(t *testing.T) {
t.Run("plain rule calc — upc.inf.cfi.sod, R.23(1), 3 months", func(t *testing.T) {
// 2026-01-15 + 3 months = 2026-04-15. No vacation overlap.
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.sod",
RuleLocalCode: "upc.inf.cfi.sod",
TriggerDate: "2026-01-15",
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if got.IsCourtSet {
t.Errorf("inf.sod is not court-set; got IsCourtSet=true")
t.Errorf("upc.inf.cfi.sod is not court-set; got IsCourtSet=true")
}
if got.DueDate != "2026-04-15" {
t.Errorf("dueDate = %q, want 2026-04-15", got.DueDate)
@@ -113,14 +113,14 @@ func TestCalculateRule(t *testing.T) {
t.Run("court-determined rule → IsCourtSet=true, no dueDate", func(t *testing.T) {
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.decision",
RuleLocalCode: "upc.inf.cfi.decision",
TriggerDate: "2026-01-15",
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if !got.IsCourtSet {
t.Errorf("inf.decision should be court-set; got IsCourtSet=false")
t.Errorf("upc.inf.cfi.decision should be court-set; got IsCourtSet=false")
}
if got.DueDate != "" {
t.Errorf("court-set dueDate = %q, want empty", got.DueDate)
@@ -128,11 +128,12 @@ func TestCalculateRule(t *testing.T) {
})
t.Run("flag-conditional rule surfaces FlagsRequired even when not satisfied", func(t *testing.T) {
// inf.def_to_ccr requires with_ccr. Without the flag, FlagsRequired
// is still surfaced so the UI can render the checkbox.
// upc.inf.cfi.def_to_ccr requires with_ccr. Without the flag,
// FlagsRequired is still surfaced so the UI can render the
// checkbox.
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.def_to_ccr",
RuleLocalCode: "upc.inf.cfi.def_to_ccr",
TriggerDate: "2026-01-15",
})
if err != nil {
@@ -149,7 +150,7 @@ func TestCalculateRule(t *testing.T) {
t.Run("flag-conditional rule with flag → FlagsApplied populated", func(t *testing.T) {
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.def_to_ccr",
RuleLocalCode: "upc.inf.cfi.def_to_ccr",
TriggerDate: "2026-01-15",
Flags: []string{"with_ccr"},
})
@@ -164,7 +165,7 @@ func TestCalculateRule(t *testing.T) {
t.Run("missing TriggerDate → error", func(t *testing.T) {
_, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.sod",
RuleLocalCode: "upc.inf.cfi.sod",
TriggerDate: "",
})
if err == nil {

View File

@@ -183,10 +183,10 @@ func TestRuleNameInLang(t *testing.T) {
func TestPredecessorMissingError(t *testing.T) {
pme := &PredecessorMissingError{
MissingRuleCode: "inf.soc",
MissingRuleCode: "upc.inf.cfi.soc",
MissingRuleNameDE: "Klageschrift",
MissingRuleNameEN: "Statement of Claim",
RequestedRuleCode: "inf.sod",
RequestedRuleCode: "upc.inf.cfi.sod",
RequestedRuleNameDE: "Klageerwiderung",
RequestedRuleNameEN: "Statement of Defence",
}
@@ -233,14 +233,14 @@ func TestAnnotateDependsOn(t *testing.T) {
socID := uuid.New()
sodID := uuid.New()
replyID := uuid.New()
socCode := "inf.soc"
sodCode := "inf.sod"
replyCode := "inf.reply"
socCode := "upc.inf.cfi.soc"
sodCode := "upc.inf.cfi.sod"
replyCode := "upc.inf.cfi.reply"
rules := []models.DeadlineRule{
{ID: socID, Code: &socCode, Name: "Klageschrift", NameEN: "Statement of Claim"},
{ID: sodID, ParentID: &socID, Code: &sodCode, Name: "Klageerwiderung", NameEN: "Statement of Defence"},
{ID: replyID, ParentID: &sodID, Code: &replyCode, Name: "Replik", NameEN: "Reply"},
{ID: socID, SubmissionCode: &socCode, Name: "Klageschrift", NameEN: "Statement of Claim"},
{ID: sodID, ParentID: &socID, SubmissionCode: &sodCode, Name: "Klageerwiderung", NameEN: "Statement of Defence"},
{ID: replyID, ParentID: &sodID, SubmissionCode: &replyCode, Name: "Replik", NameEN: "Reply"},
}
socDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)

View File

@@ -1126,11 +1126,11 @@ func (s *ProjectionService) expandCrossProceedingSpawns(
Title: title,
DependsOnRuleName: src.rule.Name,
}
if first.Code != nil {
ev.RuleCode = *first.Code
if first.SubmissionCode != nil {
ev.RuleCode = *first.SubmissionCode
}
if src.rule.Code != nil {
ev.DependsOnRuleCode = *src.rule.Code
if src.rule.SubmissionCode != nil {
ev.DependsOnRuleCode = *src.rule.SubmissionCode
}
idCopy := first.ID
ev.DeadlineRuleID = &idCopy
@@ -1227,8 +1227,8 @@ func (s *ProjectionService) collectActualsForOverrides(
}
if d.RuleID != nil {
ruleIDsWithActual[*d.RuleID] = true
if r, ok := ruleByID[*d.RuleID]; ok && r.Code != nil {
overrides[*r.Code] = anchor.Format("2006-01-02")
if r, ok := ruleByID[*d.RuleID]; ok && r.SubmissionCode != nil {
overrides[*r.SubmissionCode] = anchor.Format("2006-01-02")
}
}
if d.RuleCode != nil && *d.RuleCode != "" {
@@ -1253,8 +1253,8 @@ func (s *ProjectionService) collectActualsForOverrides(
continue
}
ruleIDsWithActual[*a.RuleID] = true
if r, ok := ruleByID[*a.RuleID]; ok && r.Code != nil {
overrides[*r.Code] = a.StartAt.UTC().Format("2006-01-02")
if r, ok := ruleByID[*a.RuleID]; ok && r.SubmissionCode != nil {
overrides[*r.SubmissionCode] = a.StartAt.UTC().Format("2006-01-02")
}
}
return nil
@@ -1305,10 +1305,10 @@ func (s *ProjectionService) hydrateAppointmentRuleIDs(ctx context.Context, proje
// which the user fixes by clicking "Datum setzen" on the SoC row.
func (s *ProjectionService) deriveTriggerDate(rules []models.DeadlineRule, overrides map[string]string) string {
for _, r := range rules {
if r.ParentID != nil || r.Code == nil {
if r.ParentID != nil || r.SubmissionCode == nil {
continue
}
if anchor, ok := overrides[*r.Code]; ok {
if anchor, ok := overrides[*r.SubmissionCode]; ok {
return anchor
}
}
@@ -1578,7 +1578,7 @@ func (s *ProjectionService) RecordAnchor(ctx context.Context, userID, projectID
return nil, err
}
rule, err := s.lookupRuleByCode(ctx, *proj.ProceedingTypeID, in.RuleCode)
rule, err := s.lookupRuleBySubmissionCode(ctx, *proj.ProceedingTypeID, in.RuleCode)
if err != nil {
return nil, err
}
@@ -1598,8 +1598,8 @@ func (s *ProjectionService) RecordAnchor(ctx context.Context, userID, projectID
}
if !anchored {
parentCode := ""
if parentRule.Code != nil {
parentCode = *parentRule.Code
if parentRule.SubmissionCode != nil {
parentCode = *parentRule.SubmissionCode
}
return nil, &PredecessorMissingError{
MissingRuleCode: parentCode,
@@ -1662,19 +1662,20 @@ func (s *ProjectionService) RecordRuleSkipped(ctx context.Context, userID, proje
return nil
}
// lookupRuleByCode resolves (proceeding_type_id, code) → DeadlineRule.
func (s *ProjectionService) lookupRuleByCode(ctx context.Context, ptID int, code string) (*models.DeadlineRule, error) {
// lookupRuleBySubmissionCode resolves (proceeding_type_id, submission_code)
// → DeadlineRule.
func (s *ProjectionService) lookupRuleBySubmissionCode(ctx context.Context, ptID int, code string) (*models.DeadlineRule, error) {
var rule models.DeadlineRule
err := s.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
WHERE proceeding_type_id = $1 AND code = $2 AND is_active = true`,
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
ptID, code)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: unknown rule_code %q", ErrInvalidInput, code)
return nil, fmt.Errorf("%w: unknown submission_code %q", ErrInvalidInput, code)
}
if err != nil {
return nil, fmt.Errorf("lookup rule by code: %w", err)
return nil, fmt.Errorf("lookup rule by submission_code: %w", err)
}
return &rule, nil
}
@@ -1770,8 +1771,8 @@ func (s *ProjectionService) upsertAnchorDeadline(ctx context.Context, userID, pr
id := uuid.New()
title := rule.Name
ruleCode := ""
if rule.Code != nil {
ruleCode = *rule.Code
if rule.SubmissionCode != nil {
ruleCode = *rule.SubmissionCode
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO paliad.deadlines
@@ -1883,8 +1884,8 @@ func (s *ProjectionService) annotateDependsOn(rows []TimelineEvent, rules []mode
if !ok {
continue
}
if parent.Code != nil {
ev.DependsOnRuleCode = *parent.Code
if parent.SubmissionCode != nil {
ev.DependsOnRuleCode = *parent.SubmissionCode
}
ev.DependsOnRuleName = ruleNameInLang(parent, lang)
if dt, ok := dateByRuleID[parent.ID]; ok && !dt.IsZero() {

View File

@@ -331,7 +331,7 @@ func TestExpandCrossProceedingSpawns(t *testing.T) {
// the seed uses the live post-Slice-9 column set.
_, err := pool.ExecContext(ctx,
`INSERT INTO paliad.deadline_rules
(id, proceeding_type_id, name, name_en, code, duration_value, duration_unit,
(id, proceeding_type_id, name, name_en, submission_code, duration_value, duration_unit,
timing, is_court_set, is_spawn,
spawn_proceeding_type_id, sequence_order, is_active, priority,
lifecycle_state, created_at, updated_at)

View File

@@ -110,7 +110,7 @@ type CreateRuleInput struct {
TriggerEventID *int64 `json:"trigger_event_id,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
Code *string `json:"code,omitempty"`
SubmissionCode *string `json:"submission_code,omitempty"`
PrimaryParty *string `json:"primary_party,omitempty"`
EventType *string `json:"event_type,omitempty"`
DurationValue int `json:"duration_value"`
@@ -168,7 +168,7 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
// + is_court_set are the new gates.
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadline_rules
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, code,
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
name, name_en, description, primary_party, event_type,
duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
@@ -187,7 +187,7 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
true,
'draft', NULL, NULL,
now(), now())`,
id, input.ProceedingTypeID, input.TriggerEventID, input.ParentID, input.ConceptID, input.Code,
id, input.ProceedingTypeID, input.TriggerEventID, input.ParentID, input.ConceptID, input.SubmissionCode,
input.Name, input.NameEN, input.PrimaryParty, input.EventType,
input.DurationValue, input.DurationUnit, input.Timing,
input.AltDurationValue, input.AltDurationUnit, input.AltRuleCode, input.AnchorAlt, input.CombineOp,
@@ -286,7 +286,7 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
newID := uuid.New()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadline_rules
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, code,
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
name, name_en, description, primary_party, event_type,
duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
@@ -296,7 +296,7 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
is_active,
lifecycle_state, draft_of, published_at,
created_at, updated_at)
SELECT $1, proceeding_type_id, trigger_event_id, parent_id, concept_id, code,
SELECT $1, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
name, name_en, description, primary_party, event_type,
duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op,

View File

@@ -76,7 +76,7 @@ func TestRuleEditorService_Lifecycle(t *testing.T) {
Name: "SLICE11A_TEST_initial",
NameEN: "SLICE11A_TEST_initial_EN",
ProceedingTypeID: &ptID,
Code: ptrString("s11a.initial"),
SubmissionCode: ptrString("s11a.initial"),
DurationValue: 30,
DurationUnit: "days",
Priority: "mandatory",
@@ -263,7 +263,7 @@ func TestRuleEditorService_Preview(t *testing.T) {
// Slice 9 (t-paliad-195) dropped is_mandatory / is_optional.
if _, err := pool.ExecContext(ctx, `
INSERT INTO paliad.deadline_rules
(id, proceeding_type_id, code, name, name_en,
(id, proceeding_type_id, submission_code, name, name_en,
duration_value, duration_unit, timing,
is_court_set, is_spawn,
priority, lifecycle_state, is_active, sequence_order,

View File

@@ -0,0 +1,118 @@
package services
import (
"context"
"os"
"regexp"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// submissionCodeShapeRegex is the proceeding-code-prefixed shape
// installed by mig 098 (t-paliad-209): the proceeding's 3-segment code
// (`^[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_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
// 4+-segment proceeding-code-prefixed shape ratified for t-paliad-209.
// Sibling of TestProceedingCodeShape — same pattern, same goal: catch
// drift between the migration's hard invariant and runtime state.
//
// Archived rows (proceeding `_archived_litigation`) are exempted; mig
// 098's §6.1 assertion does the same by gating on lifecycle_state =
// 'published'. Their codes get the archived prefix and the wider shape
// they end up with sits outside the 4+-segment canonical form by
// design.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring the pattern in
// proceeding_codes_shape_test.go.
func TestSubmissionCodeShape(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
var rows []struct {
ID string `db:"id"`
SubmissionCode *string `db:"submission_code"`
}
if err := pool.SelectContext(ctx, &rows,
`SELECT dr.id::text AS id, dr.submission_code
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE dr.is_active = true
AND dr.lifecycle_state = 'published'
AND pt.category = 'fristenrechner'
ORDER BY dr.id`); err != nil {
t.Fatalf("load active+published deadline_rules rows: %v", err)
}
if len(rows) == 0 {
t.Fatal("no active+published fristenrechner deadline_rules — mig 098 likely not applied")
}
for _, r := range rows {
if r.SubmissionCode == nil {
t.Errorf("deadline_rules[id=%s] submission_code is NULL", r.ID)
continue
}
if !submissionCodeShapeRegex.MatchString(*r.SubmissionCode) {
t.Errorf("deadline_rules[id=%s] submission_code=%q does not match shape %s",
r.ID, *r.SubmissionCode, submissionCodeShapeRegex.String())
}
}
}
// TestSubmissionCodeShapeRegexStandalone exercises the regex without a
// DB so the shape rule is verified on every `go test ./...` run.
func TestSubmissionCodeShapeRegexStandalone(t *testing.T) {
good := []string{
"upc.inf.cfi.soc",
"upc.inf.cfi.sod",
"upc.inf.cfi.def_to_ccr",
"upc.rev.cfi.app",
"de.inf.lg.klage",
"de.inf.bgh.revision",
"de.null.bgh.berufung",
"dpma.appeal.bpatg.begruendung",
"epa.opp.opd.beschwerde_begr",
}
for _, code := range good {
if !submissionCodeShapeRegex.MatchString(code) {
t.Errorf("good code %q rejected by submission-code shape regex", code)
}
}
bad := []string{
"inf.soc", // pre-mig-098: 2 segments
"upc.inf", // 2 segments
"upc.inf.cfi", // proceeding code shape, not a submission code
"UPC.INF.CFI.SOC", // uppercase
"upc-inf-cfi-soc", // dashes
"",
}
for _, code := range bad {
if submissionCodeShapeRegex.MatchString(code) {
t.Errorf("bad code %q accepted by submission-code shape regex", code)
}
}
}