Compare commits
27 Commits
mai/ohm/wo
...
mai/mendel
| Author | SHA1 | Date | |
|---|---|---|---|
| 621fe35d79 | |||
| 8414aa4c14 | |||
| 92780cf726 | |||
| a0082d2b0d | |||
| c921925c68 | |||
| 22cfdb909f | |||
| 4ddcd28d26 | |||
| c10f8cff70 | |||
| 5ae1e5ad01 | |||
| 06c826a818 | |||
| 8020cb2ddb | |||
| a5b94739b4 | |||
| 283c9e8f67 | |||
| dece61107b | |||
| 8bf1626997 | |||
| 7f49851abf | |||
| 518b2d9617 | |||
| 4131d2e2a6 | |||
| d507db22a7 | |||
| a0a3ec32a3 | |||
| f9d32a90e7 | |||
| a18b825bee | |||
| 7d275cac6b | |||
| 21727bf1ca | |||
| d126913185 | |||
| edcf41d203 | |||
| 391be09b1e |
582
docs/design-paliad-test-strategy-2026-05-19.md
Normal file
582
docs/design-paliad-test-strategy-2026-05-19.md
Normal 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 | 60–90 s | merge |
|
||||
| **Full (every merge to main)** | + L4 full + L3 + L6 | 3–4 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).
|
||||
- **60–90 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 | 60–90 s gate, 3–4 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: **60–90 s gate, 3–4 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.
|
||||
429
docs/proposals/legal-citation-backfill-2026-05-18.md
Normal file
429
docs/proposals/legal-citation-backfill-2026-05-18.md
Normal file
@@ -0,0 +1,429 @@
|
||||
# Legal-citation Backfill Proposals — t-paliad-208 (Workstream A)
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Author:** huygens (researcher)
|
||||
**Status:** DRAFT — for m's review, not yet migrated
|
||||
**Branch:** `mai/huygens/workstream-a-backfill`
|
||||
**Adjacent:** parallel-track with t-paliad-209 (workstream B — `code` rename + UI cleanup; different fields, no overlap)
|
||||
**Successor:** mig 097 will UPDATE the rows m approves; backup snapshot `deadline_rules_pre_097`
|
||||
|
||||
---
|
||||
|
||||
## 0. Read-this-first
|
||||
|
||||
### 0.1 What this doc is
|
||||
|
||||
Today's audit (paliadin/head, 2026-05-18) found that **130 of 213 active+published rows in `paliad.deadline_rules`** have `rule_code IS NULL`, and 122 have `legal_source IS NULL`. The internal slug field `code` (e.g. `inf.sod`, `de_null.berufung`) had been mistaken for a legal citation; it is just the per-proceeding submission identifier. The actual RoP / ZPO / EPÜ / PatG / UPCA citation belongs in `rule_code` (display form) + `legal_source` (structured locator).
|
||||
|
||||
This document proposes a citation per rule. m approves; head re-tasks for migration 097.
|
||||
|
||||
### 0.2 Field convention (profiled from the 83 already-populated rows)
|
||||
|
||||
| Field | Purpose | Examples from live data |
|
||||
|---|---|---|
|
||||
| `rule_code` | **Human display form**, what we'd write in a brief | `§ 276 ZPO`, `§ 110 PatG`, `Art. 99 EPÜ`, `R. 71(3) EPÜ`, `R. 116 EPÜ`, `RPBA Art. 12`, `RoP.029.a`, `RoP.220.1.a`, `RoP.151`, `RoP.49.1` |
|
||||
| `legal_source` | **Structured locator** (forum-prefixed, no zero padding) for cross-system joins / lex extraction | `DE.ZPO.276.1`, `DE.PatG.111.1`, `EU.EPÜ.108`, `EU.EPC-R.71.3`, `EU.RPBA.12.1.c`, `UPC.RoP.29.a`, `UPC.RoP.220.1` |
|
||||
|
||||
**Sub-conventions observed in live data**
|
||||
|
||||
- `legal_source` prefixes: `DE.<statute>.<n>.<para>`, `EU.EPÜ.<n>.<para>`, `EU.EPC-R.<n>.<para>`, `EU.RPBA.<n>.<para>.<letter>`, `UPC.RoP.<n>.<sub>`.
|
||||
- `rule_code` padding for UPC RoP is **inconsistent today**: rules below 100 are mostly 3-digit padded (`RoP.029.a`, `RoP.030.1`, `RoP.049.2.a`, `RoP.056.1`) but `rev.defence` carries an un-padded `RoP.49.1`. Rules ≥100 are never padded (`RoP.137.2`, `RoP.220.1`).
|
||||
- **Proposed normalization:** 3-digit pad for rules <100, no pad for ≥100. mig 097 should also normalize `RoP.49.1 → RoP.049.1` (1 outlier row, `rev.defence`) as a side-fix. m to confirm.
|
||||
- `legal_source` for UPC RoP **never** pads (`UPC.RoP.29.a`, not `UPC.RoP.029.a`). I follow that.
|
||||
|
||||
### 0.3 Triage philosophy — events vs. deadlines
|
||||
|
||||
Of the 130 NULL-rule_code rows, 53 carry a `proceeding_type_id` and 77 are orphans (`proceeding_type_id IS NULL`, also `code IS NULL`). Within the proceeding-typed bucket, most are **event markers** (zero `duration_value`, `event_type ∈ {hearing, decision, filing}`) that anchor other deadlines rather than computing one of their own.
|
||||
|
||||
I classify each row as one of:
|
||||
|
||||
| Category | Treatment | Examples |
|
||||
|---|---|---|
|
||||
| **Deadline** (positive duration, fires off an anchor) | Cite the operative procedural norm. Confidence usually HIGH. | `inf.sod` Klageerwiderung 3 months → RoP.23 |
|
||||
| **Constitutive event** (zero duration, but a statute defines it) | Cite the constitutive norm (matches existing convention: `de_inf.klage` already has `DE.ZPO.253`). Confidence HIGH where the norm is canonical. | Klageerhebung → § 253 ZPO; Anmeldung EP → Art. 75 EPÜ; Klage UPC → RoP.13.1 |
|
||||
| **Service / trigger event** (zero duration, third-party delivery) | Cite the service norm (§ 317 ZPO etc.) with MEDIUM confidence — these are anchor events for downstream timers, not deadlines on a party. m may prefer NULL here. **FLAG.** | `de_inf_olg.urteil_lg` Zustellung LG-Urteil |
|
||||
| **Court-scheduled event** (hearing, judgment-issuance) | Either NULL (recommended) or cite the general norm authorising the court to schedule. **FLAG.** | Mündliche Verhandlung BGH; OLG-Urteil |
|
||||
| **Court-set duration** (positive duration but `is_court_set=true`, or local practice) | Cite the framing norm (e.g. § 273 ZPO for ZPO patent practice), MEDIUM, FLAG. | `de_inf.replik` 4 weeks (LG patent practice) |
|
||||
|
||||
**Where I am proposing NULL**, the row stays as-is on the DB side (mig 097 simply doesn't touch it). The FLAG list at the bottom of this doc enumerates every NULL proposal so m can override with an explicit citation if desired.
|
||||
|
||||
### 0.4 Counts
|
||||
|
||||
- 130 rows in scope (rule_code IS NULL; is_active=true; lifecycle_state='published')
|
||||
- 53 proceeding-typed + 77 orphan (no proceeding_type_id, no code)
|
||||
- 8 rows already carry a `legal_source` — those are **easy wins**: only `rule_code` needs proposing
|
||||
- ~ 40 HIGH-confidence proposals
|
||||
- ~ 35 MEDIUM-confidence proposals
|
||||
- ~ 55 FLAG entries (court-scheduled events, combined-pleading rows, ambiguous orphans)
|
||||
|
||||
The orphan bucket carries a noticeable number of **duplicates** (six "Mängelbeseitigung / Zahlung" rows, two "Beginn des Hauptsacheverfahrens", two "Antrag auf Patentänderung", etc.). Those are likely vestiges of older Fristenrechner pipelines; backfilling them with the same citation is fine, but m may want a separate dedup pass (out of scope here; flag in § 4).
|
||||
|
||||
---
|
||||
|
||||
## 1. Easy wins — rows with `legal_source` already set, `rule_code` missing (8)
|
||||
|
||||
For these, the structured locator is already in the DB; only the display form is missing.
|
||||
|
||||
| id | code / name | duration | existing `legal_source` | proposed `rule_code` | conf |
|
||||
|---|---|---|---|---|---|
|
||||
| `1f532c82…` | `de_inf.klage` / Klageerhebung | event | `DE.ZPO.253` | `§ 253 ZPO` | HIGH |
|
||||
| `20254f4e…` | (orphan) Einspruch gegen Versäumnisurteil | 2 weeks | `DE.ZPO.339.1` | `§ 339 ZPO` | HIGH |
|
||||
| `3c36f149…` | (orphan) Schriftsatznachreichung (§ 296a ZPO) | 3 weeks | `DE.ZPO.296a` | `§ 296a ZPO` | HIGH |
|
||||
| `f1099cf6…` | (orphan) Weiterbehandlungsantrag (Art. 121 EPÜ) | 2 months | `EU.EPC-R.135.1` | `R. 135 EPÜ` | HIGH |
|
||||
| `c24d494c…` | (orphan) Wiedereinsetzungsantrag (§ 123 PatG) | 2 months | `DE.PatG.123.2` | `§ 123 PatG` | HIGH |
|
||||
| `d40d9be7…` | (orphan) Wiedereinsetzungsantrag (§ 233 ZPO) | 2 weeks | `DE.ZPO.234.1` | `§ 234 ZPO` | HIGH |
|
||||
| `23c6f445…` | (orphan) Wiedereinsetzungsantrag (Art. 122 EPÜ) | 2 months | `EU.EPC-R.136.1` | `R. 136 EPÜ` | HIGH |
|
||||
| `b588fa64…` | (orphan) Wiedereinsetzungsantrag (DPMA) | 2 months | `DE.PatG.123.2` | `§ 123 PatG` | HIGH |
|
||||
|
||||
**Naming note on the two Wiedereinsetzung-`§ 123 PatG` rows.** Both `c24d494c…` ("§ 123 PatG" name) and `b588fa64…` ("DPMA" name) map to the same statute — § 123 PatG (Wiedereinsetzung) applies to all DPMA-Verfahren, so the duplication is a pure naming choice. mig 097 fills both; potential dedup is a separate question (§ 4 FLAG-A).
|
||||
|
||||
---
|
||||
|
||||
## 2. Proceeding-typed rows (53)
|
||||
|
||||
Grouped by `proceeding_types.code`. Within each group: alphabetical by `code`.
|
||||
|
||||
### 2.1 `upc.inf.cfi` — Verletzungsverfahren CFI (4 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `inf.decision` | Entscheidung | event | decision | *(NULL)* | *(NULL)* | RoP.118 — but this is the court's own decision, not a party deadline | **FLAG-B** |
|
||||
| `inf.interim` | Zwischenverfahren | event | hearing | *(NULL)* | *(NULL)* | RoP.101 ff. governs interim procedure; not a single norm | **FLAG-B** |
|
||||
| `inf.oral` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | RoP.111-117 (oral procedure); court-scheduled | **FLAG-B** |
|
||||
| `inf.soc` | Klageerhebung (Statement of claim) | event | filing | `RoP.013.1` | `UPC.RoP.13.1` | RoP.13 — Statement of claim contents | HIGH |
|
||||
|
||||
### 2.2 `upc.rev.cfi` — Nichtigkeitsverfahren CFI (6 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `rev.app` | Nichtigkeitsklage | event | filing | `RoP.042` | `UPC.RoP.42` | RoP.42 — Statement for revocation | HIGH |
|
||||
| `rev.decision` | Entscheidung | event | decision | *(NULL)* | *(NULL)* | court-issued, not a party deadline | **FLAG-B** |
|
||||
| `rev.interim` | Zwischenverfahren | event | hearing | *(NULL)* | *(NULL)* | not a single norm | **FLAG-B** |
|
||||
| `rev.oral` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | court-scheduled | **FLAG-B** |
|
||||
| `rev.reply` | Replik | 2 months | filing | `RoP.052` | `UPC.RoP.52` | RoP.52 — Reply to defence in revocation | MED (**FLAG-C**: duration vs. norm) |
|
||||
| `rev.rejoin` | Duplik | 2 months | filing | `RoP.052` | `UPC.RoP.52` | RoP.52 — Rejoinder | MED (**FLAG-C**: duration vs. norm) |
|
||||
|
||||
**FLAG-C:** RoP.52(1) sets the reply to 2 months but RoP.52(2) sets the rejoinder to 1 month from service of the reply. m's `rev.rejoin` says 2 months — verify whether the rule duration is correct or whether `RoP.52.2` (1 month) is the right citation. Cross-check with the existing `rev.rejoin_cci` row which uses RoP.056.4 (cci context); the main-pleadings rejoinder lives in RoP.52.
|
||||
|
||||
### 2.3 `upc.pi.cfi` — Einstweilige Maßnahmen (4 rules)
|
||||
|
||||
All four rules are currently NULL on both fields.
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `pi.app` | Antrag | event | filing | `RoP.206` | `UPC.RoP.206` | RoP.206 — Application for provisional measures | HIGH |
|
||||
| `pi.oral` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | RoP.209 — at judge's discretion | **FLAG-B** |
|
||||
| `pi.order` | Beschluss | event | decision | *(NULL)* | *(NULL)* | RoP.211 — court-issued | **FLAG-B** |
|
||||
| `pi.response` | Erwiderung | event | filing | *(NULL)* | *(NULL)* | RoP.209.1 — judge sets time; no statutory period | **FLAG-B** (alt: `RoP.209.1` / `UPC.RoP.209.1` to flag as court-set) |
|
||||
|
||||
### 2.4 `upc.apl.merits` — Berufungsverfahren Merits (3 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `app.decision` | Entscheidung | event | decision | *(NULL)* | *(NULL)* | RoP.350 — appellate decision | **FLAG-B** |
|
||||
| `app.oral` | Mündliche Verhandlung | event | hearing | `RoP.243` | `UPC.RoP.243` | RoP.243 — oral procedure in appeal | MED |
|
||||
| `app.response` | Berufungserwiderung | 2 months | filing | `RoP.235.1` | `UPC.RoP.235.1` | RoP.235.1 — Statement of response | MED (**FLAG-C**: RoP.235.1 says 3 months for main-judgment appeals; 2 months may be a residual from a different appeal track. Verify duration vs. norm.) |
|
||||
|
||||
### 2.5 `upc.apl.order` — Berufungsverfahren Anordnungen (1 rule)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `app_ord.order` | Anordnung / angegriffene Entscheidung | event | decision | *(NULL)* | *(NULL)* | trigger event for orders-appeal; RoP.220.1.c references it | **FLAG-B** (alt: `RoP.220.1.c` to surface) |
|
||||
|
||||
### 2.6 `upc.apl.cost` — Berufungsverfahren Kosten (1 rule)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `cost.decision` | Kostenfestsetzungsbeschluss | event | decision | *(NULL)* | *(NULL)* | RoP.150 ff. — cost decision in the assessment proceedings | **FLAG-B** |
|
||||
|
||||
### 2.7 `upc.dmgs.cfi` — Schadensbemessungsverfahren (1 rule)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `damages.app` | Antrag auf Schadensbemessung | event | filing | `RoP.131` | `UPC.RoP.131` | RoP.131 — Application for damages determination | HIGH |
|
||||
|
||||
### 2.8 `upc.disc.cfi` — Bucheinsichtsverfahren (1 rule)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `disc.app` | Antrag auf Bucheinsicht | event | filing | `RoP.141` | `UPC.RoP.141` | RoP.141 — Application for order to lay open books | HIGH |
|
||||
|
||||
### 2.9 `de.inf.lg` — Verletzungsverfahren LG (5 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `de_inf.klage` | Klageerhebung | event | filing | `§ 253 ZPO` | `DE.ZPO.253` *(already set)* | § 253 ZPO — Klageschrift | HIGH (rule_code only) |
|
||||
| `de_inf.replik` | Replik | 4 weeks | filing | `§ 273 ZPO` | `DE.ZPO.273` | § 273 ZPO — vorbereitende Anordnungen / court-set period (Düsseldorfer Praxis) | MED (**FLAG-D**: 4 weeks is local LG practice, no statutory period; flag `is_court_set=true` already true in DB) |
|
||||
| `de_inf.duplik` | Duplik | 4 weeks | filing | `§ 273 ZPO` | `DE.ZPO.273` | same | MED (**FLAG-D**) |
|
||||
| `de_inf.termin` | Haupttermin | event | hearing | *(NULL)* | *(NULL)* | § 272 / § 137 ZPO — court-scheduled | **FLAG-B** |
|
||||
| `de_inf.urteil` | Urteil | event | decision | *(NULL)* | *(NULL)* | § 300 ZPO — court-issued | **FLAG-B** |
|
||||
|
||||
### 2.10 `de.inf.olg` — Berufungsverfahren OLG Verletzung (3 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `de_inf_olg.urteil_lg` | Zustellung LG-Urteil | event | filing (trigger) | `§ 317 ZPO` | `DE.ZPO.317` | § 317 ZPO — Zustellung von Urteilen | MED (**FLAG-E**: service-trigger event — may be NULL per philosophy) |
|
||||
| `de_inf_olg.termin` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | court-scheduled | **FLAG-B** |
|
||||
| `de_inf_olg.urteil_olg` | OLG-Urteil | event | decision | *(NULL)* | *(NULL)* | court-issued | **FLAG-B** |
|
||||
|
||||
### 2.11 `de.inf.bgh` — Revision/NZB BGH Verletzung (3 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `de_inf_bgh.urteil_olg` | Zustellung OLG-Urteil | event | filing (trigger) | `§ 317 ZPO` | `DE.ZPO.317` | § 317 ZPO — Zustellung | MED (**FLAG-E**) |
|
||||
| `de_inf_bgh.termin` | Mündliche Verhandlung BGH | event | hearing | *(NULL)* | *(NULL)* | § 555 i.V.m. § 137 ZPO — court-scheduled | **FLAG-B** |
|
||||
| `de_inf_bgh.urteil_bgh` | BGH-Urteil | event | decision | *(NULL)* | *(NULL)* | § 562, § 563 ZPO — court-issued | **FLAG-B** |
|
||||
|
||||
### 2.12 `de.null.bpatg` — Nichtigkeitsverfahren BPatG (3 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `de_null.klage` | Nichtigkeitsklage | event | filing | `§ 81 PatG` | `DE.PatG.81.1` | § 81 PatG — Nichtigkeitsklage einreichen | HIGH |
|
||||
| `de_null.termin` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | § 89 PatG | **FLAG-B** |
|
||||
| `de_null.urteil` | Urteil | event | decision | *(NULL)* | *(NULL)* | § 84 PatG | **FLAG-B** |
|
||||
|
||||
### 2.13 `de.null.bgh` — Berufung BGH Nichtigkeit (3 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `de_null_bgh.urteil_bpatg` | Zustellung BPatG-Urteil | event | filing (trigger) | `§ 99 PatG` | `DE.PatG.99.1` | § 99 PatG verweist auf ZPO; Zustellung der BPatG-Urteile | MED (**FLAG-E**) |
|
||||
| `de_null_bgh.termin` | Mündliche Verhandlung BGH | event | hearing | *(NULL)* | *(NULL)* | § 113 PatG i.V.m. ZPO | **FLAG-B** |
|
||||
| `de_null_bgh.urteil_bgh` | BGH-Urteil | event | decision | *(NULL)* | *(NULL)* | § 119 PatG | **FLAG-B** |
|
||||
|
||||
### 2.14 `dpma.opp.dpma` — Einspruchsverfahren DPMA (2 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `dpma_opp.publish` | Veröffentlichung der Erteilung | event | filing (trigger) | `§ 58 PatG` | `DE.PatG.58.1` | § 58(1) PatG — Veröffentlichung der Erteilung im Patentblatt | HIGH |
|
||||
| `dpma_opp.entscheidung` | DPMA-Entscheidung | event | decision | *(NULL)* | *(NULL)* | § 47 PatG ff. | **FLAG-B** |
|
||||
|
||||
### 2.15 `dpma.appeal.bpatg` — Beschwerdeverfahren BPatG vs. DPMA (3 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `dpma_bpatg.entscheidung` | Zustellung DPMA-Entscheidung | event | filing (trigger) | `§ 47 PatG` | `DE.PatG.47.1` | § 47 PatG — Zustellung der Entscheidung im DPMA-Verfahren | MED (**FLAG-E**: trigger-event citation. Alternative `§ 127 PatG` for service procedure.) |
|
||||
| `dpma_bpatg.entsch_bpatg` | BPatG-Entscheidung | event | decision | *(NULL)* | *(NULL)* | § 79 PatG | **FLAG-B** |
|
||||
| `dpma_bpatg.termin` | Mündliche Verhandlung BPatG | event | hearing | *(NULL)* | *(NULL)* | § 78 PatG | **FLAG-B** |
|
||||
|
||||
### 2.16 `dpma.appeal.bgh` — Rechtsbeschwerdeverfahren BGH (2 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `dpma_bgh.entsch_bpatg` | Zustellung BPatG-Entscheidung | event | filing (trigger) | `§ 79 PatG` | `DE.PatG.79.1` | § 79 PatG — Zustellung der BPatG-Entscheidung | MED (**FLAG-E**) |
|
||||
| `dpma_bgh.entsch_bgh` | BGH-Entscheidung | event | decision | *(NULL)* | *(NULL)* | § 107 PatG | **FLAG-B** |
|
||||
|
||||
### 2.17 `epa.grant.exa` — EP-Erteilungsverfahren (3 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `ep_grant.filing` | Anmeldung | event | filing | `Art. 75 EPÜ` | `EU.EPÜ.75` | Art. 75 EPÜ — Filing of European patent application | HIGH |
|
||||
| `ep_grant.search` | Recherchenbericht | 6 months | decision | `Art. 92 EPÜ` | `EU.EPÜ.92` | Art. 92 EPÜ — Drawing up of the European search report | MED (the 6-month figure is a Richtwert per `deadline_notes` — not a statutory deadline. Could also cite `R. 65 EPÜ` if we want the issuance procedure.) |
|
||||
| `ep_grant.grant` | Erteilung (B1) | event | decision | `Art. 97 EPÜ` | `EU.EPÜ.97.1` | Art. 97(1) EPÜ — Decision to grant | HIGH |
|
||||
|
||||
### 2.18 `epa.opp.opd` — Einspruchsverfahren EPA (2 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `epa_opp.grant` | Veröffentlichung der Erteilung | event | filing (trigger) | `Art. 97 EPÜ` | `EU.EPÜ.97.3` | Art. 97(3) EPÜ — mention of grant; trigger for the 9-month Einspruchsfrist (Art. 99(1) EPÜ) | HIGH |
|
||||
| `epa_opp.entsch` | Entscheidung | event | decision | `Art. 101 EPÜ` | `EU.EPÜ.101` | Art. 101 EPÜ — Decision on opposition | HIGH |
|
||||
|
||||
### 2.19 `epa.opp.boa` — Beschwerdeverfahren BoA (3 rules)
|
||||
|
||||
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `epa_app.entsch` | Zustellung der Beschwerdeentscheidung | event | filing (trigger) | `R. 111 EPÜ` | `EU.EPC-R.111` | R. 111 EPÜ — Form and notification of decisions | MED (**FLAG-E**: service-trigger citation. Could also cite `Art. 119 EPÜ` for notification.) |
|
||||
| `epa_app.oral` | Mündliche Verhandlung | event | hearing | `Art. 116 EPÜ` | `EU.EPÜ.116` | Art. 116 EPÜ — Oral proceedings | HIGH |
|
||||
| `epa_app.entsch2` | Entscheidung | event | decision | `Art. 111 EPÜ` | `EU.EPÜ.111` | Art. 111 EPÜ — Decision in respect of appeals | HIGH |
|
||||
|
||||
---
|
||||
|
||||
## 3. Orphan rows — `proceeding_type_id IS NULL` and `code IS NULL` (77)
|
||||
|
||||
Identified by `id` (UUID first 8 chars) + name. These are the older Fristenrechner catalogue rows that pre-date the proceeding-typed slice and were never re-anchored to a proceeding. Many are 1:1 duplicates of rules that now live in proceeding-typed form.
|
||||
|
||||
### 3.1 UPC RoP — main-pleadings track (15)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `e34097d6…` | Klageerwiderung | 3 mo | `RoP.023` | `UPC.RoP.23.1` | RoP.23.1 — Statement of defence | HIGH | dup of `inf.sod` |
|
||||
| `7d8a4804…` | Nichtigkeitswiderklage | 3 mo | `RoP.025.1` | `UPC.RoP.25.1` | RoP.25.1 — Counterclaim for revocation | HIGH | — |
|
||||
| `c7523e6b…` | Verletzungswiderklage | 2 mo | `RoP.049.2.b` | `UPC.RoP.49.2.b` | RoP.49.2.b — Counterclaim for infringement in revocation | HIGH | dup of `rev.cc_inf` |
|
||||
| `c57f62f8…` | Vorgängige Einrede | 1 mo | `RoP.019.1` | `UPC.RoP.19.1` | RoP.19.1 — Preliminary objection | HIGH | dup of `inf.prelim` / `rev.prelim` |
|
||||
| `cec1a865…` | Erwiderung Nichtigkeitswiderklage **+** Replik Klageerwiderung | 2 mo | `RoP.029.a` | `UPC.RoP.29.a` | RoP.29.a / .b — combined Defence-to-CCR + Reply to SoD | HIGH (**FLAG-F**: combined-pleading orphan — m to confirm one citation is sufficient or whether row should be split) |
|
||||
| `84b390e0…` | Replik auf die Klageerwiderung | 2 mo | `RoP.029.b` | `UPC.RoP.29.b` | RoP.29.b — Reply to defence | HIGH | dup of `inf.reply` |
|
||||
| `176cc1ca…` | Duplik zur Replik auf die Klageerwiderung | 1 mo | `RoP.029.c` | `UPC.RoP.29.c` | RoP.29.c — Rejoinder | HIGH | dup of `inf.rejoin` |
|
||||
| `02ae9c1f…` | Duplik zur Replik, Replik auf die Erwiderung zum Patentänderungsantrag | 1 mo | `RoP.029.c` | `UPC.RoP.29.c` | combined: RoP.29.c + RoP.32.3 | MED (**FLAG-F**) |
|
||||
| `ec2a1274…` | Replik auf Erwiderung Widerklage, Duplik Replik Klageerwiderung, Erwiderung Patentänderungsantrag | 2 mo | `RoP.029.d` | `UPC.RoP.29.d` | combined: RoP.29.d + RoP.29.c + RoP.32.1 | MED (**FLAG-F**: three-norm combined row) |
|
||||
| `a32dcec1…` | Erwiderung auf die Nichtigkeitsklage | 2 mo | `RoP.049.1` | `UPC.RoP.49.1` | RoP.49.1 — Defence to revocation | HIGH | dup of `rev.defence` |
|
||||
| `37bd034b…` | Replik Erwiderung Nichtigkeitsklage + Erwiderung Patentänderungsantrag + Erwiderung Verletzungswiderklage | 2 mo | `RoP.051` | `UPC.RoP.51` | combined: RoP.51 + RoP.49.2.a-reply + RoP.56.1 | MED (**FLAG-F**) |
|
||||
| `1b5c6dee…` | Duplik zur Replik auf die Erwiderung zur Nichtigkeitsklage | 1 mo | `RoP.052` | `UPC.RoP.52` | RoP.52 — Rejoinder in revocation | MED |
|
||||
| `bea86f9b…` | Erwiderung auf die Verletzungswiderklage | 2 mo | `RoP.056.1` | `UPC.RoP.56.1` | RoP.56.1 | HIGH | dup of `rev.def_cci` |
|
||||
| `4834c957…` | Replik auf die Erwiderung zur Verletzungswiderklage | 1 mo | `RoP.056.3` | `UPC.RoP.56.3` | RoP.56.3 | HIGH | dup of `rev.reply_def_cci` |
|
||||
| `7b548c48…` | Duplik (Verletzungswiderklage + Patentänderungsantrag) | 1 mo | `RoP.056.4` | `UPC.RoP.56.4` | combined: RoP.56.4 + RoP.32.3 | MED (**FLAG-F**) |
|
||||
|
||||
### 3.2 UPC RoP — Patentänderungs-Track (5)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `fb7050c6…` | Antrag auf Patentänderung | 2 mo | `RoP.030.1` | `UPC.RoP.30.1` | RoP.30.1 (infringement context) | MED (**FLAG-G**: 2 rows with identical name + 2-month dur; one likely refers to `RoP.30.1` infringement, other to `RoP.49.2.a` revocation) |
|
||||
| `21e67ac1…` | Antrag auf Patentänderung | 2 mo | `RoP.049.2.a` | `UPC.RoP.49.2.a` | RoP.49.2.a (revocation context) | MED (**FLAG-G**) |
|
||||
| `7e65a434…` | Erwiderung auf den Antrag auf Patentänderung | 2 mo | `RoP.032.1` | `UPC.RoP.32.1` | RoP.32.1 — Defence to application to amend | HIGH | dup of `inf.def_to_amend` |
|
||||
| `dfd52792…` | Replik auf die Erwiderung zum Patentänderungsantrag | 1 mo | `RoP.032.3` | `UPC.RoP.32.3` | RoP.32.3 — Reply | HIGH | dup of `inf.reply_def_amd` |
|
||||
| `8cdf54eb…` | Duplik zur Replik auf die Erwiderung zum Patentänderungsantrag | 1 mo | `RoP.032.3` | `UPC.RoP.32.3` | RoP.32.3 — Rejoinder | HIGH | dup of `inf.rejoin_amd` |
|
||||
|
||||
### 3.3 UPC RoP — appeal track (16)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `1dfba5b1…` | Berufungsschrift gegen Entscheidung nach R. 220.1(a)/(b) | 2 mo | `RoP.224.1.a` | `UPC.RoP.224.1.a` | RoP.224.1.a — Notice of appeal, main-judgment track | HIGH | dup of `app.notice` |
|
||||
| `5c0508f4…` | Berufungsschrift gegen Entscheidung nach R. 220.1(a)/(b) | 2 mo | `RoP.224.1.a` | `UPC.RoP.224.1.a` | same | HIGH | duplicate-of-duplicate (**FLAG-A**) |
|
||||
| `d560b3b6…` | Berufungsschrift gegen Anordnung R. 220.1(c) / R. 220.2 / 221.3 | 15 d | `RoP.224.1.b` | `UPC.RoP.224.1.b` | RoP.224.1.b — Notice of appeal, orders/leave track | HIGH | dup of `app_ord.with_leave`-family |
|
||||
| `791fd0f7…` | Berufungsbegründung Entscheidung R. 220.1(a)/(b) | 4 mo | `RoP.225.1` | `UPC.RoP.225.1` | RoP.225.1 — Statement of grounds, main track | HIGH | dup of `app.grounds` |
|
||||
| `573df3d1…` | Berufungsbegründung Entscheidung R. 220.1(a)/(b) | 4 mo | `RoP.225.1` | `UPC.RoP.225.1` | same | HIGH | duplicate-of-duplicate (**FLAG-A**) |
|
||||
| `c3a369f9…` | Berufungsbegründung Anordnung R. 220.1(c) / R. 220.2 / 221.3 | 15 d | `RoP.225.2` | `UPC.RoP.225.2` | RoP.225.2 — Statement of grounds, orders/leave | MED (**FLAG-H**: RoP.225.2 form; verify 15d figure aligns with current RoP version) |
|
||||
| `91e367dd…` | Berufung (Anordnungen & mit Zulassung) | 15 d | `RoP.224.1.b` | `UPC.RoP.224.1.b` | same | MED | dup of `app_ord.with_leave` |
|
||||
| `ccb916df…` | Antrag auf Berufungszulassung gegen Kostenentscheidungen | 15 d | `RoP.221.1` | `UPC.RoP.221.1` | RoP.221.1 — Leave to appeal cost decisions | HIGH | dup of `cost.leave_app` |
|
||||
| `342e749d…` | Antrag auf Ermessensüberprüfung | 15 d | `RoP.220.3` | `UPC.RoP.220.3` | RoP.220.3 — Discretionary review | HIGH | dup of `app_ord.discretion` |
|
||||
| `d4f739cd…` | Anfechtung einer Entscheidung über Verwerfung der Berufung als unzulässig | 1 mo | `RoP.234.1` | `UPC.RoP.234.1` | RoP.234 — Inadmissibility of appeal review | MED (**FLAG-H**: confirm sub-paragraph; RoP.234 governs the topic but the 1-month review window may sit elsewhere) |
|
||||
| `10374392…` | Berufungserwiderung (zur Berufung nach R. 224.2(a)) | 3 mo | `RoP.235.1` | `UPC.RoP.235.1` | RoP.235.1 — Statement of response, main track | HIGH |
|
||||
| `4c585c6d…` | Berufungserwiderung (zur Berufung nach R. 224.2(b)) | 15 d | `RoP.235.4` | `UPC.RoP.235.4` | RoP.235.4 — Statement of response, orders/leave track | MED (**FLAG-H**: confirm RoP.235.4 vs. RoP.235.2 in current RoP version) |
|
||||
| `6e39b653…` | Anschlussberufungsschrift (zur Berufung R. 224.2(a)) | 3 mo | `RoP.237.1` | `UPC.RoP.237.1` | RoP.237.1 — Cross-appeal | HIGH |
|
||||
| `a00e51bb…` | Anschlussberufungsschrift (zur Berufung R. 224.2(b)) | 15 d | `RoP.237.2` | `UPC.RoP.237.2` | RoP.237 — Cross-appeal in orders track | MED (**FLAG-H**) |
|
||||
| `6b989e85…` | Erwiderung auf Anschlussberufungsschrift (R. 224.2(a)) | 2 mo | `RoP.238.1` | `UPC.RoP.238.1` | RoP.238.1 — Reply to cross-appeal | HIGH | dup of `app.cross_a_reply` |
|
||||
| `e78f4652…` | Erwiderung auf Anschlussberufungsschrift (R. 224.2(b)) | 15 d | `RoP.238.2` | `UPC.RoP.238.2` | RoP.238.2 — Reply to cross-appeal, orders track | HIGH | dup of `app_ord.cross_reply` |
|
||||
|
||||
### 3.4 UPC RoP — Schadensbemessung / Rechnungslegung (7)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `d414f603…` | Erwiderung Antrag auf Schadensersatzbemessung | 2 mo | `RoP.137.2` | `UPC.RoP.137.2` | RoP.137.2 | HIGH | dup of `damages.defence` |
|
||||
| `9f39e263…` | Replik Erwiderung Schadensersatzbemessung | 1 mo | `RoP.139` | `UPC.RoP.139` | RoP.139 | HIGH | dup of `damages.reply` |
|
||||
| `067ffdf0…` | Duplik Replik Schadensersatzbemessung | 1 mo | `RoP.139` | `UPC.RoP.139` | RoP.139 | HIGH | dup of `damages.rejoin` |
|
||||
| `429b8ec0…` | Erwiderung Antrag auf Rechnungslegung | 2 mo | `RoP.142.2` | `UPC.RoP.142.2` | RoP.142.2 — Defence in account procedure | HIGH | dup of `disc.defence` |
|
||||
| `8d36fc76…` | Replik Erwiderung Rechnungslegung | 14 d | `RoP.142.3` | `UPC.RoP.142.3` | RoP.142.3 | HIGH | dup of `disc.reply` |
|
||||
| `ed82fec9…` | Duplik Replik Erwiderung Rechnungslegung | 14 d | `RoP.142.3` | `UPC.RoP.142.3` | RoP.142.3 | HIGH | dup of `disc.rejoin` |
|
||||
| `eed69e8b…` | Antrag auf Kostenentscheidung | 1 mo | `RoP.151` | `UPC.RoP.151` | RoP.151 — Application for cost decision | HIGH | dup of `inf.cost_app` |
|
||||
|
||||
### 3.5 UPC RoP — provisional / PI (6)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `ba335c99…` | Beginn des Hauptsacheverfahrens | 31 d | `RoP.213.1` | `UPC.RoP.213.1` | RoP.213.1 — 31 days or 20 working days after PI granted | HIGH |
|
||||
| `d886f46f…` | Beginn des Hauptsacheverfahrens | 31 d | `RoP.213.1` | `UPC.RoP.213.1` | same — duplicate row (**FLAG-A**) | HIGH |
|
||||
| `1f1f72ef…` | Antrag auf Überprüfung der Beweissicherungsanordnung | 30 d | `RoP.197.3` | `UPC.RoP.197.3` | RoP.197.3 — Review of evidence preservation order | HIGH |
|
||||
| `3e2f5697…` | Erneuerung der Schutzschrift | 6 mo | `RoP.207.9` | `UPC.RoP.207.9` | RoP.207.9 — Protective letter, 6-month validity | HIGH |
|
||||
|
||||
### 3.6 UPC RoP — feststellungs / Widerruf-Track (4)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `521bf607…` | Erwiderung auf negative Feststellungsklage | 2 mo | *(NULL)* | *(NULL)* | UPC declaration of non-infringement procedure follows RoP.49 ff. by analogy (RoP.69 references) | **FLAG-I**: negative declaration track has no single statutory norm; cite either `RoP.069` / `UPC.RoP.69` (general procedure) or leave NULL pending m's call |
|
||||
| `e887b1fb…` | Replik Erwiderung negative Feststellungsklage | 1 mo | *(NULL)* | *(NULL)* | same | **FLAG-I** |
|
||||
| `0cf1d755…` | Duplik Replik Erwiderung negative Feststellungsklage | 1 mo | *(NULL)* | *(NULL)* | same | **FLAG-I** |
|
||||
|
||||
### 3.7 UPC RoP — formalities / Registry (14)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `d058f412…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | RoP.16.4 — Notice to remedy defects | HIGH |
|
||||
| `c690c323…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | same — duplicate (**FLAG-A**) | HIGH |
|
||||
| `5f2884a4…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
|
||||
| `13600049…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
|
||||
| `ceb780ba…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
|
||||
| `d51c50eb…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
|
||||
| `3bc40027…` | Mängelbeseitigung / Einreichung schriftlicher Stellungnahme | 14 d | `RoP.016.5` | `UPC.RoP.16.5` | RoP.16.5 — Written observations after Registry notice | MED |
|
||||
| `69e356b7…` | Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit | 14 d | `RoP.262.2` | `UPC.RoP.262.2` | RoP.262.2 — Confidentiality vis-à-vis public (note in DB confirms) | HIGH |
|
||||
| `57e6eeca…` | Berichtigung von Entscheidungen und Anordnungen | 1 mo | `RoP.353` | `UPC.RoP.353` | RoP.353 — Rectification of decisions/orders | HIGH |
|
||||
| `8ec233b9…` | Antrag auf Überprüfung verfahrensleitender Anordnung | 15 d | `RoP.333.1` | `UPC.RoP.333.1` | RoP.333.1 — Review of procedural order | HIGH |
|
||||
| `d124c95b…` | Antrag auf Aufhebung oder Änderung Entscheidung des Amtes | 1 mo | *(NULL)* | *(NULL)* | unclear which Amts-Entscheidung this targets — Registry order? Unitary-effect refusal? | **FLAG-J** (recommend NULL; ask m what proceeding-context this row maps to) |
|
||||
| `0531b6ba…` | Antrag auf Aufhebung Entscheidung EPA über einheitliche Wirkung | 3 wk | `RoP.097.1` | `UPC.RoP.97.1` | RoP.97.1 — Action against EPO decision on unitary effect | MED (**FLAG-H**: verify 3-week period vs. norm; current RoP gives 1 month for such applications under R.88 EPÜ-UPC; possibly outdated) |
|
||||
| `6b6b967c…` | Antrag auf Verweisung an die Zentralkammer | 10 d | `RoP.037.4` | `UPC.RoP.37.4` | RoP.37 governs division apportionment; .4 is the 10-day observation period | MED (**FLAG-H**: confirm sub-paragraph) |
|
||||
| `002c2ba7…` | Antrag auf Folgemaßnahmen rechtskräftiger Validitätsentscheidung | 2 mo | *(NULL)* | *(NULL)* | likely refers to post-revocation register-correction request; norm uncertain | **FLAG-J** |
|
||||
|
||||
### 3.8 UPC RoP — translation / interpretation (3)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `bb7bafcb…` | Antrag auf Simultanübersetzung | 1 mo (before) | `RoP.109.1` | `UPC.RoP.109.1` | RoP.109.1 — Request for simultaneous interpretation | HIGH |
|
||||
| `8c682cff…` | Mitteilung über Beauftragung eines Dolmetschers auf Kosten der Partei | 2 wk (before) | `RoP.109.5` | `UPC.RoP.109.5` | RoP.109.5 — Notice of own-cost interpreter | MED (**FLAG-H**: confirm sub-paragraph; RoP.109 governs interpretation but the specific 2-week notice rule may sit at .4 or .5) |
|
||||
| `9ed513c1…` | Einreichung von Übersetzungen von Schriftstücken | 1 mo | `RoP.007.2` | `UPC.RoP.7.2` | RoP.7.2 — Language of documents | MED (**FLAG-H**: alternative `RoP.7.4` for translations of party-submitted documents) |
|
||||
| `902cc5d5…` | Klärung von Übersetzungsfragen | 2 wk | *(NULL)* | *(NULL)* | unclear which "Übersetzungsfrage" rule | **FLAG-J** |
|
||||
|
||||
### 3.9 UPC RoP — review / rehearing (2)
|
||||
|
||||
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `372e86e3…` | Antrag auf Wiederaufnahme (schwerwiegender Verfahrensmangel) | 2 mo | `RoP.247.2` | `UPC.RoP.247.2` | RoP.247.2 — Application for rehearing within 2 months | HIGH |
|
||||
| `58de9573…` | Antrag auf Wiederaufnahme (Straftat) | 2 mo | `RoP.247.2` | `UPC.RoP.247.2` | RoP.247.1(b) substantively (criminal act ground); RoP.247.2 for the 2-month period | HIGH |
|
||||
|
||||
### 3.10 Already-cited orphans (covered in § 1 Easy wins, 7 rows)
|
||||
|
||||
`20254f4e…`, `3c36f149…`, `f1099cf6…`, `c24d494c…`, `d40d9be7…`, `23c6f445…`, `b588fa64…` — see § 1.
|
||||
|
||||
---
|
||||
|
||||
## 4. FLAG summary — items needing m's call
|
||||
|
||||
| FLAG | Topic | Count | Decision needed |
|
||||
|---|---|---|---|
|
||||
| **A** | Genuine duplicate orphan rows (same name + dur + citation) | ~10 | Confirm the dedup pass should happen in mig 097 (or a follow-up). Recommended: leave duplicates in place for mig 097 (fills all of them with the same citation); dedup separately so the rule-resolution semantics don't drift. |
|
||||
| **B** | Court-scheduled / court-issued event rows (Mündliche Verhandlung, Urteil, Entscheidung) | ~22 | Confirm NULL is the right default. Alternative: cite the framing norm with a "context" note. |
|
||||
| **C** | UPC RoP duration vs. norm mismatch (`rev.reply` / `rev.rejoin` / `app.response`) | 3 | Verify the rule durations are correct as stored — proposed citations are canonical but rule duration may be from an older RoP version. |
|
||||
| **D** | German LG patent practice: 4-week replik/duplik (court-set) | 2 | Confirm `§ 273 ZPO` is the cite m wants (no statutory period, framing norm only). |
|
||||
| **E** | Service / trigger-event citations (`§ 317 ZPO`, `R. 111 EPÜ` etc.) | 6 | These are anchor-events for downstream timers, not deadlines. Confirm whether to cite (current proposal) or leave NULL. |
|
||||
| **F** | Combined-pleading orphan rows (one row = several norms) | 5 | Confirm one citation is acceptable, or whether the rows should be split before mig 097 (out of scope here). |
|
||||
| **G** | Twin "Antrag auf Patentänderung" orphans (2-mo, identical name) | 2 | Confirm one is infringement-context (`RoP.30.1`), the other revocation-context (`RoP.49.2.a`). |
|
||||
| **H** | RoP sub-paragraph uncertainty (current text vs. older version) | ~8 | Spot-check against current published RoP; my citations are canonical but small `.x` numbers may need a tweak. |
|
||||
| **I** | Negative-declaration track (no single UPC norm) | 3 | Confirm citing `RoP.69` (procedure-by-analogy) vs. leaving NULL. |
|
||||
| **J** | Orphan with unclear scope | 3 | `d124c95b…` (Aufhebung Entscheidung des Amtes), `002c2ba7…` (Folgemaßnahmen Validitätsentscheidung), `902cc5d5…` (Klärung Übersetzungsfragen). m to identify which UPC norm. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Side-fix (recommend bundled in mig 097)
|
||||
|
||||
**RoP-display normalization**: `rev.defence` currently carries `rule_code = "RoP.49.1"`. All other RoP rules under 100 use 3-digit padding (`RoP.029.a`, `RoP.049.2.a` etc.). mig 097 should normalize `RoP.49.1 → RoP.049.1` in that one row, while filling the 130 NULL rows with consistently padded values.
|
||||
|
||||
```sql
|
||||
-- side-fix candidate
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.049.1'
|
||||
WHERE rule_code = 'RoP.49.1'
|
||||
AND code = 'rev.defence'; -- only one row; idempotent
|
||||
```
|
||||
|
||||
This is opt-in; m to confirm before mig 097 ships.
|
||||
|
||||
---
|
||||
|
||||
## 6. Migration 097 hints (for the coder who writes it)
|
||||
|
||||
**Shape m has asked for:**
|
||||
|
||||
- `UPDATE paliad.deadline_rules SET rule_code = …, legal_source = … WHERE id = … AND rule_code IS NULL AND legal_source IS [NULL|expected];`
|
||||
- Idempotent: `WHERE rule_code IS NULL` (or `IS DISTINCT FROM`) guard so re-applying is a no-op.
|
||||
- Backup snapshot: `CREATE TABLE paliad.deadline_rules_pre_097 AS SELECT * FROM paliad.deadline_rules` before any UPDATEs.
|
||||
- Wrap in `audit_reason = 't-paliad-208 legal-citation backfill'` (matches `paliad.audit_log` pattern used elsewhere).
|
||||
- Touch only the m-approved rows from § 1, § 2, § 3 — FLAG rows (those with `*(NULL)*` in the proposed columns) stay untouched until m resolves them.
|
||||
- Side-fix § 5 (`RoP.49.1 → RoP.049.1`) only if m confirms.
|
||||
|
||||
**Counts the migration should match (assuming m approves all HIGH proposals as-is):**
|
||||
|
||||
- Easy wins (§ 1): 8 `rule_code` UPDATEs (legal_source already set)
|
||||
- Proceeding-typed HIGH/MED proposals (§ 2): ~25 rows
|
||||
- Orphan HIGH/MED proposals (§ 3): ~50 rows
|
||||
- Total expected `rule_code` writes: ~83 rows
|
||||
- Total expected `legal_source` writes: ~75 rows (8 of the easy wins already have one)
|
||||
- FLAG rows left NULL: ~47 rows pending m's decisions
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions for m
|
||||
|
||||
1. **NULL for event-markers (FLAG-B):** confirm NULL is correct for the 22 court-scheduled / court-issued event rows. If m wants citations there too, I'll do a second pass.
|
||||
2. **Trigger-event citations (FLAG-E):** apply `§ 317 ZPO` to LG/OLG service rows, or leave NULL?
|
||||
3. **Duplicates (FLAG-A):** mig 097 fills duplicates with the same citation; do you want a separate dedup pass scheduled (filing `t-paliad-21x`) or is the duplicate count acceptable for now?
|
||||
4. **Combined-pleading orphans (FLAG-F):** keep one citation per row, or split each row into N rows before mig 097?
|
||||
5. **Negative-declaration track (FLAG-I):** cite `RoP.69` by analogy, or leave NULL?
|
||||
6. **Side-fix (§ 5):** normalize the one `RoP.49.1` outlier as part of mig 097?
|
||||
|
||||
Once m answers, head can re-task this same worker (or a fresh coder) to write mig 097 against the approved proposals.
|
||||
BIN
frontend/public/patentstyle/HL-Patents-Style.dotm
Normal file
BIN
frontend/public/patentstyle/HL-Patents-Style.dotm
Normal file
Binary file not shown.
5
frontend/public/patentstyle/version.json
Normal file
5
frontend/public/patentstyle/version.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "v0.260518",
|
||||
"dotm_url": "https://paliad.msbls.de/patentstyle/HL-Patents-Style.dotm",
|
||||
"sha256": "5CEA98A29D2FD6D9970B9A2499054DF52685A1116459E07F9290B0D0ADD521F4"
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -235,11 +235,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.upc.disc.cfi": "Bucheinsicht",
|
||||
"deadlines.upc.apl.cost": "Berufung Kosten",
|
||||
"deadlines.upc.apl.order": "Berufung Anordnungen",
|
||||
"deadlines.de.inf.lg": "Verletzungsklage (LG)",
|
||||
"deadlines.de.inf.olg": "Berufung OLG",
|
||||
"deadlines.de.inf.bgh": "Revision/NZB BGH",
|
||||
"deadlines.de.null.bpatg": "Nichtigkeitsverfahren",
|
||||
"deadlines.de.null.bgh": "Berufung BGH (Nichtigk.)",
|
||||
"deadlines.de.group.inf": "Verletzungsverfahren",
|
||||
"deadlines.de.group.null": "Nichtigkeitsverfahren",
|
||||
"deadlines.de.inf.lg": "LG (1. Instanz)",
|
||||
"deadlines.de.inf.olg": "OLG (Berufung)",
|
||||
"deadlines.de.inf.bgh": "BGH (Revision / NZB)",
|
||||
"deadlines.de.null.bpatg": "BPatG (1. Instanz)",
|
||||
"deadlines.de.null.bgh": "BGH (Berufung)",
|
||||
"deadlines.epa.opp.opd": "Einspruchsverfahren",
|
||||
"deadlines.epa.opp.boa": "Beschwerdeverfahren",
|
||||
"deadlines.epa.grant.exa": "EP-Erteilungsverfahren",
|
||||
@@ -837,6 +839,18 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"cal.month.9": "Oktober",
|
||||
"cal.month.10": "November",
|
||||
"cal.month.11": "Dezember",
|
||||
"cal.view.month": "Monat",
|
||||
"cal.view.week": "Woche",
|
||||
"cal.view.day": "Tag",
|
||||
"cal.month.prev": "Vorheriger Monat",
|
||||
"cal.month.next": "Nächster Monat",
|
||||
"cal.week.prev": "Vorherige Woche",
|
||||
"cal.week.next": "Nächste Woche",
|
||||
"cal.day.prev": "Vorheriger Tag",
|
||||
"cal.day.next": "Nächster Tag",
|
||||
"cal.day.back_to_month": "Zurück zum Monat",
|
||||
"cal.day.open_day": "Tagesansicht öffnen",
|
||||
"cal.day.no_entries": "Keine Einträge an diesem Tag.",
|
||||
|
||||
// Akten detail — Fristen tab (Phase E)
|
||||
|
||||
@@ -1591,7 +1605,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"events.toggle.deadline": "Fristen",
|
||||
"events.toggle.appointment": "Termine",
|
||||
"events.toggle.all": "Beides",
|
||||
"events.filter.status.all": "Alle",
|
||||
"events.filter.status.all": "Alle (auch vergangene)",
|
||||
"events.filter.status.upcoming": "Ab heute",
|
||||
"events.summary.later": "Sp\u00e4ter",
|
||||
"events.col.date": "Datum",
|
||||
"events.col.location": "Ort",
|
||||
@@ -2237,6 +2252,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.shape.calendar": "Kalender",
|
||||
"views.shape.timeline": "Timeline",
|
||||
"views.timeline.caveat.body": "Custom Views zeigen nur eingetretene Ereignisse. Für prognostizierte Fristen das Projekt-Chart öffnen.",
|
||||
"views.timeline.zoom.label": "Zoom",
|
||||
"views.timeline.zoom.in": "Heranzoomen",
|
||||
"views.timeline.zoom.out": "Herauszoomen",
|
||||
"views.timeline.zoom.1y": "±1 J.",
|
||||
"views.timeline.zoom.2y": "±2 J.",
|
||||
"views.timeline.zoom.all": "Alles",
|
||||
"views.save_as": "Als Ansicht speichern",
|
||||
"views.action.edit": "Bearbeiten",
|
||||
"views.empty.title": "Keine Einträge gefunden.",
|
||||
@@ -2802,11 +2823,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.upc.disc.cfi": "Lay-open Books",
|
||||
"deadlines.upc.apl.cost": "Cost-Decision Appeal",
|
||||
"deadlines.upc.apl.order": "Order Appeal (15-day)",
|
||||
"deadlines.de.inf.lg": "Infringement (Regional Court)",
|
||||
"deadlines.de.inf.olg": "Appeal OLG",
|
||||
"deadlines.de.inf.bgh": "Revision / NZB BGH",
|
||||
"deadlines.de.null.bpatg": "Nullity",
|
||||
"deadlines.de.null.bgh": "Appeal BGH (Nullity)",
|
||||
"deadlines.de.group.inf": "Infringement proceedings",
|
||||
"deadlines.de.group.null": "Nullity proceedings",
|
||||
"deadlines.de.inf.lg": "LG (1st instance)",
|
||||
"deadlines.de.inf.olg": "OLG (Appeal)",
|
||||
"deadlines.de.inf.bgh": "BGH (Revision / NZB)",
|
||||
"deadlines.de.null.bpatg": "BPatG (1st instance)",
|
||||
"deadlines.de.null.bgh": "BGH (Appeal)",
|
||||
"deadlines.epa.opp.opd": "Opposition",
|
||||
"deadlines.epa.opp.boa": "Appeal",
|
||||
"deadlines.epa.grant.exa": "Grant Procedure",
|
||||
@@ -3404,6 +3427,18 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"cal.month.9": "October",
|
||||
"cal.month.10": "November",
|
||||
"cal.month.11": "December",
|
||||
"cal.view.month": "Month",
|
||||
"cal.view.week": "Week",
|
||||
"cal.view.day": "Day",
|
||||
"cal.month.prev": "Previous month",
|
||||
"cal.month.next": "Next month",
|
||||
"cal.week.prev": "Previous week",
|
||||
"cal.week.next": "Next week",
|
||||
"cal.day.prev": "Previous day",
|
||||
"cal.day.next": "Next day",
|
||||
"cal.day.back_to_month": "Back to month",
|
||||
"cal.day.open_day": "Open day view",
|
||||
"cal.day.no_entries": "Nothing scheduled this day.",
|
||||
|
||||
// Akten detail — Fristen tab (Phase E)
|
||||
|
||||
@@ -4142,7 +4177,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"events.toggle.deadline": "Deadlines",
|
||||
"events.toggle.appointment": "Appointments",
|
||||
"events.toggle.all": "Both",
|
||||
"events.filter.status.all": "All",
|
||||
"events.filter.status.all": "All (incl. past)",
|
||||
"events.filter.status.upcoming": "From today",
|
||||
"events.summary.later": "Later",
|
||||
"events.col.date": "Date",
|
||||
"events.col.location": "Location",
|
||||
@@ -4788,6 +4824,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.shape.calendar": "Calendar",
|
||||
"views.shape.timeline": "Timeline",
|
||||
"views.timeline.caveat.body": "Custom Views show actual events only. Open the project's chart for projected rules.",
|
||||
"views.timeline.zoom.label": "Zoom",
|
||||
"views.timeline.zoom.in": "Zoom in",
|
||||
"views.timeline.zoom.out": "Zoom out",
|
||||
"views.timeline.zoom.1y": "±1 yr",
|
||||
"views.timeline.zoom.2y": "±2 yr",
|
||||
"views.timeline.zoom.all": "All",
|
||||
"views.save_as": "Save as view",
|
||||
"views.action.edit": "Edit",
|
||||
"views.empty.title": "No matches found.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -653,8 +653,13 @@ export type I18nKey =
|
||||
| "bottomnav.add.title"
|
||||
| "bottomnav.badge.deadlines"
|
||||
| "bottomnav.menu"
|
||||
| "cal.day.back_to_month"
|
||||
| "cal.day.fri"
|
||||
| "cal.day.mon"
|
||||
| "cal.day.next"
|
||||
| "cal.day.no_entries"
|
||||
| "cal.day.open_day"
|
||||
| "cal.day.prev"
|
||||
| "cal.day.sat"
|
||||
| "cal.day.sun"
|
||||
| "cal.day.thu"
|
||||
@@ -672,6 +677,13 @@ export type I18nKey =
|
||||
| "cal.month.7"
|
||||
| "cal.month.8"
|
||||
| "cal.month.9"
|
||||
| "cal.month.next"
|
||||
| "cal.month.prev"
|
||||
| "cal.view.day"
|
||||
| "cal.view.month"
|
||||
| "cal.view.week"
|
||||
| "cal.week.next"
|
||||
| "cal.week.prev"
|
||||
| "caldav.delete"
|
||||
| "caldav.delete.confirm"
|
||||
| "caldav.delete.done"
|
||||
@@ -919,6 +931,8 @@ export type I18nKey =
|
||||
| "deadlines.court.set"
|
||||
| "deadlines.date.edit.hint"
|
||||
| "deadlines.de"
|
||||
| "deadlines.de.group.inf"
|
||||
| "deadlines.de.group.null"
|
||||
| "deadlines.de.inf.bgh"
|
||||
| "deadlines.de.inf.lg"
|
||||
| "deadlines.de.inf.olg"
|
||||
@@ -1362,6 +1376,7 @@ export type I18nKey =
|
||||
| "events.empty.hint"
|
||||
| "events.empty.title"
|
||||
| "events.filter.status.all"
|
||||
| "events.filter.status.upcoming"
|
||||
| "events.row.type.appointment"
|
||||
| "events.row.type.deadline"
|
||||
| "events.summary.later"
|
||||
@@ -2421,6 +2436,12 @@ export type I18nKey =
|
||||
| "views.source.project_event"
|
||||
| "views.subtitle"
|
||||
| "views.timeline.caveat.body"
|
||||
| "views.timeline.zoom.1y"
|
||||
| "views.timeline.zoom.2y"
|
||||
| "views.timeline.zoom.all"
|
||||
| "views.timeline.zoom.in"
|
||||
| "views.timeline.zoom.label"
|
||||
| "views.timeline.zoom.out"
|
||||
| "views.title"
|
||||
| "views.toast.inaccessible_n"
|
||||
| "views.toast.inaccessible_one";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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ä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ä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>
|
||||
|
||||
@@ -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 — was ist das?</h2>
|
||||
|
||||
59
internal/db/migrations/097_legal_citation_backfill.down.sql
Normal file
59
internal/db/migrations/097_legal_citation_backfill.down.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- Reverses mig 097. Restores rule_code + legal_source on every row
|
||||
-- touched by the backfill (and the rev.defence normalization) from the
|
||||
-- paliad.deadline_rules_pre_097 snapshot, refreshes the deadline_search
|
||||
-- materialized view, then drops the snapshot.
|
||||
--
|
||||
-- audit_reason wrapper required by the mig 079 audit trigger.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 097 (down): revert t-paliad-210 legal-citation backfill — restore rule_code/legal_source from deadline_rules_pre_097 snapshot',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Restore rule_code + legal_source from the pre_097 snapshot for every
|
||||
-- row whose current values diverge from the snapshot. Symmetric across
|
||||
-- the § 1 / § 2 / § 3 backfills and the § 5 rev.defence normalization
|
||||
-- in one pass. If the snapshot table is missing (down run before up),
|
||||
-- the restore is a no-op.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_snap_exists boolean;
|
||||
BEGIN
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'deadline_rules_pre_097'
|
||||
) INTO v_snap_exists;
|
||||
|
||||
IF NOT v_snap_exists THEN
|
||||
RAISE NOTICE
|
||||
'mig 097 (down): snapshot table paliad.deadline_rules_pre_097 missing — nothing to restore';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET rule_code = snap.rule_code,
|
||||
legal_source = snap.legal_source
|
||||
FROM paliad.deadline_rules_pre_097 snap
|
||||
WHERE dr.id = snap.id
|
||||
AND (dr.rule_code IS DISTINCT FROM snap.rule_code
|
||||
OR dr.legal_source IS DISTINCT FROM snap.legal_source);
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Refresh deadline_search so the reverted rule_code / legal_source
|
||||
-- values repopulate the materialized view.
|
||||
-- =============================================================================
|
||||
|
||||
REFRESH MATERIALIZED VIEW paliad.deadline_search;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Drop the snapshot so a re-applied up migration captures a fresh
|
||||
-- snapshot of the current state.
|
||||
-- =============================================================================
|
||||
|
||||
DROP TABLE IF EXISTS paliad.deadline_rules_pre_097;
|
||||
684
internal/db/migrations/097_legal_citation_backfill.up.sql
Normal file
684
internal/db/migrations/097_legal_citation_backfill.up.sql
Normal file
@@ -0,0 +1,684 @@
|
||||
-- t-paliad-210 / legal-citation backfill — apply huygens's HIGH/MED
|
||||
-- proposals from docs/proposals/legal-citation-backfill-2026-05-18.md
|
||||
-- (commit 391be09) PLUS m's 2026-05-18 FLAG walk-through (paliadin/head
|
||||
-- instruction-msg 2002). Scope grew from the original brief: m approved
|
||||
-- filling almost every category, with only 3 FLAG-J rows left NULL.
|
||||
--
|
||||
-- Touches (in 8 buckets, ~135 rows):
|
||||
--
|
||||
-- § 1 Easy wins — 6 rows. rule_code only. The 2
|
||||
-- § 123 PatG twins (Wiedereinsetzung)
|
||||
-- move into the FLAG-A dedup bucket
|
||||
-- below; not filled here.
|
||||
--
|
||||
-- § 2 HIGH/MED proceeding-typed — 15 rows. rule_code + legal_source.
|
||||
--
|
||||
-- § 3 HIGH/MED orphans — 47 rows. rule_code + legal_source.
|
||||
-- For UPC rows also rule_codes[]
|
||||
-- normalized to ARRAY[rule_code].
|
||||
-- Excludes 3 archive-dest dup rows
|
||||
-- that are filled via the canonical
|
||||
-- in § 4 instead (5c0508f4 /
|
||||
-- 791fd0f7 / d886f46f).
|
||||
--
|
||||
-- § 4 FLAG-A dedup (clean only) — 3 canonical fills + 3 archive
|
||||
-- flips. Only sets where the
|
||||
-- duplicate rows share an existing
|
||||
-- rule_codes[] value (or both are
|
||||
-- NULL) are deduped:
|
||||
-- * 2× "Wiedereinsetzungsantrag
|
||||
-- § 123 PatG" — canonical
|
||||
-- b588fa64 (lowest UUID),
|
||||
-- archive c24d494c.
|
||||
-- * 2× "Berufungsschrift R.220.1
|
||||
-- (a)/(b)" — canonical 1dfba5b1
|
||||
-- (filled in § 3.3), archive
|
||||
-- 5c0508f4.
|
||||
-- * 2× "Berufungsbegründung R.220.1
|
||||
-- (a)/(b)" — canonical 573df3d1
|
||||
-- (filled in § 3.3), archive
|
||||
-- 791fd0f7.
|
||||
--
|
||||
-- DEFERRED (paliadin/head msg 2006,
|
||||
-- pending m's call): 6× "Mängel-
|
||||
-- beseitigung / Zahlung" and 2×
|
||||
-- "Beginn des Hauptsacheverfahrens".
|
||||
-- Each row in those sets carries a
|
||||
-- DIFFERENT existing rule_codes[]
|
||||
-- value (Mängelbeseitigung: RoP.207
|
||||
-- .6.a, RoP.253.2, RoP.016.3.a,
|
||||
-- RoP.027.2, RoP.089.2, RoP.229.2;
|
||||
-- Beginn-Hauptsache: RoP.198 vs
|
||||
-- RoP.213). These may be distinct
|
||||
-- procedural-context rules masquer-
|
||||
-- ading as duplicates; m owns the
|
||||
-- collapse-or-preserve decision.
|
||||
-- Mig 097 leaves all 8 rows
|
||||
-- untouched (rule_code stays NULL,
|
||||
-- rule_codes[] stays as-is, neither
|
||||
-- archived nor filled).
|
||||
--
|
||||
-- § 5 FLAG-B court-scheduled — 26 rows. Per m: "try to find the
|
||||
-- rules — they often exist." Cites
|
||||
-- the framing norm authorising the
|
||||
-- court to schedule the event (RoP.111
|
||||
-- for UPC oral hearings, RoP.118 for
|
||||
-- UPC decisions, § 285 ZPO / § 300
|
||||
-- ZPO for DE Verhandlung / Urteil,
|
||||
-- § 47 / 78 / 79 / 107 PatG for
|
||||
-- DPMA/BPatG/BGH variants, etc.).
|
||||
--
|
||||
-- § 6 FLAG-C/D rubber-stamp — 5 rows. rev.reply/rev.rejoin/
|
||||
-- app.response use canonical RoP.5x
|
||||
-- regardless of duration-vs-norm
|
||||
-- mismatch (m: "just go ahead").
|
||||
-- de_inf.replik/de_inf.duplik cite
|
||||
-- § 273 ZPO (court-set framing).
|
||||
--
|
||||
-- § 7 FLAG-E service triggers — 6 rows (DE/EPA). Service-trigger
|
||||
-- citations on Zustellung events.
|
||||
-- UPC initial-submission rows carry
|
||||
-- the RoP.271.b 10-day deferral as a
|
||||
-- secondary cite in rule_codes[]
|
||||
-- (handled in § 9 below).
|
||||
--
|
||||
-- § 8 FLAG-F combined-pleading — 5 rows. Use rule_codes[] multi-cite
|
||||
-- array (column already exists from
|
||||
-- mig 095). Primary cite in
|
||||
-- rule_code, full set in rule_codes[].
|
||||
--
|
||||
-- § 9 FLAG-G/H/I + RoP.271.b — 13 rows. G: 2 Patentänderung
|
||||
-- orphans split by INF/REV context.
|
||||
-- H: 8 sub-paragraph spot-checks
|
||||
-- applied as-is per the doc. I: 3
|
||||
-- negative-declaration rows cite
|
||||
-- RoP.069 by analogy.
|
||||
-- Plus: 5 UPC initial-submission rows
|
||||
-- append RoP.271.b to rule_codes[]
|
||||
-- as the 10-day service deferral.
|
||||
-- m flagged this distinct from the
|
||||
-- primary substantive cite.
|
||||
--
|
||||
-- § 10 R.19 label rename — 2 rows max. inf.prelim / rev.prelim:
|
||||
-- set name to "Einspruch (R. 19 VerfO)"
|
||||
-- / "Einspruch (R. 19 i.V.m. R. 46
|
||||
-- VerfO)" + rule_code 'RoP.019.1'.
|
||||
-- Originally drafted in fermi's
|
||||
-- t-paliad-207 session; m applied the
|
||||
-- rename live on prod and asked us to
|
||||
-- consolidate the mig here per Path-A.
|
||||
-- Guard `name LIKE 'Vorab-Einrede%'`
|
||||
-- makes this a defensive no-op on the
|
||||
-- prod DB (fermi already wrote there)
|
||||
-- but applies cleanly on any future
|
||||
-- deploy that hasn't seen the live
|
||||
-- write.
|
||||
--
|
||||
-- § 11 Side-fix RoP.49.1 → .049.1 — 1 row. rev.defence carries an
|
||||
-- un-padded rule_code; all other UPC
|
||||
-- RoP rules under 100 use 3-digit
|
||||
-- padding. legal_source stays
|
||||
-- 'UPC.RoP.49.1' (structured locator
|
||||
-- never pads).
|
||||
--
|
||||
-- FLAG-J kept NULL (3 rows: d124c95b — Aufhebung Entscheidung des
|
||||
-- Amtes, 002c2ba7 — Folgemaßnahmen Validitätsentscheidung, 902cc5d5 —
|
||||
-- Klärung Übersetzungsfragen). m will pick them up later via
|
||||
-- /admin/rules. Existing rule_codes[] on these is left untouched.
|
||||
--
|
||||
-- Idempotent:
|
||||
-- * Backfill UPDATEs guarded on `rule_code IS NULL` (the de-novo fill
|
||||
-- bucket) — re-running is a no-op.
|
||||
-- * Archive UPDATEs guarded on `is_active = true AND lifecycle_state
|
||||
-- = 'published'` — re-running is a no-op.
|
||||
-- * Normalization UPDATE guarded on `rule_code = 'RoP.49.1'` — no-op
|
||||
-- after first apply.
|
||||
-- * Prelim rename UPDATEs guarded on `name LIKE 'Vorab-Einrede%'` —
|
||||
-- no-op after first apply or on prod (fermi already wrote).
|
||||
-- * Snapshot table uses CREATE TABLE IF NOT EXISTS.
|
||||
-- * Materialized-view refresh is safe to repeat.
|
||||
--
|
||||
-- audit_reason is set at the top via set_config(..., true) so the
|
||||
-- mig-079 audit trigger on paliad.deadline_rules accepts the UPDATEs.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 097: t-paliad-210 legal-citation backfill — m''s FLAG walk-through 2026-05-18 (paliadin/head msg 2002). HIGH/MED proposals from docs/proposals/legal-citation-backfill-2026-05-18.md (commit 391be09) plus FLAG-A dedup + FLAG-B court-scheduled cites + FLAG-F rule_codes[] multi-cite + RoP.271.b on UPC initial submissions + RoP.49.1 padding normalization + R.19 prelim rename (fermi/t-paliad-207 consolidated)',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 0. Backup snapshot of paliad.deadline_rules BEFORE the backfill. Full
|
||||
-- table snapshot for the complete pre-097 baseline. Matches the
|
||||
-- mig 096 pattern (proceeding_types_pre_096).
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_097 AS
|
||||
SELECT *, now() AS snapshotted_at
|
||||
FROM paliad.deadline_rules;
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_rules_pre_097 IS
|
||||
'Snapshot of paliad.deadline_rules taken before mig 097 backfilled '
|
||||
'rule_code + legal_source + rule_codes[] across huygens''s HIGH/MED '
|
||||
'proposals (t-paliad-208) and m''s expanded FLAG walk-through '
|
||||
'(2026-05-18). Source-of-truth for the down migration; persists '
|
||||
'post-backfill as the permanent audit anchor — also retains the '
|
||||
'pre-dedup per-row rule_codes[] for the Mängelbeseitigung × 6 + '
|
||||
'Beginn-Hauptsache × 2 sets in case m later wants to recover the '
|
||||
'procedural-context citations.';
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. § 1 Easy wins (6 rows). legal_source already populated; only
|
||||
-- rule_code missing. The 2 § 123 PatG Wiedereinsetzung twins
|
||||
-- (c24d494c…, b588fa64…) are handled in § 4 below as part of the
|
||||
-- FLAG-A dedup.
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 253 ZPO'
|
||||
WHERE id = '1f532c82-9e6d-4f48-bd16-fa2fc71d5880' AND rule_code IS NULL; -- de_inf.klage / Klageerhebung
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 339 ZPO'
|
||||
WHERE id = '20254f4e-d213-4cf6-8f5f-1d9d36eeb6ac' AND rule_code IS NULL; -- Einspruch gegen Versäumnisurteil
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 296a ZPO'
|
||||
WHERE id = '3c36f149-3a81-456e-aac1-d4d18bfcb16b' AND rule_code IS NULL; -- Schriftsatznachreichung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'R. 135 EPÜ'
|
||||
WHERE id = 'f1099cf6-4c87-430e-b1c5-488bd44cb143' AND rule_code IS NULL; -- Weiterbehandlungsantrag (Art. 121 EPÜ)
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 234 ZPO'
|
||||
WHERE id = 'd40d9be7-e1b6-451c-bee2-6eaee2307ec5' AND rule_code IS NULL; -- Wiedereinsetzungsantrag (§ 233 ZPO)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'R. 136 EPÜ'
|
||||
WHERE id = '23c6f445-4ed2-4ade-8ea0-c4ab6b364bb6' AND rule_code IS NULL; -- Wiedereinsetzungsantrag (Art. 122 EPÜ)
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. § 2 Proceeding-typed HIGH/MED (15 rows). rule_code + legal_source.
|
||||
-- Note: rule_codes[] is set in § 9 for the 5 UPC initial-submission
|
||||
-- rows (inf.soc / rev.app / pi.app / damages.app / disc.app) to
|
||||
-- include the RoP.271.b secondary cite. For DE/EPA rows here,
|
||||
-- rule_codes[] is left untouched (currently NULL and not used for
|
||||
-- DE/EPA citations in this corpus).
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.013.1', legal_source = 'UPC.RoP.13.1'
|
||||
WHERE id = '42be6c9b-8e84-4804-962f-94c3315aca1b' AND rule_code IS NULL; -- upc.inf.cfi / inf.soc
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.042', legal_source = 'UPC.RoP.42'
|
||||
WHERE id = '995c108e-e73a-4f9c-b79f-47abe7c94108' AND rule_code IS NULL; -- upc.rev.cfi / rev.app
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.206', legal_source = 'UPC.RoP.206'
|
||||
WHERE id = 'ed0194b7-74ab-4402-8971-7211f6036ff9' AND rule_code IS NULL; -- upc.pi.cfi / pi.app
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.243', legal_source = 'UPC.RoP.243', rule_codes = ARRAY['RoP.243']::text[]
|
||||
WHERE id = '85f92b72-c654-4429-8e91-03402f9438c6' AND rule_code IS NULL; -- upc.apl.merits / app.oral
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.131', legal_source = 'UPC.RoP.131'
|
||||
WHERE id = '3e1719e8-f6f6-4260-8f02-754bd214937f' AND rule_code IS NULL; -- upc.dmgs.cfi / damages.app
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.141', legal_source = 'UPC.RoP.141'
|
||||
WHERE id = 'eb1fa1d1-b345-42ba-ab14-79f5284166b0' AND rule_code IS NULL; -- upc.disc.cfi / disc.app
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 81 PatG', legal_source = 'DE.PatG.81.1'
|
||||
WHERE id = 'ba33e704-18f6-4486-8107-abdb1e9cbfad' AND rule_code IS NULL; -- de.null.bpatg / de_null.klage
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 58 PatG', legal_source = 'DE.PatG.58.1'
|
||||
WHERE id = '972f8fe4-8f4c-4497-9736-d60399ae5989' AND rule_code IS NULL; -- dpma.opp.dpma / dpma_opp.publish
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'Art. 75 EPÜ', legal_source = 'EU.EPÜ.75'
|
||||
WHERE id = 'a1766364-1478-4b13-ae02-0a94367c585e' AND rule_code IS NULL; -- epa.grant.exa / ep_grant.filing
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'Art. 92 EPÜ', legal_source = 'EU.EPÜ.92'
|
||||
WHERE id = '63069ae5-e380-4db5-b020-d1856f31300c' AND rule_code IS NULL; -- epa.grant.exa / ep_grant.search
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'Art. 97 EPÜ', legal_source = 'EU.EPÜ.97.1'
|
||||
WHERE id = '86b3a295-d76b-4566-955d-55f7a394524e' AND rule_code IS NULL; -- epa.grant.exa / ep_grant.grant
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'Art. 97 EPÜ', legal_source = 'EU.EPÜ.97.3'
|
||||
WHERE id = '520dd205-7b4a-45f4-b87f-e2be5d1e183e' AND rule_code IS NULL; -- epa.opp.opd / epa_opp.grant
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'Art. 101 EPÜ', legal_source = 'EU.EPÜ.101'
|
||||
WHERE id = '8961a54b-2645-4af4-b0f5-114128150839' AND rule_code IS NULL; -- epa.opp.opd / epa_opp.entsch
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'Art. 116 EPÜ', legal_source = 'EU.EPÜ.116'
|
||||
WHERE id = '926f333d-55d2-4a12-890e-0508a4ea1bd4' AND rule_code IS NULL; -- epa.opp.boa / epa_app.oral
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'Art. 111 EPÜ', legal_source = 'EU.EPÜ.111'
|
||||
WHERE id = 'd0949eaf-da69-4972-90c2-7e6c1bebcd79' AND rule_code IS NULL; -- epa.opp.boa / epa_app.entsch2
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. § 3 Orphan HIGH/MED (47 rows). rule_code + legal_source. For UPC
|
||||
-- rows also normalize rule_codes[] to ARRAY[rule_code] so the
|
||||
-- structured tooling field matches the display field. The orphan
|
||||
-- archive destinations (5c0508f4 / 791fd0f7 / d886f46f) are NOT
|
||||
-- filled here — they're flipped to archived in § 4.
|
||||
-- =============================================================================
|
||||
|
||||
-- § 3.1 main-pleadings track (10 rows)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.023', legal_source = 'UPC.RoP.23.1', rule_codes = ARRAY['RoP.023']::text[]
|
||||
WHERE id = 'e34097d6-670d-447a-bdfe-b42df20ba459' AND rule_code IS NULL; -- Klageerwiderung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.025.1', legal_source = 'UPC.RoP.25.1', rule_codes = ARRAY['RoP.025.1']::text[]
|
||||
WHERE id = '7d8a4804-0ebc-42c4-8552-624350cd81f3' AND rule_code IS NULL; -- Nichtigkeitswiderklage
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.049.2.b', legal_source = 'UPC.RoP.49.2.b', rule_codes = ARRAY['RoP.049.2.b']::text[]
|
||||
WHERE id = 'c7523e6b-579d-4d80-afb3-e1cf11238d40' AND rule_code IS NULL; -- Verletzungswiderklage
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.019.1', legal_source = 'UPC.RoP.19.1', rule_codes = ARRAY['RoP.019.1']::text[]
|
||||
WHERE id = 'c57f62f8-bb52-4232-be85-9125fa93f58c' AND rule_code IS NULL; -- Vorgängige Einrede
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.029.b', legal_source = 'UPC.RoP.29.b', rule_codes = ARRAY['RoP.029.b']::text[]
|
||||
WHERE id = '84b390e0-1ca4-461a-942c-4ad94c643750' AND rule_code IS NULL; -- Replik auf Klageerwiderung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.029.c', legal_source = 'UPC.RoP.29.c', rule_codes = ARRAY['RoP.029.c']::text[]
|
||||
WHERE id = '176cc1ca-2b25-49ee-9c3e-8afed1673b7d' AND rule_code IS NULL; -- Duplik Replik Klageerwiderung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.049.1', legal_source = 'UPC.RoP.49.1', rule_codes = ARRAY['RoP.049.1']::text[]
|
||||
WHERE id = 'a32dcec1-6aaa-4a3c-936c-9a761d9362f0' AND rule_code IS NULL; -- Erwiderung auf Nichtigkeitsklage
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.052', legal_source = 'UPC.RoP.52', rule_codes = ARRAY['RoP.052']::text[]
|
||||
WHERE id = '1b5c6dee-0032-4be8-864c-f2ab945aacc5' AND rule_code IS NULL; -- Duplik Replik Erwiderung Nichtigkeitsklage
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.056.1', legal_source = 'UPC.RoP.56.1', rule_codes = ARRAY['RoP.056.1']::text[]
|
||||
WHERE id = 'bea86f9b-37d5-4f6e-b6bd-f0c01f053b66' AND rule_code IS NULL; -- Erwiderung auf Verletzungswiderklage
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.056.3', legal_source = 'UPC.RoP.56.3', rule_codes = ARRAY['RoP.056.3']::text[]
|
||||
WHERE id = '4834c957-2518-40e9-ad62-447f3f220d33' AND rule_code IS NULL; -- Replik Erwiderung Verletzungswiderklage
|
||||
|
||||
-- § 3.2 Patentänderungs-Track (1 row; FLAG-G twin rows are handled in § 9)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.032.1', legal_source = 'UPC.RoP.32.1', rule_codes = ARRAY['RoP.032.1']::text[]
|
||||
WHERE id = '7e65a434-f5c6-4391-a65c-d02de735f551' AND rule_code IS NULL; -- Erwiderung auf Patentänderungsantrag
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.032.3', legal_source = 'UPC.RoP.32.3', rule_codes = ARRAY['RoP.032.3']::text[]
|
||||
WHERE id = 'dfd52792-840f-42c4-8b71-0f77d07cbb53' AND rule_code IS NULL; -- Replik Erwiderung Patentänderung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.032.3', legal_source = 'UPC.RoP.32.3', rule_codes = ARRAY['RoP.032.3']::text[]
|
||||
WHERE id = '8cdf54eb-5189-47fd-a390-6a0ee98e5243' AND rule_code IS NULL; -- Duplik Replik Erwiderung Patentänderung
|
||||
|
||||
-- § 3.3 appeal track (8 fills; 2 archive-destinations handled in § 4)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.224.1.a', legal_source = 'UPC.RoP.224.1.a', rule_codes = ARRAY['RoP.224.1.a']::text[]
|
||||
WHERE id = '1dfba5b1-4ed1-40c1-9cf6-4ed8ff7a0818' AND rule_code IS NULL; -- Berufungsschrift canonical
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.224.1.b', legal_source = 'UPC.RoP.224.1.b', rule_codes = ARRAY['RoP.224.1.b']::text[]
|
||||
WHERE id = 'd560b3b6-9437-4b22-b62c-957d4a37d21a' AND rule_code IS NULL; -- Berufungsschrift Orders
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.225.1', legal_source = 'UPC.RoP.225.1', rule_codes = ARRAY['RoP.225.1']::text[]
|
||||
WHERE id = '573df3d1-8ea2-4a6e-b0d4-fc3cd10506da' AND rule_code IS NULL; -- Berufungsbegründung canonical
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.224.1.b', legal_source = 'UPC.RoP.224.1.b', rule_codes = ARRAY['RoP.224.1.b']::text[]
|
||||
WHERE id = '91e367dd-ffe6-4012-ac6a-b61c32e2b3b7' AND rule_code IS NULL; -- Berufung (Anordnungen & mit Zulassung)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.221.1', legal_source = 'UPC.RoP.221.1', rule_codes = ARRAY['RoP.221.1']::text[]
|
||||
WHERE id = 'ccb916df-4ee3-4dde-bcb0-6a5b557c0cba' AND rule_code IS NULL; -- Berufungszulassung Kosten
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.220.3', legal_source = 'UPC.RoP.220.3', rule_codes = ARRAY['RoP.220.3']::text[]
|
||||
WHERE id = '342e749d-c2bc-4148-974b-ac0331b76229' AND rule_code IS NULL; -- Ermessensüberprüfung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.235.1', legal_source = 'UPC.RoP.235.1', rule_codes = ARRAY['RoP.235.1']::text[]
|
||||
WHERE id = '10374392-b8db-4738-8a61-f8ce0fabcc3e' AND rule_code IS NULL; -- Berufungserwiderung (224.2(a))
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.237.1', legal_source = 'UPC.RoP.237.1', rule_codes = ARRAY['RoP.237.1']::text[]
|
||||
WHERE id = '6e39b653-1328-40e1-95f1-071fdf46eed6' AND rule_code IS NULL; -- Anschlussberufung (224.2(a))
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.238.1', legal_source = 'UPC.RoP.238.1', rule_codes = ARRAY['RoP.238.1']::text[]
|
||||
WHERE id = '6b989e85-e739-4e3b-bfd1-52b0e0c35f61' AND rule_code IS NULL; -- Erwiderung Anschlussberufung (224.2(a))
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.238.2', legal_source = 'UPC.RoP.238.2', rule_codes = ARRAY['RoP.238.2']::text[]
|
||||
WHERE id = 'e78f4652-acf9-4ecd-ac48-888ce475173f' AND rule_code IS NULL; -- Erwiderung Anschlussberufung (224.2(b))
|
||||
|
||||
-- § 3.4 Schadensbemessung / Rechnungslegung (7 rows)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.137.2', legal_source = 'UPC.RoP.137.2', rule_codes = ARRAY['RoP.137.2']::text[]
|
||||
WHERE id = 'd414f603-14c1-49f2-91be-e305eba696e3' AND rule_code IS NULL; -- Erwiderung Schadensbemessung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.139', legal_source = 'UPC.RoP.139', rule_codes = ARRAY['RoP.139']::text[]
|
||||
WHERE id = '9f39e263-e9ec-4805-a82e-c7551a22c78d' AND rule_code IS NULL; -- Replik Schadensbemessung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.139', legal_source = 'UPC.RoP.139', rule_codes = ARRAY['RoP.139']::text[]
|
||||
WHERE id = '067ffdf0-180b-488f-a369-249f6bcb9faa' AND rule_code IS NULL; -- Duplik Schadensbemessung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.142.2', legal_source = 'UPC.RoP.142.2', rule_codes = ARRAY['RoP.142.2']::text[]
|
||||
WHERE id = '429b8ec0-227a-4945-8b20-6ad79330a490' AND rule_code IS NULL; -- Erwiderung Rechnungslegung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.142.3', legal_source = 'UPC.RoP.142.3', rule_codes = ARRAY['RoP.142.3']::text[]
|
||||
WHERE id = '8d36fc76-61b9-4e99-b113-eed4c9c4b2c7' AND rule_code IS NULL; -- Replik Rechnungslegung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.142.3', legal_source = 'UPC.RoP.142.3', rule_codes = ARRAY['RoP.142.3']::text[]
|
||||
WHERE id = 'ed82fec9-2346-494f-a0ff-f41e64c26942' AND rule_code IS NULL; -- Duplik Rechnungslegung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.151', legal_source = 'UPC.RoP.151', rule_codes = ARRAY['RoP.151']::text[]
|
||||
WHERE id = 'eed69e8b-0dc8-4d97-83f0-5694d539b46a' AND rule_code IS NULL; -- Kostenentscheidung
|
||||
|
||||
-- § 3.5 provisional / PI (2 rows; canonical ba335c99 + the d886f46f archive handled in § 4)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.197.3', legal_source = 'UPC.RoP.197.3', rule_codes = ARRAY['RoP.197.3']::text[]
|
||||
WHERE id = '1f1f72ef-5a67-4d6a-9a80-82e53375177a' AND rule_code IS NULL; -- Beweissicherungsanordnung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.207.9', legal_source = 'UPC.RoP.207.9', rule_codes = ARRAY['RoP.207.9']::text[]
|
||||
WHERE id = '3e2f5697-3012-4bae-bd4d-44998dd3b75b' AND rule_code IS NULL; -- Schutzschrift
|
||||
|
||||
-- § 3.7 formalities / Registry (4 fills; 5 Mängelbeseitigung dups + FLAG-J 2 rows handled separately)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.016.5', legal_source = 'UPC.RoP.16.5', rule_codes = ARRAY['RoP.016.5']::text[]
|
||||
WHERE id = '3bc40027-9ebf-4f3d-880d-bf9de6da3ec0' AND rule_code IS NULL; -- Mängelbeseitigung / Stellungnahme
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.262.2', legal_source = 'UPC.RoP.262.2', rule_codes = ARRAY['RoP.262.2']::text[]
|
||||
WHERE id = '69e356b7-79b3-42d7-972b-44d4e35ebdbc' AND rule_code IS NULL; -- Vertraulichkeit
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.353', legal_source = 'UPC.RoP.353', rule_codes = ARRAY['RoP.353']::text[]
|
||||
WHERE id = '57e6eeca-8695-4af3-96cc-16ebd8bc3f2c' AND rule_code IS NULL; -- Berichtigung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.333.1', legal_source = 'UPC.RoP.333.1', rule_codes = ARRAY['RoP.333.1']::text[]
|
||||
WHERE id = '8ec233b9-3bc4-4015-a158-86af233e52b3' AND rule_code IS NULL; -- Verfahrensleitende Anordnung
|
||||
|
||||
-- § 3.8 translation / interpretation (1 row; FLAG-H/J handled in § 9 / left NULL)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.109.1', legal_source = 'UPC.RoP.109.1', rule_codes = ARRAY['RoP.109.1']::text[]
|
||||
WHERE id = 'bb7bafcb-9d91-4bf7-ae2c-6634652d9906' AND rule_code IS NULL; -- Simultanübersetzung
|
||||
|
||||
-- § 3.9 review / rehearing (2 rows)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.247.2', legal_source = 'UPC.RoP.247.2', rule_codes = ARRAY['RoP.247.2']::text[]
|
||||
WHERE id = '372e86e3-c8ff-4cb5-9389-66acdbc96e57' AND rule_code IS NULL; -- Wiederaufnahme (schwerwiegend)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.247.2', legal_source = 'UPC.RoP.247.2', rule_codes = ARRAY['RoP.247.2']::text[]
|
||||
WHERE id = '58de9573-07db-4d8d-9b00-8fab0d71d88c' AND rule_code IS NULL; -- Wiederaufnahme (Straftat)
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. § 4 FLAG-A dedup (clean only). 1 canonical fill (the other 2
|
||||
-- canonicals are filled in § 3.3) + 3 archive flips. Canonical
|
||||
-- selection per m's spec: lowest UUID. None of the archive
|
||||
-- candidates have FK references in mgmt.deadline_rules / paliad.
|
||||
-- appointments / paliad.deadlines / paliad.deadline_rules (parent_id
|
||||
-- or draft_of) — verified pre-mig. Archive over DELETE per m
|
||||
-- (audit trail).
|
||||
--
|
||||
-- Mängelbeseitigung 6× and Beginn-Hauptsache 2× are intentionally
|
||||
-- NOT deduped in this mig — see header for the deferred-decision
|
||||
-- rationale. Their rows stay active+published+rule_code IS NULL
|
||||
-- until m's call lands.
|
||||
-- =============================================================================
|
||||
|
||||
-- Canonical fill for the § 123 PatG twin (legal_source already
|
||||
-- DE.PatG.123.2). The other 2 canonicals (Berufungsschrift 1dfba5b1
|
||||
-- and Berufungsbegründung 573df3d1) are filled in § 3.3 above.
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 123 PatG'
|
||||
WHERE id = 'b588fa64-a727-4cfb-a45d-69a835a3b05a' AND rule_code IS NULL;
|
||||
|
||||
-- Archive flips (3 rows: the non-canonical sides of the 3 clean dedup
|
||||
-- sets). After this each set has exactly 1 active+published row.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_active = false, lifecycle_state = 'archived'
|
||||
WHERE id IN (
|
||||
'c24d494c-0da1-4f01-aa74-0f37f99fe1ae', -- Wiedereinsetzung § 123 PatG dup
|
||||
'5c0508f4-020a-4ef5-bcc7-1ee85eafe0b3', -- Berufungsschrift dup
|
||||
'791fd0f7-a448-4711-b1aa-63e6df1e7c57' -- Berufungsbegründung dup
|
||||
)
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. § 5 FLAG-B court-scheduled events (26 rows). Cite the framing norm
|
||||
-- that authorises the court to schedule the event. UPC RoP.111 /
|
||||
-- RoP.118 / RoP.101 / RoP.209 / RoP.211 / RoP.350 / RoP.220.1.c /
|
||||
-- RoP.157. DE § 285 ZPO / § 300 ZPO / § 89 PatG / § 84 PatG / § 113
|
||||
-- PatG / § 119 PatG. DPMA § 47 / 78 / 79 / 107 PatG.
|
||||
-- =============================================================================
|
||||
|
||||
-- UPC court-scheduled events
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.118', legal_source = 'UPC.RoP.118', rule_codes = ARRAY['RoP.118']::text[]
|
||||
WHERE id = '60d71f1e-a0e8-42cd-85e9-89f3c808868f' AND rule_code IS NULL; -- inf.decision
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.101', legal_source = 'UPC.RoP.101', rule_codes = ARRAY['RoP.101']::text[]
|
||||
WHERE id = '7b118633-92b2-4c91-8512-6cb929288f10' AND rule_code IS NULL; -- inf.interim
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.111', legal_source = 'UPC.RoP.111', rule_codes = ARRAY['RoP.111']::text[]
|
||||
WHERE id = 'd4c01a6f-d147-4505-bf1c-9aaf88b15287' AND rule_code IS NULL; -- inf.oral
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.118', legal_source = 'UPC.RoP.118', rule_codes = ARRAY['RoP.118']::text[]
|
||||
WHERE id = 'f382cfe4-6703-40f8-a43d-0fe02d62d0fa' AND rule_code IS NULL; -- rev.decision
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.101', legal_source = 'UPC.RoP.101', rule_codes = ARRAY['RoP.101']::text[]
|
||||
WHERE id = 'ccad91ef-da04-4b81-a979-658578fb97c4' AND rule_code IS NULL; -- rev.interim
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.111', legal_source = 'UPC.RoP.111', rule_codes = ARRAY['RoP.111']::text[]
|
||||
WHERE id = '38e8982b-5cc9-41b3-b477-37ce4bd4e7c4' AND rule_code IS NULL; -- rev.oral
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.209', legal_source = 'UPC.RoP.209', rule_codes = ARRAY['RoP.209']::text[]
|
||||
WHERE id = 'e4a61ebf-c49b-450f-9d94-bb06098536b4' AND rule_code IS NULL; -- pi.oral
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.211', legal_source = 'UPC.RoP.211', rule_codes = ARRAY['RoP.211']::text[]
|
||||
WHERE id = '7b93a8b7-115d-42b4-9d1d-34684ddf5206' AND rule_code IS NULL; -- pi.order
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.209.1', legal_source = 'UPC.RoP.209.1', rule_codes = ARRAY['RoP.209.1']::text[]
|
||||
WHERE id = '30ffe572-aa77-4dcb-9292-a4750289f75c' AND rule_code IS NULL; -- pi.response (court-set)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.350', legal_source = 'UPC.RoP.350', rule_codes = ARRAY['RoP.350']::text[]
|
||||
WHERE id = '685bad4f-3c3e-425d-8839-2f765d0fc96e' AND rule_code IS NULL; -- app.decision
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.220.1.c', legal_source = 'UPC.RoP.220.1.c', rule_codes = ARRAY['RoP.220.1.c']::text[]
|
||||
WHERE id = 'c2865575-d7d6-436d-b61c-0a266217f76c' AND rule_code IS NULL; -- app_ord.order
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.157', legal_source = 'UPC.RoP.157', rule_codes = ARRAY['RoP.157']::text[]
|
||||
WHERE id = '01db67c9-5621-48ca-9dbd-d652b6237b24' AND rule_code IS NULL; -- cost.decision
|
||||
|
||||
-- DE court-scheduled events
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 285 ZPO', legal_source = 'DE.ZPO.285'
|
||||
WHERE id = 'a95af317-2fdb-43c9-ab66-c8b2099aaa5a' AND rule_code IS NULL; -- de_inf.termin
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 300 ZPO', legal_source = 'DE.ZPO.300'
|
||||
WHERE id = 'e46d2ae7-74bf-4c06-9e55-921242d36f2a' AND rule_code IS NULL; -- de_inf.urteil
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 285 ZPO', legal_source = 'DE.ZPO.285'
|
||||
WHERE id = '2a16f77f-408f-48c4-9d71-8ea5926d4dca' AND rule_code IS NULL; -- de_inf_olg.termin
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 300 ZPO', legal_source = 'DE.ZPO.300'
|
||||
WHERE id = '7d7d88c5-895e-4855-8f4d-2e160ff74998' AND rule_code IS NULL; -- de_inf_olg.urteil_olg
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 285 ZPO', legal_source = 'DE.ZPO.285'
|
||||
WHERE id = 'b1460f90-419e-47ae-978a-8e32ffafad73' AND rule_code IS NULL; -- de_inf_bgh.termin
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 300 ZPO', legal_source = 'DE.ZPO.300'
|
||||
WHERE id = '803460ac-f6bd-4194-b5ab-140175644648' AND rule_code IS NULL; -- de_inf_bgh.urteil_bgh
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 89 PatG', legal_source = 'DE.PatG.89'
|
||||
WHERE id = 'ab60e712-bc56-4326-8df0-413881996bf3' AND rule_code IS NULL; -- de_null.termin
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 84 PatG', legal_source = 'DE.PatG.84'
|
||||
WHERE id = '1476829a-cc92-4221-b182-846fc99ad941' AND rule_code IS NULL; -- de_null.urteil
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 113 PatG', legal_source = 'DE.PatG.113'
|
||||
WHERE id = 'd077816d-bce4-4cb7-bd67-7b52edbf7fb9' AND rule_code IS NULL; -- de_null_bgh.termin
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 119 PatG', legal_source = 'DE.PatG.119'
|
||||
WHERE id = '816e9756-efff-4e40-b650-f0b31bdc21e5' AND rule_code IS NULL; -- de_null_bgh.urteil_bgh
|
||||
|
||||
-- DPMA / BPatG / BGH-PatG court-scheduled events
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 47 PatG', legal_source = 'DE.PatG.47'
|
||||
WHERE id = '193a85e2-5794-463a-8c45-73174a54cea9' AND rule_code IS NULL; -- dpma_opp.entscheidung
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 79 PatG', legal_source = 'DE.PatG.79'
|
||||
WHERE id = 'baaff831-6a3f-43ed-96bb-eae6ad73f6fc' AND rule_code IS NULL; -- dpma_bpatg.entsch_bpatg
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 78 PatG', legal_source = 'DE.PatG.78'
|
||||
WHERE id = '446694c2-5b34-4ecd-9bf7-7eee055b0d1b' AND rule_code IS NULL; -- dpma_bpatg.termin
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 107 PatG', legal_source = 'DE.PatG.107'
|
||||
WHERE id = '99c02992-1a77-4694-b773-941ac9876bb5' AND rule_code IS NULL; -- dpma_bgh.entsch_bgh
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. § 6 FLAG-C/D rubber-stamp (5 rows). UPC RoP duration-vs-norm
|
||||
-- mismatches get the canonical citation per m ("just go ahead"). DE
|
||||
-- LG patent-practice 4-week replik/duplik cite § 273 ZPO (court-set
|
||||
-- framing).
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.052', legal_source = 'UPC.RoP.52', rule_codes = ARRAY['RoP.052']::text[]
|
||||
WHERE id = '7e0ea937-d81b-4dee-897e-0d8bc0543f34' AND rule_code IS NULL; -- rev.reply (FLAG-C)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.052', legal_source = 'UPC.RoP.52', rule_codes = ARRAY['RoP.052']::text[]
|
||||
WHERE id = 'b7890351-c6d6-46e4-b064-0513a1808e6d' AND rule_code IS NULL; -- rev.rejoin (FLAG-C)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.235.1', legal_source = 'UPC.RoP.235.1', rule_codes = ARRAY['RoP.235.1']::text[]
|
||||
WHERE id = 'd6600ceb-d1d5-408a-a7c9-1026f304ac7f' AND rule_code IS NULL; -- app.response (FLAG-C)
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 273 ZPO', legal_source = 'DE.ZPO.273'
|
||||
WHERE id = 'd46d915e-fd46-4167-88b5-6d22bcbb8882' AND rule_code IS NULL; -- de_inf.replik (FLAG-D)
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 273 ZPO', legal_source = 'DE.ZPO.273'
|
||||
WHERE id = 'ca9b52cb-e986-4c3a-9e89-e799e6a6ac33' AND rule_code IS NULL; -- de_inf.duplik (FLAG-D)
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. § 7 FLAG-E service triggers (6 rows, DE/EPA). § 317 ZPO for LG/OLG
|
||||
-- judgment-service, § 99 / § 47 / § 79 PatG for the PatG variants,
|
||||
-- R. 111 EPÜ for EPA notification.
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 317 ZPO', legal_source = 'DE.ZPO.317'
|
||||
WHERE id = '106d8a0b-514b-4021-8b65-7debff71f1d3' AND rule_code IS NULL; -- de_inf_olg.urteil_lg
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 317 ZPO', legal_source = 'DE.ZPO.317'
|
||||
WHERE id = 'd071b5c6-f33e-44e8-8656-4e9cccf55701' AND rule_code IS NULL; -- de_inf_bgh.urteil_olg
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 99 PatG', legal_source = 'DE.PatG.99.1'
|
||||
WHERE id = 'bdae7319-7435-40e9-be19-6ce21fdb9946' AND rule_code IS NULL; -- de_null_bgh.urteil_bpatg
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 47 PatG', legal_source = 'DE.PatG.47.1'
|
||||
WHERE id = '327390f9-3c1b-496f-8e63-2bf19c380dfe' AND rule_code IS NULL; -- dpma_bpatg.entscheidung
|
||||
UPDATE paliad.deadline_rules SET rule_code = '§ 79 PatG', legal_source = 'DE.PatG.79.1'
|
||||
WHERE id = 'd3ea5e50-f7e2-40f1-bb16-30664acc2e2b' AND rule_code IS NULL; -- dpma_bgh.entsch_bpatg
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'R. 111 EPÜ', legal_source = 'EU.EPC-R.111'
|
||||
WHERE id = '79c27f9b-5195-4272-90d6-ea6a43cd0938' AND rule_code IS NULL; -- epa_app.entsch
|
||||
|
||||
-- =============================================================================
|
||||
-- 8. § 8 FLAG-F combined-pleading rows (5 rows). Primary cite in
|
||||
-- rule_code + legal_source; full set of citations in rule_codes[]
|
||||
-- so downstream tooling can resolve any of the combined norms.
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.029.a', legal_source = 'UPC.RoP.29.a',
|
||||
rule_codes = ARRAY['RoP.029.a', 'RoP.029.b']::text[]
|
||||
WHERE id = 'cec1a865-30a4-46c9-8abf-630d4478b91a' AND rule_code IS NULL; -- Erwid CCR + Replik SoD
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.029.c', legal_source = 'UPC.RoP.29.c',
|
||||
rule_codes = ARRAY['RoP.029.c', 'RoP.032.3']::text[]
|
||||
WHERE id = '02ae9c1f-2aa0-4e0e-acf1-ae235588a64f' AND rule_code IS NULL; -- Duplik Replik + Replik Erwid Patentänderung
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.029.d', legal_source = 'UPC.RoP.29.d',
|
||||
rule_codes = ARRAY['RoP.029.d', 'RoP.029.c', 'RoP.032.1']::text[]
|
||||
WHERE id = 'ec2a1274-ffd8-42e7-9e27-582365d04d6e' AND rule_code IS NULL; -- Replik Erwid Widerklage + Duplik Replik Klageerwid + Erwid Patentänderung
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.051', legal_source = 'UPC.RoP.51',
|
||||
rule_codes = ARRAY['RoP.051', 'RoP.049.2.a', 'RoP.056.1']::text[]
|
||||
WHERE id = '37bd034b-79e3-4c3c-a21d-b078aaf2ea04' AND rule_code IS NULL; -- Replik Erwid Nichtigkeit + Erwid Patent + Erwid Widerklage
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.056.4', legal_source = 'UPC.RoP.56.4',
|
||||
rule_codes = ARRAY['RoP.056.4', 'RoP.032.3']::text[]
|
||||
WHERE id = '7b548c48-6fef-4387-8123-e1f1e4ee6da2' AND rule_code IS NULL; -- Duplik (Verletzungswiderklage + Patentänderung)
|
||||
|
||||
-- =============================================================================
|
||||
-- 9. § 9 FLAG-G/H/I + RoP.271.b. Patentänderung INF/REV split (G),
|
||||
-- sub-paragraph spot-checks (H, applied as-is per doc), negative-
|
||||
-- declaration RoP.069 by analogy (I), and the RoP.271.b 10-day
|
||||
-- service-deferral secondary cite on UPC initial submissions.
|
||||
-- =============================================================================
|
||||
|
||||
-- FLAG-G: Patentänderungs-Twin (INF vs REV context)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.030.1', legal_source = 'UPC.RoP.30.1', rule_codes = ARRAY['RoP.030.1']::text[]
|
||||
WHERE id = 'fb7050c6-a18b-47e4-8811-46ca3677d549' AND rule_code IS NULL; -- Patentänderung INF
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.049.2.a', legal_source = 'UPC.RoP.49.2.a', rule_codes = ARRAY['RoP.049.2.a']::text[]
|
||||
WHERE id = '21e67ac1-fe40-44d1-ae2e-ea90e0b97598' AND rule_code IS NULL; -- Patentänderung REV
|
||||
|
||||
-- FLAG-H: sub-paragraph spot-checks (8 rows, applied per doc proposal)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.225.2', legal_source = 'UPC.RoP.225.2', rule_codes = ARRAY['RoP.225.2']::text[]
|
||||
WHERE id = 'c3a369f9-4f56-4c88-b11c-f98d05d3b376' AND rule_code IS NULL; -- Berufungsbegründung Orders
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.234.1', legal_source = 'UPC.RoP.234.1', rule_codes = ARRAY['RoP.234.1']::text[]
|
||||
WHERE id = 'd4f739cd-444d-48c0-98c4-70f0521b4916' AND rule_code IS NULL; -- Anfechtung Verwerfung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.235.4', legal_source = 'UPC.RoP.235.4', rule_codes = ARRAY['RoP.235.4']::text[]
|
||||
WHERE id = '4c585c6d-fb5c-4a99-a798-86a05c757bf7' AND rule_code IS NULL; -- Berufungserwiderung Orders
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.237.2', legal_source = 'UPC.RoP.237.2', rule_codes = ARRAY['RoP.237.2']::text[]
|
||||
WHERE id = 'a00e51bb-bcb6-48d0-9aa5-2216e9480c5c' AND rule_code IS NULL; -- Anschlussberufung Orders
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.097.1', legal_source = 'UPC.RoP.97.1', rule_codes = ARRAY['RoP.097.1']::text[]
|
||||
WHERE id = '0531b6ba-98cc-48f4-adb8-da8b7a7c3535' AND rule_code IS NULL; -- Aufhebung EPA Einheitswirkung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.037.4', legal_source = 'UPC.RoP.37.4', rule_codes = ARRAY['RoP.037.4']::text[]
|
||||
WHERE id = '6b6b967c-65fd-4172-9640-1ffff8a46704' AND rule_code IS NULL; -- Verweisung Zentralkammer
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.109.5', legal_source = 'UPC.RoP.109.5', rule_codes = ARRAY['RoP.109.5']::text[]
|
||||
WHERE id = '8c682cff-3423-41d8-81ca-b5b461461682' AND rule_code IS NULL; -- Dolmetscher own-cost
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.007.2', legal_source = 'UPC.RoP.7.2', rule_codes = ARRAY['RoP.007.2']::text[]
|
||||
WHERE id = '9ed513c1-68df-455e-810e-a5d8d7b85729' AND rule_code IS NULL; -- Übersetzungen Schriftstücke
|
||||
|
||||
-- FLAG-I: negative-declaration track (3 rows, RoP.069 by analogy per m)
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.069', legal_source = 'UPC.RoP.69', rule_codes = ARRAY['RoP.069']::text[]
|
||||
WHERE id = '521bf607-1c69-4dc5-a09e-70339bbe4684' AND rule_code IS NULL; -- Erwid neg. Feststellungsklage
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.069', legal_source = 'UPC.RoP.69', rule_codes = ARRAY['RoP.069']::text[]
|
||||
WHERE id = 'e887b1fb-83ff-4073-b81b-c10dde6dc2c6' AND rule_code IS NULL; -- Replik neg. Feststellung
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.069', legal_source = 'UPC.RoP.69', rule_codes = ARRAY['RoP.069']::text[]
|
||||
WHERE id = '0cf1d755-3ba5-44ce-87ca-f98bb076c995' AND rule_code IS NULL; -- Duplik neg. Feststellung
|
||||
|
||||
-- RoP.271.b — 10-day service deferral on UPC initial submissions.
|
||||
-- Set rule_codes[] to [primary substantive cite, 'RoP.271.b'] for the
|
||||
-- 5 UPC initial-submission rows whose § 2 UPDATEs above only set
|
||||
-- rule_code + legal_source. Idempotent via the IS DISTINCT FROM guard
|
||||
-- — re-running matches no rows.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_codes = target.rule_codes
|
||||
FROM (VALUES
|
||||
('42be6c9b-8e84-4804-962f-94c3315aca1b'::uuid, ARRAY['RoP.013.1', 'RoP.271.b']::text[]), -- inf.soc
|
||||
('995c108e-e73a-4f9c-b79f-47abe7c94108'::uuid, ARRAY['RoP.042', 'RoP.271.b']::text[]), -- rev.app
|
||||
('ed0194b7-74ab-4402-8971-7211f6036ff9'::uuid, ARRAY['RoP.206', 'RoP.271.b']::text[]), -- pi.app
|
||||
('3e1719e8-f6f6-4260-8f02-754bd214937f'::uuid, ARRAY['RoP.131', 'RoP.271.b']::text[]), -- damages.app
|
||||
('eb1fa1d1-b345-42ba-ab14-79f5284166b0'::uuid, ARRAY['RoP.141', 'RoP.271.b']::text[]) -- disc.app
|
||||
) AS target(id, rule_codes)
|
||||
WHERE paliad.deadline_rules.id = target.id
|
||||
AND paliad.deadline_rules.rule_codes IS DISTINCT FROM target.rule_codes;
|
||||
|
||||
-- =============================================================================
|
||||
-- 10. § 10 R.19 label rename (inf.prelim / rev.prelim). Defensive
|
||||
-- idempotent backstop for fermi's live prod write. Matches no rows
|
||||
-- on the current prod DB (fermi already renamed) and on the first
|
||||
-- post-mig fresh-deploy too. Catches any future prod that hasn't
|
||||
-- seen the live write.
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET name = 'Einspruch (R. 19 VerfO)', rule_code = 'RoP.019.1'
|
||||
WHERE code = 'inf.prelim' AND name LIKE 'Vorab-Einrede%';
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET name = 'Einspruch (R. 19 i.V.m. R. 46 VerfO)', rule_code = 'RoP.019.1'
|
||||
WHERE code = 'rev.prelim' AND name LIKE 'Vorab-Einrede%';
|
||||
|
||||
-- =============================================================================
|
||||
-- 11. § 11 Side-fix: normalize the one un-padded UPC RoP <100 rule_code
|
||||
-- outlier. legal_source stays 'UPC.RoP.49.1' (structured locator
|
||||
-- never pads — convention § 0.2 of the proposal doc).
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.049.1'
|
||||
WHERE rule_code = 'RoP.49.1'
|
||||
AND code = 'rev.defence';
|
||||
|
||||
-- =============================================================================
|
||||
-- 12. Refresh the deadline_search materialized view so search hits
|
||||
-- return the newly populated rule_code + legal_source values.
|
||||
-- =============================================================================
|
||||
|
||||
REFRESH MATERIALIZED VIEW paliad.deadline_search;
|
||||
|
||||
-- =============================================================================
|
||||
-- 13. Hard assertions. Verifies the post-state matches the plan.
|
||||
--
|
||||
-- a) 11 active+published rows remain rule_code IS NULL: the 3
|
||||
-- FLAG-J rows (m picks them up via /admin/rules) plus the 8
|
||||
-- rows whose dedup decision is deferred (Mängelbeseitigung 6×
|
||||
-- + Beginn-Hauptsache 2×).
|
||||
-- b) No un-padded RoP.49.1 outlier remains.
|
||||
-- c) Padded RoP.049.1 present at least twice (rev.defence
|
||||
-- normalized + a32dcec1 orphan filled).
|
||||
-- d) Each of the 3 clean-dedup sets has exactly 1 active+published
|
||||
-- row after the archive flips.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_null_after integer;
|
||||
v_old_outlier integer;
|
||||
v_new_padded integer;
|
||||
v_dup_count integer;
|
||||
BEGIN
|
||||
-- (a) 3 FLAG-J + 8 deferred-dedup rows stay NULL.
|
||||
SELECT count(*) INTO v_null_after
|
||||
FROM paliad.deadline_rules
|
||||
WHERE rule_code IS NULL
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
IF v_null_after <> 11 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 097: expected 11 rule_code IS NULL active+published rows after backfill (3 FLAG-J + 8 deferred dedup), got %',
|
||||
v_null_after;
|
||||
END IF;
|
||||
|
||||
-- (b) RoP.49.1 outlier normalized.
|
||||
SELECT count(*) INTO v_old_outlier
|
||||
FROM paliad.deadline_rules
|
||||
WHERE rule_code = 'RoP.49.1';
|
||||
IF v_old_outlier <> 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 097: expected 0 RoP.49.1 rows after normalization, got %',
|
||||
v_old_outlier;
|
||||
END IF;
|
||||
|
||||
-- (c) RoP.049.1 present at least twice.
|
||||
SELECT count(*) INTO v_new_padded
|
||||
FROM paliad.deadline_rules
|
||||
WHERE rule_code = 'RoP.049.1';
|
||||
IF v_new_padded < 2 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 097: expected >= 2 RoP.049.1 rows after normalization + orphan fill, got %',
|
||||
v_new_padded;
|
||||
END IF;
|
||||
|
||||
-- (d) Each clean-dedup set has exactly 1 active+published row.
|
||||
|
||||
SELECT count(*) INTO v_dup_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND id IN (
|
||||
'b588fa64-a727-4cfb-a45d-69a835a3b05a',
|
||||
'c24d494c-0da1-4f01-aa74-0f37f99fe1ae'
|
||||
);
|
||||
IF v_dup_count <> 1 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 097 dedup: Wiedereinsetzung-§123-PatG set must have 1 active+published row, got %',
|
||||
v_dup_count;
|
||||
END IF;
|
||||
|
||||
SELECT count(*) INTO v_dup_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND id IN (
|
||||
'1dfba5b1-4ed1-40c1-9cf6-4ed8ff7a0818',
|
||||
'5c0508f4-020a-4ef5-bcc7-1ee85eafe0b3'
|
||||
);
|
||||
IF v_dup_count <> 1 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 097 dedup: Berufungsschrift set must have 1 active+published row, got %',
|
||||
v_dup_count;
|
||||
END IF;
|
||||
|
||||
SELECT count(*) INTO v_dup_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND id IN (
|
||||
'573df3d1-8ea2-4a6e-b0d4-fc3cd10506da',
|
||||
'791fd0f7-a448-4711-b1aa-63e6df1e7c57'
|
||||
);
|
||||
IF v_dup_count <> 1 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 097 dedup: Berufungsbegründung set must have 1 active+published row, got %',
|
||||
v_dup_count;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -226,24 +226,31 @@ BEGIN
|
||||
-- 6.1 Every active+published row has the proceeding-code-prefixed
|
||||
-- 4+-segment shape. Archived rows (`_archived_litigation` ones)
|
||||
-- keep their shorter shape by design — they're carved out.
|
||||
-- Suffix segments may include digits (existing data — e.g. EPA rule
|
||||
-- codes like `epa.opp.boa.r106` / `epa.grant.exa.r71_3` carry the
|
||||
-- statutory rule number in the suffix). Allow [a-z_0-9] per segment.
|
||||
SELECT count(*) INTO v_bad_shape
|
||||
FROM paliad.deadline_rules
|
||||
WHERE is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND submission_code !~ '^[a-z_]+\.[a-z_]+\.[a-z_]+\.[a-z_]+(\..*)?$';
|
||||
AND submission_code !~ '^[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+(\..*)?$';
|
||||
IF v_bad_shape <> 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 098: expected every active+published deadline_rules row to match the 4+-segment submission_code shape, got % violators',
|
||||
v_bad_shape;
|
||||
END IF;
|
||||
|
||||
-- 6.2 No NULL submission_code on active+published rows. The column
|
||||
-- is nullable for legacy reasons, but every live row should
|
||||
-- carry a code after the prefix step.
|
||||
-- 6.2 No NULL submission_code on active+published rows that BELONG
|
||||
-- to a proceeding. Orphan rows (`proceeding_type_id IS NULL`)
|
||||
-- are cross-cutting rules without a fixed proceeding home
|
||||
-- (Wiedereinsetzung, Schriftsatznachreichung, etc.) — they
|
||||
-- legitimately carry NULL submission_code because there's no
|
||||
-- proceeding to prefix with. Exempt them.
|
||||
SELECT count(*) INTO v_null_codes
|
||||
FROM paliad.deadline_rules
|
||||
WHERE is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND proceeding_type_id IS NOT NULL
|
||||
AND submission_code IS NULL;
|
||||
IF v_null_codes <> 0 THEN
|
||||
RAISE EXCEPTION
|
||||
|
||||
10
internal/db/migrations/099_drop_with_po_flag.down.sql
Normal file
10
internal/db/migrations/099_drop_with_po_flag.down.sql
Normal 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;
|
||||
34
internal/db/migrations/099_drop_with_po_flag.up.sql
Normal file
34
internal/db/migrations/099_drop_with_po_flag.up.sql
Normal 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%';
|
||||
26
internal/db/migrations/100_ccr_visible_rule.down.sql
Normal file
26
internal/db/migrations/100_ccr_visible_rule.down.sql
Normal 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';
|
||||
97
internal/db/migrations/100_ccr_visible_rule.up.sql
Normal file
97
internal/db/migrations/100_ccr_visible_rule.up.sql
Normal 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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -54,6 +54,15 @@ type UIDeadline struct {
|
||||
Priority string `json:"priority"`
|
||||
RuleRef string `json:"ruleRef"`
|
||||
LegalSource string `json:"legalSource,omitempty"`
|
||||
// LegalSourceDisplay is the pretty form (e.g. "UPC RoP R.220(1)")
|
||||
// of LegalSource, produced by FormatLegalSourceDisplay. Frontend
|
||||
// renders this in the deadline card meta line; falls back to
|
||||
// RuleRef when LegalSource is empty.
|
||||
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
|
||||
// LegalSourceURL is the youpc.org/laws permalink when the cited
|
||||
// body is hosted there (UPCRoP / UPCA / UPCS today). Empty for
|
||||
// DE/EPA/EU bodies — the renderer shows display text without a link.
|
||||
LegalSourceURL string `json:"legalSourceURL,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
NotesEN string `json:"notesEN,omitempty"`
|
||||
DueDate string `json:"dueDate"`
|
||||
@@ -283,6 +292,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
}
|
||||
if r.LegalSource != nil {
|
||||
d.LegalSource = *r.LegalSource
|
||||
d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
|
||||
d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
|
||||
}
|
||||
if r.DeadlineNotes != nil {
|
||||
d.Notes = *r.DeadlineNotes
|
||||
@@ -599,6 +610,7 @@ type RuleCalculationRule struct {
|
||||
RuleRef string `json:"ruleRef,omitempty"`
|
||||
LegalSource string `json:"legalSource,omitempty"`
|
||||
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
|
||||
LegalSourceURL string `json:"legalSourceURL,omitempty"`
|
||||
DurationValue int `json:"durationValue"`
|
||||
DurationUnit string `json:"durationUnit"`
|
||||
Party string `json:"party,omitempty"`
|
||||
@@ -670,6 +682,7 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
|
||||
if rule.LegalSource != nil {
|
||||
out.Rule.LegalSource = *rule.LegalSource
|
||||
out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource)
|
||||
out.Rule.LegalSourceURL = BuildLegalSourceURL(*rule.LegalSource)
|
||||
}
|
||||
if rule.PrimaryParty != nil {
|
||||
out.Rule.Party = *rule.PrimaryParty
|
||||
@@ -1217,6 +1230,8 @@ func (s *FristenrechnerService) calculateByTriggerEvent(
|
||||
}
|
||||
if r.LegalSource != nil {
|
||||
d.LegalSource = *r.LegalSource
|
||||
d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
|
||||
d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
|
||||
}
|
||||
if r.DeadlineNotes != nil {
|
||||
d.Notes = *r.DeadlineNotes
|
||||
|
||||
@@ -14,13 +14,15 @@ import (
|
||||
|
||||
// submissionCodeShapeRegex is the proceeding-code-prefixed shape
|
||||
// installed by mig 098 (t-paliad-209): the proceeding's 3-segment code
|
||||
// (`^[a-z_]+\.[a-z_]+\.[a-z_]+\.`) followed by at least one suffix
|
||||
// segment (and optional further dot-separated segments). The regex
|
||||
// allows underscores so the legacy archived bucket (`_archived_…`) and
|
||||
// hand-seeded test rules (e.g. `s11a.initial`) match alongside the
|
||||
// canonical taxonomy. Mirrors the assertion in mig 098 §6.1.
|
||||
// (`^[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+\.`) followed by at least one
|
||||
// suffix segment (and optional further dot-separated segments). The
|
||||
// regex allows digits so EPA suffixes like `r106` / `r71_3` / `r116`
|
||||
// (statutory rule numbers in the suffix) pass alongside canonical
|
||||
// dotted-word codes. Underscores cover the legacy archived bucket
|
||||
// (`_archived_…`) and hand-seeded test rules. Mirrors the assertion in
|
||||
// mig 098 §6.1.
|
||||
var submissionCodeShapeRegex = regexp.MustCompile(
|
||||
`^[a-z_]+\.[a-z_]+\.[a-z_]+\.[a-z_]+(\..*)?$`)
|
||||
`^[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+(\..*)?$`)
|
||||
|
||||
// TestSubmissionCodeShape walks every active+published row in
|
||||
// paliad.deadline_rules and asserts that submission_code matches the
|
||||
|
||||
Reference in New Issue
Block a user