Compare commits
2 Commits
main
...
mai/atlas/
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d85f8599a | |||
| 40dfe6df6e |
@@ -1,242 +0,0 @@
|
||||
# Paliad CI gate (t-paliad-282 / m/paliad#114).
|
||||
#
|
||||
# Single workflow, two purposes:
|
||||
#
|
||||
# - On every push: gate tier — build + unit + migration smoke. Red gate
|
||||
# means no further work and (on main) no deploy.
|
||||
# - On push to main with gate green: deploy step — calls the Dokploy
|
||||
# compose-deploy API for paliad's compose Zx147ycurfYagKRl_Zzyo, then
|
||||
# polls /health/ready until the new container reports 200.
|
||||
#
|
||||
# The deploy step REPLACES the previous Gitea-push → Dokploy webhook path
|
||||
# (per m's Q11.4 pick: soft-launch with both alive for ~1 week, then
|
||||
# disable the Dokploy auto-deploy toggle). Soft-launch leaves Dokploy's
|
||||
# autoDeploy=true intact today — the workflow's deploy step is additive
|
||||
# and idempotent (Dokploy's deploy is itself idempotent).
|
||||
#
|
||||
# Catches the three failure classes from 2026-05-25:
|
||||
#
|
||||
# - brunel slot collision (~13:20) — TestMigrations_NoDuplicateSlot,
|
||||
# pure unit, no DB needed.
|
||||
# - hermes dropped-col refs (~16:05) — TestBootSmoke, applies all NEW
|
||||
# migrations (those not in the snapshot) end-to-end against a
|
||||
# scratch DB restored from internal/db/testdata/prod-snapshot.sql.
|
||||
# - mig 129 42501 ownership (~14:56→) — TestMigrations_EndToEndAsAppRole,
|
||||
# applies new migrations as the prod-shaped `postgres` role (which
|
||||
# is NOT a superuser on supabase/postgres — same shape as
|
||||
# youpc-supabase prod, see internal/db/testdata/README.md).
|
||||
#
|
||||
# Snapshot approach: dump paliad schema + applied_migrations rows from
|
||||
# prod, commit them. CI restores → ApplyMigrations sees existing migs as
|
||||
# applied, only runs NEW migs (the ones this PR adds). This sidesteps the
|
||||
# fresh-DB idempotence requirement on historical migrations (some of
|
||||
# which use raw COMMIT or pre-installed extensions and can't be replayed
|
||||
# from scratch). To refresh: `make refresh-snapshot`.
|
||||
#
|
||||
# Design: docs/design-cicd-pre-deploy-gate-2026-05-25.md (cronus inventor
|
||||
# shift, t-paliad-282).
|
||||
|
||||
name: Paliad CI gate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'mai/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.24'
|
||||
BUN_VERSION: '1.2'
|
||||
|
||||
jobs:
|
||||
# Gate job 1 — pure build. Catches go/bun build breakage that local
|
||||
# `go build` would catch but which a worker might have skipped before
|
||||
# pushing. Fast (~60 s) so a red here surfaces immediately.
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: go build
|
||||
run: go build ./...
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: bun install + build
|
||||
working-directory: frontend
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
|
||||
# Gate job 2 — Go test suite + migration smoke against snapshot-restored
|
||||
# scratch DB.
|
||||
#
|
||||
# The Postgres service container uses the same supabase/postgres image
|
||||
# as youpc-supabase prod. The CI scratch DB starts empty; a setup step
|
||||
# installs pg_trgm + restores the snapshot. After restore, paliad
|
||||
# schema is at HEAD-of-snapshot and applied_migrations covers every
|
||||
# migration up to (and including) the snapshot's max version.
|
||||
#
|
||||
# ApplyMigrations called in TestBootSmoke / TestMigrations_EndToEndAsAppRole
|
||||
# sees the snapshot's applied set, finds whatever NEW migrations this
|
||||
# PR added on top, and applies only those. The role-split smoke runs as
|
||||
# `postgres` (which is NOT a superuser on supabase/postgres, matching
|
||||
# the prod role topology) — any new migration that needs supabase_admin
|
||||
# privilege fails here as it would in prod.
|
||||
test-go:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
# supabase/postgres baked-in auth schema + supabase role topology
|
||||
# matches youpc-supabase prod. `postgres` here is NOT a superuser
|
||||
# (verified live: \du postgres shows "Create role, Create DB,
|
||||
# Replication, Bypass RLS" — no Superuser). This is the prod-shaped
|
||||
# role the deploy uses.
|
||||
postgres:
|
||||
image: supabase/postgres:15.8.1.060
|
||||
env:
|
||||
POSTGRES_PASSWORD: ci
|
||||
POSTGRES_DB: paliad_scratch
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Install postgresql-client
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq postgresql-client
|
||||
|
||||
# Snapshot restore. Two prep steps as supabase_admin (the actual
|
||||
# superuser): GRANT CREATE so the `postgres` role can later create
|
||||
# schemas if a new mig needs it; install pg_trgm so the snapshot's
|
||||
# trigram indexes restore. Snapshot itself loads as `postgres`.
|
||||
- name: Provision + restore snapshot
|
||||
env:
|
||||
PGPASSWORD: ci
|
||||
run: |
|
||||
set -euo pipefail
|
||||
psql -h localhost -U supabase_admin -d paliad_scratch -v ON_ERROR_STOP=1 \
|
||||
-c "GRANT CREATE ON DATABASE paliad_scratch TO postgres;" \
|
||||
-c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
|
||||
psql -h localhost -U postgres -d paliad_scratch -v ON_ERROR_STOP=1 \
|
||||
-f internal/db/testdata/prod-snapshot.sql
|
||||
|
||||
# Pre-flight: catches brunel slot collision in seconds, no DB
|
||||
# contact (still useful even though the test-go job has Postgres
|
||||
# running, because the failure mode is independent).
|
||||
- name: Migration coordination check
|
||||
run: go test -count=1 -run TestMigrations_NoDuplicateSlot ./internal/db/
|
||||
|
||||
# Role-split end-to-end apply. Connects as `postgres` (NOT a
|
||||
# superuser on supabase/postgres) and runs ApplyMigrations against
|
||||
# the snapshot-restored DB. Existing migs are skipped (already in
|
||||
# applied_migrations); NEW migs in this PR apply here. If a new
|
||||
# migration assumes supabase_admin privilege, fails with the same
|
||||
# 42501 error class that took paliad.de offline on 2026-05-25.
|
||||
- name: Migration end-to-end (deploy role)
|
||||
env:
|
||||
TEST_APP_DATABASE_URL: postgres://postgres:ci@localhost:5432/paliad_scratch?sslmode=disable
|
||||
run: go test -count=1 -run TestMigrations_EndToEndAsAppRole ./internal/db/
|
||||
|
||||
# Boot smoke. Confirms ApplyMigrations succeeds + applied set
|
||||
# matches on-disk set + /healthz returns 200 + /health/ready
|
||||
# returns 200 (the live-pool variant via TestHealthReady_Live).
|
||||
- name: Boot smoke + readiness
|
||||
env:
|
||||
TEST_DATABASE_URL: postgres://postgres:ci@localhost:5432/paliad_scratch?sslmode=disable
|
||||
run: go test -count=1 -run 'TestBootSmoke|TestHealthReady_Live' ./cmd/server/
|
||||
|
||||
# Full Go test suite WITHOUT TEST_DATABASE_URL so live-DB service
|
||||
# tests skip (same shape as a developer laptop without a scratch
|
||||
# DB). Live-DB tests in internal/services/* will be activated by a
|
||||
# follow-up shift once the snapshot is verified stable across
|
||||
# multiple PRs — they need investigation against supabase/postgres
|
||||
# 15.8 (parameter type inference differs subtly from youpc-supabase).
|
||||
- name: go test ./... (pure + skip-on-no-DB)
|
||||
run: go test -count=1 ./internal/... ./cmd/...
|
||||
|
||||
# Deploy step. Only runs on push to main and only after both gate jobs
|
||||
# are green. Calls Dokploy's compose.deploy with the paliad compose ID
|
||||
# (Zx147ycurfYagKRl_Zzyo) and polls /health/ready until it returns 200
|
||||
# or times out.
|
||||
#
|
||||
# Skipped on PR / feature branch pushes — those run the gate tier as
|
||||
# a status check but don't trigger a prod deploy. Dokploy's existing
|
||||
# autoDeploy=true webhook continues to fire during the soft-launch
|
||||
# window (per Q11.4); it can be disabled in the Dokploy UI once this
|
||||
# workflow has gated ≥5 successful green deploys.
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, test-go]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Trigger Dokploy compose deploy
|
||||
env:
|
||||
DOKPLOY_KEY: ${{ secrets.DOKPLOY_TOKEN }}
|
||||
DOKPLOY_API: http://100.99.98.201:3000/api/trpc
|
||||
COMPOSE_ID: Zx147ycurfYagKRl_Zzyo
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${DOKPLOY_KEY:-}" ]; then
|
||||
echo "ERROR: DOKPLOY_TOKEN secret is not configured."
|
||||
echo " Set the secret in Gitea repo settings before this step can deploy."
|
||||
exit 2
|
||||
fi
|
||||
echo "==> POST compose.deploy"
|
||||
curl -sS --connect-timeout 5 --max-time 30 \
|
||||
-X POST \
|
||||
-H "x-api-key: $DOKPLOY_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"json\":{\"composeId\":\"$COMPOSE_ID\"}}" \
|
||||
"$DOKPLOY_API/compose.deploy"
|
||||
echo
|
||||
|
||||
- name: Wait for /health/ready
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "==> polling https://paliad.de/health/ready"
|
||||
# Up to 5 minutes (60 × 5 s) — paliad's cold-start is normally
|
||||
# ≤30 s; the longer budget covers slow image pulls + migration
|
||||
# apply.
|
||||
for i in $(seq 1 60); do
|
||||
status=$(curl -sS --connect-timeout 3 --max-time 5 \
|
||||
-o /dev/null -w '%{http_code}' \
|
||||
https://paliad.de/health/ready || echo "000")
|
||||
if [ "$status" = "200" ]; then
|
||||
echo "ready after ${i} poll(s)"
|
||||
exit 0
|
||||
fi
|
||||
echo " [$i/60] status=$status — sleeping 5s"
|
||||
sleep 5
|
||||
done
|
||||
echo "ERROR: /health/ready did not return 200 within 5 minutes."
|
||||
echo " The deploy fired but the new container is not serving."
|
||||
echo " Investigate: ssh mlake 'docker logs --tail 50 compose-transmit-multi-byte-driver-v7jth9-web-1'"
|
||||
exit 1
|
||||
93
Makefile
93
Makefile
@@ -21,26 +21,18 @@
|
||||
# the test runner's working dirs. None of them touch internal/db/migrations/
|
||||
# files.
|
||||
|
||||
.PHONY: help verify-migrations verify-mig verify-mig-app test test-go test-frontend refresh-snapshot snapshot-upc
|
||||
.PHONY: help verify-migrations verify-mig test test-go
|
||||
|
||||
help:
|
||||
@echo "Paliad — developer targets"
|
||||
@echo ""
|
||||
@echo " verify-migrations Dry-run pending migrations + boot smoke (needs TEST_DATABASE_URL)"
|
||||
@echo " verify-mig Alias for verify-migrations"
|
||||
@echo " verify-mig-app End-to-end migration smoke as non-superuser role"
|
||||
@echo " (needs TEST_APP_DATABASE_URL — t-paliad-282 / m/paliad#114)"
|
||||
@echo " test Short test pass — covers gate tier"
|
||||
@echo " test-go Full Go suite with race detector"
|
||||
@echo " test-frontend Frontend bun:test suite"
|
||||
@echo " snapshot-upc Regenerate pkg/litigationplanner/embedded/upc/ from live DB"
|
||||
@echo " (needs DATABASE_URL — see cmd/gen-upc-snapshot/README.md)"
|
||||
@echo ""
|
||||
@echo "Set TEST_DATABASE_URL to enable live-DB tests. Example:"
|
||||
@echo " export TEST_DATABASE_URL=postgres://paliad:...@localhost:11833/paliad_test"
|
||||
@echo ""
|
||||
@echo "Set TEST_APP_DATABASE_URL to enable the role-split smoke. Example:"
|
||||
@echo " export TEST_APP_DATABASE_URL=postgres://paliad_app:...@localhost:5432/paliad_scratch"
|
||||
|
||||
# Gate target — the test that would have caught mig 098 / mig 099 before
|
||||
# deploy. Combines:
|
||||
@@ -79,86 +71,3 @@ test:
|
||||
# (full suite, not per-PR).
|
||||
test-go:
|
||||
go test -race ./...
|
||||
|
||||
# Frontend bun:test suite. Runs the 4 existing pure-TS tests today; will
|
||||
# grow as mendel's Slice 3 (frontend test infill) lands.
|
||||
test-frontend:
|
||||
cd frontend && bun test
|
||||
|
||||
# Role-split end-to-end migration smoke — the catch for the mig 129 42501
|
||||
# ownership class (m/paliad#114). Runs ApplyMigrations as a non-superuser
|
||||
# role against TEST_APP_DATABASE_URL. Fails the build if any migration
|
||||
# assumes more privilege than the deploy role has.
|
||||
#
|
||||
# Developer setup (local):
|
||||
# psql -c "CREATE ROLE paliad_app LOGIN PASSWORD 'ci' NOSUPERUSER;"
|
||||
# psql -c "CREATE DATABASE paliad_scratch OWNER paliad_app;"
|
||||
# export TEST_APP_DATABASE_URL=postgres://paliad_app:ci@localhost:5432/paliad_scratch
|
||||
verify-mig-app:
|
||||
@if [ -z "$$TEST_APP_DATABASE_URL" ]; then \
|
||||
echo "ERROR: TEST_APP_DATABASE_URL is not set."; \
|
||||
echo " The role-split migration smoke cannot run without a non-superuser scratch DB."; \
|
||||
echo " See Makefile comments above this target for setup."; \
|
||||
exit 2; \
|
||||
fi
|
||||
go test -count=1 -run TestMigrations_EndToEndAsAppRole ./internal/db/
|
||||
|
||||
# Refresh the prod schema snapshot used by CI's migration smoke
|
||||
# (t-paliad-282 / m/paliad#114). Connects to youpc-supabase prod, dumps
|
||||
# the paliad schema + applied_migrations rows, strips rows beyond the
|
||||
# current branch's max on-disk version, and writes
|
||||
# internal/db/testdata/prod-snapshot.sql.
|
||||
#
|
||||
# When to refresh:
|
||||
# - After merging a PR that added a new migration to main.
|
||||
# - When CI's migration smoke starts spuriously failing because the
|
||||
# snapshot's applied set diverges from on-disk by more than this
|
||||
# branch's worth of new migs.
|
||||
#
|
||||
# Requires PALIAD_PROD_DATABASE_URL env var (a Postgres URL with
|
||||
# pg_dump rights on youpc-supabase). Example:
|
||||
# export PALIAD_PROD_DATABASE_URL='postgres://postgres:PW@100.99.98.201:11833/postgres'
|
||||
refresh-snapshot:
|
||||
@if [ -z "$$PALIAD_PROD_DATABASE_URL" ]; then \
|
||||
echo "ERROR: PALIAD_PROD_DATABASE_URL is not set."; \
|
||||
echo " Refresh requires read access to youpc-supabase prod."; \
|
||||
exit 2; \
|
||||
fi
|
||||
@echo "==> dumping paliad schema (no owner, no privs)..."
|
||||
@pg_dump --schema-only --schema=paliad --no-owner --no-privileges \
|
||||
--no-publications --no-subscriptions \
|
||||
"$$PALIAD_PROD_DATABASE_URL" > internal/db/testdata/prod-snapshot.sql.tmp
|
||||
@echo "==> appending applied_migrations rows..."
|
||||
@pg_dump --data-only --table=paliad.applied_migrations \
|
||||
--no-owner --no-privileges \
|
||||
"$$PALIAD_PROD_DATABASE_URL" >> internal/db/testdata/prod-snapshot.sql.tmp
|
||||
@echo "==> stripping pg16 \\restrict / \\unrestrict commands for pg15 compat..."
|
||||
@sed -i.bak '/^\\restrict /d; /^\\unrestrict /d' internal/db/testdata/prod-snapshot.sql.tmp
|
||||
@rm -f internal/db/testdata/prod-snapshot.sql.tmp.bak
|
||||
@echo "==> stripping applied_migrations rows beyond branch's max on-disk version..."
|
||||
@MAX_VER=$$(ls internal/db/migrations/*.up.sql | xargs -I{} basename {} | sed 's/_.*//' | sort -n | tail -1); \
|
||||
awk -v max=$$MAX_VER ' \
|
||||
/^[0-9]+\t/ { split($$0, a, "\t"); if (a[1]+0 > max) next; } \
|
||||
{ print } \
|
||||
' internal/db/testdata/prod-snapshot.sql.tmp > internal/db/testdata/prod-snapshot.sql
|
||||
@rm internal/db/testdata/prod-snapshot.sql.tmp
|
||||
@wc -l internal/db/testdata/prod-snapshot.sql
|
||||
|
||||
# Regenerate the embedded UPC snapshot from a live paliad DB. The
|
||||
# generator applies pending migrations first, then SELECTs the UPC
|
||||
# subset and writes JSON files under pkg/litigationplanner/embedded/upc/.
|
||||
#
|
||||
# Requires DATABASE_URL — Slice C of the litigation-planner extraction
|
||||
# (m/paliad#124 §19). See cmd/gen-upc-snapshot/README.md for the full
|
||||
# operator runbook.
|
||||
snapshot-upc:
|
||||
@if [ -z "$$DATABASE_URL" ]; then \
|
||||
echo "ERROR: DATABASE_URL is not set."; \
|
||||
echo " Snapshot generation needs read access to a paliad DB."; \
|
||||
echo " Set DATABASE_URL to the live paliad Postgres, then re-run."; \
|
||||
exit 2; \
|
||||
fi
|
||||
@echo "==> regenerating UPC snapshot from $$DATABASE_URL"
|
||||
go run ./cmd/gen-upc-snapshot
|
||||
@echo "==> running snapshot tests against the regenerated data"
|
||||
go test ./pkg/litigationplanner/embedded/upc/...
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# gen-upc-snapshot
|
||||
|
||||
Regenerates the embedded UPC snapshot consumed by
|
||||
`pkg/litigationplanner/embedded/upc`. Slice C of the litigation-planner
|
||||
extraction (m/paliad#124 §19). See
|
||||
`docs/design-litigation-planner-2026-05-26.md` §19 for the full design.
|
||||
|
||||
## When to regenerate
|
||||
|
||||
After any change that affects the public UPC rule corpus:
|
||||
|
||||
- new rules merged via the admin rule-editor
|
||||
- a deadline-rule migration that touches UPC rows
|
||||
- a `paliad.holidays` update (new public holidays / vacation runs)
|
||||
- a `paliad.courts` update (new UPC LD opens, etc.)
|
||||
- a `paliad.proceeding_types` change for `jurisdiction = 'UPC'`
|
||||
|
||||
The snapshot is operator-controlled — there is no CI regeneration in v1.
|
||||
|
||||
## How to regenerate
|
||||
|
||||
```sh
|
||||
make snapshot-upc
|
||||
```
|
||||
|
||||
or directly:
|
||||
|
||||
```sh
|
||||
DATABASE_URL=postgres://... go run ./cmd/gen-upc-snapshot
|
||||
```
|
||||
|
||||
Flags:
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|-----------------|----------------------------------------|---------|
|
||||
| `-output` | `./pkg/litigationplanner/embedded/upc` | directory to write JSON files into |
|
||||
| `-version` | auto-derived (`YYYY-MM-DD-N`) | override the snapshot version |
|
||||
| `-source-label` | empty | text label written to `meta.json` (`paliad-prod`, `paliad-dev`, …) |
|
||||
|
||||
The generator:
|
||||
|
||||
1. Applies pending migrations against `DATABASE_URL` (snapshot always matches schema HEAD).
|
||||
2. SELECTs UPC active proceeding_types + their published+active rules + referenced trigger_events + DE/UPC holidays + UPC courts.
|
||||
3. Writes pretty-printed JSON to `<output>/{proceeding_types,rules,trigger_events,holidays,courts,meta}.json`.
|
||||
|
||||
## Idempotence
|
||||
|
||||
Running twice with the same DB state produces the same JSON (modulo `meta.generated_at`). Diff-friendly in git.
|
||||
|
||||
## Versioning
|
||||
|
||||
`meta.json.version` uses `YYYY-MM-DD-N` where N starts at 1 and increments on same-day regenerations. The generator reads the existing `meta.json` and bumps automatically.
|
||||
|
||||
## After regeneration
|
||||
|
||||
1. Review the diff: `git diff pkg/litigationplanner/embedded/upc/`.
|
||||
2. Run tests: `go test ./pkg/litigationplanner/embedded/upc/...`.
|
||||
3. Commit with a message like `chore(snapshot): regenerate UPC snapshot (<reason>)`.
|
||||
4. Notify any downstream consumer (youpc.org) that a new paliad release is available.
|
||||
@@ -1,307 +0,0 @@
|
||||
// Command gen-upc-snapshot reads paliad's live deadline corpus and
|
||||
// writes the UPC subset as JSON files under
|
||||
// pkg/litigationplanner/embedded/upc/. The package's embedded
|
||||
// catalog/holiday/court implementations then serve this data without
|
||||
// any DB roundtrip — letting youpc.org (or any future consumer) run
|
||||
// the litigationplanner engine against the canonical UPC rule set.
|
||||
//
|
||||
// Slice C (m/paliad#124 §19). See docs/design-litigation-planner-2026-05-26.md
|
||||
// §19 for the full design.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// DATABASE_URL=postgres://... go run ./cmd/gen-upc-snapshot \
|
||||
// [-output ./pkg/litigationplanner/embedded/upc] \
|
||||
// [-version 2026-05-26-1] \
|
||||
// [-source-label paliad-dev-supabase]
|
||||
//
|
||||
// The generator applies migrations against DATABASE_URL before
|
||||
// SELECTing (so the snapshot always matches schema HEAD). Idempotent —
|
||||
// running twice with the same DB state produces the same JSON.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultOutput = "./pkg/litigationplanner/embedded/upc"
|
||||
defaultSourceLabel = ""
|
||||
)
|
||||
|
||||
// Meta is the version block written to meta.json. The embedded sub-
|
||||
// package re-defines this type so consumers can decode it without
|
||||
// importing the cmd; the cmd holds the canonical write shape.
|
||||
type Meta struct {
|
||||
Version string `json:"version"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
PaliadCommit string `json:"paliad_commit,omitempty"`
|
||||
SourceDBLabel string `json:"source_db_label,omitempty"`
|
||||
RuleCount int `json:"rule_count"`
|
||||
ProceedingCount int `json:"proceeding_count"`
|
||||
TriggerEventCount int `json:"trigger_event_count"`
|
||||
HolidayCount int `json:"holiday_count"`
|
||||
CourtCount int `json:"court_count"`
|
||||
}
|
||||
|
||||
// EmbeddedHoliday is the holiday row shape the embedded snapshot
|
||||
// stores. JSON tags mirror paliad.holidays so the generator's SELECT
|
||||
// scans onto it directly + the embedded HolidayCalendar reads the
|
||||
// same tag.
|
||||
type EmbeddedHoliday struct {
|
||||
Date string `db:"date_iso" json:"date"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Country *string `db:"country" json:"country,omitempty"`
|
||||
Regime *string `db:"regime" json:"regime,omitempty"`
|
||||
State *string `db:"state" json:"state,omitempty"`
|
||||
HolidayType string `db:"holiday_type" json:"holiday_type"`
|
||||
}
|
||||
|
||||
// EmbeddedCourt is the court row shape the embedded snapshot stores.
|
||||
type EmbeddedCourt struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
NameDE string `db:"name_de" json:"name_de"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Country string `db:"country" json:"country"`
|
||||
Regime *string `db:"regime" json:"regime,omitempty"`
|
||||
CourtType string `db:"court_type" json:"court_type"`
|
||||
ParentID *string `db:"parent_id" json:"parent_id,omitempty"`
|
||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
output := flag.String("output", defaultOutput, "directory to write JSON files into")
|
||||
version := flag.String("version", "", "explicit snapshot version (auto-derived if empty)")
|
||||
sourceLabel := flag.String("source-label", defaultSourceLabel, "label for source_db in meta.json")
|
||||
flag.Parse()
|
||||
|
||||
url := os.Getenv("DATABASE_URL")
|
||||
if url == "" {
|
||||
log.Fatal("DATABASE_URL must be set")
|
||||
}
|
||||
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
log.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
log.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := run(ctx, pool, *output, *version, *sourceLabel); err != nil {
|
||||
log.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string) error {
|
||||
if err := os.MkdirAll(output, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir output: %w", err)
|
||||
}
|
||||
|
||||
// 1. Proceeding types — UPC primaries only. The unified upc.apl row
|
||||
// from B1 mig 134 is included; the 3 archived old appeal codes
|
||||
// (is_active=false) are filtered out by the is_active predicate.
|
||||
// The kind='proceeding' predicate (mig 153, t-paliad-325) belts the
|
||||
// is_active filter so phase/side_action/meta rows can't slip into
|
||||
// the embedded catalog even if some future deploy re-activates one
|
||||
// for an admin task.
|
||||
var procs []litigationplanner.ProceedingType
|
||||
if err := pool.SelectContext(ctx, &procs, `
|
||||
SELECT id, code, name, name_en, description, jurisdiction,
|
||||
category, default_color, sort_order, is_active,
|
||||
trigger_event_label_de, trigger_event_label_en,
|
||||
appeal_target
|
||||
FROM paliad.proceeding_types
|
||||
WHERE jurisdiction = 'UPC'
|
||||
AND is_active = true
|
||||
AND kind = 'proceeding'
|
||||
ORDER BY sort_order, id`); err != nil {
|
||||
return fmt.Errorf("select proceeding_types: %w", err)
|
||||
}
|
||||
|
||||
if len(procs) == 0 {
|
||||
return fmt.Errorf("no active UPC proceeding_types — refusing to write empty snapshot")
|
||||
}
|
||||
|
||||
procIDs := make([]int, 0, len(procs))
|
||||
for _, p := range procs {
|
||||
procIDs = append(procIDs, p.ID)
|
||||
}
|
||||
|
||||
// 2. Deadline rules — published + active rules for those proceedings.
|
||||
const ruleCols = `id, proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type, duration_value,
|
||||
duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_active,
|
||||
created_at, updated_at,
|
||||
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
|
||||
priority, is_court_set, lifecycle_state, draft_of, published_at,
|
||||
choices_offered, applies_to_target`
|
||||
|
||||
q, args, err := sqlx.In(`
|
||||
SELECT `+ruleCols+`
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id IN (?)
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
ORDER BY proceeding_type_id, sequence_order`, procIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build rules IN: %w", err)
|
||||
}
|
||||
q = pool.Rebind(q)
|
||||
var rules []litigationplanner.Rule
|
||||
if err := pool.SelectContext(ctx, &rules, q, args...); err != nil {
|
||||
return fmt.Errorf("select rules: %w", err)
|
||||
}
|
||||
|
||||
// 3. Trigger events referenced by any UPC rule's trigger_event_id.
|
||||
triggerIDSet := make(map[int64]struct{})
|
||||
for _, r := range rules {
|
||||
if r.TriggerEventID != nil {
|
||||
triggerIDSet[*r.TriggerEventID] = struct{}{}
|
||||
}
|
||||
}
|
||||
var triggers []litigationplanner.TriggerEvent
|
||||
if len(triggerIDSet) > 0 {
|
||||
triggerIDs := make([]int64, 0, len(triggerIDSet))
|
||||
for id := range triggerIDSet {
|
||||
triggerIDs = append(triggerIDs, id)
|
||||
}
|
||||
q, args, err := sqlx.In(`
|
||||
SELECT id, code, name, name_de, description, is_active, created_at
|
||||
FROM paliad.trigger_events
|
||||
WHERE id IN (?)
|
||||
ORDER BY id`, triggerIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build triggers IN: %w", err)
|
||||
}
|
||||
q = pool.Rebind(q)
|
||||
if err := pool.SelectContext(ctx, &triggers, q, args...); err != nil {
|
||||
return fmt.Errorf("select trigger_events: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Holidays — DE national + UPC regime entries. The embedded
|
||||
// calendar serves UPC computations so both axes matter.
|
||||
var holidays []EmbeddedHoliday
|
||||
if err := pool.SelectContext(ctx, &holidays, `
|
||||
SELECT to_char(date, 'YYYY-MM-DD') AS date_iso,
|
||||
name, country, regime, state, holiday_type
|
||||
FROM paliad.holidays
|
||||
WHERE country = 'DE' OR regime = 'UPC'
|
||||
ORDER BY date, name`); err != nil {
|
||||
return fmt.Errorf("select holidays: %w", err)
|
||||
}
|
||||
|
||||
// 5. Courts — UPC subset.
|
||||
var courts []EmbeddedCourt
|
||||
if err := pool.SelectContext(ctx, &courts, `
|
||||
SELECT id, code, name_de, name_en, country, regime, court_type, parent_id, sort_order
|
||||
FROM paliad.courts
|
||||
WHERE is_active = true
|
||||
AND (regime = 'UPC' OR court_type LIKE 'upc%')
|
||||
ORDER BY sort_order, id`); err != nil {
|
||||
return fmt.Errorf("select courts: %w", err)
|
||||
}
|
||||
|
||||
// 6. Compose meta.
|
||||
meta := Meta{
|
||||
Version: resolveVersion(version, output),
|
||||
GeneratedAt: time.Now().UTC().Truncate(time.Second),
|
||||
PaliadCommit: gitCommitShort(),
|
||||
SourceDBLabel: sourceLabel,
|
||||
RuleCount: len(rules),
|
||||
ProceedingCount: len(procs),
|
||||
TriggerEventCount: len(triggers),
|
||||
HolidayCount: len(holidays),
|
||||
CourtCount: len(courts),
|
||||
}
|
||||
|
||||
// 7. Write each file.
|
||||
files := []struct {
|
||||
name string
|
||||
data any
|
||||
}{
|
||||
{"proceeding_types.json", procs},
|
||||
{"rules.json", rules},
|
||||
{"trigger_events.json", triggers},
|
||||
{"holidays.json", holidays},
|
||||
{"courts.json", courts},
|
||||
{"meta.json", meta},
|
||||
}
|
||||
for _, f := range files {
|
||||
path := filepath.Join(output, f.name)
|
||||
buf, err := json.MarshalIndent(f.data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %s: %w", f.name, err)
|
||||
}
|
||||
buf = append(buf, '\n')
|
||||
if err := os.WriteFile(path, buf, 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("snapshot written: version=%s rules=%d proceedings=%d triggers=%d holidays=%d courts=%d → %s",
|
||||
meta.Version, meta.RuleCount, meta.ProceedingCount,
|
||||
meta.TriggerEventCount, meta.HolidayCount, meta.CourtCount, output)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveVersion picks a date-stamped version slug, bumping the suffix
|
||||
// past any pre-existing same-day version found in the existing
|
||||
// meta.json. If the caller passed -version, that wins.
|
||||
func resolveVersion(explicit, output string) string {
|
||||
if explicit != "" {
|
||||
return explicit
|
||||
}
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
// Read prior meta to detect same-day collisions.
|
||||
prior, err := os.ReadFile(filepath.Join(output, "meta.json"))
|
||||
if err != nil {
|
||||
return today + "-1"
|
||||
}
|
||||
var pm Meta
|
||||
if err := json.Unmarshal(prior, &pm); err != nil {
|
||||
return today + "-1"
|
||||
}
|
||||
if !strings.HasPrefix(pm.Version, today+"-") {
|
||||
return today + "-1"
|
||||
}
|
||||
// Same day: bump the suffix.
|
||||
suffix := pm.Version[len(today)+1:]
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(suffix, "%d", &n); err != nil {
|
||||
return today + "-1"
|
||||
}
|
||||
return fmt.Sprintf("%s-%d", today, n+1)
|
||||
}
|
||||
|
||||
// gitCommitShort returns the short SHA of the paliad checkout. Best-
|
||||
// effort — empty string when we're not in a git checkout.
|
||||
func gitCommitShort() string {
|
||||
out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
// Command seed-orphan-concept-drafts stages draft sequencing_rules for
|
||||
// deadline_concepts that have rule_count=0 ("orphans"). It calls the
|
||||
// same services.RuleEditorService.Create that POST
|
||||
// /admin/api/procedural-events runs internally, so the audit trigger
|
||||
// + INSTEAD-OF view trigger fan-out into procedural_events +
|
||||
// sequencing_rules + legal_sources fire identically. No HTTP/auth
|
||||
// shell, no direct SQL writes by this command.
|
||||
//
|
||||
// All rules are created with lifecycle_state='draft' (forced by the
|
||||
// service). The admin still reviews + publishes via
|
||||
// /admin/procedural-events.
|
||||
//
|
||||
// t-paliad-320: editorial backlog from t-paliad-193, four remaining
|
||||
// orphan concepts: counterclaim-for-revocation, versaeumnisurteil-
|
||||
// einspruch, schriftsatznachreichung, weiterbehandlung. The
|
||||
// weiterbehandlung concept gets two drafts (EPC Art. 121 + R. 135
|
||||
// versus DPatG § 123a) since the two regimes have different durations
|
||||
// and jurisdictions.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// DATABASE_URL=postgres://… go run ./cmd/seed-orphan-concept-drafts \
|
||||
// [-dry-run] [-reason "free-text audit reason"]
|
||||
//
|
||||
// Idempotency: the command refuses to insert if any rule for a given
|
||||
// (concept, proceeding_type, rule_code) already exists. Safe to re-run
|
||||
// after a partial failure.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// draftSpec captures one CreateRuleInput plus the metadata the command
|
||||
// needs to resolve concept_id + proceeding_type_id from human-readable
|
||||
// slugs/codes. ProceedingCode == "" means event-rooted
|
||||
// (proceeding_type_id = NULL), used for cross-cutting rules whose
|
||||
// jurisdiction has no matching proceeding_type yet.
|
||||
type draftSpec struct {
|
||||
Label string // human label for log output
|
||||
ConceptSlug string
|
||||
ProceedingCode string // "" → NULL proceeding_type_id (event-rooted)
|
||||
SubmissionCode string
|
||||
Name string
|
||||
NameEN string
|
||||
EventKind string
|
||||
PrimaryParty string // "" → omit (NULL)
|
||||
DurationValue int
|
||||
DurationUnit string
|
||||
Timing string
|
||||
Priority string
|
||||
IsCourtSet bool
|
||||
RuleCode string
|
||||
LegalSource string
|
||||
DeadlineNotes string
|
||||
DeadlineNotesEn string
|
||||
}
|
||||
|
||||
func drafts() []draftSpec {
|
||||
return []draftSpec{
|
||||
// ─── 1. counterclaim-for-revocation (UPC R.25.1 ∧ R.23) ───────
|
||||
{
|
||||
Label: "counterclaim-for-revocation → upc.ccr.cfi",
|
||||
ConceptSlug: "counterclaim-for-revocation",
|
||||
ProceedingCode: "upc.ccr.cfi",
|
||||
SubmissionCode: "upc.ccr.cfi.lodge",
|
||||
Name: "Widerklage auf Nichtigkeit (CCR)",
|
||||
NameEN: "Counterclaim for Revocation (CCR)",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "defendant",
|
||||
DurationValue: 3,
|
||||
DurationUnit: "months",
|
||||
Timing: "after",
|
||||
Priority: "mandatory",
|
||||
IsCourtSet: false,
|
||||
RuleCode: "RoP.025",
|
||||
LegalSource: "UPC.RoP.25.1",
|
||||
DeadlineNotes: "Die Widerklage auf Nichtigkeit (Counterclaim for Revocation, CCR) ist gemeinsam mit der Klageerwiderung (Statement of Defence) einzureichen — d. h. innerhalb von 3 Monaten ab Zustellung der Klageschrift " +
|
||||
"(R. 23 i. V. m. R. 25.1 RoP). Inhaltliche Anforderungen folgen R. 25-30 RoP (insbes. R. 25.1(a)-(c) zu Antrag, Tatsachen und Beweismitteln; R. 27 zu Verfahren nach Einreichung; R. 30 zu einem Antrag auf Änderung des Patents).",
|
||||
DeadlineNotesEn: "The Counterclaim for Revocation (CCR) must be lodged together with the Statement of Defence — i.e. within 3 months of service of the Statement of Claim " +
|
||||
"(Rule 23 in conjunction with Rule 25.1 RoP). Substantive requirements follow Rules 25-30 RoP (in particular R. 25.1(a)-(c) on the application, facts and evidence; R. 27 on post-filing procedure; R. 30 on any application to amend the patent).",
|
||||
},
|
||||
|
||||
// ─── 2. versaeumnisurteil-einspruch (ZPO § 339) ───────────────
|
||||
{
|
||||
Label: "versaeumnisurteil-einspruch → de.inf.lg",
|
||||
ConceptSlug: "versaeumnisurteil-einspruch",
|
||||
ProceedingCode: "de.inf.lg",
|
||||
SubmissionCode: "de.inf.lg.einspruch_vu",
|
||||
Name: "Einspruch gegen Versäumnisurteil",
|
||||
NameEN: "Objection to default judgment",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "defendant",
|
||||
DurationValue: 2,
|
||||
DurationUnit: "weeks",
|
||||
Timing: "after",
|
||||
Priority: "mandatory",
|
||||
IsCourtSet: false,
|
||||
RuleCode: "§ 339 ZPO",
|
||||
LegalSource: "DE.ZPO.339.1",
|
||||
DeadlineNotes: "Notfrist von 2 Wochen ab Zustellung des Versäumnisurteils (§ 339(1) ZPO). " +
|
||||
"Bei Auslandszustellung oder öffentlicher Bekanntmachung bestimmt das Gericht die Einspruchsfrist gesondert im Versäumnisurteil oder durch nachträglichen Beschluss (§ 339(2) ZPO) — in diesem Fall die gerichtlich festgesetzte Frist mit „Datum setzen“ überschreiben. " +
|
||||
"Form: schriftlich oder zu Protokoll der Geschäftsstelle (§ 340(1) ZPO); die Einspruchsbegründung kann bis zum Verhandlungstermin nachgereicht werden (§ 340(3) ZPO).",
|
||||
DeadlineNotesEn: "Statutory two-week emergency period (Notfrist) from service of the default judgment (§ 339(1) ZPO). " +
|
||||
"If service is abroad or by public notice, the court sets the objection period separately in the default judgment or by a subsequent order (§ 339(2) ZPO) — in that case override with the court-set date. " +
|
||||
"Form: in writing or before the registry clerk (§ 340(1) ZPO); substantive grounds may be filed up to the oral hearing (§ 340(3) ZPO).",
|
||||
},
|
||||
|
||||
// ─── 3. schriftsatznachreichung (ZPO § 283) ───────────────────
|
||||
{
|
||||
Label: "schriftsatznachreichung → de.inf.lg",
|
||||
ConceptSlug: "schriftsatznachreichung",
|
||||
ProceedingCode: "de.inf.lg",
|
||||
SubmissionCode: "de.inf.lg.nachreichung",
|
||||
Name: "Schriftsatznachreichung",
|
||||
NameEN: "Subsequent written submission",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "", // concept.party = "both" → no default
|
||||
DurationValue: 3,
|
||||
DurationUnit: "weeks",
|
||||
Timing: "after",
|
||||
Priority: "optional",
|
||||
IsCourtSet: true,
|
||||
RuleCode: "§ 283 ZPO",
|
||||
LegalSource: "DE.ZPO.283",
|
||||
DeadlineNotes: "Vom Gericht in der mündlichen Verhandlung gesetzte Schriftsatzfrist gem. § 283 ZPO. " +
|
||||
"Sie wird nur auf Antrag einer Partei bestimmt, die sich auf neues Vorbringen des Gegners nicht erklären konnte. " +
|
||||
"Die konkrete Frist (in der Praxis 2-3 Wochen) und der nachfolgende Verkündungstermin werden im Sitzungsprotokoll bzw. in der prozessleitenden Verfügung festgelegt — Default-Frist hier 3 Wochen, mit „Datum setzen“ überschreiben, sobald die Verfügung vorliegt. " +
|
||||
"Nach Fristablauf darf das Gericht keine weiteren Erklärungen mehr berücksichtigen (§ 283 S. 2, § 296a ZPO).",
|
||||
DeadlineNotesEn: "Court-set written-submission period under § 283 ZPO, granted on a party's application when it could not respond at the oral hearing to the opponent's new submissions. " +
|
||||
"The actual period (in practice 2-3 weeks) and the announcement date are set in the hearing record / case-management order — default 3 weeks here, override via „set date“ once the order is on the file. " +
|
||||
"After expiry, the court will disregard further submissions (§ 283 sent. 2, § 296a ZPO).",
|
||||
},
|
||||
|
||||
// ─── 4. weiterbehandlung — EPC variant (Art. 121 + R. 135) ────
|
||||
{
|
||||
Label: "weiterbehandlung (EPC) → epa.grant.exa",
|
||||
ConceptSlug: "weiterbehandlung",
|
||||
ProceedingCode: "epa.grant.exa",
|
||||
SubmissionCode: "epa.grant.exa.weiterbeh",
|
||||
Name: "Antrag auf Weiterbehandlung",
|
||||
NameEN: "Request for further processing",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "claimant",
|
||||
DurationValue: 2,
|
||||
DurationUnit: "months",
|
||||
Timing: "after",
|
||||
Priority: "mandatory",
|
||||
IsCourtSet: false,
|
||||
RuleCode: "Art. 121 EPÜ",
|
||||
LegalSource: "EU.EPC-R.135.1",
|
||||
DeadlineNotes: "Antrag auf Weiterbehandlung gem. Art. 121 EPÜ i. V. m. R. 135(1) EPÜ — 2 Monate ab Zustellung der Mitteilung über die Fristversäumung bzw. den eingetretenen Rechtsverlust. " +
|
||||
"Der Antrag wird durch Zahlung der vorgeschriebenen Weiterbehandlungsgebühr gestellt; die versäumte Handlung muss innerhalb derselben 2-Monats-Frist nachgeholt werden (R. 135(1) EPÜ). " +
|
||||
"Die Frist ist nicht verlängerbar. Ausgeschlossen sind insbesondere die Frist für die Weiterbehandlung selbst sowie die in R. 135(2) EPÜ ausdrücklich aufgeführten Fristen (u. a. die Beschwerdefrist nach Art. 108 EPÜ, die Prioritätsfrist nach Art. 87 EPÜ und die Frist zur Wiedereinsetzung).",
|
||||
DeadlineNotesEn: "Request for further processing under Article 121 EPC in conjunction with Rule 135(1) EPC — two months from notification of the communication concerning the missed time limit or the loss of rights. " +
|
||||
"The request is made by payment of the further-processing fee; the omitted act must be completed within the same two-month period (Rule 135(1) EPC). " +
|
||||
"The period is non-extendable. Excluded: the further-processing period itself and the periods listed in Rule 135(2) EPC (notably the appeal period under Art. 108 EPC, the priority period under Art. 87 EPC, and the re-establishment period).",
|
||||
},
|
||||
|
||||
// ─── 5. weiterbehandlung — DPatG § 123a variant ───────────────
|
||||
// No `dpma.grant.*` proceeding_type exists yet, so this rule is
|
||||
// event-rooted (proceeding_type_id NULL) — same pattern as 78
|
||||
// other cross-cutting rules. Editorial follow-up: create a
|
||||
// `dpma.grant.dpma` proceeding_type and reassign.
|
||||
{
|
||||
Label: "weiterbehandlung (DPatG § 123a) → event-rooted (NULL proceeding_type)",
|
||||
ConceptSlug: "weiterbehandlung",
|
||||
ProceedingCode: "", // event-rooted
|
||||
SubmissionCode: "dpma.grant.weiterbeh",
|
||||
Name: "Antrag auf Weiterbehandlung (DPMA)",
|
||||
NameEN: "Request for further processing (DPMA, § 123a PatG)",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "claimant",
|
||||
DurationValue: 1,
|
||||
DurationUnit: "months",
|
||||
Timing: "after",
|
||||
Priority: "mandatory",
|
||||
IsCourtSet: false,
|
||||
RuleCode: "§ 123a PatG",
|
||||
LegalSource: "DE.PatG.123a.1",
|
||||
DeadlineNotes: "Antrag auf Weiterbehandlung einer DPMA-Patentanmeldung gem. § 123a PatG — 1 Monat ab Zustellung der Mitteilung über die Rechtsfolge der Fristversäumung. " +
|
||||
"Innerhalb dieser Frist müssen (i) der Antrag schriftlich gestellt, (ii) die versäumte Handlung nachgeholt und (iii) die Weiterbehandlungsgebühr nach Patentkostengesetz (PatKostG) gezahlt werden. " +
|
||||
"§ 123a PatG erfasst ausschließlich Anmeldungsfristen, deren Versäumung kraft Gesetzes die Zurückweisung der Anmeldung zur Folge hat. Für sonstige Fristversäumnisse kommt nur die Wiedereinsetzung nach § 123 PatG in Betracht (1 Monat ab Wegfall des Hindernisses, max. 1 Jahr ab Fristablauf). " +
|
||||
"HINWEIS — Taxonomie: bisher kein dpma.grant.* proceeding_type vorhanden; Regel daher event-rooted (proceeding_type_id NULL). Editorial follow-up: dpma.grant.dpma proceeding_type anlegen und diese Regel umhängen.",
|
||||
DeadlineNotesEn: "Request for further processing of a DPMA patent application under § 123a PatG — 1 month from notification of the consequence of the missed deadline. " +
|
||||
"Within this period the applicant must (i) file the written request, (ii) complete the omitted act, and (iii) pay the further-processing fee under the German Patent Costs Act (PatKostG). " +
|
||||
"§ 123a PatG covers only application-stage deadlines whose statutory consequence is rejection. For other missed deadlines, re-establishment under § 123 PatG is the only route (1 month from removal of the obstacle, max 1 year from the missed deadline). " +
|
||||
"TAXONOMY NOTE: no dpma.grant.* proceeding_type exists yet; this rule is event-rooted (proceeding_type_id NULL). Editorial follow-up: create a dpma.grant.dpma proceeding_type and reassign this rule.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
dryRun := flag.Bool("dry-run", false, "log the planned drafts but do not write")
|
||||
reason := flag.String("reason", "t-paliad-320: editorial seed of orphan deadline-concept rules (researcher darwin + lex)", "audit reason recorded with each Create()")
|
||||
flag.Parse()
|
||||
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
log.Fatal("DATABASE_URL not set — export the paliad postgres URL before running")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
conn, err := sqlx.Connect("postgres", dbURL)
|
||||
if err != nil {
|
||||
log.Fatalf("connect db: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
rules := services.NewDeadlineRuleService(conn)
|
||||
editor := services.NewRuleEditorService(conn, rules)
|
||||
|
||||
conceptIDs := map[string]uuid.UUID{}
|
||||
proceedingIDs := map[string]int{}
|
||||
specs := drafts()
|
||||
|
||||
for _, s := range specs {
|
||||
if _, ok := conceptIDs[s.ConceptSlug]; ok {
|
||||
continue
|
||||
}
|
||||
var id uuid.UUID
|
||||
if err := conn.GetContext(ctx, &id,
|
||||
`SELECT id FROM paliad.deadline_concepts WHERE slug = $1`, s.ConceptSlug); err != nil {
|
||||
log.Fatalf("lookup concept %q: %v", s.ConceptSlug, err)
|
||||
}
|
||||
conceptIDs[s.ConceptSlug] = id
|
||||
}
|
||||
for _, s := range specs {
|
||||
if s.ProceedingCode == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := proceedingIDs[s.ProceedingCode]; ok {
|
||||
continue
|
||||
}
|
||||
var id int
|
||||
if err := conn.GetContext(ctx, &id,
|
||||
`SELECT id FROM paliad.proceeding_types WHERE code = $1`, s.ProceedingCode); err != nil {
|
||||
log.Fatalf("lookup proceeding_type %q: %v", s.ProceedingCode, err)
|
||||
}
|
||||
proceedingIDs[s.ProceedingCode] = id
|
||||
}
|
||||
|
||||
fmt.Printf("Seeding %d drafts (dry-run=%v)\n", len(specs), *dryRun)
|
||||
|
||||
for i, s := range specs {
|
||||
conceptID := conceptIDs[s.ConceptSlug]
|
||||
var procID *int
|
||||
if s.ProceedingCode != "" {
|
||||
p := proceedingIDs[s.ProceedingCode]
|
||||
procID = &p
|
||||
}
|
||||
|
||||
// Idempotency: refuse if a rule with the same (concept, proceeding,
|
||||
// rule_code) already exists in any lifecycle state.
|
||||
if existing, err := findExisting(ctx, conn, conceptID, procID, s.RuleCode); err != nil {
|
||||
log.Fatalf("[%d] idempotency check failed for %s: %v", i+1, s.Label, err)
|
||||
} else if existing != uuid.Nil {
|
||||
fmt.Printf(" [%d] SKIP %s — already exists as %s\n", i+1, s.Label, existing)
|
||||
continue
|
||||
}
|
||||
|
||||
input := services.CreateRuleInput{
|
||||
Name: s.Name,
|
||||
NameEN: s.NameEN,
|
||||
ProceedingTypeID: procID,
|
||||
DurationValue: s.DurationValue,
|
||||
DurationUnit: s.DurationUnit,
|
||||
Priority: s.Priority,
|
||||
IsCourtSet: s.IsCourtSet,
|
||||
}
|
||||
input.ConceptID = &conceptID
|
||||
code := s.SubmissionCode
|
||||
input.SubmissionCode = &code
|
||||
ek := s.EventKind
|
||||
input.EventType = &ek
|
||||
t := s.Timing
|
||||
input.Timing = &t
|
||||
rc := s.RuleCode
|
||||
input.RuleCode = &rc
|
||||
ls := s.LegalSource
|
||||
input.LegalSource = &ls
|
||||
dn := s.DeadlineNotes
|
||||
input.DeadlineNotes = &dn
|
||||
dne := s.DeadlineNotesEn
|
||||
input.DeadlineNotesEn = &dne
|
||||
if s.PrimaryParty != "" {
|
||||
pp := s.PrimaryParty
|
||||
input.PrimaryParty = &pp
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
fmt.Printf(" [%d] DRY %s (concept=%s, proc=%s, code=%s, %d %s, %s)\n",
|
||||
i+1, s.Label, conceptID, codeOrNil(procID), code, s.DurationValue, s.DurationUnit, s.RuleCode)
|
||||
continue
|
||||
}
|
||||
|
||||
row, err := editor.Create(ctx, input, *reason)
|
||||
if err != nil {
|
||||
log.Fatalf(" [%d] CREATE failed for %s: %v", i+1, s.Label, err)
|
||||
}
|
||||
fmt.Printf(" [%d] OK %s → id=%s lifecycle=%s\n",
|
||||
i+1, s.Label, row.ID, row.LifecycleState)
|
||||
}
|
||||
|
||||
fmt.Println("Done.")
|
||||
}
|
||||
|
||||
func findExisting(ctx context.Context, conn *sqlx.DB, conceptID uuid.UUID, procID *int, ruleCode string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
q := `
|
||||
SELECT sr.id
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE pe.concept_id = $1
|
||||
AND sr.rule_code IS NOT DISTINCT FROM $2
|
||||
AND sr.proceeding_type_id IS NOT DISTINCT FROM $3
|
||||
LIMIT 1`
|
||||
err := conn.GetContext(ctx, &id, q, conceptID, ruleCode, procID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return uuid.Nil, nil
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
func codeOrNil(p *int) string {
|
||||
if p == nil {
|
||||
return "<NULL>"
|
||||
}
|
||||
return fmt.Sprintf("%d", *p)
|
||||
}
|
||||
@@ -159,41 +159,17 @@ func main() {
|
||||
submissionVarsSvc := services.NewSubmissionVarsService(pool, projectSvc, partySvc, users)
|
||||
submissionRenderer := services.NewSubmissionRenderer()
|
||||
submissionDraftSvc := services.NewSubmissionDraftService(pool, projectSvc, submissionVarsSvc, submissionRenderer)
|
||||
// t-paliad-313 Composer Slice A — base catalog + section seeding.
|
||||
// AttachComposer wires both into the draft service so Create
|
||||
// seeds base_id + submission_sections rows on new drafts. v1
|
||||
// fallback path stays active for pre-Composer drafts (base_id
|
||||
// NULL, no section rows).
|
||||
submissionBaseSvc := services.NewBaseService(pool)
|
||||
submissionSectionSvc := services.NewSectionService(pool)
|
||||
submissionDraftSvc.AttachComposer(submissionBaseSvc, submissionSectionSvc, branding.Name)
|
||||
// t-paliad-313 Slice B — render-pipeline assembler. Reuses the
|
||||
// existing SubmissionRenderer for the final placeholder pass so
|
||||
// the {{rule.X}} alias contract stays preserved inside the
|
||||
// composed body.
|
||||
submissionComposerSvc := services.NewSubmissionComposer(submissionRenderer)
|
||||
// t-paliad-315 Slice C — building-block library.
|
||||
submissionBuildingBlockSvc := services.NewBuildingBlockService(pool, branding.Name)
|
||||
// t-paliad-349 docforge slice 4/6 — uploaded-template store
|
||||
// (Postgres bytea) backing the authoring surface.
|
||||
templateStoreSvc := services.NewPgTemplateStore(pool)
|
||||
// t-paliad-225 Slice A — user-authored checklist templates.
|
||||
// Slice B adds checklist_shares grants + admin promotion.
|
||||
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
|
||||
sysAuditSvc := services.NewSystemAuditLogService(pool)
|
||||
checklistTemplateSvc := services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users)
|
||||
svcBundle = &handlers.Services{
|
||||
Pool: pool,
|
||||
Project: projectSvc,
|
||||
Team: teamSvc,
|
||||
PartnerUnit: partnerUnitSvc,
|
||||
Party: partySvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
SubmissionBase: submissionBaseSvc,
|
||||
SubmissionSection: submissionSectionSvc,
|
||||
SubmissionComposer: submissionComposerSvc,
|
||||
SubmissionBuildingBlock: submissionBuildingBlockSvc,
|
||||
TemplateStore: templateStoreSvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
@@ -242,21 +218,6 @@ func main() {
|
||||
// is captured into __meta of every export and printed in the
|
||||
// embedded README.
|
||||
Export: services.NewExportService(pool, branding.Name),
|
||||
// t-paliad-265 / m/paliad#96 — per-event-card optional choices.
|
||||
EventChoice: services.NewEventChoiceService(pool, projectSvc, users),
|
||||
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions.
|
||||
Scenario: services.NewScenarioService(pool, projectSvc, rules),
|
||||
// m/paliad#149 Phase 2 P0 (mig 154) — per-project scenario_flags
|
||||
// SSoT. Drives Verfahrensablauf + Mode B result-view conditional
|
||||
// rendering and per-rule selection state (`rule:<uuid>` keys).
|
||||
ScenarioFlags: services.NewScenarioFlagsService(pool, projectSvc),
|
||||
// t-paliad-340 / m/paliad#153 B0 (mig 157) — Litigation Builder.
|
||||
// CRUD over the new normalised scenarios + scenario_proceedings
|
||||
// + scenario_events + scenario_shares tables. B4 adds the
|
||||
// Akte-mode dual-write: project-backed scenarios write through
|
||||
// to paliad.projects.scenario_flags + paliad.deadlines via the
|
||||
// injected project + scenarioFlags services.
|
||||
ScenarioBuilder: services.NewScenarioBuilderService(pool, projectSvc, services.NewScenarioFlagsService(pool, projectSvc), services.NewFristenrechnerService(rules, holidays, courts)),
|
||||
}
|
||||
|
||||
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
|
||||
@@ -373,11 +334,6 @@ func main() {
|
||||
log.Printf("CalDAV start: %v", err)
|
||||
}
|
||||
reminderSvc.Start(bgCtx)
|
||||
// Slice B.4 (mig 140, t-paliad-305): legacy paliad.deadline_rules
|
||||
// dropped. The B.2 dual-write drift-check loop is retired — the
|
||||
// procedural_events / sequencing_rules / legal_sources tables
|
||||
// are now the source of truth and there is no parallel side to
|
||||
// compare against. Pre-drop drift was verified clean in mig 140.
|
||||
go func() {
|
||||
<-bgCtx.Done()
|
||||
log.Println("background services: shutdown signal received")
|
||||
|
||||
@@ -98,51 +98,6 @@ func TestBootSmoke(t *testing.T) {
|
||||
if body := strings.TrimSpace(rec.Body.String()); body != "ok" {
|
||||
t.Errorf("GET /healthz: body=%q; want \"ok\"", body)
|
||||
}
|
||||
|
||||
// (4) Readiness probe. With a nil Services bundle the endpoint MUST
|
||||
// report 503 — that's the contract documented in handlers/handlers.go.
|
||||
// A separate svc-with-Pool case is exercised in TestHealthReady (live).
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/health/ready", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("GET /health/ready (nil svc): status=%d; want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthReady_Live asserts the readiness probe answers 200 when the
|
||||
// pool is reachable, 503 when it isn't. Requires TEST_DATABASE_URL.
|
||||
//
|
||||
// Why a separate test: TestBootSmoke runs Register with svc=nil to keep
|
||||
// its setup minimal; the pool-reachable path needs the pool wired in
|
||||
// through svc.Pool. Two tests, two assertions, no entanglement.
|
||||
func TestHealthReady_Live(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live readiness probe")
|
||||
}
|
||||
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("db.ApplyMigrations: %v", err)
|
||||
}
|
||||
pool, err := db.OpenPool(url)
|
||||
if err != nil {
|
||||
t.Fatalf("open pool: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
authClient := auth.NewClient("https://test.invalid", "anon-key", []byte("test-secret"))
|
||||
handlers.Register(mux, authClient, "", &handlers.Services{Pool: pool})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/health/ready", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("GET /health/ready (live pool): status=%d, body=%q; want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body := strings.TrimSpace(rec.Body.String()); body != "ready" {
|
||||
t.Errorf("GET /health/ready (live pool): body=%q; want \"ready\"", body)
|
||||
}
|
||||
}
|
||||
|
||||
// embeddedMigrationVersions returns every N where N_*.up.sql exists in
|
||||
|
||||
@@ -1,738 +0,0 @@
|
||||
# Assessment — Deadline + Procedural-Events System
|
||||
|
||||
**Phase 1 of RFC m/paliad#149.** Read-only audit of every consumer of
|
||||
`paliad.sequencing_rules` + `paliad.procedural_events` + the legacy
|
||||
`paliad.trigger_events`, the corpus they project, and the surfaces that
|
||||
read them.
|
||||
|
||||
- Author: athena (consultant role)
|
||||
- Date: 2026-05-27
|
||||
- Live data: youpc Supabase (`paliad` schema), counts captured during the
|
||||
audit window (mig 153 applied).
|
||||
- Scope: assessment only. No design proposals; no schema sketches; no
|
||||
recommendations on column shape. Phase 2 (inventor) decides those.
|
||||
|
||||
---
|
||||
|
||||
## 0. Headline numbers
|
||||
|
||||
| Bucket | Total | Active + published | Notes |
|
||||
|---|--:|--:|---|
|
||||
| `procedural_events` | 236 | 222 | 5 drafts, 9 archived/inactive |
|
||||
| `sequencing_rules` | 236 | 226 | 1:1 row-mirror with events (mig 136 + 140) |
|
||||
| `trigger_events` (legacy) | 110 | — | bigint-keyed catalog; lives parallel to events |
|
||||
| `proceeding_types` | 50 | 23 kind=`proceeding`; 0 active in kind=`phase`/`side_action`/`meta` (mig 153 flipped them off) |
|
||||
|
||||
Rules-corpus shape (active + published, 226 rows):
|
||||
|
||||
| Classification | Rows |
|
||||
|---|--:|
|
||||
| Parent only (chain-linked) | 105 |
|
||||
| Both parent + legacy trigger | 2 |
|
||||
| Legacy `trigger_event_id` only — `proceeding_type_id IS NULL` | **73** |
|
||||
| Neither (root) — `proceeding_type_id` set | 46 |
|
||||
|
||||
Other corpus signals:
|
||||
|
||||
- `condition_expr` populated: 18 rules. Three distinct keys: `flag` (14),
|
||||
`op` + `args` (4 each — always nested AND).
|
||||
- `is_spawn = true`: 4 rules. All four point at the **inactive**
|
||||
`upc.apl.merits` (id=11). The active appeal type is id=160
|
||||
(`upc.apl.unified`). See risk R3.
|
||||
- `is_court_set = true`: 46 rules.
|
||||
- `is_bilateral = true`: 4 rules.
|
||||
- `choices_offered` populated: 28 rules. Three shapes:
|
||||
`{appellant:[…]}` (20), `{skip:[…]}` (6), `{include_ccr:[…]}` (2).
|
||||
- `applies_to_target` populated: 16 rules.
|
||||
- 67 distinct events act as chain-anchors (= parent of ≥1 active rule).
|
||||
That is the *derived* trigger set today.
|
||||
- `paliad.project_event_choices`: schema present, **0 rows** live.
|
||||
- `paliad.scenarios` (mig 145): table created, **0 rows**.
|
||||
`paliad.projects.active_scenario_id`: **0/18 projects** populated.
|
||||
|
||||
A more granular per-proceeding-type breakdown is in §4.
|
||||
|
||||
---
|
||||
|
||||
## 1. Audit — consumers of `sequencing_rules` + `procedural_events`
|
||||
|
||||
Every read site, by surface. File paths are repo-relative.
|
||||
|
||||
### 1.1 Direct services
|
||||
|
||||
| Service | File | What it reads | Surface(s) it backs |
|
||||
|---|---|---|---|
|
||||
| `DeadlineRuleService` | `internal/services/deadline_rule_service.go:14-365` | `paliad.deadline_rules_unified` view (sequencing_rules + procedural_events + legal_sources), + `paliad.trigger_events` for parent-chain labels (`:226-285`) | Admin rules list/editor, Fristenrechner result panel |
|
||||
| `FristenrechnerService` | `internal/services/fristenrechner.go:115-172,1-700+` | `sequencing_rules` + `procedural_events` (proceeding-type catalog; `EXISTS` over rules); scenarios table (`:583-627`) | `/api/tools/fristenrechner` (Mode A + Mode B + Mode C) |
|
||||
| `FristenrechnerService.LookupFollowUps` | `internal/services/fristenrechner_followups.go:87-403` | resolves anchor by `pe.id`/`pe.code`/`sr.id` (`:241-287`); one-hop children via `parent_id` (`:345-403`) | `/api/tools/fristenrechner/follow-ups` |
|
||||
| `DeadlineSearchService` | `internal/services/fristenrechner_search_events.go:143-170,194,233,696` | sequencing_rules ⋈ procedural_events ⋈ proceeding_types + legal_sources; counts child rules via `parent_id` subquery | `/api/tools/fristenrechner/search` |
|
||||
| `EventDeadlineService` | `internal/services/event_deadline_service.go:31-79,186-195,244` | `paliad.trigger_events` + `sequencing_rules WHERE trigger_event_id IS NOT NULL` | `/api/tools/event-deadlines` (legacy bigint surface) |
|
||||
| `EventTriggerService` | `internal/services/event_trigger_service.go:24-230` | `event_types.trigger_event_id` bridge + sequencing_rules | `/api/tools/event-trigger` |
|
||||
| `RuleEditorService` | `internal/services/rule_editor_service.go:104,136,232,371,381,459,625-843` | full CRUD on sequencing_rules + procedural_events; reads `trigger_event_id` as an optional filter on list | `/admin/api/procedural-events/*` (Slice B.5) |
|
||||
| `RuleEditorOrphans` | `internal/services/rule_editor_orphans.go:218-224` | sub-select on sequencing_rules for orphaned deadlines | `/admin/api/orphans` |
|
||||
| `DualWriteService` | `internal/services/dual_write.go` (+ `dual_write_test.go:50-300`) | parity assertion between legacy + unified projection | internal — write-side guard, no HTTP |
|
||||
| `ProjectionService` (SmartTimeline) | `internal/services/projection_service.go:3+` | composes the timeline by reading via `DeadlineRuleService` + `FristenrechnerService`; does NOT touch `sequencing_rules` directly | `GET /api/projects/{id}/timeline`, milestone + counterclaim endpoints in `internal/handlers/projection.go:35-436+` |
|
||||
| `ExportService` | `internal/services/export_service.go:1680` | bulk-exports `paliad.trigger_events` as the `ref__trigger_events` workbook sheet | `/api/admin/export/*` |
|
||||
| `EventChoiceService` | `internal/services/event_choice_service.go:15-180` | reads + writes `paliad.project_event_choices` | per-project flag persistence (no rows live today) |
|
||||
| `EventTypeService` | `internal/services/event_type_service.go:40-414` | user-defined `paliad.event_types` rows with optional `trigger_event_id` bridge | `/api/event-types` + Pipeline C compose |
|
||||
| `ProjectService.validateProceedingTypeCategory` | `internal/services/project_service.go:1176-1267` | reads `paliad.proceeding_types.category` + `kind` + `is_active` | binding guard for `projects.proceeding_type_id` (sister to mig-153 trigger) |
|
||||
|
||||
The handlers behind each route are listed in §1.2.
|
||||
|
||||
### 1.2 HTTP routes
|
||||
|
||||
Every route that ultimately surfaces sequencing/event data. Path
|
||||
literals + handler file:line cited.
|
||||
|
||||
**Knowledge-tool surface (public-ish, behind auth):**
|
||||
|
||||
| Route | Handler | Reads |
|
||||
|---|---|---|
|
||||
| `POST /api/tools/fristenrechner` | `internal/handlers/fristenrechner.go:39-95+` | `FristenrechnerService.CalculateForProceeding` → engine in `pkg/litigationplanner` |
|
||||
| `GET /api/tools/fristenrechner/search` | `internal/handlers/fristenrechner_search.go` (filter params: `event_kind`, `primary_party`, `jurisdiction`) | `DeadlineSearchService.SearchEvents` |
|
||||
| `GET /api/tools/fristenrechner/follow-ups` | `internal/handlers/fristenrechner_followups.go:27-65` | `FristenrechnerService.LookupFollowUps` |
|
||||
| `GET /api/tools/proceeding-types` | `internal/handlers/event_types.go` | proceeding_types filter (event_kind, jurisdiction) |
|
||||
| `GET /api/tools/trigger-events` | `internal/handlers/event_types.go` | trigger_events catalog (active only) |
|
||||
| `POST /api/tools/event-trigger` | `internal/handlers/event_trigger.go:39-106` | unified Pipeline-A + Pipeline-C compose |
|
||||
| `POST /api/tools/event-deadlines` | `internal/handlers/deadline_rules_db.go:67+` | **legacy** bigint trigger_event_id → rule list |
|
||||
|
||||
**SmartTimeline surface (project-bound):**
|
||||
|
||||
| Route | Handler | Reads |
|
||||
|---|---|---|
|
||||
| `GET /api/projects/{id}/timeline` | `internal/handlers/projection.go:35-109` | `ProjectionService.Render` (no direct rule reads — composes via services) |
|
||||
| `POST /api/projects/{id}/timeline/milestone` | `internal/handlers/projection.go:445+` | milestone insert; reads `proceeding_type.kind` via service |
|
||||
| `POST /api/projects/{id}/timeline/counterclaim` | `internal/handlers/projection.go:387-436` | spawns CCR project; reads `parent_id` on response composition |
|
||||
|
||||
**Admin editor surface (`/admin/procedural-events/*`):**
|
||||
|
||||
| Route | Handler | Reads |
|
||||
|---|---|---|
|
||||
| `GET /admin/procedural-events` | `internal/handlers/admin_rules.go:399-402` | page shell |
|
||||
| `GET /admin/procedural-events/{id}/edit` | `:403-470` | editor form (full rule + event JSON) |
|
||||
| `GET /admin/api/procedural-events` | `:101-160` | paginated list w/ canonical `code` + `event_kind` (Slice B.5 wrapper) |
|
||||
| `GET /admin/api/procedural-events/{id}` | `:161-179` | single rule fetch |
|
||||
| `POST /admin/api/procedural-events` | `:180-204` | create draft |
|
||||
| `PATCH /admin/api/procedural-events/{id}` | `:205-233` | edit draft |
|
||||
| `POST /admin/api/procedural-events/{id}/publish` | `:257-279` | publish flow |
|
||||
| `GET /admin/api/procedural-events/{id}/audit` | `:326-361` | audit log |
|
||||
| `GET /admin/api/orphans` | `:471-484` | orphaned deadlines (Slice 10 backfill UI) |
|
||||
| `POST /admin/api/orphans/{id}/resolve` | `:485-520` | link orphan to rule |
|
||||
| `/admin/rules/*` → `/admin/procedural-events/*` | `:761-772` | **301 redirects** (legacy bookmarks; one-slice deprecation window) |
|
||||
| `?trigger_event_id=…` query param | `:119-122` | exposes legacy trigger filter on the admin list |
|
||||
|
||||
**Scenarios surface (mig 145):**
|
||||
|
||||
| Route | Handler |
|
||||
|---|---|
|
||||
| `GET /api/scenarios?project=<id>|abstract=true` | `internal/handlers/scenarios.go:51-90` |
|
||||
| `GET /api/scenarios/{id}` | `:92-113` |
|
||||
| `POST /api/scenarios` | `:115-136` |
|
||||
| `PATCH /api/scenarios/{id}` | `:138-164` |
|
||||
| `DELETE /api/scenarios/{id}` | `:166-200+` |
|
||||
| `POST /api/paliadin/suggest/deadline` | `internal/handlers/paliadin_suggest.go:63+` (deadline drafts via Paliadin; does not read rules directly — calls into `DeadlineService`) |
|
||||
|
||||
Registration: `internal/handlers/handlers.go:497-501, 880`.
|
||||
|
||||
### 1.3 Frontend (TypeScript) consumers
|
||||
|
||||
These call the routes above; **no direct DB access**. References per the
|
||||
i18n key search and `frontend/src/client/*` greps:
|
||||
|
||||
- `frontend/src/admin-rules-list.tsx:24-105+` — admin list page shell;
|
||||
hits `/admin/api/procedural-events*`.
|
||||
- `frontend/src/admin-rules-edit.tsx:29-187+` — admin editor form; reads
|
||||
`procedural_events.edit.field.{code,event_kind,parent}` i18n keys.
|
||||
- `frontend/src/verfahrensablauf.tsx` — proceeding-type ablauf page
|
||||
(mode C); hits `/api/tools/fristenrechner` with proceeding shape.
|
||||
- `frontend/src/client/fristenrechner-wizard.ts:80` — Mode A wizard;
|
||||
`r4: string // procedural_events.code`.
|
||||
- `frontend/src/client/fristenrechner-mode-a.ts` — Mode A search; hits
|
||||
`/api/tools/fristenrechner/search?kind=events`.
|
||||
- `frontend/src/client/fristenrechner-result.ts` — result panel; hits
|
||||
`/api/tools/fristenrechner/follow-ups`.
|
||||
- `frontend/src/client/projects-new.ts` — type-aware project wizard;
|
||||
hits `/api/tools/fristenrechner?proceeding_type_code=…`.
|
||||
- `frontend/src/client/deadlines-detail.ts` — deadline CRUD detail.
|
||||
- i18n keys: `admin.procedural_events.list/edit/col.*` and translations
|
||||
in `frontend/src/client/i18n.ts:3193-3204, 6338-6346+`.
|
||||
|
||||
### 1.4 Offline snapshot
|
||||
|
||||
- `cmd/gen-upc-snapshot/main.go:150-268` — reads `paliad.trigger_events`,
|
||||
the legacy `paliad.deadline_rules` projection (now via the unified
|
||||
view), and `paliad.proceeding_types`. Writes JSON to
|
||||
`pkg/litigationplanner/embedded/upc/{trigger_events.json,
|
||||
rules.json, proceeding_types.json, meta.json}`.
|
||||
- `pkg/litigationplanner/catalog.go` + `engine.go` + `types.go:73-156` —
|
||||
Rule struct carries `TriggerEventID`, `SpawnProceedingTypeID`,
|
||||
`ConditionExpr`, `Priority`, `IsCourtSet`, `PrimaryParty`, `IsSpawn`,
|
||||
`SpawnLabel`, `CombineOp`. youpc.org consumes this snapshot.
|
||||
|
||||
### 1.5 Migrations touching the tables (chronological)
|
||||
|
||||
`internal/db/migrations/`:
|
||||
|
||||
`028_youpc_deadlines_import`, `030_event_types`, `033_trigger_events_de`,
|
||||
`035_event_deadlines_title_de_backfill`, `038_concept_links_and_legal_source`,
|
||||
`046_cross_cutting_triggers`, `047_deadline_search_view`,
|
||||
`051_proceeding_display_order`, `063_frist_verpasst_upc`,
|
||||
`078_unified_rule_columns`, `091_drop_legacy_rule_columns`,
|
||||
`098_submission_codes_prefix_and_rename`, `125_cross_cutting_filter_legal_source`,
|
||||
`132_wave1_tier1_rule_additions`, **`136_procedural_events_additive`**
|
||||
(the schema-authoritative additive split), `139_deadline_rules_unified_view`,
|
||||
**`140_drop_deadline_rules`** (legacy projection dropped),
|
||||
`151_dedupe_null_procedural_events`, `152_dedupe_identical_sequencing_rule_clones`,
|
||||
**`153_proceeding_types_kind`** (kind discriminator + projects FK trigger).
|
||||
|
||||
Mig 145 is scenario-side: creates `paliad.scenarios` (table, **not**
|
||||
a `scenarios` jsonb column on `projects` — the RFC text was imprecise)
|
||||
and `paliad.projects.active_scenario_id` FK.
|
||||
|
||||
---
|
||||
|
||||
## 2. Health-check per consumer
|
||||
|
||||
### 2.1 Works — green
|
||||
|
||||
- **`DualWriteService` parity.** Every CRUD on the editor surface
|
||||
keeps sequencing_rules + procedural_events + legal_sources locked,
|
||||
asserted by `dual_write_test.go:50-202`.
|
||||
- **Admin editor (`/admin/procedural-events/*`).** Full create / edit /
|
||||
publish / audit loop. Drafts state respected.
|
||||
- **Mode A picker via search.** `DeadlineSearchService` filters by
|
||||
`event_kind` / `primary_party` / `jurisdiction`; returns child-rule
|
||||
counts (`fristenrechner_search_events.go:159`).
|
||||
- **Mode B Verfahrensablauf calc.** `pkg/litigationplanner.CalculateRule`
|
||||
+ the `proceeding_type` fan-out works for every type that has any
|
||||
rule (17/23).
|
||||
- **`gen-upc-snapshot`.** UPC snapshot for youpc.org keeps shipping;
|
||||
no DB writes; reads only.
|
||||
- **Counterclaim spawn project creation.**
|
||||
`internal/handlers/projection.go:387-436` + mig 153 trigger guard
|
||||
reject any non-`proceeding` `proceeding_type_id`.
|
||||
- **EventChoiceService** SQL is wired and tested — but see §2.3.
|
||||
|
||||
### 2.2 Works with known caveats — yellow
|
||||
|
||||
- **Spawn rules.** Behaviour is correct in the abstract (rule fires,
|
||||
user can spawn a child case), but every spawn target points at the
|
||||
**inactive** `upc.apl.merits` (id=11). Surfaces that resolve the
|
||||
spawn target via `paliad.proceeding_types` will return an inactive
|
||||
row. See R3. Cited at `sequencing_rules` 4 rows; service code in
|
||||
`fristenrechner_followups.go:388` SELECTs `spt.code` via
|
||||
`LEFT JOIN paliad.proceeding_types spt ON spt.id = sr.spawn_proceeding_type_id`
|
||||
— no `is_active` filter on the join. Frontend renders an "open
|
||||
Berufungsverfahren" CTA that points at a UI flow expecting the
|
||||
active id=160 (`upc.apl.unified`).
|
||||
- **Legacy 73 globals.** 73 rules with `proceeding_type_id IS NULL`
|
||||
and `trigger_event_id NOT NULL`. These all anchor on legacy
|
||||
`null.<8hex>` event codes that don't match any `proceeding_types.code`
|
||||
prefix. They are consumed via `/api/tools/event-deadlines` (the
|
||||
bigint route) AND surface on the unified view. They have no place
|
||||
in the Mode B "proceeding-type ablauf" view because they have no
|
||||
proceeding. See R4.
|
||||
- **Legacy `/api/tools/event-deadlines` route.** Live, used by
|
||||
Pipeline-C `event_types` consumers (`EventTypeService`). The
|
||||
`ExportService:1680` also still emits `ref__trigger_events` to the
|
||||
workbook. Deprecation has been deferred — see R5.
|
||||
|
||||
### 2.3 Broken / leaky — red
|
||||
|
||||
- **B1 — Follow-up cross-party filter is over-broad.**
|
||||
`fristenrechner_followups.go:358-367`:
|
||||
|
||||
```go
|
||||
if party == "claimant" || party == "defendant" {
|
||||
args = append(args, party)
|
||||
where = append(where, fmt.Sprintf(
|
||||
"(sr.primary_party = $%d OR sr.primary_party = 'both' OR sr.primary_party IS NULL)",
|
||||
len(args)))
|
||||
}
|
||||
```
|
||||
|
||||
The filter keeps `both` + `NULL` rules but **drops cross-party
|
||||
follow-ups**. From the corpus there are 39 active rules whose
|
||||
`primary_party` differs from their parent's primary_party (excluding
|
||||
`court`). Example: `upc.inf.cfi.def_to_ccr` is claimant-filed; its
|
||||
child rule `RoP.029.d → reply_def_ccr` is defendant-filed. With
|
||||
`party=claimant` selected on the result view, the defendant child
|
||||
is hidden and the user reads "Keine Folge-Fristen" — a lie. This
|
||||
is the exact bug the RFC §"What's actually broken" item 2 calls
|
||||
out.
|
||||
|
||||
- **B2 — Picker doesn't distinguish triggers from leaves.**
|
||||
`LookupFollowUps` (`fristenrechner_followups.go:241-287`) resolves
|
||||
by `pe.id` / `pe.code` / `sr.id` with no
|
||||
"is-this-event-actually-a-trigger" gate. The data already supports
|
||||
derivation — 67 of 222 active events act as a chain anchor. The
|
||||
picker just isn't wired to the derivation. Compounding: 4 events
|
||||
are *spawn-only* consequences (`upc.{inf,rev,pi,dmgs}.cfi.appeal_spawn`)
|
||||
— picking one returns the spawn rule itself with no follow-ups,
|
||||
which surfaces as "Keine Folge-Fristen".
|
||||
|
||||
- **B3 — Scenario state is forked across three stores by design but
|
||||
zero stores by data.**
|
||||
- `paliad.project_event_choices` (mig 129) — schema present, 0 rows.
|
||||
`EventChoiceService` reads + writes it via
|
||||
`internal/services/event_choice_service.go:74,123,180`.
|
||||
- `paliad.scenarios` (mig 145) — 0 rows, 0/18 projects bound via
|
||||
`active_scenario_id`. `ScenarioService.LoadScenarios` in
|
||||
`internal/services/fristenrechner.go:583-627` reads it.
|
||||
- DOM state on the result view — Verfahrensablauf checkbox state
|
||||
only lives client-side. Confirmed by absence of a write path
|
||||
from `verfahrensablauf.tsx` to either DB-side store.
|
||||
|
||||
The RFC's "three independent stores" claim is *architecturally*
|
||||
true today, but every store is empty. Risk is dormant — until
|
||||
someone enables persistence on either path and the divergence
|
||||
materialises. See R6.
|
||||
|
||||
- **B4 — 6 active `proceeding_types` have zero rules.**
|
||||
`upc.bsv.cfi`, `upc.ccr.cfi`, `upc.costs.cfi`, `upc.dni.cfi`,
|
||||
`upc.epo.review`, `upc.pl.cfi`. They appear in
|
||||
`/api/tools/proceeding-types` (`is_active=true` + `kind='proceeding'`)
|
||||
but produce empty timelines when chosen. The Mode A picker can
|
||||
bind a project to them; the Mode B result view is blank.
|
||||
|
||||
### 2.4 Dead-or-decaying code
|
||||
|
||||
- **`paliad.trigger_events` table.** 110 rows; columns
|
||||
`(id, code, name, name_de, description, is_active, created_at, concept_id)`.
|
||||
Bigint PK. No `parent_id`, no `proceeding_type_id`. Consumed by:
|
||||
`deadline_rule_service.go:226-285` (label fallback), `event_deadline_service.go`
|
||||
(legacy route), `event_type_service.go` (Pipeline C bridge),
|
||||
`export_service.go:1680` (workbook sheet), and 80 active
|
||||
sequencing_rules' `trigger_event_id` (which is in turn primarily a
|
||||
bridge for the 73 globals + 7 hybrid rules with a real proceeding).
|
||||
- **Inactive proceeding_types still referenced by spawn rules.**
|
||||
id 11 (`upc.apl.merits`), 19 (`upc.apl.cost`), 20 (`upc.apl.order`).
|
||||
Mig 138 (`appeal_target_backfill_merits_order`) split them, mig
|
||||
later unified them onto id 160. The 4 spawn rules' FK was not
|
||||
updated.
|
||||
- **3 non-`proceeding` kinds.** 23 rows total
|
||||
(`phase` × 4 + `side_action` × 10 + `meta` × 9), all
|
||||
`is_active=false` after mig 153. Live in the table for audit;
|
||||
unused by any active surface. The Slice 10 orphan-resolution path
|
||||
(`rule_editor_orphans.go`) could theoretically encounter them, but
|
||||
active = false filters them out.
|
||||
|
||||
---
|
||||
|
||||
## 3. Rules-corpus quality audit (live data)
|
||||
|
||||
### 3.1 `parent_id` coverage
|
||||
|
||||
- 107/226 active+published rules have `parent_id` set (**47%**, matches
|
||||
RFC).
|
||||
- 119/226 do not. Decomposition (active+published):
|
||||
|
||||
| Subset | Rows | Meaning |
|
||||
|---|--:|---|
|
||||
| `parent_id NULL` AND `trigger_event_id IS NULL` AND `proceeding_type_id` set | 46 | Genuine proceeding-level roots (each PT has 1–6 such). |
|
||||
| `parent_id NULL` AND `trigger_event_id` set AND `proceeding_type_id NULL` | 73 | The legacy globals — no place in the new chain model yet. |
|
||||
|
||||
Of the 46 proceeding-level roots:
|
||||
|
||||
| `proceeding_type.code` | roots | active rules |
|
||||
|---|--:|--:|
|
||||
| `de.inf.lg` | 5 | 9 |
|
||||
| `de.null.bpatg` | 4 | 10 |
|
||||
| `epa.grant.exa` | 4 | 7 |
|
||||
| `upc.apl.unified` | 6 | 16 |
|
||||
| `epa.opp.boa` | 3 | 8 |
|
||||
| `upc.pi.cfi` | 3 | 7 |
|
||||
| `epa.opp.opd` | 2 | 8 |
|
||||
| `de.inf.bgh`, `de.inf.olg`, `de.null.bgh`, `dpma.appeal.bgh`, `dpma.appeal.bpatg`, `dpma.opp.dpma`, `upc.disc.cfi` | 1 each | various |
|
||||
| `upc.dmgs.cfi`, `upc.inf.cfi`, `upc.rev.cfi` | 4 each | 8/25/17 |
|
||||
|
||||
Most "root" rules are legitimate (the chain start event has no logical
|
||||
predecessor — `Klageerhebung`, `Zustellung`, `Veröffentlichung`,
|
||||
`Anmeldung`, etc.). A small number are leaves whose parent chain just
|
||||
hasn't been seeded (e.g. `de.inf.lg.berufung` / `de.inf.lg.beruf_begr`
|
||||
list "Berufungsfrist" and "Berufungsbegründung" as parent-NULL despite
|
||||
both having a logical predecessor in `de.inf.lg.urteil`).
|
||||
|
||||
### 3.2 `condition_expr` usage
|
||||
|
||||
18 rules use the column. Three keys total:
|
||||
|
||||
| Key | Uses | Sample shape |
|
||||
|---|--:|---|
|
||||
| `flag` | 14 | `{"flag":"with_ccr"}`, `{"flag":"with_amend"}`, `{"flag":"with_cci"}` |
|
||||
| `op` | 4 | `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` |
|
||||
| `args` | 4 | always nested under an `op:and` |
|
||||
|
||||
Distinct expressions (4 total, all UPC inf/rev):
|
||||
`{"flag":"with_ccr"}` (×6), `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` (×4), `{"flag":"with_cci"}` (×4), `{"flag":"with_amend"}` (×4).
|
||||
|
||||
No formal validation at write time — `RuleEditorService` accepts the
|
||||
column as freeform jsonb. The 3 flags are de-facto convention.
|
||||
|
||||
### 3.3 Spawn distribution
|
||||
|
||||
4 rules, all in the UPC CFI cluster, all `priority='optional'` +
|
||||
`primary_party='both'` + spawn target id=11 (`upc.apl.merits`, inactive):
|
||||
|
||||
| Anchor event | Spawn label | Target |
|
||||
|---|---|---|
|
||||
| `upc.inf.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) |
|
||||
| `upc.rev.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) |
|
||||
| `upc.pi.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) |
|
||||
| `upc.dmgs.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) |
|
||||
|
||||
### 3.4 `primary_party` distribution
|
||||
|
||||
Excluding the 73 globals (all NULL), the published+active rules split:
|
||||
|
||||
| `proceeding_type` cluster | `claimant` | `defendant` | `both` | `court` |
|
||||
|---|--:|--:|--:|--:|
|
||||
| `upc.inf.cfi` (25) | 6 | 7 | 8 | 4 |
|
||||
| `upc.rev.cfi` (17) | 6 | 7 | 1 | 3 |
|
||||
| `upc.apl.unified` (16) | 0 | 0 | 12 | 4 |
|
||||
| `de.null.bpatg` (10) | 2 | 2 | 3 | 3 |
|
||||
| `de.inf.lg` (9) | 2 | 3 | 2 | 2 |
|
||||
| `epa.opp.opd` (8) | 0 | 1 | 6 | 1 |
|
||||
| `epa.opp.boa` (8) | 0 | 0 | 6 | 2 |
|
||||
| `de.inf.bgh` (8) | 0 | 0 | 6 | 2 |
|
||||
| `upc.dmgs.cfi` (8) | 2 | 2 | 1 | 3 |
|
||||
|
||||
39 rules have a `primary_party` value that differs from their parent
|
||||
rule's `primary_party` (excluding `court` ↔ anything, which is
|
||||
trivial). All 39 are legitimate "ball-in-other-court" hand-offs
|
||||
(claimant SoC → defendant SoD → claimant Reply → defendant Rejoinder
|
||||
…). The /follow-ups filter (§2.3 B1) hides all of them when the user
|
||||
picks a perspective.
|
||||
|
||||
### 3.5 `is_court_set` coverage
|
||||
|
||||
46 rules carry `is_court_set=true`. Distribution: every proceeding has
|
||||
at least one (the decision / order / oral-hearing rows). Highest:
|
||||
`de.inf.lg` (5), `epa.grant.exa` (4), `upc.apl.unified` (4),
|
||||
`upc.inf.cfi` (3), `upc.rev.cfi` (3), `upc.pi.cfi` (3), `upc.dmgs.cfi`
|
||||
(3). Calculator skips these in date math — they surface as
|
||||
"wird vom Gericht bestimmt" markers.
|
||||
|
||||
### 3.6 Legacy `trigger_event_id` overlap with `parent_id`
|
||||
|
||||
| Combination | Rows |
|
||||
|---|--:|
|
||||
| `parent_id` set AND `trigger_event_id` set | **2** |
|
||||
| `parent_id` set AND `trigger_event_id` NULL | 105 |
|
||||
| `parent_id` NULL AND `trigger_event_id` set | 73 |
|
||||
| `parent_id` NULL AND `trigger_event_id` NULL | 46 |
|
||||
|
||||
**Overlap is 2 rules out of 226 (0.9%).** The two models are
|
||||
effectively **disjoint** in the corpus: the 73 legacy globals own the
|
||||
`trigger_event_id` lane; the 105 chain-linked rules own `parent_id`.
|
||||
The schema permits both columns to be set simultaneously, and 2 rules
|
||||
exercise that — but they are outliers, not a documented pattern.
|
||||
|
||||
The legacy `paliad.trigger_events` table is still read for label
|
||||
display by `deadline_rule_service.go:226-285` (the "abhängig von …"
|
||||
chip rule fallback when `parent_id` isn't set) and for the legacy
|
||||
`/api/tools/event-deadlines` route.
|
||||
|
||||
---
|
||||
|
||||
## 4. Editorial gap map
|
||||
|
||||
Per `proceeding_type` (active, kind=`proceeding`). Columns:
|
||||
|
||||
- **A** = active+published rules
|
||||
- **P** = rules with `parent_id` set
|
||||
- **R** = rules without `parent_id` (roots + leaves with missing parent)
|
||||
- **E** = active+published events whose code matches this PT's
|
||||
prefix
|
||||
|
||||
| PT code | A | P | R | E | Health |
|
||||
|---|--:|--:|--:|--:|---|
|
||||
| `upc.inf.cfi` | 25 | 21 | 4 | 25 | 84% chained — strongest |
|
||||
| `upc.rev.cfi` | 17 | 13 | 4 | 17 | 76% |
|
||||
| `upc.apl.unified` | 16 | 10 | 6 | 16 † | 63% — code-prefix issue, see below |
|
||||
| `de.null.bpatg` | 10 | 6 | 4 | 10 | 60% |
|
||||
| `de.inf.lg` | 9 | 4 | 5 | 9 | 44% — gappy |
|
||||
| `epa.opp.opd` | 8 | 6 | 2 | 8 | 75% |
|
||||
| `epa.opp.boa` | 8 | 5 | 3 | 8 | 63% |
|
||||
| `de.inf.bgh` | 8 | 7 | 1 | 8 | 88% |
|
||||
| `upc.dmgs.cfi` | 8 | 4 | 4 | 8 | 50% |
|
||||
| `upc.pi.cfi` | 7 | 4 | 3 | 7 | 57% |
|
||||
| `de.inf.olg` | 7 | 6 | 1 | 7 | 86% |
|
||||
| `epa.grant.exa` | 7 | 3 | 4 | 7 | 43% |
|
||||
| `de.null.bgh` | 6 | 5 | 1 | 6 | 83% |
|
||||
| `dpma.appeal.bpatg` | 5 | 4 | 1 | 5 | 80% |
|
||||
| `dpma.appeal.bgh` | 4 | 3 | 1 | 4 | 75% |
|
||||
| `dpma.opp.dpma` | 4 | 3 | 1 | 4 | 75% |
|
||||
| `upc.disc.cfi` | 4 | 3 | 1 | 4 | 75% |
|
||||
| `upc.bsv.cfi` | 0 | 0 | 0 | 0 | **unruled** |
|
||||
| `upc.ccr.cfi` | 0 | 0 | 0 | 0 | **unruled** |
|
||||
| `upc.costs.cfi` | 0 | 0 | 0 | 0 | **unruled** |
|
||||
| `upc.dni.cfi` | 0 | 0 | 0 | 0 | **unruled** |
|
||||
| `upc.epo.review` | 0 | 0 | 0 | 0 | **unruled** |
|
||||
| `upc.pl.cfi` | 0 | 0 | 0 | 0 | **unruled** |
|
||||
|
||||
† `upc.apl.unified` (id=160) is the active type, but its 16 events
|
||||
retain the *legacy* code prefixes `upc.apl.{merits,cost,order}.*`
|
||||
from the pre-unification taxonomy. The rules' `proceeding_type_id`
|
||||
was rebound to 160; the event codes were not renamed. Functional but
|
||||
inconsistent — see R3.
|
||||
|
||||
**Events with no rule:** 0. Every active+published event has at least
|
||||
one rule (corpus is 1:1 since mig 136). Editorial gap is therefore
|
||||
parent-chain-shaped, not rule-coverage-shaped.
|
||||
|
||||
**Unmatched-prefix events:** 69 events with `code LIKE 'null.%'`. They
|
||||
have rules (the 73 legacy globals — note the disparity: 73 rules but
|
||||
69 events, because dedupe in mig 151 collapsed some duplicates while
|
||||
the rules still point at the canonical event). They do not belong to
|
||||
any proceeding_type and never will under the current taxonomy.
|
||||
|
||||
---
|
||||
|
||||
## 5. Risk register
|
||||
|
||||
Eleven items. Each: what, where, severity. Severity scale:
|
||||
**critical** (user-visible incorrect output / data loss possible) →
|
||||
**high** (user-visible UX lie, no data corruption) → **medium**
|
||||
(developer-trap; breaks at next refactor) → **low** (cosmetic / dead
|
||||
code, deferred maintenance).
|
||||
|
||||
### R1 — Cross-party follow-up filter drops legitimate hand-offs — **high**
|
||||
|
||||
- Where: `internal/services/fristenrechner_followups.go:358-367`.
|
||||
- Effect: with `party=claimant|defendant`, 39 active rules are hidden
|
||||
because their `primary_party` is the *other* side. Result-view
|
||||
reports "Keine Folge-Fristen" on chains that continue cross-party
|
||||
(e.g. `def_to_ccr` claimant-filed → `reply_def_ccr` defendant-filed
|
||||
in `upc.inf.cfi`).
|
||||
- Impact: UX lies to users about chain completion; can lead to missed
|
||||
deadlines on the opposing side's view.
|
||||
|
||||
### R2 — Picker accepts spawn-only and leaf events — **high**
|
||||
|
||||
- Where: `internal/services/fristenrechner_followups.go:241-287` (anchor
|
||||
resolution does not check chain-anchor status); `internal/services/fristenrechner_search_events.go`
|
||||
(search returns every event).
|
||||
- Effect: Picking `upc.{inf,rev,pi,dmgs}.cfi.appeal_spawn` (spawn-only)
|
||||
shows the spawn rule itself but no follow-ups → "Keine Folge-Fristen".
|
||||
Picking a leaf event (e.g. `upc.inf.cfi.def_to_ccr`) only reaches
|
||||
whatever hop-1 children exist on the leaf's own party, see R1.
|
||||
- 67/222 active events are chain-anchors. Today's picker shows all
|
||||
222 with equal weight.
|
||||
|
||||
### R3 — 4 spawn rules point at an inactive `proceeding_type` — **high**
|
||||
|
||||
- Where: 4 rows in `paliad.sequencing_rules` with `is_spawn=true`
|
||||
and `spawn_proceeding_type_id=11` (`upc.apl.merits`, `is_active=false`).
|
||||
The active appeal type is id=160 (`upc.apl.unified`).
|
||||
- Effect: any consumer that joins on `spt.is_active=true` (none today,
|
||||
but the moment any does) returns NULL for the spawn target. Today
|
||||
the join is permissive (`fristenrechner_followups.go:394`) — it
|
||||
returns `upc.apl.merits` to the frontend, which may surface as a
|
||||
CTA pointing at a stale type slug.
|
||||
- Plus consequence: `upc.apl.unified` events kept legacy code prefixes
|
||||
`upc.apl.{merits,cost,order}.*` even though the type rebinds to 160.
|
||||
Code/PT mismatch is harmless today; trap for any future code-prefix
|
||||
routing.
|
||||
|
||||
### R4 — 73 "global" legacy rules orphan from the chain model — **medium**
|
||||
|
||||
- Where: `paliad.sequencing_rules WHERE proceeding_type_id IS NULL AND trigger_event_id IS NOT NULL` (73 rows). Anchored on `null.<8hex>`
|
||||
procedural_events (69 distinct events, 73 rules — small overlap from
|
||||
pre-dedupe history).
|
||||
- Effect: invisible to Mode B (proceeding-type ablauf) because they
|
||||
don't bind to any PT; visible to the legacy bigint route
|
||||
`/api/tools/event-deadlines` and to /admin/procedural-events.
|
||||
- Migration debt: any "deprecate `trigger_event_id`" plan must decide
|
||||
whether to (a) reparent these onto a PT + parent chain, (b) keep them
|
||||
as floating cross-cutting rules in a separate lane, or (c) drop them.
|
||||
|
||||
### R5 — Legacy `paliad.trigger_events` table is read by 5 surfaces — **medium**
|
||||
|
||||
- Where:
|
||||
- `internal/services/deadline_rule_service.go:226-285` — bulk-load for
|
||||
"abhängig von …" chip label fallback.
|
||||
- `internal/services/event_deadline_service.go:79,244` — legacy
|
||||
`/api/tools/event-deadlines` route.
|
||||
- `internal/services/event_type_service.go:40-414` — Pipeline-C event
|
||||
types bridge (`event_types.trigger_event_id`).
|
||||
- `internal/services/export_service.go:1680` — `ref__trigger_events`
|
||||
workbook sheet.
|
||||
- `cmd/gen-upc-snapshot/main.go:185-202` — UPC offline snapshot for
|
||||
youpc.org.
|
||||
- Effect: 110-row catalog with bigint PK lives alongside the 222 active
|
||||
procedural_events (UUID PK). Two ID spaces, two label sources,
|
||||
partial overlap.
|
||||
|
||||
### R6 — Three scenario stores: 0 rows each, but 3 live read/write paths — **medium**
|
||||
|
||||
- Stores: `paliad.project_event_choices` (0 rows), `paliad.scenarios`
|
||||
(0 rows), DOM state on Verfahrensablauf checkboxes.
|
||||
- Paths:
|
||||
- `EventChoiceService` (`internal/services/event_choice_service.go:15-180`)
|
||||
reads + writes the table.
|
||||
- `ScenarioService.LoadScenarios` + handlers
|
||||
(`internal/services/fristenrechner.go:583-627`, `internal/handlers/scenarios.go:14-200+`)
|
||||
read + write the table.
|
||||
- Verfahrensablauf result view writes nothing back — DOM only.
|
||||
- Effect today: nothing — empty tables. Effect tomorrow: the moment any
|
||||
surface starts persisting, the three paths can diverge. The RFC
|
||||
(§"What's actually broken" item 3) calls out the symptom: toggling
|
||||
"Mit Widerklage" on Verfahrensablauf doesn't drive conditional
|
||||
checkboxes in result-view submission cards.
|
||||
|
||||
### R7 — 6 active `proceeding_types` are entirely unruled — **medium**
|
||||
|
||||
- Where: `upc.bsv.cfi`, `upc.ccr.cfi`, `upc.costs.cfi`, `upc.dni.cfi`,
|
||||
`upc.epo.review`, `upc.pl.cfi`. All `is_active=true`, `kind='proceeding'`,
|
||||
0 active+published rules, 0 events with their code prefix.
|
||||
- Effect: pickable on `/api/tools/proceeding-types`, bindable on
|
||||
`paliad.projects.proceeding_type_id` (mig 153 only rejects non-
|
||||
proceeding kind, not zero-rule). Binding succeeds → SmartTimeline +
|
||||
Mode B render an empty result. UX lies.
|
||||
|
||||
### R8 — `condition_expr` is freeform jsonb — **medium**
|
||||
|
||||
- Where: column declaration in mig 136; consumer in
|
||||
`deadline_rule_service.go` (selected + passed to engine in
|
||||
`pkg/litigationplanner/engine.go`); writer in
|
||||
`internal/services/rule_editor_service.go:625-843` (no validation).
|
||||
- Effect: 4 distinct shapes used today, 3 keys (`flag`, `op`, `args`).
|
||||
No write-time validation. New keys can be silently added; the
|
||||
engine consumes by switching on string literals. Refactor trap.
|
||||
|
||||
### R9 — Inactive `proceeding_types` rows linger (23) — **low**
|
||||
|
||||
- Where: mig 153 flipped 4 phase + 10 side_action + 9 meta rows to
|
||||
`is_active=false`. They still exist for audit.
|
||||
- Effect: snapshots and snapshots-of-snapshots
|
||||
(`proceeding_types_pre_153`, `procedural_events_pre_151`,
|
||||
`sequencing_rules_pre_151/_pre_152`) accumulate without a decay
|
||||
policy. Storage cost is trivial; query-shape cost is real if any
|
||||
query forgets `WHERE kind='proceeding' AND is_active=true`.
|
||||
|
||||
### R10 — `event_kind` is nullable + not enumerated in DB — **low**
|
||||
|
||||
- Where: `paliad.procedural_events.event_kind text NULL`. Code at
|
||||
`frontend/src/admin-rules-edit.tsx:187` lists `filing / hearing /
|
||||
decision / order` in the UI but the DB accepts anything.
|
||||
- Effect: drift between UI vocab and persisted values is possible.
|
||||
Currently 5 buckets: `filing`, `hearing`, `decision`, `order`, NULL
|
||||
(per RFC).
|
||||
|
||||
### R11 — `applies_to_target` + `choices_offered` lack a schema — **low**
|
||||
|
||||
- Where: `paliad.sequencing_rules.applies_to_target text[]`,
|
||||
`choices_offered jsonb`.
|
||||
- Effect: 16 rules use `applies_to_target`, 28 use `choices_offered`.
|
||||
Three observed `choices_offered` shapes: `{appellant:[…]}` (20),
|
||||
`{skip:[…]}` (6), `{include_ccr:[…]}` (2). Wire-level convention,
|
||||
no documentation. New shapes silently land if a future editor
|
||||
decides on one.
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommendation — order of operations for the inventor
|
||||
|
||||
Phase 2 design starts with the highest-stakes, hardest-to-rewind
|
||||
decisions and finishes with editorial/cleanup. Each step is a
|
||||
question for m, not a design choice for the inventor.
|
||||
|
||||
### Tier 1 — model decisions (grill first)
|
||||
|
||||
1. **Trigger semantics.** Keep `parent_id` as the canonical link?
|
||||
What is the role of `trigger_event_id` after this RFC ships? If
|
||||
deprecated, what happens to the 73 legacy globals (R4) — reparent
|
||||
onto PTs, keep as a separate "cross-cutting" lane, or drop?
|
||||
2. **Trigger discoverability.** Derive from data (events that
|
||||
parent ≥1 rule = 67 today), maintain a materialised view, or carry
|
||||
an explicit `is_trigger` flag on `procedural_events`? Affects R2.
|
||||
3. **Scenario state — single home.** Of the three stores in R6, which
|
||||
wins? Migration shape for the others? The RFC mis-spoke about
|
||||
`projects.scenarios jsonb` — the table is `paliad.scenarios` with
|
||||
a `spec` jsonb column (mig 145). Confirm which storage the inventor
|
||||
reasons from.
|
||||
4. **Cross-party display semantics.** Backend stops filtering,
|
||||
frontend groups by side? Or backend tags + frontend renders an
|
||||
"andere Partei" group? Affects R1.
|
||||
|
||||
### Tier 2 — surface decisions
|
||||
|
||||
5. **Spawn → consequence-only events.** Stop surfacing spawn-only
|
||||
events in the picker (R2), or keep them and tag visually?
|
||||
6. **Re-target the 4 spawn rules** (R3) — point at id=160 vs reseed
|
||||
legacy ids; align event code prefixes vs. accept the mismatch.
|
||||
7. **Sequence-from-proceeding-type view** (Entry A). Where does it
|
||||
live? How do its toggles persist to the chosen scenario store?
|
||||
8. **Legacy `/api/tools/event-deadlines` deprecation** (R5). Drop,
|
||||
redirect, or keep behind a flag during transition?
|
||||
|
||||
### Tier 3 — editorial + cleanup
|
||||
|
||||
9. **Editorial backfill plan.** Which of the 119 parent-NULL rules
|
||||
are real roots vs. unseeded leaves (a per-PT walkthrough by m).
|
||||
10. **Empty proceeding_types** (R7). Stub with placeholder rules, or
|
||||
hide from the picker until rules land?
|
||||
11. **`condition_expr` formalisation** (R8). Pick a grammar, document
|
||||
it, add write-time validation. Same question for `choices_offered`
|
||||
+ `applies_to_target` (R11).
|
||||
12. **Legacy `trigger_events` table fate.** Drop, archive, or
|
||||
repurpose? Depends on Q1 + Q2 above.
|
||||
|
||||
The inventor should grill m on Tier 1 before sketching anything.
|
||||
Tier 2 follows from Tier 1's decisions. Tier 3 is mechanical once
|
||||
Tier 1+2 land.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — query receipts
|
||||
|
||||
All counts in this assessment came from the live `paliad` schema on
|
||||
the youpc Supabase instance during the audit window (2026-05-27).
|
||||
Representative queries:
|
||||
|
||||
```sql
|
||||
-- §0 + §3.1 + §3.6
|
||||
SELECT
|
||||
CASE
|
||||
WHEN parent_id IS NOT NULL AND trigger_event_id IS NOT NULL THEN 'both'
|
||||
WHEN parent_id IS NOT NULL AND trigger_event_id IS NULL THEN 'parent only'
|
||||
WHEN parent_id IS NULL AND trigger_event_id IS NOT NULL THEN 'legacy only'
|
||||
ELSE 'neither (root)'
|
||||
END AS classification,
|
||||
proceeding_type_id IS NULL AS pt_null, count(*) AS rules
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE is_active AND lifecycle_state = 'published'
|
||||
GROUP BY classification, pt_null
|
||||
ORDER BY classification, pt_null;
|
||||
-- → both/false=2, legacy only/true=73, neither/false=46, parent only/false=105
|
||||
|
||||
-- §3.4
|
||||
SELECT pt.code, sr.primary_party, count(*)
|
||||
FROM paliad.sequencing_rules sr
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
|
||||
WHERE sr.is_active AND sr.lifecycle_state='published'
|
||||
GROUP BY pt.code, sr.primary_party ORDER BY pt.code, count(*) DESC;
|
||||
|
||||
-- §4 (gap map)
|
||||
SELECT pt.code, count(sr.id) AS active_rules,
|
||||
count(*) FILTER (WHERE sr.parent_id IS NULL) AS roots
|
||||
FROM paliad.proceeding_types pt
|
||||
LEFT JOIN paliad.sequencing_rules sr ON sr.proceeding_type_id = pt.id
|
||||
AND sr.is_active AND sr.lifecycle_state='published'
|
||||
WHERE pt.is_active AND pt.kind='proceeding'
|
||||
GROUP BY pt.code ORDER BY pt.code;
|
||||
|
||||
-- §3.2 (condition_expr keys)
|
||||
WITH expanded AS (
|
||||
SELECT jsonb_object_keys(condition_expr) AS k
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE condition_expr IS NOT NULL AND condition_expr::text <> '{}'
|
||||
) SELECT k, count(*) FROM expanded GROUP BY k ORDER BY count(*) DESC;
|
||||
-- → flag=14, args=4, op=4
|
||||
```
|
||||
|
||||
Full set of queries used during the audit is available in the agent
|
||||
transcript; reproducible against any read-only Supabase role.
|
||||
|
||||
— end of assessment.
|
||||
@@ -1,181 +0,0 @@
|
||||
# CI/CD runner setup — paliad
|
||||
|
||||
**Companion to:** `docs/design-cicd-pre-deploy-gate-2026-05-25.md` (Slice A, t-paliad-282 / m/paliad#114)
|
||||
**Date:** 2026-05-25
|
||||
**Audience:** mlake / mriver admin (m or head)
|
||||
|
||||
Slice A's `.gitea/workflows/test.yaml` requires (a) at least one online Gitea Actions runner and (b) a Dokploy API token wired as a repo secret. Both are one-time setup actions that paliad's source tree cannot perform itself — they live on infra-side. This doc lists them so the workflow can go green on its first run.
|
||||
|
||||
---
|
||||
|
||||
## 0. Pre-flight: what already exists
|
||||
|
||||
Verified live (2026-05-25 cronus inventor shift):
|
||||
|
||||
- Gitea 1.24.4 on `mgit.msbls.de`, `has_actions: true` on `m/paliad`.
|
||||
- `/api/v1/admin/actions/runners` reports **2 runners** registered. They are likely the shared runners used by `m/mGreen` and `m/mGeo` (both have `.gitea/workflows/deploy.yml` with `runs-on: self-hosted`).
|
||||
- `m/paliad/actions/tasks` reports `total_count=0` — paliad has never run a workflow yet.
|
||||
|
||||
The existing runners may already be capable of running paliad's workflow without further setup. The verification step (§3) below tells you whether they are.
|
||||
|
||||
---
|
||||
|
||||
## 1. Runner placement decision (m's Q11.1)
|
||||
|
||||
m's pick: **mriver**.
|
||||
|
||||
Rationale: mriver hosts the mai worker fleet but workers spend most of their time waiting on Anthropic. mlake's Dokploy + Swarm workload is more contended. A new runner on mriver adds the least pressure to either box.
|
||||
|
||||
If mriver is offline or saturated when CI first fires, fall back to the existing mlake-side runners (they're already registered; no provisioning needed).
|
||||
|
||||
---
|
||||
|
||||
## 2. One-time setup (admin steps)
|
||||
|
||||
### 2.1 Register a new Gitea Actions runner on mriver
|
||||
|
||||
```bash
|
||||
# On mriver, as m:
|
||||
# 1. Download the act_runner binary (matching Gitea 1.24.x)
|
||||
curl -L -o /usr/local/bin/act_runner \
|
||||
https://gitea.com/gitea/act_runner/releases/download/v0.2.13/act_runner-0.2.13-linux-amd64
|
||||
chmod +x /usr/local/bin/act_runner
|
||||
|
||||
# 2. Get a runner registration token. In the Gitea UI:
|
||||
# /admin → Actions → Runners → "Create new Runner"
|
||||
# (or org-scope: /m/paliad/settings/actions/runners)
|
||||
# Copy the token.
|
||||
|
||||
# 3. Register
|
||||
mkdir -p ~/act_runner && cd ~/act_runner
|
||||
act_runner register --no-interactive \
|
||||
--instance https://mgit.msbls.de \
|
||||
--token <REGISTRATION_TOKEN> \
|
||||
--name mriver-paliad-1 \
|
||||
--labels ubuntu-latest:docker://node:20-bookworm
|
||||
|
||||
# 4. Run as a systemd unit (preferred) or as a session daemon
|
||||
# Systemd unit example: /etc/systemd/system/act_runner.service
|
||||
# [Unit]
|
||||
# Description=Gitea Actions runner
|
||||
# After=network.target
|
||||
# [Service]
|
||||
# User=m
|
||||
# WorkingDirectory=/home/m/act_runner
|
||||
# ExecStart=/usr/local/bin/act_runner daemon
|
||||
# Restart=on-failure
|
||||
# [Install]
|
||||
# WantedBy=multi-user.target
|
||||
sudo systemctl enable --now act_runner
|
||||
sudo systemctl status act_runner
|
||||
```
|
||||
|
||||
**Why `ubuntu-latest:docker://node:20-bookworm` for the label?** Gitea Actions' `runs-on: ubuntu-latest` resolves via the runner's label map. Mapping it to a Docker image gives the workflow a sandbox with Docker available — required for our Postgres service container in `test.yaml`. mriver should have Docker (for `paliadin-shim`); if not, install it.
|
||||
|
||||
### 2.2 Register the Dokploy API token as a repo secret
|
||||
|
||||
The workflow's `deploy` job needs `secrets.DOKPLOY_TOKEN`. Use the existing project-wide Dokploy API key (the one stored in `~/.claude/skills/mai-dokploy/SKILL.md`).
|
||||
|
||||
In the Gitea UI:
|
||||
- Navigate to `https://mgit.msbls.de/m/paliad/settings/actions/secrets`
|
||||
- Click "Add secret"
|
||||
- Name: `DOKPLOY_TOKEN`
|
||||
- Value: `mai-ottosSyRHMhmLhhhXaCbKzbqKBuSqzqEtmKDOPelPCeimTaYsbmaVslVyEgJZGCIxVdz`
|
||||
|
||||
Or via API (mAi identity):
|
||||
```bash
|
||||
curl --netrc-file ~/.netrc-mai -sS -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
https://mgit.msbls.de/api/v1/repos/m/paliad/actions/secrets/DOKPLOY_TOKEN \
|
||||
-d '{"data":"mai-ottosSyRHMhmLhhhXaCbKzbqKBuSqzqEtmKDOPelPCeimTaYsbmaVslVyEgJZGCIxVdz"}'
|
||||
```
|
||||
|
||||
(Requires repo-owner permission. If mAi lacks it, m runs it.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Verify the runner sees the workflow
|
||||
|
||||
After (2.1) + (2.2):
|
||||
|
||||
```bash
|
||||
# Push the Slice A branch (the one this doc lives on)
|
||||
git push origin mai/cronus/coder-cicd-slice-a
|
||||
|
||||
# Confirm the runner picked up the job
|
||||
curl --netrc-file ~/.netrc-mai -sS \
|
||||
"https://mgit.msbls.de/api/v1/repos/m/paliad/actions/tasks?limit=5" | jq '.'
|
||||
```
|
||||
|
||||
A new task per job should appear (build, test-go). If `total_count` stays 0, the runner labels don't match the workflow's `runs-on`. Re-register with `--labels ubuntu-latest` (no docker:// suffix) and the existing runners on mlake will pick it up via shell mode.
|
||||
|
||||
---
|
||||
|
||||
## 4. Soft-launch (m's Q11.4)
|
||||
|
||||
m's pick: **keep both Dokploy auto-deploy and the workflow's deploy step alive for ~1 week. After ≥5 successful green deploys via the workflow, disable Dokploy's autoDeploy in the Dokploy UI for the paliad compose.**
|
||||
|
||||
While both are live, every push to main fires:
|
||||
1. Dokploy webhook (existing path) → deploys immediately, no gate.
|
||||
2. Gitea workflow → on green, ALSO calls `compose.deploy`.
|
||||
|
||||
The second call is idempotent — if Dokploy already deployed the same commit, this is a no-op. The workflow's value during soft-launch is the **gate signal**: a red workflow on a green main = the bad migration shipped via the unguarded webhook and broke prod, and the workflow is shouting about it.
|
||||
|
||||
After confidence builds:
|
||||
1. In the Dokploy UI, navigate to the paliad compose → Settings.
|
||||
2. Toggle "Auto Deploy" off.
|
||||
3. Save.
|
||||
|
||||
From this point, the only path to deploy is the workflow's deploy job. Red workflow = no deploy.
|
||||
|
||||
---
|
||||
|
||||
## 5. What Slice A catches today — and what it doesn't
|
||||
|
||||
After this branch (`mai/cronus/coder-cicd-slice-a`) merges to main:
|
||||
|
||||
### Catches (active in CI)
|
||||
|
||||
- **Build breakage** — `go build`, `go vet`, `bun run build`. Red gate, no deploy.
|
||||
- **Slot collisions** — `TestMigrations_NoDuplicateSlot` runs without a DB. A PR adding migration N when version N already exists fails at gate time. This is the brunel-class catch (m/paliad#114 ~13:20 outage).
|
||||
- **New-migration shape errors (hermes class)** — `TestBootSmoke` runs `ApplyMigrations` against the snapshot-restored DB. New migs from this PR get applied for real; any column/relation/syntax error fails the gate before merge.
|
||||
- **New-migration ownership errors (mig 129 42501 class)** — `TestMigrations_EndToEndAsAppRole` runs `ApplyMigrations` connected as `postgres` (NON-superuser on `supabase/postgres:15.8.1.060`, same role topology as youpc-supabase prod). Any migration that assumes supabase_admin privilege fails with the same `42501 must be owner` error class that took paliad.de offline on 2026-05-25.
|
||||
- **Readiness probe regressions** — `TestHealthReady_Live` confirms `/health/ready` returns 200 against a live pool, 503 against a nil pool.
|
||||
- **Pure-Go test regressions** — `go test ./internal/... ./cmd/...` runs without `TEST_DATABASE_URL` (live-DB service tests skip the same way they do on a developer laptop without a scratch DB).
|
||||
|
||||
### Mechanism — the snapshot approach
|
||||
|
||||
CI's scratch DB starts from a `pg_dump` of youpc-supabase paliad schema +
|
||||
`paliad.applied_migrations` rows, committed to `internal/db/testdata/prod-snapshot.sql`. After restore, the scratch DB is at "paliad HEAD of snapshot" and `ApplyMigrations` sees only this PR's new migrations as pending.
|
||||
|
||||
This sidesteps the fresh-DB idempotence problem: several historical migrations (notably mig 037's missing `CREATE EXTENSION pg_trgm`, mig 051's inner `COMMIT;`) can't be replayed from scratch against `supabase/postgres:15.8.1.060`. The snapshot pins everything that's already applied in prod and lets CI focus on what's new — which is what we actually care about for outage prevention.
|
||||
|
||||
Snapshot refresh: `make refresh-snapshot` with `PALIAD_PROD_DATABASE_URL` set (see `internal/db/testdata/README.md`).
|
||||
|
||||
### Known gap — live-DB service tests don't run in CI
|
||||
|
||||
`internal/services/*_test.go` tests with `TEST_DATABASE_URL` set fail against `supabase/postgres:15.8.1.060` with `42P08 inconsistent types deduced for parameter` errors on some INSERT bind paths. The same tests pass against youpc-supabase prod. Cause is unconfirmed — likely subtle differences in type inference between the dockerized image and the prod cluster's configuration. CI today runs `go test ./...` without `TEST_DATABASE_URL` so these tests skip. Not blocking outage prevention; tracked as a follow-up for the post-Slice-A coder.
|
||||
|
||||
### Migration cleanup also bundled in this PR
|
||||
|
||||
Two surgical migration improvements that surfaced during snapshot debugging — kept here because they're small and harmless:
|
||||
|
||||
- **mig 024 + 027** — `ALTER INDEX` / `ALTER POLICY` exception handlers now catch `undefined_object` OR `undefined_table` OR `duplicate_object`. Old handler caught only `undefined_object`; Postgres raises `undefined_table` when the source object never existed and `duplicate_object` when the destination already exists. The expanded handler makes the migrations truly idempotent across the three plausible states: source-still-German (rename succeeds), already-renamed (catches duplicate_object), and fresh-DB-never-had-German (catches undefined_table).
|
||||
|
||||
Other migration history bugs (mig 037 missing pg_trgm, mig 051 inner COMMIT) are tracked as a separate cleanup task — not blocking, because the snapshot bypasses them.
|
||||
|
||||
### Verification checklist (after Slice A merges)
|
||||
|
||||
1. **Workflow green on its first PR run?** Check `/m/paliad/actions`. If not, fix before merging.
|
||||
2. **Dokploy `compose.deploy` call succeeds?** The workflow's `deploy` job logs the POST response. A successful response is a Dokploy job ID; a 4xx is an auth or compose-id problem.
|
||||
3. **`/health/ready` returns 200 within 5 minutes after a green deploy?** The workflow polls this. If it times out, the migration may have failed silently inside the new container — check `docker logs --tail 50 compose-transmit-multi-byte-driver-v7jth9-web-1` on mlake.
|
||||
4. **Reproduce the slot-collision catch locally:** rename `131_…up.sql` to `129_…` (duplicate slot) → workflow MUST fail at `Migration coordination check`. Revert before pushing.
|
||||
5. **Reproduce the role-split catch locally:** add a no-op migration `132_test_supersedes.up.sql` containing `REINDEX SYSTEM paliad_scratch;` (requires superuser). Workflow MUST fail at `Migration end-to-end (deploy role)`. Revert before pushing.
|
||||
|
||||
---
|
||||
|
||||
## 6. Future polish (Slice D, m's Q4 R-pick)
|
||||
|
||||
`mai-test` post-merge shift: once Slice A is stable, wire a Gitea webhook on push-to-main that fires `/mai-test` as a follow-up shift. It runs the broader smoke + integration suite and posts results as a Gitea commit status. Not blocking; the gate doesn't depend on it.
|
||||
|
||||
Implementation belongs in `m/mAi` (the mai webhook handler), not in paliad. Out of scope for Slice A.
|
||||
@@ -1,776 +0,0 @@
|
||||
# Design — Deadline + procedural-events system revision (Phase 2 of RFC m/paliad#149)
|
||||
|
||||
**Task:** t-paliad-329
|
||||
**Gitea:** m/paliad#149 (Phase 2)
|
||||
**Inventor:** atlas (shift-1)
|
||||
**Date:** 2026-05-27
|
||||
**Status:** Draft — coder gate held; awaiting m's go on the slice train
|
||||
**Branch:** `mai/atlas/inventor-deadline-system`
|
||||
|
||||
**Builds on:**
|
||||
- `docs/assessment-deadline-system-2026-05-27.md` (athena Phase 1, 738 lines — premises here are athena's)
|
||||
- `docs/design-fristenrechner-followup-rules-2026-05-27.md` (atlas t-paliad-327, pre-ratified subset: cross-party display + scenario SSoT + spawn-only picker exclusion)
|
||||
- `docs/design-proceeding-types-taxonomy-2026-05-26.md` (mig 153 shipped; `kind` discriminator)
|
||||
- `docs/design-fristenrechner-overhaul-2026-05-26.md` (Entry B foundation S1-S6 shipped)
|
||||
|
||||
m authorised Phase 2 at 2026-05-27 11:33 ("Go on"). m's "big picture" direction at 13:53 ("yeah, b - big! We need an overall schema for all procedural events and how they are connected") makes the connection graph itself the spine of this design.
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises — reconciliation with athena's audit
|
||||
|
||||
Athena established the live data; this design takes that as given. Three cross-checks ran 2026-05-27 against the live `paliad` schema; counts match athena's §0/§3 numbers (chain-linked 107 / PT-roots 46 / legacy globals 73 / overlap 2). The only material refinement is athena's R3 finding ("4 spawn rules point at INACTIVE id=11") — which m's Q5 answer now re-interprets as **correct** rather than broken (see §3.1).
|
||||
|
||||
### §0.1 The athena↔RFC conflicts surfaced
|
||||
|
||||
| Item | RFC said | Athena found | Picked side |
|
||||
|---|---|---|---|
|
||||
| Scenario state shape | "`projects.scenarios` jsonb (mig 145)" exists | `paliad.scenarios` table exists; `projects.scenarios` jsonb does **not** | Athena. Use new `projects.scenario_flags jsonb` column (Q4) — different from both. |
|
||||
| Three stores diverge | "Three independent stores. No single source of truth." | All three stores empty (0 rows in `project_event_choices`, 0 in `scenarios`, DOM-only). Risk dormant. | Athena. Design picks one store going forward; nothing to migrate. |
|
||||
| Spawn FK is "broken" | Implied | Athena R3: 4 spawn rules point at inactive `upc.apl.merits`. | m's Q5 inverts: the unification was the bug, not the FK. Re-split apl into merits/cost/order (§3.1). |
|
||||
|
||||
### §0.2 The pre-ratified subset from t-paliad-327
|
||||
|
||||
m ratified the following on 2026-05-27 (via `AskUserQuestion`, all on-recommendation in that task) — Phase 2 carries them forward unchanged:
|
||||
|
||||
- Cross-party display: backend stops filtering by party, `is_cross_party` derived field, "Gegenseitig" badge, muted/greyed visual, unchecked default, write-back excluded unconditionally. (Folded into §2.4.)
|
||||
- Scenario flag SSoT: `paliad.projects.scenario_flags jsonb` column + GET/PATCH `/api/projects/{id}/scenario-flags`. (Folded into §2.3.)
|
||||
- Spawn-only event picker exclusion: `SearchEvents` SQL adds `AND sr.is_spawn = false`. (Folded into §2.2.)
|
||||
|
||||
These are not re-asked. They are the foundation Phase 2 builds on.
|
||||
|
||||
---
|
||||
|
||||
## §1 The overall connection schema (m's "big picture")
|
||||
|
||||
Per m's direction: document the canonical connection graph across all procedural_events + sequencing_rules + proceeding_types as a unified model.
|
||||
|
||||
### §1.1 Conceptual model in one paragraph
|
||||
|
||||
A **rule** (`paliad.sequencing_rules` row) is the atomic node. It carries one deadline for one event, on one proceeding-type. Every rule has at most one **predecessor edge** via `parent_id` → another rule whose own deadline must elapse before this one starts. The chain root (rule with `parent_id IS NULL`) is anchored to its **proceeding-type root event** (typically a filing — Klageerhebung, Veröffentlichung, Anmeldung). A small number of rules are **spawn rules** (`is_spawn=true`) — they don't compute their own deadline; instead they open a fresh proceeding of a different type, edge labelled by `spawn_proceeding_type_id`. Conditional rules carry a `condition_expr` jsonb predicate over a small flag vocabulary (`with_ccr`, `with_amend`, `with_cci`); the active subset of the graph for a given project is the rules whose predicate is satisfied by `projects.scenario_flags`. **The only canonical predecessor link is `parent_id`. The `trigger_event_id` column is deprecated** (Q1). Trigger discoverability is **derived from data**: any event whose anchor rule has `EXISTS (non-spawn child WHERE child.parent_id = anchor.id)` is a valid trigger; everything else (spawn-only consequences, terminal leaves) is filtered out at the picker (Q3, §2.2).
|
||||
|
||||
### §1.2 The shape — ASCII tree per representative PT
|
||||
|
||||
Showing 3 representative PTs (the rest follow the same structural pattern; counts in §1.4).
|
||||
|
||||
#### upc.inf.cfi (25 rules, depth 5, the densest tree)
|
||||
|
||||
```
|
||||
upc.inf.cfi (Verletzungsverfahren CFI)
|
||||
├─ RoP.013.1 soc Klageerhebung [claimant · M] ← anchor
|
||||
│ ├─ RoP.019.1 prelim Vorl. Einwendungen [defendant · O]
|
||||
│ ├─ RoP.262.2 confidentiality_response Vertraulichkeit [both · O]
|
||||
│ ├─ RoP.023 sod Klageerwiderung [defendant · M]
|
||||
│ │ └─ RoP.029.b reply Replik [claimant · M · ?with_ccr]
|
||||
│ │ └─ RoP.029.c rejoin Duplik [defendant · M · ?with_ccr]
|
||||
│ ├─ RoP.025 ccr Widerklage auf Nichtigkeit [defendant · O · ?with_ccr]
|
||||
│ │ └─ RoP.029.a def_to_ccr Erwiderung auf CCR [claimant · M · ?with_ccr]
|
||||
│ │ └─ RoP.029.d reply_def_ccr Replik auf Erw. CCR [defendant · M · ?with_ccr] ← X-party from claimant
|
||||
│ │ └─ RoP.029.e rejoin_reply_ccr Duplik auf Replik CCR [claimant · M · ?with_ccr]
|
||||
│ │ └─ RoP.030.1 app_to_amend Antrag auf Patentänderung [claimant · M · ?with_amend]
|
||||
│ │ └─ RoP.032.1 def_to_amend Erwiderung auf Änderung [defendant · M · ?with_amend]
|
||||
│ │ └─ RoP.032.3 reply_def_amd Replik auf Erw. Änderung [claimant · M · ?with_amend]
|
||||
│ │ └─ RoP.032.3 rejoin_amd Duplik auf Replik Änderung [defendant · M · ?with_amend]
|
||||
│ ├─ RoP.333.2 cmo_review Antrag CMO-Überprüfung [both · O]
|
||||
│ ├─ RoP.109.1 translation_request Übersetzungsantrag [both · O]
|
||||
│ ├─ RoP.109.5 translations_lodge Übersetzungen einreichen [both · M]
|
||||
│ ├─ RoP.118.4 cons_orders Antrag Folgenanordnungen [both · O]
|
||||
│ ├─ RoP.151 cost_app Kostenantrag [both · O]
|
||||
│ ├─ RoP.353 rectification Berichtigungsantrag [both · O]
|
||||
│ └─ RoP.220.1.a appeal_spawn ⇲ Berufungsverfahren öffnen [both · O · SPAWN→ upc.apl.merits]
|
||||
├─ RoP.104 interim Zwischenanhörung [court · M]
|
||||
├─ (n/a) oral Mündliche Verhandlung [court · M]
|
||||
├─ (n/a) decision Endentscheidung [court · M]
|
||||
│ (Note: interim/oral/decision are court-set; they're chain-anchored but
|
||||
│ have no scheduled rule of their own — phase markers carried via event_kind.)
|
||||
└─ RoP.109.4 interpreter_cost Dolmetscherkosten [court · M]
|
||||
```
|
||||
|
||||
**Legend.** `[party · M|O · ?flag · SPAWN→target]`. `M` = mandatory, `O` = optional. `?flag` = conditional on the scenario flag. ← X-party = cross-party row vs claimant perspective; see §2.4 for display. SPAWN → opens a new proceeding under that PT.
|
||||
|
||||
#### upc.rev.cfi (17 rules, depth 4, mirrors inf.cfi shape)
|
||||
|
||||
Same SoC → SoD → Reply → Rejoinder spine; CCR mirrored as Erwiderung auf Widerklage on revocation. `with_cci` (Widerklage auf Verletzung — the inverse of with_ccr) replaces `with_ccr`. Same `with_amend` branch for R.30. 13 chain-linked, 5 roots, 1 spawn (→ upc.apl.merits, post-Q5 split).
|
||||
|
||||
#### upc.apl (POST-Q5 SPLIT — 3 trees, 16 rules total)
|
||||
|
||||
After §3.1 mig: id=160 `upc.apl.unified` is retired; rules re-bound to the 3 reactivated PTs (id=11 `upc.apl.merits` 7 rules / id=19 `upc.apl.cost` 2 rules / id=20 `upc.apl.order` 7 rules). Trees:
|
||||
|
||||
```
|
||||
upc.apl.merits (7 rules)
|
||||
├─ RoP.224.1.a notice Berufungseinlegung
|
||||
│ └─ RoP.224.2.a grounds Berufungsbegründung
|
||||
│ └─ RoP.235.1 response Berufungserwiderung
|
||||
│ └─ RoP.237 cross_a Anschlussberufung
|
||||
│ └─ RoP.238.1 cross_a_reply Erwiderung Anschlussberufung
|
||||
├─ (n/a) oral Mündliche Verhandlung [court · M]
|
||||
└─ (n/a) decision Entscheidung [court · M]
|
||||
|
||||
upc.apl.cost (2 rules)
|
||||
├─ RoP.221.1 leave_app Antrag auf Berufungszulassung
|
||||
└─ (n/a) decision Kostenfestsetzungsbeschluss
|
||||
|
||||
upc.apl.order (7 rules)
|
||||
├─ (n/a) order angegriffene Entscheidung
|
||||
│ ├─ RoP.220.2 with_leave Berufung mit Zulassung
|
||||
│ └─ RoP.220.3 discretion Ermessensüberprüfung
|
||||
├─ RoP.224.2.b grounds_orders Berufungsbegründung (Orders Track)
|
||||
│ └─ RoP.235.2 response_orders Berufungserwiderung (Orders Track)
|
||||
└─ RoP.237 cross Anschlussberufung
|
||||
└─ RoP.238.2 cross_reply Erwiderung Anschlussberufung
|
||||
```
|
||||
|
||||
The 3 trees are independent. Determinator UX (proceeding_mapping.go) keeps a single user-facing "Berufung" entry that fans out to one of the 3 based on what's being appealed (judgment → merits, cost decision → cost, order → order). Routing layer unchanged from t-paliad-204 S1; only the data shape changes.
|
||||
|
||||
The remaining 14 ruled PTs (de.inf.lg / .olg / .bgh, de.null.bpatg / .bgh, dpma.opp / .appeal.bpatg / .bgh, epa.opp.opd / .opp.boa / .grant.exa, upc.dmgs.cfi, upc.disc.cfi, upc.pi.cfi) follow the same shape — root anchored on a filing/grant event, chain depth 1-3, optionals and conditionals branching off the root or first-hop. Athena's §4 gap map gives the per-PT P/R counts; see also §1.4 below.
|
||||
|
||||
### §1.3 Cross-PT edges — the spawn graph (post-Q5)
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
upc_inf_cfi[upc.inf.cfi<br/>Verletzungsverfahren CFI] -.->|R.220.1.a<br/>appeal_spawn| upc_apl_merits[upc.apl.merits<br/>Berufung Hauptsache]
|
||||
upc_rev_cfi[upc.rev.cfi<br/>Nichtigkeitsverfahren CFI] -.->|R.220.1.a<br/>appeal_spawn| upc_apl_merits
|
||||
upc_dmgs_cfi[upc.dmgs.cfi<br/>Schadensbemessung] -.->|R.220.1.a<br/>appeal_spawn| upc_apl_merits
|
||||
upc_pi_cfi[upc.pi.cfi<br/>Einstweilige Maßnahmen] -.->|R.220.1.a<br/>appeal_spawn| upc_apl_order[upc.apl.order<br/>Berufung Orders Track]
|
||||
```
|
||||
|
||||
4 spawn edges, all in the UPC CFI cluster. PI appeals go to the orders track (not main proceedings); the rest go to merits. The cost-decision-appeal track (`upc.apl.cost`) is reached not via spawn but via direct filing (`leave_app` rule); cost decisions arrive within their parent proceeding and the cost-appeal opens as a standalone application.
|
||||
|
||||
DE-side, EPA-side, DPMA-side: no spawn edges today. Each tier-of-court is a separate `proceeding_type` (de.inf.lg / .olg / .bgh) with its own root + chain; chained-by-instance is not modelled as a spawn (the user explicitly creates a new project for the appeal stage). m may revisit this if DE-side workflow benefits from spawn edges; out of scope for this revision.
|
||||
|
||||
### §1.4 Per-PT health summary (post-Q5)
|
||||
|
||||
| PT code | rules | roots | chained | conditional | spawns | gap |
|
||||
|---|--:|--:|--:|--:|--:|---|
|
||||
| upc.inf.cfi | 25 | 4 | 21 | 10 | 1 | 84% chained — strongest |
|
||||
| upc.rev.cfi | 17 | 4 | 13 | 8 | 1 | 76% |
|
||||
| upc.apl.merits | 7 | 3 | 4 | 0 | 0 | post-Q5 split — to be re-rooted |
|
||||
| upc.apl.order | 7 | 3 | 4 | 0 | 0 | post-Q5 split |
|
||||
| upc.apl.cost | 2 | 1 | 1 | 0 | 0 | post-Q5 split |
|
||||
| de.inf.lg | 9 | 5 | 4 | 0 | 0 | 44% — gappy |
|
||||
| de.null.bpatg | 10 | 4 | 6 | 0 | 0 | 60% |
|
||||
| de.inf.olg | 7 | 1 | 6 | 0 | 0 | 86% |
|
||||
| de.inf.bgh | 8 | 1 | 7 | 0 | 0 | 88% |
|
||||
| de.null.bgh | 6 | 1 | 5 | 0 | 0 | 83% |
|
||||
| dpma.opp.dpma | 4 | 1 | 3 | 0 | 0 | 75% |
|
||||
| dpma.appeal.bpatg | 5 | 1 | 4 | 0 | 0 | 80% |
|
||||
| dpma.appeal.bgh | 4 | 1 | 3 | 0 | 0 | 75% |
|
||||
| epa.opp.opd | 8 | 2 | 6 | 0 | 0 | 75% |
|
||||
| epa.opp.boa | 8 | 3 | 5 | 0 | 0 | 63% |
|
||||
| epa.grant.exa | 7 | 4 | 3 | 0 | 0 | 43% |
|
||||
| upc.dmgs.cfi | 8 | 4 | 4 | 0 | 1 | 50% |
|
||||
| upc.pi.cfi | 7 | 3 | 4 | 0 | 1 | 57% |
|
||||
| upc.disc.cfi | 4 | 1 | 3 | 0 | 0 | 75% |
|
||||
| **Empty (Q6)** | | | | | | |
|
||||
| upc.bsv.cfi | 0 | — | — | — | — | unruled — badge "Keine Regeln" |
|
||||
| upc.ccr.cfi | 0 | — | — | — | — | unruled — badge |
|
||||
| upc.costs.cfi | 0 | — | — | — | — | unruled — badge |
|
||||
| upc.dni.cfi | 0 | — | — | — | — | unruled — badge |
|
||||
| upc.epo.review | 0 | — | — | — | — | unruled — badge |
|
||||
| upc.pl.cfi | 0 | — | — | — | — | unruled — badge |
|
||||
|
||||
Plus **73 legacy globals** sitting in the corpus with `proceeding_type_id IS NULL` — these are the editorial backfill target (Q2 / §4.2). Each needs to be reparented onto one of the 23 PTs.
|
||||
|
||||
---
|
||||
|
||||
## §2 Tier 1 — model decisions (m ratified all 4 on-recommendation)
|
||||
|
||||
### §2.1 `parent_id` is the canonical predecessor link
|
||||
|
||||
`paliad.sequencing_rules.parent_id` (uuid FK to another rule) is the **only** predecessor pointer going forward. `paliad.sequencing_rules.trigger_event_id` (bigint FK to legacy `paliad.trigger_events`) gets dropped at the end of the migration train (§5).
|
||||
|
||||
**Implication for the 75 rules that currently use `trigger_event_id`:**
|
||||
|
||||
- The 73 legacy globals (proceeding_type_id IS NULL): editorial walk reparents each onto a real PT chain (Q2, §4.2). Slow but right — no data is lost, just structurally normalised.
|
||||
- The 2 hybrid rules (both parent_id AND trigger_event_id set): keep `parent_id`, NULL out `trigger_event_id`. No data loss — `parent_id` already carries the live edge.
|
||||
|
||||
After backfill, `trigger_event_id` is unused — safe to drop the column (§5, Mig P4).
|
||||
|
||||
### §2.2 Trigger discoverability — derive from data
|
||||
|
||||
A `procedural_event` is a **picker-eligible trigger** when EXISTS a published+active non-spawn rule with `parent_id` pointing at this event's anchor rule. The picker SQL gains:
|
||||
|
||||
```sql
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM paliad.sequencing_rules child
|
||||
WHERE child.parent_id = anchor.id
|
||||
AND child.is_active = true
|
||||
AND child.lifecycle_state = 'published'
|
||||
AND child.is_spawn = false -- spawn-only consequences not pickable (t-paliad-327 §3a)
|
||||
)
|
||||
```
|
||||
|
||||
No new column. No materialised view. The EXISTS subquery uses the existing `sequencing_rules.parent_id` index. At today's scale (226 rules) it's cheap; at 10× scale still fine (parent_id is indexed; child lookup is index-only scan).
|
||||
|
||||
Mode A's `SearchEvents` (`internal/services/fristenrechner_search_events.go`) and Mode B R4's chip-strip both apply this filter. Terminal leaves (Duplik etc.) stay pickable — they have a non-spawn anchor rule and result in an empty follow-up list, which is honest UX (t-paliad-327 §3a.4, m ratified).
|
||||
|
||||
### §2.3 Scenario state SSoT — `projects.scenario_flags jsonb`
|
||||
|
||||
Reconfirmed from t-paliad-327 §3.2:
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
```
|
||||
|
||||
Shape:
|
||||
```json
|
||||
{ "with_ccr": true, "with_amend": false, "with_cci": false }
|
||||
```
|
||||
|
||||
Whitelist-validated against the set of flag names appearing in `sequencing_rules.condition_expr` (today: `with_ccr`, `with_amend`, `with_cci`).
|
||||
|
||||
API: `GET /api/projects/{id}/scenario-flags` returns the map; `PATCH /api/projects/{id}/scenario-flags` accepts partial deltas (null deletes a key).
|
||||
|
||||
**Kontextfrei (no project):** stays on localStorage. No DB writes when `project_id IS NULL`.
|
||||
|
||||
**Relationship with `paliad.scenarios`:** complementary, not duplicate. `scenarios.spec.flags[]` (the Litigation Planner Slice D shape) is a *named snapshot*; activating a scenario copies its flag array into `projects.scenario_flags`. Live edits write to `scenario_flags`. `paliad.project_event_choices` (the legacy empty table) is deprecated (§4.3).
|
||||
|
||||
### §2.4a Selection state + detail-level view-mode filter
|
||||
|
||||
m's reframe (14:40): the real ask isn't "rarity" — it's **detail-level control over the timeline**. Every event/rule is a card; the user picks which optional cards belong to *their* scenario; the Verfahrensablauf has a view-mode toggle that controls how much of the picture surfaces.
|
||||
|
||||
m's quote (14:40): *"It is more that I want a grade of detail in our swimlane display […] I want to show them but also be able to 'focus' by not displaying optional things. And we can select these options somehow, for example like we do with the appeal in the Decision dropdown. And if none is selected, none are displayed. We need an option 'Show unselected options' or 'show only selected' or 'mandatory' […] It would be great to basically filter events from the timeline based on whether they are selected in this scenario."*
|
||||
|
||||
The underlying mental model:
|
||||
|
||||
- **Mandatory rules** are always in the scenario. They render in every view-mode. The user cannot deselect them.
|
||||
- **Recommended rules** are *selected by default* in the scenario. The user can deselect them.
|
||||
- **Optional rules** are *not selected by default*. The user opts in via the same UI mechanism that already exists for `with_ccr` / `with_amend` (a chip / dropdown / "Aufnehmen" CTA per rule).
|
||||
- **Conditional rules** (with `condition_expr`) are gated by scenario flags first, then by selection (a conditional rule whose flag is on still respects its priority's default selection rule).
|
||||
|
||||
The Verfahrensablauf gets a three-way **detail-level toggle** (§3.3a):
|
||||
|
||||
- **Nur Pflicht (Mandatory only)** — only `priority='mandatory'` cards.
|
||||
- **Gewählt (Selected)** — mandatory + every rule the scenario has explicitly selected. Default.
|
||||
- **Alle Optionen (All considered)** — every rule that *could* belong, including unselected optionals (rendered with a dotted border + "Aufnehmen" CTA) and conditional rules whose flag isn't set (rendered greyed with a "wenn-…" hint).
|
||||
|
||||
#### Schema — no new column on `sequencing_rules`
|
||||
|
||||
The original §2.4a strawman proposed `is_edge_case boolean` as a chain-head flag. m's reframe makes that wrong: **every** optional rule is potentially "rare" depending on the lawyer's scenario; the dimension isn't a property of the rule, it's a property of the scenario.
|
||||
|
||||
Instead, the selection state lives entirely in **`projects.scenario_flags jsonb`** (already on the table from P0, §2.3) with an extended shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"with_ccr": true,
|
||||
"with_amend": false,
|
||||
"with_cci": false,
|
||||
"rule:<uuid_of_recommended_X>": false,
|
||||
"rule:<uuid_of_optional_Y>": true
|
||||
}
|
||||
```
|
||||
|
||||
The flat-map shape stays — entries are either named scenario flags (`with_*`) or per-rule selection deviations (`rule:<uuid>`). Storage only carries **deviations from the priority default**:
|
||||
- `priority='recommended'` is selected-by-default; `rule:X = false` records an explicit deselection.
|
||||
- `priority='optional'` is unselected-by-default; `rule:X = true` records an explicit selection.
|
||||
- `priority='mandatory'` is always selected; trying to store `rule:X = false` is rejected (422 from the PATCH endpoint).
|
||||
|
||||
Whitelist (Q9 catalog) gains a wildcard pattern `rule:<uuid>` — any well-formed UUID matches; the handler validates that the UUID resolves to an active+published rule on the project's proceeding_type before persisting.
|
||||
|
||||
Kontextfrei (no project): localStorage stores the same shape under a per-PT key (`scenario:upc.inf.cfi`). Different PT → different stored selection set; this matches how kontextfrei users explore.
|
||||
|
||||
#### Visual — generalising the CCR dropdown to per-rule chips
|
||||
|
||||
The existing `with_ccr` / `with_amend` checkboxes are *coarse* scenario flags. The new per-rule selection is *fine-grained* but uses the same UI vocabulary:
|
||||
|
||||
- **Selected rule**: solid card, normal background. (Identical to today's mandatory render.)
|
||||
- **Selected optional that's deselectable**: solid card with a small `[Entfernen]` chip; click removes from `selected_optionals` (writes `rule:X = false`).
|
||||
- **Unselected optional (default state in "Alle Optionen" mode)**: dotted-border card, muted background, `[Aufnehmen]` CTA. Click writes `rule:X = true`.
|
||||
- **Conditional rule whose flag isn't set**: greyed card with a "Aktivieren via 'Mit Widerklage' im Szenario" hint; clicking the hint scrolls to the scenario-flags strip.
|
||||
- **Cross-party** (§2.4): orthogonal — applies its `Gegenseitig` badge and muted style on top of whichever state above.
|
||||
|
||||
Each card thus carries up to four orthogonal axes of display state — priority, selection, conditional-gate, cross-party. The 4 axes compose; no axis dominates.
|
||||
|
||||
#### Subtree semantics — implicit via parent chain
|
||||
|
||||
When a chain head is deselected (e.g. R.109.1 Übersetzungsantrag = `false`), its descendants in the parent_id tree (R.109.4 Mitteilung etc.) **inherit the deselected state for display** without needing their own entries in `selected_optionals`. The tree renderer walks the chain; if any ancestor is unselected, the descendant doesn't render in "Gewählt" mode. In "Alle Optionen" mode, the whole subtree renders greyed under the deselected head.
|
||||
|
||||
If a descendant has its own explicit `rule:X = true` entry, that overrides the ancestor — the user has explicitly pulled this leaf into their scenario despite not selecting the parent. Edge case; documented but no special UI affordance.
|
||||
|
||||
#### Default population on project creation
|
||||
|
||||
When a project is created with `proceeding_type_id = X`, the server seeds `scenario_flags = {}`. Nothing in the map. The tree renderer computes per-rule selection on-the-fly from priority + scenario_flags entries. No upfront write-storm of "rule:X = true" for every recommended rule — only deviations land in storage.
|
||||
|
||||
#### Why this beats the `is_edge_case` boolean
|
||||
|
||||
- **No new column.** All state lives in the existing `projects.scenario_flags jsonb` from P0.
|
||||
- **Generalised.** Every optional rule is selectable, not just the few flagged as "rare". m's "sequence density is very high" complaint is solved by the user controlling which optionals belong to *their* scenario, rather than the editorial process having to decide globally which rules deserve dotted-border treatment.
|
||||
- **Composable with condition_expr.** A conditional rule is selectable when its flag is on; the selection state is independent of the flag state.
|
||||
- **Matches m's stated UX prior art.** The CCR dropdown pattern *is* the model; we're just generalising it from 3 named flags to N per-rule selections.
|
||||
|
||||
### §2.4 Cross-party display
|
||||
|
||||
From t-paliad-327 §2 (m ratified on-recommendation all 8 sub-Qs):
|
||||
|
||||
- Backend: drop the perspective WHERE clause in `queryFollowUpRows`; return all rows; add server-computed `is_cross_party` boolean.
|
||||
- UI: render cross-party rows with a `Gegenseitig` badge, muted/greyed style, unchecked by default, date visible.
|
||||
- Write-back: cross-party rows are **unconditionally excluded** from the project-deadline bulk insert, even if the user manually checks the box.
|
||||
|
||||
Composite `condition_expr` (and-of-flags) — checkbox is read-only in the result view; Verfahrensablauf is the canonical toggle surface for individual flags.
|
||||
|
||||
Sync: `document.dispatchEvent(new CustomEvent('scenario-flag-changed', { detail: { flag, value } }))`. Single-tab v1; cross-tab in Akte mode deferred.
|
||||
|
||||
---
|
||||
|
||||
## §3 Tier 2 — surface decisions
|
||||
|
||||
### §3.1 Appeal re-split: revert upc.apl.unified → merits/cost/order (m's Q5 divergent pick)
|
||||
|
||||
**m's call (2026-05-27):** *"Reverse the unification as suggested in 3. They are different proceedings, I only wanted the approach to be unified in the 'determinator' — but they are actually different proceedings!"*
|
||||
|
||||
The current state (mig 096 unified the appeal track):
|
||||
- id=160 `upc.apl.unified` is `is_active=true`, holds 16 rules.
|
||||
- id=11 `upc.apl.merits` is `is_active=false`.
|
||||
- id=19 `upc.apl.cost` is `is_active=false`.
|
||||
- id=20 `upc.apl.order` is `is_active=false`.
|
||||
- 4 spawn rules point at id=11 (inactive) — looks like the R3 bug but is actually correctly aimed at merits since cost+order arrive differently (athena R3 partially mis-classified the situation).
|
||||
- Event codes already carry the split prefix: `upc.apl.{merits,cost,order}.*`. 16 events split cleanly into 7 merits + 2 cost + 7 order.
|
||||
|
||||
The migration:
|
||||
|
||||
```sql
|
||||
-- Mig P1: re-activate the three discrete appeal PTs and retire the unified row.
|
||||
UPDATE paliad.proceeding_types SET is_active = true WHERE id IN (11, 19, 20);
|
||||
UPDATE paliad.proceeding_types SET is_active = false WHERE id = 160;
|
||||
|
||||
-- Mig P1: re-target each rule whose proceeding_type_id is currently 160
|
||||
-- to the right reactivated PT based on its event_code prefix.
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET proceeding_type_id = 11
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.id = sr.procedural_event_id
|
||||
AND sr.proceeding_type_id = 160
|
||||
AND pe.code LIKE 'upc.apl.merits.%';
|
||||
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET proceeding_type_id = 19
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.id = sr.procedural_event_id
|
||||
AND sr.proceeding_type_id = 160
|
||||
AND pe.code LIKE 'upc.apl.cost.%';
|
||||
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET proceeding_type_id = 20
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.id = sr.procedural_event_id
|
||||
AND sr.proceeding_type_id = 160
|
||||
AND pe.code LIKE 'upc.apl.order.%';
|
||||
|
||||
-- 4 spawn FKs: stay at id=11 (merits) for inf/rev/dmgs; update upc.pi.cfi's
|
||||
-- spawn to point at id=20 (order) — appeals against PI orders go to the
|
||||
-- orders track, not merits.
|
||||
UPDATE paliad.sequencing_rules
|
||||
SET spawn_proceeding_type_id = 20
|
||||
WHERE is_spawn AND procedural_event_id = (
|
||||
SELECT id FROM paliad.procedural_events WHERE code = 'upc.pi.cfi.appeal_spawn'
|
||||
);
|
||||
-- The other 3 spawn rules (inf/rev/dmgs) keep spawn_proceeding_type_id = 11
|
||||
-- (correct after re-activation).
|
||||
```
|
||||
|
||||
**Determinator UX preserved.** `internal/services/proceeding_mapping.go` (t-paliad-204 S1) keeps its single "Berufung" front door. The mapping fans out to id=11/19/20 based on what's being appealed (judgment / cost decision / order). No user-facing routing change. The change is purely structural.
|
||||
|
||||
**Active scenarios / projects pointing at id=160:** none (`paliad.scenarios` and `paliad.projects.active_scenario_id` both empty per athena §0; only 6 projects have any `proceeding_type_id` set and none of them is 160). Zero data migration on the project side.
|
||||
|
||||
### §3.2 Empty PTs — show with "Keine Regeln gepflegt" badge
|
||||
|
||||
Per m's Q6 — option 2 with a follow-on editorial note ("We need to publish rules then... but yeah, show with the badge for now"):
|
||||
|
||||
Picker query for `/api/tools/proceeding-types` gains a flag-not-filter:
|
||||
|
||||
```sql
|
||||
SELECT pt.*,
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.sequencing_rules sr
|
||||
WHERE sr.proceeding_type_id = pt.id
|
||||
AND sr.is_active AND sr.lifecycle_state = 'published'
|
||||
) AS has_rules
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.is_active AND pt.kind = 'proceeding';
|
||||
```
|
||||
|
||||
Frontend renders the chip with a muted/disabled treatment + badge "Keine Regeln gepflegt" when `has_rules = false`. Project creation can still bind to an empty PT (admin override), but Mode A/B/Verfahrensablauf surface a clear "this proceeding has no seeded rules yet" message.
|
||||
|
||||
Editorial follow-up: m publishes rules for the 6 empty PTs (`upc.bsv.cfi`, `upc.ccr.cfi`, `upc.costs.cfi`, `upc.dni.cfi`, `upc.epo.review`, `upc.pl.cfi`) over time; each new published rule auto-removes the badge for its PT. Not blocking this design.
|
||||
|
||||
### §3.3 Entry A — extend /tools/verfahrensablauf
|
||||
|
||||
Per m's Q7. The existing `/tools/verfahrensablauf` page (used by `frontend/src/client/verfahrensablauf.ts` + shared `views/verfahrensablauf-core.ts`) already serves the pick-a-PT shape. Extend it to:
|
||||
|
||||
- Render the parent_id chain as a **collapsible tree** (top-down chronological). Same data shape as §1.2's ASCII trees.
|
||||
- Expose **optionals + conditionals as toggleable checkboxes** in the tree itself. Ticking writes via `PATCH /api/projects/{id}/scenario-flags` (Akte mode) or localStorage (kontextfrei).
|
||||
- Reflect cross-party rows with the same muted style as §2.4 (Gegenseitig badge).
|
||||
- Spawn rows render as **leaf with edge annotation** (⇲ Berufungsverfahren öffnen) and a "create child case" CTA in Akte mode.
|
||||
- Optionally: a "Zur Frist-Ansicht" deeplink on each tree node → opens Mode B Fristenrechner with that event pre-locked as the trigger.
|
||||
|
||||
Backend: extend `/api/tools/fristenrechner` (the proceeding-type fan-out endpoint) to return a tree-shaped payload (`parent_id` resolved into nested children). New handler param or new endpoint `/api/tools/verfahrensablauf/tree?proceeding_type_code=X&project=Y`.
|
||||
|
||||
The legacy `/tools/fristenrechner?legacy=1` Procedure-mode page deprecates naturally — same scope, replaced by this Entry A view.
|
||||
|
||||
### §3.3a Verfahrensablauf view-mode toggle
|
||||
|
||||
A three-way segmented control above the tree at the Verfahrensablauf surface:
|
||||
|
||||
```
|
||||
┌─ Anzeige ──────────────────────────────────────┐
|
||||
│ ( ) Nur Pflicht (•) Gewählt ( ) Alle Optionen │
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Behaviour:
|
||||
- **Nur Pflicht**: only `priority='mandatory'` cards render. Tightest view.
|
||||
- **Gewählt** (default): mandatory + every rule that resolves to "selected" given current scenario state (mandatory always; recommended unless explicitly deselected via `rule:X = false`; optional only if explicitly selected via `rule:X = true`; conditional only if its flag predicate holds AND the priority-default-or-deviation puts it in the selected set). Honest summary of what *this* lawyer has chosen for *this* project.
|
||||
- **Alle Optionen**: everything that could belong, with unselected optionals rendered with the dotted-border + `[Aufnehmen]` CTA, and conditional rules whose flag isn't set rendered greyed with the activation hint.
|
||||
|
||||
**Persistence**: per-user, per-browser via `localStorage` under key `verfahrensablauf:view_mode`. Not project-scoped — the same user looking at two different projects probably wants the same verbosity. Not in `scenario_flags` either — view-mode is a UI preference, not a scenario fact. No new schema; no API; no migration.
|
||||
|
||||
Cross-surface sync: the **Mode B result view** does NOT carry its own view-mode toggle. It always renders in "Gewählt" semantics (mandatory + selected). Rationale: Mode B locks a single trigger event and lists its follow-ups; the lawyer isn't browsing the full ablauf, they're focused on one moment. The view-mode toggle is a Verfahrensablauf-only affordance.
|
||||
|
||||
The view-mode toggle composes with the scenario-flags strip (§2.3). Toggling "Mit Widerklage auf Nichtigkeit" off in "Gewählt" mode removes the CCR conditional branch from view; flipping to "Alle Optionen" re-renders the CCR branch greyed with the activation hint. The user can see what they're *not* currently considering without losing the simplified default view.
|
||||
|
||||
### §3.4 Legacy `/api/tools/event-deadlines` deprecation
|
||||
|
||||
Per m's Q8. Sequence:
|
||||
|
||||
1. **Mig P3 — 73-globals reparenting completes** (§4.2, editorial work). Once `paliad.sequencing_rules WHERE proceeding_type_id IS NULL` is empty, the legacy route has no live data shape it uniquely serves.
|
||||
2. **Code drop:** remove `/api/tools/event-deadlines` route + `EventDeadlineService` + the `deadline_rule_service.go:226-285` label-fallback path + the `ExportService:1680` workbook sheet.
|
||||
3. **Table drop:** `DROP TABLE paliad.trigger_events` (mig P4, §4.3).
|
||||
4. **Snapshot generator:** `cmd/gen-upc-snapshot/main.go` stops reading `paliad.trigger_events`; UPC snapshot for youpc.org only carries the unified rule shape.
|
||||
|
||||
The cleanup is gated on §4.2 completion. If editorial backfill is slow, the route can live behind a `/api/legacy/` prefix until done — but the design assumption is that we close the loop within the slice train.
|
||||
|
||||
---
|
||||
|
||||
## §4 Tier 3 — editorial + cleanup framework
|
||||
|
||||
### §4.1 `condition_expr` grammar formalisation
|
||||
|
||||
Per m's Q9. The grammar:
|
||||
|
||||
```ts
|
||||
type CondExpr =
|
||||
| { flag: KnownFlag } // leaf
|
||||
| { op: 'and' | 'or'; args: CondExpr[] } // composite (recursive)
|
||||
|
||||
type KnownFlag = 'with_ccr' | 'with_amend' | 'with_cci' // closed set; extensible via admin
|
||||
```
|
||||
|
||||
Implementation:
|
||||
|
||||
- A JSON-schema validator in `RuleEditorService.create`/`update` rejects writes that don't match. Today's 18 rules all conform; no data migration.
|
||||
- Known-flag whitelist sourced from a small Go constant + an admin-editable `paliad.scenario_flag_catalog(name, description, added_at)` table — keeps the vocabulary discoverable. (Lightweight ALTER, not a major migration.)
|
||||
- Engine consumer (`pkg/litigationplanner/expr.go`, currently a switch over string literals) gains exhaustive-case enforcement against the same catalog. Linter catches drift between catalog and engine.
|
||||
|
||||
`choices_offered` and `applies_to_target` (athena R11) — same grammar treatment in a separate ticket (not blocking this revision). Document their 3 known shapes (`appellant`, `skip`, `include_ccr`) in code comments meanwhile.
|
||||
|
||||
### §4.2 Editorial backfill workflow — `/admin/procedural-events` parent-NULL filter
|
||||
|
||||
Per m's Q10:
|
||||
|
||||
- Add filter chip "parent: nicht gesetzt" to the admin list at `/admin/procedural-events`. The filter URL `?parent_filter=null` (or similar).
|
||||
- Track completion per PT via the existing gap-map query (athena §3.1) — show as a progress bar in the admin shell ("upc.inf.cfi: 4/4 roots OK" / "de.inf.lg: 2/5 roots remain").
|
||||
- For the 73 globals: a separate filter `?orphan=true` showing only `proceeding_type_id IS NULL` rules. m clicks each, assigns a PT + parent rule via the editor.
|
||||
- Each save flips lifecycle_state to draft (unchanged from existing editor flow); m publishes a batch when satisfied with a PT.
|
||||
|
||||
No new code surface — the existing admin list + editor handle everything once the filter is added.
|
||||
|
||||
This is editorial work, not coder work. The design captures the framework; m drives the content at his own cadence. No mig is gated on completion (the parent-NULL filter is a feature add; rules stay valid in their current shape during the walk).
|
||||
|
||||
#### §4.2.1 Worked editorial example — R.109 translation chain
|
||||
|
||||
m flagged this case (14:35) as a concrete instance of malformed parent-chain shape. The current data for `upc.inf.cfi`:
|
||||
|
||||
| rule | event | current parent | current primary_party | correct shape |
|
||||
|---|---|---|---|---|
|
||||
| `RoP.109.1` | `upc.inf.cfi.translation_request` (Antrag auf Simultanübersetzung) | upc.inf.cfi root (Mündliche Verhandlung) | both | parent stays at MV; flagged optional (default-unselected) |
|
||||
| `RoP.109.4` | `upc.inf.cfi.interpreter_cost` (Mitteilung Dolmetscherkosten) | upc.inf.cfi root (Mündliche Verhandlung) — **WRONG** | court — **WRONG** | parent = R.109.1; primary_party = both (parties give the Mitteilung, not the court); condition_expr = `{"flag": "with_interpreter_denied"}` |
|
||||
| `RoP.109.5` | `upc.inf.cfi.translations_lodge` (Übersetzungen einreichen) | upc.inf.cfi root | both | parent = R.109.1 (lodging follows the request); priority stays mandatory but conditional via `{"flag": "with_translation_granted"}` |
|
||||
|
||||
Two new scenario flags introduced (`with_interpreter_denied`, `with_translation_granted`) get added to the `scenario_flag_catalog` (§4.1) when the editor saves these rules.
|
||||
|
||||
Editorial walk for m:
|
||||
1. Open `/admin/procedural-events?orphan=false&parent_filter=null&proceeding_type=upc.inf.cfi`.
|
||||
2. Find R.109.1, R.109.4, R.109.5 — they sit at depth 1 under the root.
|
||||
3. Edit R.109.4: set `parent_id = <R.109.1's id>`; set `primary_party = both`; set `condition_expr = {"flag": "with_interpreter_denied"}`. Save (draft).
|
||||
4. Edit R.109.5: set `parent_id = <R.109.1's id>`; set `condition_expr = {"flag": "with_translation_granted"}`. Save (draft).
|
||||
5. Publish both.
|
||||
6. The catalog accepts the two new flag names; the validator updates.
|
||||
|
||||
Result in the Verfahrensablauf tree (post-fix):
|
||||
|
||||
```
|
||||
upc.inf.cfi root
|
||||
├─ Mündliche Verhandlung (court · M)
|
||||
├─ Antrag auf Simultanübersetzung (RoP.109.1) [both · O]
|
||||
│ ├─ Mitteilung Dolmetscherkosten (RoP.109.4) [both · M · ?with_interpreter_denied]
|
||||
│ └─ Übersetzungen einreichen (RoP.109.5) [both · M · ?with_translation_granted]
|
||||
```
|
||||
|
||||
In **Gewählt** mode without scenario flags: only the root + Mündliche Verhandlung surface. R.109.1 is an unselected optional → hidden. R.109.4 + R.109.5 are conditional + below an unselected ancestor → hidden.
|
||||
|
||||
In **Gewählt** mode after the user clicks `[Aufnehmen]` on R.109.1: R.109.1 appears. R.109.4 still hidden (its flag `with_interpreter_denied` isn't set; the user would need to know the court denied the Antrag, then tick the flag in the Szenario-Flags strip). R.109.5 similarly hidden until `with_translation_granted` is on.
|
||||
|
||||
In **Alle Optionen** mode: every rule renders, conditionals greyed with their flag hint, R.109.1 dotted with `[Aufnehmen]`.
|
||||
|
||||
This is the model in miniature: the editorial fix is data-only (no schema change, just `parent_id` + `condition_expr` + `primary_party` UPDATEs via the editor); the display fix is policy that the existing scenario_flags + view-mode mechanism already supports.
|
||||
|
||||
### §4.3 `paliad.trigger_events` table fate — drop
|
||||
|
||||
Per m's Q11. Sequence (chained to §3.4):
|
||||
|
||||
1. After 73-globals reparented + route dropped + label-fallback ported to `procedural_events.name`:
|
||||
2. `DROP TABLE paliad.trigger_events` (mig P5, last in the train).
|
||||
3. Migrate `cmd/gen-upc-snapshot/main.go` to no longer SELECT from this table.
|
||||
4. Remove the `ref__trigger_events` sheet from `ExportService` workbook output.
|
||||
|
||||
The bigint PK / parallel taxonomy disappears entirely. `procedural_events` (uuid PK) is the only event catalog.
|
||||
|
||||
---
|
||||
|
||||
## §5 Schema delta + migration plan (slice train)
|
||||
|
||||
Six slices, sequential where data-coupled, parallelisable where not. Each slice ships as one or two PRs.
|
||||
|
||||
| Slice | Mig | What ships | Reversible? |
|
||||
|---|---|---|---|
|
||||
| **P0 — Scenario SSoT** | mig 154 | `ALTER TABLE projects ADD COLUMN scenario_flags jsonb`; GET/PATCH endpoints w/ extended whitelist (named flags + `rule:<uuid>` per-rule entries, validated against project's PT rule set); Verfahrensablauf + result-view binding; `scenario_flag_catalog` table (§4.1) | Yes — DROP COLUMN |
|
||||
| **P1 — Appeal re-split** | mig 155 | UPDATE proceeding_types (re-activate 11/19/20, deactivate 160); UPDATE sequencing_rules (rebind 16 rules to merits/cost/order by event_code prefix); UPDATE pi.cfi spawn FK → 20 | Reversible by inverse UPDATEs; documented in down mig |
|
||||
| **S1+S1a from t-paliad-327** | — | Cross-party display backend + frontend; spawn-only picker filter (`sr.is_spawn = false` in SearchEvents) | Yes — code-only |
|
||||
| **P2 — Empty-PT badge** | — | `has_rules` flag on /api/tools/proceeding-types; frontend muted-chip rendering | Yes — code-only |
|
||||
| **P3 — Entry A (Verfahrensablauf tree)** | — | Tree endpoint + tree UI in /tools/verfahrensablauf; three-way view-mode toggle (localStorage); per-rule `[Aufnehmen]`/`[Entfernen]` chips wire to scenario_flags `rule:<uuid>` entries; subtree-hide-on-unselected-ancestor render logic | Yes — code-only |
|
||||
| **P4 — Editorial walk (73 globals)** | — | parent-NULL filter on /admin/procedural-events; editorial work by m (no coder task per se) | Trivially reversible |
|
||||
| **P5 — trigger_event_id deprecation** | mig 156 | DROP `/api/tools/event-deadlines`; DROP `EventDeadlineService`; port label-fallback in deadline_rule_service.go; remove ref__trigger_events sheet; `ALTER TABLE sequencing_rules DROP COLUMN trigger_event_id`; `DROP TABLE trigger_events`; condition_expr write-time validator | Last; downgrade requires re-adding column + re-populating — irreversible in practice |
|
||||
|
||||
Constraint: **P5 is gated on P4 completion** (no rules can have NULL proceeding_type_id when DROP runs). All other slices ship independently.
|
||||
|
||||
Ordering rationale:
|
||||
- P0 unblocks the Fristenrechner-side bugs immediately (no waiting on appeal-split editorial).
|
||||
- P1 is data-only, low risk, can land in parallel with P0.
|
||||
- S1+S1a are code-only follow-ons to P0 (same scenario-flag plumbing).
|
||||
- P2 ships once P1 lands (re-activated PTs need badge support too).
|
||||
- P3 builds on P2 + the tree endpoint; depends on P0 for flag persistence.
|
||||
- P4 is m's editorial work — duration depends on m's cadence, not coder velocity.
|
||||
- P5 is the cleanup at the end. Only safe when P4 is done.
|
||||
|
||||
---
|
||||
|
||||
## §6 Entry A UI spec (sequence-from-proceeding-type)
|
||||
|
||||
Live URL: `/tools/verfahrensablauf?project=<id>&proceeding_type=upc.inf.cfi`.
|
||||
|
||||
### §6.1 Layout
|
||||
|
||||
```
|
||||
┌─ Akte / kontextfrei ─────────┐ ┌─ Verfahren ──┐ ┌─ Anzeige ──────────────────────────┐
|
||||
│ HL-2024-001 ▼ │ ohne Akte │ │ upc.inf.cfi ▼│ │ Nur Pflicht ⦿ Gewählt ○ Alle Optionen │
|
||||
└──────────────────────────────┘ └──────────────┘ └────────────────────────────────────┘
|
||||
|
||||
┌─ Szenario-Flags ──────────────────────────────────┐
|
||||
│ ☑ Mit Widerklage auf Nichtigkeit (with_ccr) │
|
||||
│ ☐ Mit Antrag auf Patentänderung R.30 (with_amend) │
|
||||
│ ☐ Mit Widerklage auf Verletzung (with_cci) │
|
||||
└────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ Ablauf ── (view-mode: Gewählt) ───────────────────────────────────┐
|
||||
│ 📥 Klageerhebung [claimant · mandatory] │
|
||||
│ ├─ Klageerwiderung [defendant · mandatory] │
|
||||
│ │ └─ Replik [claimant · M · ?with_ccr]│
|
||||
│ │ └─ Duplik [defendant · M · ?with_ccr]│
|
||||
│ ├─ Widerklage auf Nichtigkeit [defendant · O · ?with_ccr][Entfernen]│ ← selected optional
|
||||
│ │ └─ Erwiderung auf CCR [claimant · M · ?with_ccr]│
|
||||
│ │ └─ Replik auf Erw. CCR [defendant · M · ?with_ccr][Gegenseitig]│
|
||||
│ │ └─ Duplik auf Replik [claimant · M · ?with_ccr]│
|
||||
│ └─ ⇲ Berufungsverfahren öffnen [SPAWN → upc.apl.merits] │
|
||||
│ 🏛️ Zwischenanhörung [court · mandatory] │
|
||||
│ 🏛️ Mündliche Verhandlung [court · mandatory] │
|
||||
│ ⚖️ Endentscheidung [court · mandatory] │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
↓ (user flips view-mode to "Alle Optionen")
|
||||
|
||||
┌─ Ablauf ── (view-mode: Alle Optionen) ─────────────────────────────┐
|
||||
│ 📥 Klageerhebung [claimant · mandatory] │
|
||||
│ ├─ ┄ Vorl. Einwendungen [defendant · O] [Aufnehmen]┄ │ ← unselected, dotted
|
||||
│ ├─ Klageerwiderung [defendant · mandatory] │
|
||||
│ ├─ Widerklage auf Nichtigkeit [defendant · O · ?with_ccr][Entfernen]│
|
||||
│ ├─ ┄ Antrag auf Patentänderung [O · ?with_amend] greyed │ ← flag not set
|
||||
│ │ └─ wenn 'Mit Patentänderung' im Szenario aktiv │
|
||||
│ ├─ ┄ Antrag auf Simultanübersetzung [O] [Aufnehmen]┄ │ ← post-§4.2.1
|
||||
│ │ ├─ ┄ Mitteilung Dolmetscherkosten [M · ?with_interpreter_denied]│
|
||||
│ │ └─ ┄ Übersetzungen einreichen [M · ?with_translation_granted]│
|
||||
│ ├─ ┄ Antrag CMO-Überprüfung [both · O] [Aufnehmen]┄ │
|
||||
│ ├─ ┄ Antrag Folgenanordnungen R.118(4) [both · O] [Aufnehmen]┄ │
|
||||
│ └─ ⇲ Berufungsverfahren öffnen [SPAWN → upc.apl.merits] │
|
||||
│ 🏛️ ... │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### §6.2 Behaviour
|
||||
|
||||
- **Project picker (Step 0)** unchanged from Fristenrechner.
|
||||
- **Proceeding-type picker** chips → switching re-fetches the tree.
|
||||
- **View-mode toggle (§3.3a)** — three-way segmented control (Nur Pflicht / Gewählt / Alle Optionen). State in `localStorage["verfahrensablauf:view_mode"]`. Default = "Gewählt". Re-renders the tree on toggle; no network call.
|
||||
- **Szenario-Flags strip** reads/writes `projects.scenario_flags` (Akte) or localStorage (kontextfrei). Same `scenario-flag-changed` CustomEvent as Mode B's result view — both surfaces stay in sync. Flag entries (`with_ccr` etc.) live alongside per-rule entries (`rule:<uuid>`) in the same jsonb.
|
||||
- **Per-rule selection chips** — every non-mandatory rule's card carries `[Aufnehmen]` (unselected → tick selects) or `[Entfernen]` (selected → tick deselects). The handler PATCHes `projects.scenario_flags` with `{ "rule:<uuid>": true|false }` and fires the same `scenario-flag-changed` event.
|
||||
- **Subtree hide-on-deselect** — when a chain head (any rule with children via `parent_id`) is unselected in "Gewählt" mode, its descendants don't render. The tree walker checks each rule's full ancestor chain; any unselected ancestor hides the descendant. In "Alle Optionen" mode, descendants render greyed under the unselected ancestor.
|
||||
- **Cross-party rows** render with `Gegenseitig` badge, muted style (same as Mode B result view §2.4). Composes with selection state and view-mode independently.
|
||||
- **Spawn rows** render as leaves with the ⇲ symbol + "Neues Verfahren öffnen" CTA (Akte mode only; kontextfrei shows the badge without the CTA). Spawn rows ignore selection state — they always render in "Gewählt" + "Alle Optionen" modes since they represent a possible next-procedure rather than an in-scenario deadline.
|
||||
- **Empty PT** (the 6 unruled): tree area renders an inline "Für dieses Verfahren sind noch keine Regeln gepflegt" message + a link to /admin if the user is admin.
|
||||
- **Deeplink to Mode B:** each tree node has a "Frist berechnen" link that opens `/tools/fristenrechner?event=<code>&trigger_date=…&project=…`.
|
||||
|
||||
### §6.3 Backend
|
||||
|
||||
New handler: `GET /api/tools/verfahrensablauf/tree?proceeding_type=upc.inf.cfi&project=<id>` returns:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"proceeding_type": { "code": "upc.inf.cfi", "name_de": "...", "name_en": "..." },
|
||||
"scenario_flags": { "with_ccr": true, "with_amend": false },
|
||||
"tree": [
|
||||
{
|
||||
"rule_id": "...", "event_code": "upc.inf.cfi.soc",
|
||||
"name_de": "Klageerhebung", "primary_party": "claimant",
|
||||
"priority": "mandatory", "has_condition": false, "is_spawn": false,
|
||||
"is_cross_party": false,
|
||||
"children": [
|
||||
{ "rule_id": "...", "event_code": "upc.inf.cfi.sod", ... , "children": [...] },
|
||||
...
|
||||
]
|
||||
},
|
||||
... // chain-anchored roots
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The tree is the result of walking `parent_id` recursively from the PT's root rules (those with `parent_id IS NULL` for this PT). Computed via one recursive CTE; cached per-PT (the tree shape changes only on rule edits).
|
||||
|
||||
`is_cross_party` is computed against `projects.our_side` (Akte mode) or the request's `?party=` query param (kontextfrei).
|
||||
|
||||
---
|
||||
|
||||
## §7 Entry B UI spec — reaffirms shipped Fristenrechner Mode A+B
|
||||
|
||||
Mode A (`/tools/fristenrechner?mode=search`) and Mode B (`?mode=wizard`) — both shipped via t-paliad-322 S1-S6. Surgical follow-ons from t-paliad-327 design (§0.2):
|
||||
|
||||
- Mode A search: add `AND sr.is_spawn = false` to `SearchEvents` WHERE block + add the derived-trigger filter `EXISTS (non-spawn child)` from §2.2. Compiled together as one PR (S1+S1a).
|
||||
- Mode B R4 chip-strip: identical filter on the wizard's event-pool query.
|
||||
- Result view: stop filtering follow-ups by party server-side (§2.4); render cross-party with badge.
|
||||
- Scenario flag binding: result-view CONDITIONAL group reads/writes `projects.scenario_flags` via the new API (P0). Same CustomEvent sync as Entry A.
|
||||
|
||||
No layout changes. The mode tabs (⚡ Direkt suchen / 🧭 Geführt) stay as today. The 3rd entry path is Entry A on the verfahrensablauf page — not a Mode C.
|
||||
|
||||
---
|
||||
|
||||
## §8 Worked examples
|
||||
|
||||
### §8.1 Entry A — claimant on HL-2024-001 (upc.inf.cfi, with_ccr=true)
|
||||
|
||||
User opens `/tools/verfahrensablauf?project=HL-2024-001&proceeding_type=upc.inf.cfi`.
|
||||
|
||||
- Project context loads. `scenario_flags = {with_ccr: true}`.
|
||||
- Tree GET returns the §1.2 shape, with conditional rules' `has_condition` flagged.
|
||||
- UI renders: top-level SoC anchor → branches. The CCR branch is fully expanded because `with_ccr=true`. The R.30 amend branch renders but conditionals are greyed (with_amend=false).
|
||||
- User clicks "Mit Antrag auf Patentänderung R.30" in the Szenario-Flags strip.
|
||||
- Frontend fires `PATCH /api/projects/HL-2024-001/scenario-flags { with_amend: true }`. Server stores. CustomEvent dispatches.
|
||||
- Tree re-renders: R.30 amend branch ungreys; conditional rules become live.
|
||||
- User scrolls to "Erwiderung auf CCR" → clicks "Frist berechnen" → deeplinks to Mode B with `event=upc.inf.cfi.def_to_ccr&trigger_date=<today>&project=HL-2024-001`.
|
||||
- Mode B result view loads. Cross-party RoP.029.d (defendant Replik) shows with `Gegenseitig` badge.
|
||||
|
||||
### §8.2 Entry B — Mode A search after picker filter
|
||||
|
||||
User types "Berufung" in Mode A.
|
||||
|
||||
- Backend SQL (post-§2.2 + post-spawn filter):
|
||||
```sql
|
||||
WHERE pe.name % 'Berufung' OR pe.code % 'Berufung'
|
||||
AND sr.is_active AND sr.is_spawn = false
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM paliad.sequencing_rules child
|
||||
WHERE child.parent_id = sr.id AND child.is_active AND NOT child.is_spawn
|
||||
)
|
||||
```
|
||||
- Returns: real triggers in the appeal track (`upc.apl.merits.notice`, `upc.apl.merits.grounds`, `upc.apl.order.with_leave`, etc. — post-Q5 split). Does NOT return: `upc.{inf,rev,pi,dmgs}.cfi.appeal_spawn` (spawn-only) or terminal leaves (no children).
|
||||
|
||||
User picks `upc.apl.merits.notice` → result view loads its follow-ups. Tree renders cleanly because the Q5 split gave merits its own chain root.
|
||||
|
||||
### §8.3 Editorial flow — m reparents a legacy global
|
||||
|
||||
m opens `/admin/procedural-events?orphan=true`. Sees the 73-row list.
|
||||
|
||||
- m clicks row "Antrag auf Verlängerung der Klagefrist" (one of the legacy globals with `proceeding_type_id NULL`).
|
||||
- Editor opens. m assigns `proceeding_type_id = upc.inf.cfi` and `parent_id = <RoP.013.1 soc rule>`.
|
||||
- Save. Rule lifecycle flips to draft. m clicks Publish.
|
||||
- The rule now sits under upc.inf.cfi's tree as a hop-1 child of SoC. Mode A picker EXISTS check passes for SoC (was already passing); the tree gains one more chip.
|
||||
- 72 globals to go. m walks at own cadence; no coder time blocked.
|
||||
|
||||
---
|
||||
|
||||
## §9 Out of scope
|
||||
|
||||
- **Calculator (`pkg/litigationplanner.CalculateRule`).** Working as designed.
|
||||
- **Holiday / working-day logic.** Out of scope.
|
||||
- **`choices_offered` + `applies_to_target` formalisation** (athena R11). Same shape as condition_expr would warrant — separate ticket once condition_expr formalisation ships.
|
||||
- **Adding new proceeding_types.** The 23 are stable; editorial work fills the 6 unruled ones.
|
||||
- **DE-side spawn edges** (LG → OLG → BGH as spawns instead of separate projects). Possible v2; not driven by current pain.
|
||||
- **AI-extracted deadlines from documents.** Deferred per memory `b6a11b55…`.
|
||||
- **Cross-tab scenario-flag sync in Akte mode.** Single-tab v1; SSE/WebSocket if it matters later.
|
||||
- **`event_kind` ENUM-ing** (athena R10). Cosmetic; vocab is stable.
|
||||
|
||||
---
|
||||
|
||||
## §10 m's decisions (2026-05-27)
|
||||
|
||||
All 12 questions answered via `AskUserQuestion` on 2026-05-27 ~13:55 (3 batches of 4). 11 picks on-recommendation; Q5 diverged with verbatim reasoning. Plus 8 pre-ratified picks from t-paliad-327 carried forward (§0.2).
|
||||
|
||||
### Tier 1 — model decisions
|
||||
|
||||
- **Q1 (Trigger link canonical): `parent_id` wins, deprecate `trigger_event_id`.** [= recommendation] **Locks §2.1.** Drop the column after backfill completes.
|
||||
- **Q2 (73 legacy globals fate): Reparent onto PT chains via editorial walk.** [= recommendation] **Locks §4.2.** m drives the walk at admin /admin/procedural-events; the orphan filter is the only new UI surface.
|
||||
- **Q3 (Trigger discoverability): Derive from data.** [= recommendation] **Locks §2.2.** EXISTS subquery on parent_id; no new column, no view.
|
||||
- **Q4 (Scenario SSoT shape): `projects.scenario_flags jsonb`.** [= recommendation; confirms t-paliad-327 design under wider scrutiny] **Locks §2.3.**
|
||||
|
||||
### Tier 2 — surface decisions
|
||||
|
||||
- **Q5 (Appeal taxonomy): Reverse the unification — split upc.apl.unified back into merits/cost/order.** [≠ recommendation; m picked option 3, "reverse the unification"] m's verbatim:
|
||||
> yes, reverse the unification as suggested in 3. They are different proceedings, I only wanted the approach to be unified in the "determinator" - but they are actually different proceedings!
|
||||
**Updates §1.4 + §3.1.** Mig P1 re-activates id=11/19/20, retires id=160, rebinds 16 rules by event_code prefix, retargets the pi.cfi spawn FK to id=20. Determinator routing layer (proceeding_mapping.go) keeps the single "Berufung" front door but fans out to the 3 PTs.
|
||||
- **Q6 (Empty PTs): Show with "Keine Regeln gepflegt" badge for now.** [= recommendation; option 2] m's note: "We need to publish rules then... but yeah, show with the badge for now." **Locks §3.2.** Editorial follow-up is m's; not blocking the design.
|
||||
- **Q7 (Entry A location): Fold into /tools/verfahrensablauf.** [= recommendation] **Locks §3.3 + §6.**
|
||||
- **Q8 (Legacy /event-deadlines route): Drop after Tier 1 + 73-globals reparenting.** [= recommendation] **Locks §3.4. Gated on §4.2 completion.**
|
||||
|
||||
### Tier 3 — editorial + cleanup framework
|
||||
|
||||
- **Q9 (condition_expr grammar): Lock to `{flag: "X"} | {op: "and"|"or", args: [...]}`.** [= recommendation] **Locks §4.1.** Write-time JSON-schema validator + known-flag catalog table.
|
||||
- **Q10 (Editorial backfill workflow): Admin /admin/procedural-events with parent-NULL filter.** [= recommendation] **Locks §4.2.** No new UI surface beyond the filter chip.
|
||||
- **Q11 (`trigger_events` table fate): Drop after route is gone.** [= recommendation] **Locks §4.3.** Sequenced as Mig P5, last in the slice train.
|
||||
- **Q12 (Visual format): ASCII trees per PT + Mermaid for spawn edges.** [= recommendation] **Locks §1.2 + §1.3.**
|
||||
|
||||
### 10.0a Post-ratification additions (m, 2026-05-27 14:34–14:40)
|
||||
|
||||
After the §10 main grilling, m added three directions on top of the ratified design. None re-opened a Tier 1 decision; all extended the Verfahrensablauf surface.
|
||||
|
||||
- **Selection state + detail-level filter (m 14:40, supersedes earlier "rarity" framing).** Every optional rule becomes a per-scenario selectable card; selection state lives in the existing `projects.scenario_flags jsonb` with extended shape (`{flag: bool, "rule:<uuid>": bool}`). Recommended = default-selected; optional = default-unselected; mandatory = locked. Deviations only land in storage. No new column on `sequencing_rules`. **Locks §2.4a.** Replaces the pre-clarification strawman that proposed `is_edge_case boolean` — m's reframe makes that wrong (rarity is a scenario property, not a rule property).
|
||||
- **View-mode toggle on Verfahrensablauf.** Three-way segmented control: Nur Pflicht / Gewählt / Alle Optionen. Per-user persistence via `localStorage["verfahrensablauf:view_mode"]`. Default "Gewählt". **Locks §3.3a.** Mode B result view does NOT carry the toggle — it's a Verfahrensablauf-only affordance.
|
||||
- **R.109 chain editorial worked example.** m flagged R.109.1 / R.109.4 / R.109.5 as a concrete editorial-backfill case (wrong parent_id, wrong primary_party on R.109.4, missing condition_expr on R.109.4/.5). Folded as **§4.2.1** worked example demonstrating the parent-NULL filter workflow without code change. Two new scenario-flag names introduced (`with_interpreter_denied`, `with_translation_granted`); both land in the `scenario_flag_catalog` (§4.1) at edit time.
|
||||
|
||||
These additions don't change the slice train sequence (§5). They tighten P0 (the `scenario_flags` PATCH endpoint now validates `rule:<uuid>` keys against the project's active rule set) and P3 (Entry A tree now renders the view-mode toggle + per-rule selection chips), but no new mig is added.
|
||||
|
||||
### 10.1 What changed from the strawman as a result
|
||||
|
||||
Beyond §10.0a additions, the Q5 divergence is the only material change:
|
||||
|
||||
- **Mig P1 (appeal re-split)** is now part of the slice train. It was NOT in the strawman; the strawman assumed athena's R3 was a simple FK retarget. m's pick recasts the unification itself as the bug.
|
||||
- §1.4 per-PT table shows 3 separate appeal PT rows (merits/cost/order) instead of one unified row. The 16 rules under id=160 redistribute to id=11/19/20.
|
||||
- §1.3 spawn graph fan-out has merits (3 edges from inf/rev/dmgs) + order (1 edge from pi) as distinct targets instead of all 4 pointing at a single unified row.
|
||||
|
||||
All other §1-§8 sections hold as originally drafted.
|
||||
|
||||
---
|
||||
|
||||
## §11 Synthesis links
|
||||
|
||||
- mBrian: file as `[synthesis]` linked `triggered_by` t-paliad-329; `related_to` athena's assessment (`document-assessment-deadline-system`) + my proceeding_types taxonomy synthesis + Fristenrechner overhaul synthesis + t-paliad-327 follow-up rules synthesis.
|
||||
- Cross-refs: `docs/assessment-deadline-system-2026-05-27.md` (athena), `docs/design-fristenrechner-followup-rules-2026-05-27.md` (atlas, pre-ratified subset), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus, S1-S6 shipped), `docs/design-proceeding-types-taxonomy-2026-05-26.md` (atlas, mig 153 shipped).
|
||||
- Related migrations: 084 (condition_expr backfill), 136 (procedural_events additive), 140 (drop legacy deadline_rules), 145 (`scenarios` table), 153 (proceeding_types.kind).
|
||||
- Coder phase (deferred per inventor SKILL): runs after m ratifies. Slice ordering per §5. NOT cronus (parked) / NOT atlas (inventor). A pattern-fluent Sonnet coder picks up P0 first; P1 + S1/S1a can parallelise; P3 follows; P4 + P5 are gated on each other.
|
||||
@@ -421,7 +421,7 @@ The editor is the **largest single surface** in Phase 3. ~3-4 PRs of work depend
|
||||
| `POST /api/admin/rules` | POST | global_admin | Create a new rule from scratch (starts as `lifecycle_state='draft'`). |
|
||||
| `GET /admin/rules/{id}/audit` | GET | global_admin | Audit log for this rule. |
|
||||
| `POST /admin/rules/{id}/preview` | POST | global_admin | Preview-on-trigger-date — runs calculator with this draft replacing its published peer; returns the resulting timeline (no persistence). |
|
||||
| _(removed t-paliad-297)_ migration-export endpoint | — | — | Was a SQL-export tool generating `*.up.sql` from audit rows. Workflow shifted to hand-written numbered migrations; tool removed in m/paliad#129. |
|
||||
| `POST /admin/rules/export-migration` | POST | global_admin | Export pending (draft + audit-since-last-export) rules as a `*.up.sql` blob the human can paste into `internal/db/migrations/`. Sets `migration_exported=true` on the audit rows. |
|
||||
|
||||
### 4.2 Draft → published lifecycle
|
||||
|
||||
|
||||
@@ -1,553 +0,0 @@
|
||||
# Design — Fristenrechner complete UX overhaul (dual-mode + project write-back)
|
||||
|
||||
**Task:** t-paliad-322
|
||||
**Gitea:** m/paliad#146
|
||||
**Inventor:** cronus (shift-1)
|
||||
**Date:** 2026-05-26
|
||||
**Status:** Draft for m's ratification — coder gate held
|
||||
|
||||
## 0. Premises verified live (before designing)
|
||||
|
||||
Verified against the live youpc Postgres (port 11833, paliad schema) and the live source tree on `mai/cronus/inventor-fristenrechner` @ HEAD.
|
||||
|
||||
### 0.1 Rule-and-event corpus today
|
||||
|
||||
| Table | Active+published rows | Notes |
|
||||
|---|---|---|
|
||||
| `paliad.procedural_events` | 222 (236 total) | The events that anchor a deadline. 4 `event_kind` buckets: `filing`, `hearing`, `decision`, `order`, plus `NULL` for legacy/dpma stragglers. |
|
||||
| `paliad.sequencing_rules` | 231 | The deadlines themselves, anchored on `procedural_event_id` and (sometimes) `trigger_event_id`. 80 carry a `trigger_event_id`, 4 are `is_spawn=true`, 45 are `is_court_set=true`, 18 carry a `condition_expr`. |
|
||||
| `paliad.deadline_concepts` | 57 | Hub layer above events (Klageerhebung, Wiedereinsetzung, …). |
|
||||
| `paliad.proceeding_types` | 46 fristenrechner | 4 jurisdictions: UPC (35), DE (5), EPA (3), DPMA (3). |
|
||||
| `paliad.event_categories` | 125 (103 leaves) | The current cascade tree — 5 user-bucket roots (`cms-eingang`, `muendl-verhandlung`, `beschluss-entscheidung`, `frist-verpasst`, `ich-moechte-einreichen`) + `sonstiges` leaf. UI hides the forward-workflow root (`HIDDEN_CASCADE_ROOTS` in `client/fristenrechner.ts:2605`). |
|
||||
| `paliad.deadlines` | 10 (8 with `sequencing_rule_id`) | Demand-side still tiny. The 2 without `sequencing_rule_id` are manual entries. |
|
||||
|
||||
Live `primary_party` vocabulary on `sequencing_rules`: `claimant`, `defendant`, `both`, `court`, `NULL`. Live `priority` vocabulary: `mandatory`, `recommended`, `optional` (no `informational` rows yet — Phase 2 reserved the slot but seeding is deferred).
|
||||
|
||||
### 0.2 The legacy `deadline_rules` reader is a view
|
||||
|
||||
`paliad.deadline_rules_unified` (mig 139, Slice B.3) is a **view** over `sequencing_rules ⋈ procedural_events ⋈ legal_sources`. All Go calculator paths read through it (see `deadline_rule_service.go:70`). The physical `paliad.deadline_rules` table was dropped in mig 140; the view is the canonical legacy-shape reader. Important for this design: there is no "trigger event" table parallel to events — the rule rows themselves are the things the wizard must land on. `trigger_events` (110 rows) is the youpc-parity legacy table used by `/api/tools/event-deadlines` only.
|
||||
|
||||
### 0.3 The frontend today (`/tools/fristenrechner`)
|
||||
|
||||
Two server-rendered surfaces share the same page (`frontend/src/fristenrechner.tsx`, 657 lines) — the legacy "Procedure mode" (R1 step-list, proceeding picker, trigger date, flag checkboxes) and the **Pathway-B row stack** (`buildRowStack` at `client/fristenrechner.ts:2848`, 4009 lines total). Row stack composes three row kinds via a single `.fristen-row` primitive:
|
||||
|
||||
| Row | Source | Filter or qualifier today |
|
||||
|---|---|---|
|
||||
| R1 Perspective (Beide / Klägerseite / Beklagtenseite) | `currentPerspective`, prefilled from `project.our_side` | hybrid — narrows party-tagged cascade chips AND is used as a column-bucket hint in the result view |
|
||||
| R2 Inbox channel (CMS / beA / Postal / Alle) | `currentInboxChannel` | filter — narrows cascade by forum (CMS → upc, beA → de, …) |
|
||||
| R3..Rn Cascade chain | `event_categories` tree | each step narrows children by `inboxFilterAllowsForums` + `perspectiveAllowsParty` + `cascadeChildAllowsProject` |
|
||||
|
||||
The cascade auto-walks single-child branches under a project context and stops at the first branching point. The user picks a leaf; the leaf's slug feeds `/api/tools/fristenrechner/search?event_category_slug=…` which returns concept cards. Each card expands inline to a calc panel (trigger-date input + flags + computed deadline + "in Akte" CTA).
|
||||
|
||||
### 0.4 What is broken in this UI (m's verdict, 2026-05-26 21:21)
|
||||
|
||||
m's brief in m/paliad#146 enumerates four visible bugs:
|
||||
|
||||
1. **"Beide" as default perspective** is incoherent for the headline use case ("file a deadline because something happened" — you ARE one side).
|
||||
2. **R2 (inbox) does not constrain R3 (cascade)** the way a user expects — picking CMS still leaves "Mündliche Verhandlung" / "Frist verpasst" on the next step. (Cause: those roots have `forums=NULL` in the seed → neutral → visible from every inbox.)
|
||||
3. **Mixed axes** — the form layers filters (forum, inbox channel) on top of qualifiers (event-kind, perspective, proceeding_type) without making the difference visible. The user can't tell which picks narrow and which define.
|
||||
4. **Trigger vs follow-up confusion** — the wizard's purpose is to identify the *trigger event*, then surface the *follow-up deadlines*. Today that split is not reflected in the form. After landing on a leaf, the user gets a flat list of concept cards and has to figure out which one is "the thing that happened" vs "the thing I have to file next".
|
||||
|
||||
m's verdict: "complete overhaul. Should be easy to use."
|
||||
|
||||
### 0.5 Anchor files for the eventual coder
|
||||
|
||||
- `frontend/src/client/fristenrechner.ts` (4009 LoC) — page brain. `buildRowStack` @ L2848, `renderRowStack` @ L3112, `runB1Search` (concept-card render) downstream, `expandCardCalc` @ L1337 (inline calc panel), `openSaveModal` @ L290 + `submitSave` @ L374 (project write-back).
|
||||
- `frontend/src/fristenrechner.tsx` (657 LoC) — server-rendered shell. Contains both the Procedure-mode form **and** the Pathway-B row-stack scaffold. The new design replaces the row-stack scaffold; the procedure-mode form survives.
|
||||
- `internal/handlers/fristenrechner.go` + `_search.go` + `_event_categories.go` — three handler files. `POST /api/tools/fristenrechner` (procedure-mode calc), `GET /search` (concept cards), `GET /event-categories` (cascade tree).
|
||||
- `internal/services/fristenrechner.go` (661 LoC) — calculator adapter to `pkg/litigationplanner`. The calculator is **not** touched by this design.
|
||||
- `internal/handlers/deadlines.go:167` + `services/deadline_service.go:411` (`CreateBulk`) — the project write-back endpoint (`POST /api/projects/{id}/deadlines/bulk`). This survives; the design extends its caller.
|
||||
|
||||
### 0.6 Adjacent design docs to read alongside
|
||||
|
||||
- `docs/design-determinator-row-cascade-2026-05-13.md` — the row-cascade pillars (Project-driven narrowing / Visual hierarchy overhaul / Persistent row stack). This overhaul **keeps** Pillars 2 and 3 and reworks Pillar 1's contract.
|
||||
- `docs/design-fristen-phase2-2026-05-15.md` — the unified `sequencing_rules` model the calculator already runs on.
|
||||
- `docs/audit-fristen-logic-2026-05-13.md` — the trigger-event / Pipeline-A-vs-C distinction.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
**One page, two complementary entry paths, one result surface, one write-back.**
|
||||
|
||||
```text
|
||||
┌───────────────────────── /tools/fristenrechner ─────────────────────────┐
|
||||
│ │
|
||||
│ ╭──────── Akte / kontextfrei ────────╮ (Step 0 — unchanged today) │
|
||||
│ │ HL-2024-001 ▼ | ohne Akte │ │
|
||||
│ ╰─────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ╭────── Entry mode tabs ──────╮ │
|
||||
│ │ [⚡ Direkt suchen] │ ◀── A: power user, search + chips │
|
||||
│ │ [🧭 Geführt] │ ◀── B: 3-5 question wizard │
|
||||
│ ╰─────────────────────────────╯ │
|
||||
│ │
|
||||
│ ┌── Mode A: Suche ──────────────┐ ┌── Mode B: Wizard ────────────────┐│
|
||||
│ │ search-box ▢▢▢▢▢▢▢▢▢▢▢▢▢▢▢ │ │ R1 Was ist passiert? ✓ filing ││
|
||||
│ │ filter chips: │ │ R2 Forum? ✓ UPC ││
|
||||
│ │ Forum · Proceeding · Event- │ │ R3 Verfahren? ✓ INF ││
|
||||
│ │ Kind · Partei │ │ R4 Welcher Schritt? [active] ││
|
||||
│ │ ┌ Ergebnis-Liste ────────────┐│ │ R5 Welche Seite? ✓ Kläger ││
|
||||
│ │ │ procedural_event hits ││ │ ││
|
||||
│ │ │ [Trigger einrasten →] ││ │ (Direkt-suchen ←) ││
|
||||
│ │ └────────────────────────────┘│ └───────────────────────────────────┘│
|
||||
│ └────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ════ shared from here ═══════════════════════════════════════════════ │
|
||||
│ │
|
||||
│ ┌── Trigger event (locked) ──────────────────────────────────────────┐ │
|
||||
│ │ 📥 Klageschrift wurde eingereicht │ │
|
||||
│ │ upc.inf.cfi · Verletzungsverfahren · Klägerseite │ │
|
||||
│ │ Trigger-Datum: [📅 2026-05-20] (heute) │ │
|
||||
│ │ ändern ↩ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌── Folge-Fristen ────────────────────────────────────────────────────┐ │
|
||||
│ │ ◉ MANDATORY (auto-checked) │ │
|
||||
│ │ ☑ Klageerwiderung (3 Monate) — 20.08.2026 — RoP 23 ✏ Datum │ │
|
||||
│ │ ☑ ... │ │
|
||||
│ │ ◇ OPTIONAL │ │
|
||||
│ │ □ Wiedereinsetzungsantrag (R.320) — bei Versäumnis │ │
|
||||
│ │ ◊ CONDITIONAL │ │
|
||||
│ │ □ Erwiderung auf Nichtigkeitswiderklage nur wenn CCR │ │
|
||||
│ │ ⇲ SPAWNED │ │
|
||||
│ │ ☑ Berufung gegen Endurteil (kein Datum) │ │
|
||||
│ │ ╭────────────────────────────╮ │ │
|
||||
│ │ │ 4 ausgewählt → in Akte ▶ │ │ │
|
||||
│ │ ╰────────────────────────────╯ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The two modes never compete: they're two front doors into the **same** locked-trigger-event → follow-up-list → write-back flow.
|
||||
|
||||
---
|
||||
|
||||
## 2. Axis taxonomy — ratified (filters vs qualifiers)
|
||||
|
||||
The headline source of today's UX confusion is the unmarked mixing of *filters* (narrowing the question space without committing to an answer) and *qualifiers* (parts of the eventual deadline definition).
|
||||
|
||||
| Axis | Role | Source | Constrains | Visual in new UI |
|
||||
|---|---|---|---|---|
|
||||
| `forum` | **filter** | derived from `proceeding_types.jurisdiction` (UPC/DE/EPA/DPMA), or from `project.proceeding_type_id`, or user pick | which `proceeding_types` are reachable; which `event_categories` are visible | Mode A: filter chip strip. Mode B: explicit wizard row (R2). Pre-filled + collapsed when there's a project. |
|
||||
| `proceeding_type` | **qualifier** | `project.proceeding_type_id` (binds via mig 096 codes) OR user pick during wizard | the set of `sequencing_rules` rows that can apply | Mode A: filter chip strip. Mode B: explicit wizard row (R3). Pre-filled + collapsed when there's a project. |
|
||||
| `event_kind` | **filter** | `procedural_events.event_kind` (filing / hearing / decision / order) | which `procedural_events` are reachable as triggers | Mode A: filter chip strip. Mode B: explicit wizard row (R1 — the headline question). |
|
||||
| `inbox channel` | **filter** (today) → **out of scope row** (new) | user pick | nothing the user can see (the rule corpus has no "inbox" column; it was only used to recolour the cascade) | Removed from the primary wizard. Pushed into Mode A's secondary chips (off by default). See §3.3. |
|
||||
| `perspective (our_side)` | **qualifier in file-mode**, **filter in explore-mode** | `project.our_side` OR user pick OR implicit-via-event-kind | `sequencing_rules.primary_party`; result-view column bucketing | Wizard tail (R5) **only when** the trigger event's follow-ups actually differ by side. Pre-filled when project has `our_side`. |
|
||||
| `instance_level` (first / appeal / cassation) | qualifier | `project.instance_level` (mig 084) — sparse | rare — used to disambiguate APP+DE | Surfaced only when the wizard hits APP+DE-style ambiguity. |
|
||||
|
||||
**Rule:** a **filter** narrows the visible options without locking in a deadline answer; it can be cleared and re-applied. A **qualifier** is part of the resulting deadline calculation and is locked the moment it's picked. Filters must propagate forward (Mode A's forum-chip narrows the proceeding-chip's options). Qualifiers are picked once and the answer view reads them.
|
||||
|
||||
The "Beide" perspective default (today's bug) is wrong because perspective is a *qualifier* in the headline use case ("file a deadline because something happened — you are one side"), not a *filter*. New default in Mode B: derive from the project's `our_side`, otherwise force a R5 pick (no "Beide"). See Q8 for the explore-mode exception.
|
||||
|
||||
---
|
||||
|
||||
## 3. Mode taxonomy
|
||||
|
||||
### 3.1 Mode A — "⚡ Direkt suchen" (power user)
|
||||
|
||||
Two visually distinct strips (per m §11.Q3):
|
||||
|
||||
```text
|
||||
┌── Filter (eingrenzen) ─────────────────────────────────────────────────┐
|
||||
│ Forum: [UPC] [DE] [EPA] [DPMA] [Alle] │
|
||||
│ Verfahren: [upc.inf.cfi] [...] [Alle] │
|
||||
│ Was passierte: [📥 Eingereicht] [🏛️ Termin] [⚖️ Entscheidung] [📜 Verfügung] [Alle] │
|
||||
│ Partei: [Klägerseite] [Beklagtenseite] [Beide] │
|
||||
├── Suchen ──────────────────────────────────────────────────────────────┤
|
||||
│ 🔎 [_______________________________________________________________] │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
┌── Ergebnisse (klicken = als Trigger einrasten) ────────────────────────┐
|
||||
│ 📥 Klageerhebung · upc.inf.cfi · UPC · 3 Folge-Fristen → │
|
||||
│ ... │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Single text input, four filter chip strips above it (Forum · Proceeding · Event-Kind · Partei), and a ranked result list of `procedural_events` underneath. The "Filter" strip is visibly grouped (e.g. light background + "Filter (eingrenzen)" header) so users see at a glance that those picks narrow but don't commit; clicking a result row IS the commit (the qualifier action).
|
||||
|
||||
- Search hits `/api/tools/fristenrechner/search` (extended to return events, not just concepts — see §6.1).
|
||||
- Filter chips compose with the text query (`?forum=upc&pt=upc.inf.cfi&kind=filing&party=defendant&q=Klageerwiderung`).
|
||||
- Result rows are individual `procedural_events` (not aggregated concept-cards). Each row shows: name (DE/EN), proceeding_type code, jurisdiction badge, event_kind icon, the rule-count it triggers ("3 Folge-Fristen").
|
||||
- Click a row → "lock as trigger event" → page transitions to the §4 result view.
|
||||
- Power affordance: a row with multiple linked rules can be locked in **per-rule** ("nur diese Frist") via a kebab menu on the row. (Sane default: lock the whole event; the kebab is for the lawyer who only wants one specific reactive deadline.)
|
||||
|
||||
### 3.2 Mode B — "🧭 Geführt" (the wizard)
|
||||
|
||||
A 3-5 question row stack that lands on one `procedural_events` row.
|
||||
|
||||
**Question order (strawman; m to ratify in Q5):**
|
||||
|
||||
1. **R1 — Was ist passiert?** Chips: 📥 Eingereicht (`filing`) · 🏛️ Termin (`hearing`) · ⚖️ Entscheidung (`decision`) · 📜 Verfügung (`order`) · 🕒 Frist versäumt (special bucket, routes to Wiedereinsetzung). One chip = one `event_kind` (or the special). Always asked. ~6 chips, fits one row.
|
||||
2. **R2 — Vor welchem Gericht / bei welchem Amt?** Chips: UPC · LG/OLG/BGH · EPA · DPMA. Pre-filled from `project.proceeding_type → jurisdiction` (or `project.court` substring). **Skipped if R1 narrows to a single forum** (e.g. "Termin" + project has UPC → R2 is implied).
|
||||
3. **R3 — In welchem Verfahren?** Chips: every active `proceeding_type` whose jurisdiction matches R2 AND whose event roster contains at least one event with R1's kind. Pre-filled from `project.proceeding_type_id`. **Auto-skipped** when the narrowed scope has only one candidate.
|
||||
4. **R4 — Welches Schriftstück / Welcher Termin?** This is the wizard's landing question. Chips = `procedural_events` filtered by (R2 forum, R3 proceeding_type, R1 event_kind). Typical scope: 1-12 events. If the user types into this row, the chip layout flips to a search list (same widget as Mode A's result list, narrowed to the wizard's filters).
|
||||
5. **R5 — Vertreten Sie Kläger- oder Beklagtenseite?** Asked **only when** the selected event's `sequencing_rules` have follow-ups that differ by `primary_party` (a quick "are there both claimant- and defendant-tagged rules among the follow-ups?" check on the catalog). Pre-filled from `project.our_side`. **Skipped otherwise.**
|
||||
|
||||
**Row badges** (per m §11.Q3): each wizard row carries a small "Filter" or "Qualifier" tag next to its row-number badge. R1 (event_kind), R2 (forum) → "Filter". R3 (proceeding_type), R4 (procedural_event), R5 (perspective) → "Qualifier". A user can tell at a glance which picks lock in vs which narrow.
|
||||
|
||||
Branching policy (locked):
|
||||
|
||||
- Pre-fill + collapse a row when the answer is implied by the project (Determinator §4 pattern, unchanged).
|
||||
- Auto-skip a row when the narrowed scope has exactly one option (the user has effectively no choice). Show the skipped row as a compact `.fristen-row.is-prefilled` line with "(aus Akte)" or "(implizit aus R1)" annotation. *Don't hide the row* — m's "see your selections" pillar from the row-cascade design demands every decision stays visible.
|
||||
- A user-edited upstream answer **preserves compatible downstream picks** (m §11.Q10): if a re-picked R2 (forum) keeps the existing R1 (event_kind) legal, R1 stays; if it makes R3 (proceeding_type) illegal, R3 resets to active. Rows whose pick was carried across an upstream change render with a one-render "erhalten" annotation so the user notices.
|
||||
- "Welches Schriftstück?" (R4) is the landing question. Once R4 is answered, the wizard exits and the §4 result view takes over.
|
||||
|
||||
### 3.3 The dropped `inbox channel` row
|
||||
|
||||
R2-inbox in today's row stack is removed from the primary surface for both modes. Rationale:
|
||||
|
||||
- The rule corpus has no `inbox` column. The cascade's `forums=['cms']` etc. tags were a presentation-layer reflection of which forum naturally arrives on which channel — but the rule itself doesn't change based on whether a UPC document arrived via CMS or by post (it can't; only CMS is legal). So the only honest role for "inbox" is to nudge the forum filter on Mode A.
|
||||
- Mode A keeps inbox as a *secondary* chip strip ("Erweitert" toggle, off by default). Picking CMS auto-sets the forum chip to UPC; picking beA auto-sets it to DE. The user can override.
|
||||
- Mode B never asks. The wizard derives forum from project context or from R2.
|
||||
|
||||
This collapses one bug class entirely (R2-not-constraining-R3) by retiring R2 from the headline path.
|
||||
|
||||
---
|
||||
|
||||
## 4. Shared result view — "follow-up deadlines"
|
||||
|
||||
Once a trigger event is locked (via Mode A click or Mode B R4 pick), the same result view renders.
|
||||
|
||||
### 4.1 Trigger card (sticky header)
|
||||
|
||||
```text
|
||||
┌─ Trigger-Ereignis ─────────────────────────────────────────────────────┐
|
||||
│ 📥 Klageerhebung │
|
||||
│ upc.inf.cfi · Verletzungsverfahren · UPC │
|
||||
│ ⓘ "Einreichung der Klageschrift gemäß R.13 RoP" │
|
||||
│ Trigger-Datum: 📅 2026-05-20 [ändern ↩] │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`Trigger-Datum` defaults to today. The user can change it inline (date picker). Changing it re-renders the follow-ups with new computed dates.
|
||||
|
||||
The "ändern" link drops back to whichever mode the user came from with R1-R4 still answered. (Per Q4: the wizard preserves compatible upstream picks rather than rebooting.)
|
||||
|
||||
### 4.2 Follow-up groups
|
||||
|
||||
Group `sequencing_rules` rows that have the trigger event as **anchor** (i.e. `sr.procedural_event_id = trigger.id`) into 4 visible groups:
|
||||
|
||||
1. **MANDATORY** (`priority='mandatory'`) — pre-checked. The bread-and-butter follow-ups.
|
||||
2. **RECOMMENDED** (`priority='recommended'`) — pre-checked. Best-practice fillings (R.19 EPÜ Einspruch, replication briefs).
|
||||
3. **OPTIONAL** (`priority='optional'`) — unchecked. Discretionary actions (R.320 Wiedereinsetzung).
|
||||
4. **CONDITIONAL** (`condition_expr IS NOT NULL`) — unchecked, with the condition rendered ("nur wenn CCR im Verfahren"). Lawyer ticks if applicable.
|
||||
|
||||
Plus a fifth implicit bucket:
|
||||
|
||||
5. **SPAWNED / CROSS-PROCEEDING** (`is_spawn=true`, `spawn_proceeding_type_id IS NOT NULL`) — surfaced as a separate sub-section with a clear "leitet ein neues Verfahren ein" annotation. Pre-checked when mandatory.
|
||||
|
||||
Recommendation (Q6): **4 visible groups, with SPAWNED inlined into whichever priority bucket it belongs to but tagged with a "⇲ neues Verfahren" badge.** Five groups is too many for a one-page result; folding SPAWNED into its priority keeps the math right (mandatory spawned = mandatory) while still flagging the cross-proceeding implication.
|
||||
|
||||
### 4.3 Per-rule row
|
||||
|
||||
```text
|
||||
☑ Klageerwiderung ✏ Datum
|
||||
3 Monate nach Klageerhebung 20.08.2026
|
||||
RoP 23 · Beklagtenseite
|
||||
ⓘ Schriftlich, mit Vollmacht. Erstmaliges Bestreiten der Patentverletzung.
|
||||
```
|
||||
|
||||
Columns: checkbox · title (DE/EN) · duration phrase · computed due date · rule citation · party stance · expandable notes.
|
||||
|
||||
Inline date editor (✏ Datum) lets the lawyer override the computed date for this rule (same affordance as today's `wireDateEditClicks`). The override flows into the write-back payload.
|
||||
|
||||
`is_court_set=true` rules render with the "wird vom Gericht bestimmt" placeholder instead of a date and are unchecked-by-default (matches the current `openSaveModal` behaviour).
|
||||
|
||||
### 4.4 Result-view footer (write-back CTA)
|
||||
|
||||
```text
|
||||
┌─ Auswahl ──────────────────────────────────────────────────────────────┐
|
||||
│ 4 Fristen ausgewählt → In Akte HL-2024-001 eintragen ▶ │
|
||||
│ (oder: 2 mit eigenem Datum, 2 mit Standardberechnung) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The CTA opens a **confirm-and-edit-dates modal** (per m §11.Q6) where the lawyer can revise each selected deadline's due date one last time, then commits via `POST /api/projects/{id}/deadlines/bulk` (today's endpoint).
|
||||
|
||||
**Kontextfrei mode (no Akte)** — per m §11.Q7, the entire write-back footer **does not render** when `project == null`. The result view stays informational. In its place, an inline nudge appears above the deadline groups:
|
||||
|
||||
```text
|
||||
ⓘ Tipp: Wähle oben eine Akte, um diese Fristen einzutragen.
|
||||
```
|
||||
|
||||
The "oben" link focuses the Akte picker. Once a project is picked, the nudge collapses and the footer materialises; no page reload, no result-view rebuild (the trigger event and date persist across the project pick).
|
||||
|
||||
Modal payload per deadline (extends today's `CreateDeadlineInput`):
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Klageerwiderung",
|
||||
"rule_code": "RoP 23",
|
||||
"due_date": "2026-08-20",
|
||||
"original_due_date": "2026-08-20",
|
||||
"source": "fristenrechner",
|
||||
"rule_id": "<sequencing_rules.id>", /* maps to deadlines.sequencing_rule_id */
|
||||
"notes": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**audit_reason wording (per Q12):** every row inserted via this flow carries an audit-log breadcrumb on the project (matches the deadline `Verlauf` pattern). Default reason string:
|
||||
|
||||
> `Aus Fristenrechner — Trigger: {trigger_event_name} ({trigger_date_iso})`
|
||||
|
||||
e.g. `Aus Fristenrechner — Trigger: Klageerhebung (2026-05-20)`. Falls into `paliad.project_events` with `kind='deadline_created'` via the existing `DeadlineService.CreateBulk` audit hook; no schema change needed.
|
||||
|
||||
---
|
||||
|
||||
## 5. URL / state representation
|
||||
|
||||
The new flow keeps Pathway-B's URL-as-state contract, simplified:
|
||||
|
||||
| Param | Owner | Meaning |
|
||||
|---|---|---|
|
||||
| `project` | Step 0 | Active project UUID. Drives the prefills. |
|
||||
| `mode` | mode tab | `wizard` (default) or `search`. |
|
||||
| `q` | Mode A | Free text query. |
|
||||
| `forum` | Mode A | Comma-separated forum codes (`upc,de`). Mode B writes this only when the wizard derives it. |
|
||||
| `pt` | Mode A | Selected proceeding_type code. |
|
||||
| `kind` | Mode A | event_kind chip pick. |
|
||||
| `party` | both | Perspective. Mode A's chip; Mode B's R5. |
|
||||
| `wizard` | Mode B | Dotted state cursor encoding which row is active and the picks made: `wizard=kind:filing,forum:upc,pt:upc.inf.cfi,active:event`. |
|
||||
| `event` | both | The locked trigger `procedural_events.code`. Once set, the result view renders. |
|
||||
| `trigger_date` | result | ISO date. Default = today; URL only carries it when overridden. |
|
||||
| `selected` | result | Comma-separated `sequencing_rules.id` checkbox state. Only carried when it differs from the priority default. |
|
||||
|
||||
Deep links work end-to-end: `?project=…&event=upc.inf.cfi.soc&trigger_date=2026-05-20&selected=…` jumps a colleague straight to the result view with the same picks. (Per Q11 — query string, not pathname.)
|
||||
|
||||
`popstate` rebuilds the page from the params alone (same pattern as today). The wizard state cursor lets browser back/forward step the wizard rows instead of dropping back to the page root.
|
||||
|
||||
---
|
||||
|
||||
## 6. Backend contract changes
|
||||
|
||||
### 6.1 Extend `/api/tools/fristenrechner/search`
|
||||
|
||||
Today returns concept-cards. Add an alternate response shape (or a `?kind=events` flag) that returns `procedural_events` rows directly:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "Klageerhebung",
|
||||
"filters": { "forum": "upc", "pt": null, "kind": "filing", "party": null },
|
||||
"events": [
|
||||
{
|
||||
"id": "<uuid>",
|
||||
"code": "upc.inf.cfi.soc",
|
||||
"name_de": "Klageerhebung",
|
||||
"name_en": "Statement of Claim",
|
||||
"event_kind": "filing",
|
||||
"proceeding_type": { "code": "upc.inf.cfi", "jurisdiction": "UPC", "name": "..." },
|
||||
"follow_up_count": 3,
|
||||
"concept_id": "<uuid>",
|
||||
"score": 0.92
|
||||
}
|
||||
],
|
||||
"total": 12
|
||||
}
|
||||
```
|
||||
|
||||
The concept-card shape stays available for the legacy Pathway-B-filter route (kept as a deep-link compat surface, not user-facing).
|
||||
|
||||
### 6.2 New `/api/tools/fristenrechner/follow-ups`
|
||||
|
||||
Given a trigger event id + trigger date + optional party qualifier, return the follow-up `sequencing_rules` rows, grouped + with computed dates. Wire shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"trigger": { "id": "...", "code": "upc.inf.cfi.soc", "name_de": "Klageerhebung", "event_kind": "filing", "proceeding_type": { "code": "upc.inf.cfi", "name_de": "Verletzungsverfahren", "jurisdiction": "UPC" } },
|
||||
"trigger_date": "2026-05-20",
|
||||
"party": "claimant",
|
||||
"follow_ups": [
|
||||
{
|
||||
"rule_id": "<uuid>",
|
||||
"title_de": "Klageerwiderung",
|
||||
"title_en": "Defence",
|
||||
"priority": "mandatory",
|
||||
"primary_party": "defendant",
|
||||
"duration_phrase": "3 Monate",
|
||||
"due_date": "2026-08-20",
|
||||
"is_court_set": false,
|
||||
"is_spawn": false,
|
||||
"condition_expr": null,
|
||||
"rule_code": "RoP 23",
|
||||
"notes_de": "...",
|
||||
"spawn_label": null,
|
||||
"spawn_proceeding_type": null,
|
||||
"appeal_target": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: `FristenrechnerService.LookupFollowUps(ctx, eventID, triggerDate, party)` — wraps `catalog.LookupEvents(axes={EventID:…, Depth:Next})` (already implemented for the Litigation Planner per `services/fristenrechner.go:251`) and runs the result through `pkg/litigationplanner.Calculate` to fill the dates. The calculator is unchanged.
|
||||
|
||||
### 6.3 No schema changes
|
||||
|
||||
This design is pure UX + handler shape. The unified `sequencing_rules` model already has every column needed (priority, condition_expr, spawn_*, is_court_set, primary_party, applies_to_target). No migration accompanies this design.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration plan — from current row stack to the overhaul
|
||||
|
||||
Drop nothing on day one; co-exist for one release. The cutover is by URL flag.
|
||||
|
||||
| Phase | What changes | What survives | Branch |
|
||||
|---|---|---|---|
|
||||
| **S1 — Backend** | Add `GET /search?kind=events`. Add `GET /follow-ups`. Both feature-flagged behind a request header (off by default). | Existing endpoints. | one PR |
|
||||
| **S2 — Result view** | New `frontend/src/client/fristenrechner-result.ts` module — given a trigger event + date, render the §4 result view. Mount under a `?overhaul=1` query flag on /tools/fristenrechner. The legacy `renderProcedureResults` stays. | All today's UI. | one PR |
|
||||
| **S3 — Mode A** | New search-with-filter-chips UI. Mount alongside the row stack under `?overhaul=1`. | Row stack still primary. | one PR |
|
||||
| **S4 — Mode B (wizard)** | New `frontend/src/client/fristenrechner-wizard.ts` — the 3-5 row stack. Replaces today's `buildRowStack` only when `?overhaul=1`. Project prefill logic from `buildRowStack` ports 1:1. | The legacy row stack stays in place under no flag. | one PR |
|
||||
| **S5 — Flip the flag** | `?overhaul=1` becomes the default. Legacy row stack and `event_categories`-based cascade rendered with a hard-coded `?legacy=1` for two weeks. | Procedure mode (the upper half of `fristenrechner.tsx`) is unchanged throughout. | one PR |
|
||||
| **S6 — Cleanup** | Drop the `buildRowStack` function tree and the `event_categories`-served cascade endpoint (the table can stay — it's still semantically a useful taxonomy for future tools, just not the Fristenrechner's UI). Drop the `HIDDEN_CASCADE_ROOTS` constant and the cascade-segment bridge. | None of today's row-stack code. | one PR |
|
||||
|
||||
Single project per slice; each PR rebases off main; no shared branches.
|
||||
|
||||
The `event_categories` table itself **stays** — `audit-fristen-logic-2026-05-13.md` §2.4 already calls it "a config layer" useful for taxonomy work. The Fristenrechner just no longer reads it. Future tools (the "Ich möchte einreichen" forward-workflow surface m hid in `HIDDEN_CASCADE_ROOTS`) can resurrect it without DB migration.
|
||||
|
||||
---
|
||||
|
||||
## 8. Worked example — "PA at LG Düsseldorf bekommt einen Hinweisbeschluss via CMS in einer aktiven Akte"
|
||||
|
||||
Project: `HL-2024-001`, proceeding_type=`de.inf.lg` (Verletzungsverfahren LG), `our_side='defendant'`, `court='LG Düsseldorf'`.
|
||||
|
||||
### 8.1 Wizard path (Mode B, default)
|
||||
|
||||
User opens /tools/fristenrechner with that project in Step 0. Mode tab defaults to "🧭 Geführt".
|
||||
|
||||
Wizard rows render top-to-bottom, pre-filled where the project implies:
|
||||
|
||||
```text
|
||||
[1] Was ist passiert? [ active — chips for filing/hearing/decision/order/missed ]
|
||||
[2] Vor welchem Gericht? ✓ LG (aus Akte: HL-2024-001) ← prefilled+collapsed
|
||||
[3] In welchem Verfahren? ✓ Verletzungsverfahren (de.inf.lg) ← prefilled+collapsed
|
||||
```
|
||||
|
||||
User clicks ⚖️ Entscheidung in R1.
|
||||
|
||||
Row stack updates:
|
||||
```text
|
||||
[1] Was ist passiert? ✓ Entscheidung ← answered
|
||||
[2] Vor welchem Gericht? ✓ LG (aus Akte) ← prefilled
|
||||
[3] In welchem Verfahren? ✓ Verletzungsverfahren (de.inf.lg) ← prefilled
|
||||
[4] Welche Entscheidung konkret? [ active — chips: Urteil, Beschluss, Hinweisbeschluss, ... ]
|
||||
```
|
||||
|
||||
R4 chip set is the `procedural_events` whose `proceeding_type_id` = de.inf.lg AND `event_kind` = 'decision'. (Hinweisbeschluss is in this set — `de.inf.lg.hinweisbeschluss` or similar.)
|
||||
|
||||
User clicks Hinweisbeschluss. The wizard checks: do the follow-up rules differ by `primary_party`? In this case yes (the Hinweis triggers a reply window for the defendant only). So R5 fires:
|
||||
|
||||
```text
|
||||
[5] Welche Seite vertreten Sie? ✓ Beklagtenseite (aus Akte) ← prefilled
|
||||
```
|
||||
|
||||
R5 is pre-filled from `project.our_side='defendant'`. The user could click ändern to override, but doesn't.
|
||||
|
||||
Wizard transitions to the §4 result view. Trigger card: "📜 Hinweisbeschluss · de.inf.lg · LG · Beklagtenseite". Trigger date defaults to today.
|
||||
|
||||
### 8.2 Result view
|
||||
|
||||
Three follow-ups in scope (illustrative):
|
||||
|
||||
```text
|
||||
MANDATORY
|
||||
☑ Stellungnahme zum Hinweisbeschluss (Frist 4 Wochen) — 24.06.2026 — ZPO §139
|
||||
RECOMMENDED
|
||||
☑ Anpassung der Klageerwiderung — 24.06.2026 — best practice
|
||||
OPTIONAL
|
||||
□ Antrag auf Fristverlängerung (begründet) — auf Antrag
|
||||
```
|
||||
|
||||
User unchecks "Anpassung", changes the Stellungnahme date inline to 2026-06-20 (one weekday earlier), clicks "In Akte HL-2024-001 eintragen ▶".
|
||||
|
||||
Modal opens with the 1 selected deadline + the user's date override. User confirms.
|
||||
|
||||
### 8.3 Write-back
|
||||
|
||||
Server-side: `POST /api/projects/HL-2024-001/deadlines/bulk` with one `CreateDeadlineInput`:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Stellungnahme zum Hinweisbeschluss",
|
||||
"rule_code": "ZPO §139",
|
||||
"due_date": "2026-06-20",
|
||||
"original_due_date": "2026-06-24",
|
||||
"source": "fristenrechner",
|
||||
"rule_id": "<sr-uuid>",
|
||||
"notes": null
|
||||
}
|
||||
```
|
||||
|
||||
`DeadlineService.CreateBulk` inserts the row into `paliad.deadlines` (with `sequencing_rule_id` populated from `rule_id`), creates the audit event with the wording "Aus Fristenrechner — Trigger: Hinweisbeschluss (2026-05-26)", and the user is redirected to `/deadlines?project_id=…` with a green success toast.
|
||||
|
||||
### 8.4 Mode A path for the same user
|
||||
|
||||
User flips the mode tab to "⚡ Direkt suchen". Filter chips auto-load to Forum=DE + Proceeding=de.inf.lg (from project context). User types "Hinweis" — the result list shows `de.inf.lg.hinweisbeschluss` (and maybe `upc.inf.cfi.hinweis` filtered out because Forum=DE narrows it). User clicks. Same result view appears.
|
||||
|
||||
Total clicks Mode A: 2 (type + click). Mode B: 2 (R1 chip + R4 chip; R2/R3/R5 prefilled). The wizard wins for trainees who don't know vocabulary; search wins for power users who know "Hinweisbeschluss" and can type 4 chars.
|
||||
|
||||
---
|
||||
|
||||
## 9. What's NOT in scope
|
||||
|
||||
- **Replacing the `sequencing_rules` model.** Phase 3 schema is already what the calculator runs on.
|
||||
- **Paliadin (LLM) integration into the wizard.** A "Frist-Extraktion aus Dokument" path is filed elsewhere (memory `b6a11b55…`) and stays out of this design. The wizard could later call out to Paliadin for "the user typed something we don't know" — Phase 2 of *this* overhaul, not Phase 1.
|
||||
- **Calendar / Outlook sync** of created deadlines. Separate t-paliad ticket per project-status.md long-term goals.
|
||||
- **Editing `sequencing_rules`** from the result view. Read-only here. The admin surface at `/admin/procedural-events` handles editing.
|
||||
- **The Procedure-mode surface** (upper half of `fristenrechner.tsx`). The proceeding-picker + trigger-date + flag-checkbox UI stays exactly as it is today. That surface answers a different question ("show me the full procedural ablauf for upc.inf.cfi") and is the right tool for that question; the overhaul targets only the Pathway-B / row-stack half of the page.
|
||||
|
||||
---
|
||||
|
||||
## 10. Open questions for m (12 questions, batched for `AskUserQuestion`)
|
||||
|
||||
All 12 questions tracked in m/paliad#146 § "Open design questions". Each gets a recommended option (listed first in the AskUserQuestion call). Bundled into 3 batches of 4.
|
||||
|
||||
| # | Topic | Recommended pick |
|
||||
|---|---|---|
|
||||
| Q1 | Single page or stepper? | Single page with mode-tabs + collapsible rows. |
|
||||
| Q2 | Mode switcher placement | Tab pair under Step-0 ("Akte / kontextfrei"). |
|
||||
| Q3 | Filter-vs-qualifier UX | Qualifiers carry a small "(Pflichtangabe)" tag; filters render in a slimmer pill. |
|
||||
| Q4 | Cascade tree (keep/replace) | Replace with the 5-question wizard. Drop `event_categories` from the Fristenrechner UI (table stays). |
|
||||
| Q5 | Result grouping | 4 visible groups (Mandatory / Recommended / Optional / Conditional), SPAWNED folded with badge. |
|
||||
| Q6 | Project write-back UX | Confirm-and-edit-dates modal (revise each date once before commit). |
|
||||
| Q7 | No-project mode | CTA disabled with hint ("Wähle eine Akte oben"). Match today's pattern. |
|
||||
| Q8 | Perspective semantics by mode | Mode B (file): qualifier — required pick. Mode A (search): filter — optional. |
|
||||
| Q9 | Trigger-date input timing | In the result-view trigger card; default today; inline editable. |
|
||||
| Q10 | Backward navigation | Preserve compatible downstream picks; reset only those invalidated. |
|
||||
| Q11 | Deep-link encoding | Query string (`?event=…&trigger_date=…`). |
|
||||
| Q12 | Audit reason wording | `Aus Fristenrechner — Trigger: {name} ({date})`. |
|
||||
|
||||
(Recommendations land as the "first option" in each AskUserQuestion call per the inventor SKILL contract.)
|
||||
|
||||
---
|
||||
|
||||
## 11. m's decisions (2026-05-26)
|
||||
|
||||
All 12 questions answered via `AskUserQuestion` on 2026-05-26 21:30. Recording each pick + the reasoning where it diverges from the inventor's recommendation. Sections of the design that are now load-bearing on these answers carry a "(m §11.Q{n})" cross-reference.
|
||||
|
||||
- **Q1 (Page layout): Single page, mode-tabs.** [= recommendation] Both modes share /tools/fristenrechner; the mode-tabs swap the question surface in place. Result view is shared. **Locks §3, §4, §5.**
|
||||
- **Q2 (Mode switcher): Tab pair under Step-0.** [= recommendation] "⚡ Direkt suchen" / "🧭 Geführt" tabs render directly below the Akte picker. Project context survives the tab flip; compatible filter picks (forum, proceeding) carry across.
|
||||
- **Q3 (Filter-vs-qualifier UX): Section split — Filter above, Qualifier below.** [≠ recommendation; m picked option 2.] Mode A's filter chips render in a "Filter (eingrenzen)" strip on top; below it, the result list is the qualifier surface (clicking a row locks). Mode B wizard rows carry a small "Filter" / "Qualifier" badge in the row badge area (e.g. R1/R2 = Filter, R3/R5 = Qualifier). The "(Pflichtangabe)" tag from the original recommendation is replaced by this section-level visual hierarchy. **Updates §3.1 (Mode A layout) and §3.2 (wizard row badges).**
|
||||
- **Q4 (Cascade tree): Replace with wizard, keep table.** [= recommendation] The Fristenrechner UI stops reading `paliad.event_categories`. The table stays for future tools (the hidden "Ich möchte einreichen" forward-workflow). **Locks §3.2 and the cleanup in §7 S6.**
|
||||
- **Q5 (Result grouping): 4 groups + SPAWNED badge.** [= recommendation] Mandatory / Recommended / Optional / Conditional are the four sub-sections; spawned rules fold into their priority bucket with a `⇲ neues Verfahren` badge. **Locks §4.2.**
|
||||
- **Q6 (Write-back UX): Confirm-and-edit-dates modal.** [= recommendation] Inline checkbox selection in the result view → "In Akte eintragen ▶" → modal with editable due-date fields per row + Akte picker. **Locks §4.4.**
|
||||
- **Q7 (No-project mode): Hide the CTA entirely.** [≠ recommendation; m picked option 3.] In kontextfrei mode the result view renders without the write-back footer at all — no disabled-with-hint button. Rationale (inferred from m's pick): the result view is informational by design in explore mode, and a permanently-disabled CTA is visual noise. **Updates §4.4** — the CTA is conditional on `project != null`, not on `disabled`. The hint message moves into the Step-0 picker: when a user is in kontextfrei mode and reaches a result view, a one-line nudge appears above the result groups ("Tipp: Wähle oben eine Akte, um diese Fristen einzutragen") with a link to focus the Akte picker. This preserves the affordance discovery without polluting the footer.
|
||||
- **Q8 (Perspective semantics): Mode B qualifier, Mode A filter.** [= recommendation] Wizard Mode B's R5 is required and Klagerseite/Beklagtenseite only (no "Beide"); Mode A's perspective chip is a filter with a "Beide" option, off by default. **Locks §2 axis table and §3.2 R5 description.**
|
||||
- **Q9 (Trigger-date input): In the result-view trigger card.** [= recommendation] The sticky header card on the result view shows the date; default today; inline editable. Changing it re-renders follow-up dates live. **Locks §4.1.**
|
||||
- **Q10 (Backward navigation): Preserve compatible picks.** [= recommendation] Re-opening any wizard row keeps downstream picks that are still legal under the new upstream value; resets only the picks the new value invalidates. A small chip-strip annotation ("erhalten") appears for one render-cycle on rows whose pick was carried so the user notices. **Updates §3.2 branching policy.**
|
||||
- **Q11 (Deep-link encoding): Query string.** [= recommendation] `?project=…&mode=…&event=…&trigger_date=…&selected=…&forum=…&pt=…&kind=…&party=…` — every state piece is a query param. `popstate` rebuilds the page from params. **Locks §5.**
|
||||
- **Q12 (Audit reason wording): `Aus Fristenrechner — Trigger: {name} ({date})`.** [= recommendation] German-locale, includes the trigger event name and its ISO date. Stored as `paliad.project_events.metadata->>'audit_reason'` via the existing `DeadlineService.CreateBulk` audit hook. **Locks §4.4.**
|
||||
|
||||
### 11.1 What changed from the strawman as a result
|
||||
|
||||
Two follow-on edits flow from m's picks:
|
||||
|
||||
1. **§3.1 Mode A layout** — top strip is "Filter (eingrenzen)" with the four filter chip groups (Forum · Proceeding · Event-Kind · Partei); the result list directly below carries the implicit "click here to lock" qualifier action. No "(Pflichtangabe)" tag.
|
||||
2. **§4.4 Write-back footer** — the footer is rendered conditionally on `project != null`. The kontextfrei-mode informational nudge moves into the result view body above the deadline groups.
|
||||
|
||||
These edits don't change the §7 migration plan or the §6 backend contracts.
|
||||
|
||||
---
|
||||
|
||||
## 12. Synthesis links
|
||||
|
||||
- mBrian topic: `topic-fristenrechner` (existing) — file this design as a `[synthesis]` node linked `triggered_by` t-paliad-322 and `related_to` the row-cascade + Phase 2 designs.
|
||||
- Related memories: row-cascade design `0fbd2c1a-…`, Phase 2 design `a454dc86-…`, audit logic `f6c0c3a2-…`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ A full org export today is **< 600 rows of user content** plus reference data
|
||||
|
||||
**Audit trail.** Lives in `paliad.project_events` (93 rows). One row per lifecycle event with `event_type`, `metadata jsonb`, `event_date`, `created_by`. The auditing union (`AuditService.ListEntries`) joins 5 sources (project_events, partner_unit_events, deadline_rule_audit, policy_audit_log, reminder_log). For the export we treat `project_events` as primary; the four auxiliary logs are scope-specific.
|
||||
|
||||
**Existing export precedent.** _(Originally pointed at the admin rule-migration export. That tool was deleted in m/paliad#129 / t-paliad-297. The gating pattern — `adminGate(users, …)` on a download endpoint that streams a generated artifact — still lives on other admin handlers, e.g. `handleAdminDownloadBackup` for `/api/admin/backups/{id}/file`.)_ Re-use the gating helper.
|
||||
**Existing export precedent.** `/admin/rules/export` + `/admin/api/rules/export-migrations` (handlers/admin_rules.go) — admin-gated, streams a generated SQL artifact. Same shape as what we want for the Excel exports. Re-use the gating helper.
|
||||
|
||||
**No Go xlsx library on `go.mod` today.** This design picks **`github.com/xuri/excelize/v2`** in §3.
|
||||
|
||||
@@ -591,7 +591,7 @@ No other slice deltas. v1 still ships slices 1+2+3.
|
||||
- `docs/design-data-model-v2.md` — projects + mandanten + ltree path + can_see_project predicate.
|
||||
- `docs/design-approval-policy-ui-2026-05-07.md` — 5-source audit union (this design adds the 6th source).
|
||||
- `docs/design-profession-vs-project-role-2026-05-07.md` — profession ladder for the §4 project gate.
|
||||
- `internal/handlers/backups.go` — `handleAdminDownloadBackup` (precedent for admin-gated artifact download; the older rule-migration export precedent was removed in t-paliad-297).
|
||||
- `internal/handlers/admin_rules.go:303` — `handleAdminExportRuleMigrations` (precedent for admin-gated export-as-download).
|
||||
- `internal/services/project_service.go:15` — visibility predicate.
|
||||
- `internal/services/derivation_service.go` — `EffectiveProjectRole` for the project gate.
|
||||
- `github.com/xuri/excelize/v2` — chosen xlsx library.
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
# Slice B.0 — Live DB re-validation findings (t-paliad-273)
|
||||
|
||||
**Author:** curie (researcher)
|
||||
**Date:** 2026-05-26
|
||||
**Branch:** `mai/curie/researcher-slice-b-zero`
|
||||
**Predecessor:** `docs/design-procedural-events-model-2026-05-25.md` (cronus, t-paliad-262)
|
||||
**Scope:** READ-ONLY re-validation of the design doc's §1 premises against the live youpc Supabase `paliad` schema. No migration SQL written, no writes to `deadline_rules` or any table. B.1 (additive migration) remains blocked pending m's greenlight.
|
||||
|
||||
This document does **not** redesign the schema. It does **not** propose new structural changes. It records what the live DB looks like ~24 hours after the design was authored, flags every claim that drifted, and gives the eventual B.1 coder a current-as-of-2026-05-26 baseline to plan against.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
The design doc's §1 premises were sound on 2026-05-25. **All numeric premises drifted in the 24 hours since.** The qualitative model (`deadline_rules` conflates three concepts; live `deadlines.rule_id` FK; snapshot precedent established; no `proceeding_event*` tables) still holds.
|
||||
|
||||
The Q5 default ("10 archived multi-row submission_codes collapse safely") is now **moot**: those rows were removed from the live DB between 2026-05-25 15:30 and 2026-05-26 13:30. There are now **zero** multi-row submission codes; every active submission_code maps 1:1 to one rule row. B.1 backfill no longer needs the multi-row collapse logic that §5 of the design doc anticipated.
|
||||
|
||||
The Q6 default ("concept_id attaches to procedural event, not sequencing rule") is **directionally correct but needs refinement**. The empirical attachment is **above** the procedural-event level — `deadline_concepts` rows cluster legal meaning *across* jurisdictional procedural-event variants. One concept_id can span 15 distinct submission_codes (e.g. "Berufungsfrist" across BGH / BPatG / LG / OLG for both PatG and ZPO paths). The FK in §4.1's draft schema (`procedural_events.concept_id REFERENCES deadline_concepts(id)`, N:1) is **already correctly shaped** for this — no schema change needed. The verbal claim in the design doc should be tightened to "one `deadline_concept` row may be referenced by many procedural events; the FK lives on `procedural_events`."
|
||||
|
||||
Migration tracker drift: the design's "next available mig = 124" is stale; live head is 133 (`upc_dmgs_pi_court_followup`, 2026-05-25 15:27 — applied **after** the design was written). **Next available is 134.** Ten migrations landed since the doc was authored — 124..133. None of them touched `deadline_rules` schema, but they did mutate row content (the missing 23 rows and the new event_type/legal_source distribution come from migs 127/128/132/133).
|
||||
|
||||
The design's claimed migration tracker `paliad.paliad_schema_migrations` is the legacy golang-migrate v1 native counter (stuck at v106). The **canonical** tracker is `paliad.applied_migrations` (one row per applied migration, with checksum + applied_at). `internal/db/migrate.go:9-21` is the source of truth. Project CLAUDE.md still says `paliad.paliad_schema_migrations`; that's a stale doc, not a B.0-scope fix.
|
||||
|
||||
One doc-side bug fixed by this slice: design doc §1 + m/paliad#93 issue body referenced `paliad.deadlines.deadline_rule_id`. Live column is `paliad.deadlines.rule_id`. Both files patched on this branch.
|
||||
|
||||
---
|
||||
|
||||
## §1 Headline-count drift table
|
||||
|
||||
All numbers taken 2026-05-26 ~13:30 UTC against the live `paliad` schema.
|
||||
|
||||
| Metric | Design (2026-05-25) | Live (2026-05-26) | Δ | Notes |
|
||||
|---|--:|--:|--:|---|
|
||||
| `deadline_rules` row count | 254 | **231** | -23 | All rows `is_active = true`. No soft-deletes in flight. |
|
||||
| Rows with `submission_code` | 177 | **153** | -24 | |
|
||||
| Distinct `submission_code` values | 158 | **153** | -5 | **All 5 lost are the multi-row `_archived_litigation.*` codes** — see §2. |
|
||||
| Rows with `legal_source` | 102 | **112** | +10 | |
|
||||
| Distinct `legal_source` values | 70 | **87** | +17 | New jurisdictional variants seeded by recent migs (127/132/133). |
|
||||
| Rows with `concept_id` (linked to `deadline_concepts`) | 125 | **129** | +4 | 56% of the corpus is concept-linked, vs 49% in the design. |
|
||||
| `paliad.deadlines` rows | 1 | **5** | +4 | Still tiny — destructive cutover stays cheap. |
|
||||
| `paliad.submission_drafts` rows | 4 | **7** | +3 | |
|
||||
| Rules in `lifecycle_state = 'draft'` | 4 | **0** | -4 | All 4 design-era drafts were published or discarded. |
|
||||
|
||||
### event_type distribution
|
||||
|
||||
| `event_type` | Design | Live | Δ |
|
||||
|---|--:|--:|--:|
|
||||
| `filing` | 130 | 105 | -25 |
|
||||
| NULL | 77 | 89 | +12 |
|
||||
| `decision` | 25 | 21 | -4 |
|
||||
| `hearing` | 21 | 15 | -6 |
|
||||
| `order` | 1 | 1 | 0 |
|
||||
| **Total** | **254** | **231** | -23 |
|
||||
|
||||
The -23 row delta lands almost entirely in `filing` (-25) and `hearing` (-6), offset by +12 NULL — consistent with the disappearance of the `_archived_litigation.*` filings and a few archived `hearing` rows, plus seeding of new structural / parent-only rows by recent migrations.
|
||||
|
||||
### What did NOT drift (qualitative claims, still valid)
|
||||
|
||||
- `paliad.deadline_rules` carries 39 columns (design said 38 — drift +1; likely from mig 128 `deadline_rules_unit_check` which adds a CHECK without adding a column — or one of migs 124-133 added a column. Not investigated further; out of B.0 scope).
|
||||
- `paliad.deadlines.rule_id` (uuid, nullable) is the FK column to `paliad.deadline_rules.id`. **Confirmed via `information_schema.referential_constraints`** — `rule_id → paliad.deadline_rules(id)`. The doc-side mention of `deadline_rule_id` was always a typo.
|
||||
- `paliad.deadlines.rule_code` + `paliad.deadlines.custom_rule_text` both still present (the denormalized-display columns from mig 122).
|
||||
- `paliad.submission_drafts` uses `(project_id uuid nullable, submission_code text NOT NULL)` as its key — **no FK to deadline_rules**. Confirms the design's claim that the Schriftsätze surface filters on a text key, not on `deadline_rules.id`.
|
||||
- No `paliad.proceeding_event*` tables exist (einstein's 2026-05-08 graph design was never built — still the case).
|
||||
|
||||
---
|
||||
|
||||
## §2 Archived submission_code audit (Q5 re-confirm)
|
||||
|
||||
**Premise re-checked:** "10 archived multi-row submission_codes (`_archived_litigation.*`) collapse safely into single procedural events with multiple sequencing variants."
|
||||
|
||||
**Finding:** the premise is **moot in the live DB**.
|
||||
|
||||
```sql
|
||||
SELECT submission_code, COUNT(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code LIKE '_archived_litigation.%'
|
||||
GROUP BY submission_code;
|
||||
-- 0 rows
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT submission_code, COUNT(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code IS NOT NULL
|
||||
GROUP BY submission_code
|
||||
HAVING COUNT(*) > 1;
|
||||
-- 0 rows
|
||||
```
|
||||
|
||||
Every active submission_code in the live corpus is 1:1 with its `deadline_rules` row. The 10 multi-row codes the design anticipated no longer exist.
|
||||
|
||||
**Consequence for B.1 backfill:**
|
||||
|
||||
- The §5.1 / §5.2 backfill SQL the design sketched (collapsing N rows-with-same-submission_code into 1 procedural_event + N sequencing_rules) is **simpler than expected**: a straight 1:1 backfill, no GROUP-BY-and-collapse step needed.
|
||||
- B.1's `INSERT INTO paliad.procedural_events ... SELECT DISTINCT submission_code ...` becomes equivalent to `INSERT ... SELECT submission_code, ... FROM deadline_rules WHERE submission_code IS NOT NULL`. No deduplication needed.
|
||||
- The 78 rows where `submission_code IS NULL` (231 - 153) still need a B.1 decision: do they become `procedural_events` rows (with synthetic codes), do they become free-standing `sequencing_rules` with `procedural_event_id` NULL, or do they get parked? This was implicit in the design (the 77 NULLs were framed as "structural / parent-only rows in the proceeding tree"); B.1 should make the decision explicit and document it in the migration's `.up.sql` comments.
|
||||
|
||||
---
|
||||
|
||||
## §3 concept_id attachment shape (Q6 re-confirm)
|
||||
|
||||
**Premise re-checked:** "concept_id attaches to procedural event, not sequencing rule."
|
||||
|
||||
**Finding:** **partly true.** The FK direction the design proposes (`procedural_events.concept_id → deadline_concepts.id`, N:1) is correct. The verbal phrasing in Q6's default needs refinement — the empirical attachment is **above** the procedural-event level, not "at" it.
|
||||
|
||||
### Empirical pattern
|
||||
|
||||
129 of 231 rows carry a `concept_id`. Those 129 rows reference **53 distinct `deadline_concepts`** rows. Averages: 2.43 rows-per-concept, 2.42 submission-codes-per-concept (the two are nearly identical because today's corpus has no multi-row submission codes — see §2). Span distribution:
|
||||
|
||||
- 33 of 53 concepts (62%) attach to exactly 1 submission_code → procedural-event-scoped.
|
||||
- 20 of 53 concepts (38%) attach to >1 submission_code → cross-procedural-event scoped.
|
||||
- Maximum: 1 concept attaches to **15 distinct submission_codes**.
|
||||
|
||||
### Example: one concept, four procedural events
|
||||
|
||||
The concept `b85b2e5a-4064-40b2-b862-24b7abaa5b94` ("Berufungsfrist / Berufungsschrift") is referenced by 4 `deadline_rules` rows that today carry these 4 distinct submission_codes:
|
||||
|
||||
| rule_code | submission_code | court | name |
|
||||
|---|---|---|---|
|
||||
| § 110 PatG | `de.null.bgh.berufung` | BGH | Berufungsschrift |
|
||||
| § 110 PatG | `de.null.bpatg.berufung` | BPatG | Berufungsfrist |
|
||||
| § 517 ZPO | `de.inf.lg.berufung` | LG | Berufungsfrist |
|
||||
| § 517 ZPO | `de.inf.olg.berufung` | OLG | Berufungsfrist |
|
||||
|
||||
Under Slice B's target schema (§4.1), each of these four rows becomes a separate `procedural_events` row (different `code`s, different jurisdiction-specific names, different `legal_source_id`s), but **all four reference the same `deadline_concepts.id`**.
|
||||
|
||||
### Implication for B.1
|
||||
|
||||
- `procedural_events.concept_id` should be **nullable** (62% of rows today have no concept link — the §4.1 sketch already allows this).
|
||||
- The constraint must be **N:1, not 1:1** (one `deadline_concept` may be referenced by many `procedural_events`). The §4.1 sketch (`concept_id uuid REFERENCES paliad.deadline_concepts(id)`) is already correctly N:1; a hypothetical "UNIQUE INDEX on `procedural_events.concept_id`" would break the existing data. **Do not add UNIQUE.**
|
||||
- The design doc's Q6 phrasing can be tightened to: "concept_id attaches to procedural event (N procedural events → 1 concept). Sequencing rules do not carry concept_id." — but this is a wording nit, not a structural change. It does **not** block B.1.
|
||||
|
||||
---
|
||||
|
||||
## §4 Snapshot precedent audit
|
||||
|
||||
**Premise re-checked:** the `paliad.deadline_rules_pre_<N>` snapshot pattern is established and ready for B.4's destructive drop.
|
||||
|
||||
**Finding:** confirmed and consistent.
|
||||
|
||||
Snapshot tables in `paliad`:
|
||||
|
||||
| Snapshot table | Origin migration |
|
||||
|---|---|
|
||||
| `deadlines_pre_089` | mig 089 |
|
||||
| `deadline_rules_pre_091` | mig 091 (destructive drop of legacy columns) |
|
||||
| `event_deadlines_pre_092` | mig 092 |
|
||||
| `event_deadline_rule_codes_pre_092` | mig 092 |
|
||||
| `deadline_rules_pre_093` | mig 093 |
|
||||
| `proceeding_types_pre_093` | mig 093 |
|
||||
| `projects_pre_094` | mig 094 |
|
||||
| `deadline_rules_pre_095` | mig 095 |
|
||||
| `proceeding_types_pre_096` | mig 096 |
|
||||
| `deadline_rules_pre_098` | mig 098 |
|
||||
|
||||
Pattern: `<original_table>_pre_<migration_number>`. Always created in the `.up.sql` of the destructive migration as `CREATE TABLE paliad.<t>_pre_<N> AS TABLE paliad.<t>;` (followed by the destructive DROP / ALTER).
|
||||
|
||||
**B.4's template:** before `DROP TABLE paliad.deadline_rules;` (and `ALTER TABLE paliad.deadlines DROP COLUMN rule_id;`), `mig <N>.up.sql` must include:
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.deadline_rules_pre_<N> AS TABLE paliad.deadline_rules;
|
||||
-- (optional) CREATE TABLE paliad.deadlines_pre_<N> AS TABLE paliad.deadlines;
|
||||
```
|
||||
|
||||
This is non-negotiable per m's snapshot policy and the precedent of migs 089-098. B.4 should not enter the deploy queue without it.
|
||||
|
||||
---
|
||||
|
||||
## §5 deadlines.rule_id doc bug — verified + patched
|
||||
|
||||
**Premise re-checked:** the live column on `paliad.deadlines` referencing `deadline_rules` is named `rule_id`, not `deadline_rule_id`.
|
||||
|
||||
**Verification:**
|
||||
|
||||
```sql
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema='paliad' AND table_name='deadlines' AND column_name LIKE '%rule%';
|
||||
-- rule_id (uuid, nullable)
|
||||
-- rule_code (text, nullable)
|
||||
-- custom_rule_text (text, nullable)
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT kcu.column_name, ccu.table_name, ccu.column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu ON ...
|
||||
JOIN information_schema.constraint_column_usage ccu ON ...
|
||||
WHERE tc.constraint_type='FOREIGN KEY' AND tc.table_schema='paliad' AND tc.table_name='deadlines';
|
||||
-- rule_id → paliad.deadline_rules.id
|
||||
```
|
||||
|
||||
**Fix applied on this branch:**
|
||||
|
||||
- `docs/design-procedural-events-model-2026-05-25.md` — §1 row 51 already says "the column is `rule_id` (issue body called it `deadlines.deadline_rule_id` — that's a doc-side typo)". §1 row 63 (the "Doc-side bug flagged" line) already names the fix target. **No change needed to the design doc — the inventor already flagged and described the bug; B.0 just re-confirms it.**
|
||||
- `m/paliad#93` issue body — line 56 says `paliad.deadlines.deadline_rule_id` in the Q3 migration shape. Patched via Gitea API on this slice. See §6 of this report.
|
||||
|
||||
---
|
||||
|
||||
## §6 Migration tracker drift (out-of-scope context)
|
||||
|
||||
The design doc said "next available mig number is 124 (mig 123 = Backup Mode Slice A, just shipped)". Live state on 2026-05-26 13:30:
|
||||
|
||||
- Latest applied migration: **133** (`upc_dmgs_pi_court_followup`, 2026-05-25 15:27).
|
||||
- Next available: **134**.
|
||||
- Migrations 124-133 (all applied after the design was authored):
|
||||
|
||||
```
|
||||
124 de_inf_lg_replik_duplik_sequencing (2026-05-25 13:49)
|
||||
125 cross_cutting_filter_legal_source (2026-05-25 14:13)
|
||||
126 users_inbox_seen_at (2026-05-25 13:51)
|
||||
127 wave0_tier0_deadline_fixes (2026-05-25 14:13)
|
||||
128 deadline_rules_unit_check (2026-05-25 14:13)
|
||||
129 project_event_choices (2026-05-25 15:02)
|
||||
130 submission_drafts_language (2026-05-25 15:05)
|
||||
131 submission_drafts_party_selection (2026-05-25 15:02)
|
||||
132 wave1_tier1_rule_additions (2026-05-25 15:40)
|
||||
133 upc_dmgs_pi_court_followup (2026-05-25 15:27)
|
||||
```
|
||||
|
||||
These touched `deadline_rules` content (wave0/wave1 rule additions, sequencing fixes, unit checks) and adjacent tables, but did not change the conflated-three-concepts shape that motivates Slice B. The structural premise of the design holds; the row-level numbers shifted.
|
||||
|
||||
**Side observation (not a B.0 fix scope):** the project's `CLAUDE.md` says "Migration tracker is `paliad.paliad_schema_migrations` (avoids collision with other apps on the shared `public.schema_migrations`)." That sentence is stale. The **canonical tracker is `paliad.applied_migrations`** (per `internal/db/migrate.go:9-21,53,105`). `paliad.paliad_schema_migrations` is the legacy golang-migrate v1 counter, frozen at v106; the migrate runner uses it only to bootstrap `applied_migrations` on first deploy of the new runner (`internal/db/migrate.go:219-240`). Recommend a separate doc-fix slice (out of B.0 scope) to update `.claude/CLAUDE.md`.
|
||||
|
||||
---
|
||||
|
||||
## §7 Updated B.1 brief (no-op / minor adjustments only)
|
||||
|
||||
What the live data means for the design's §5 migration plan:
|
||||
|
||||
1. **Backfill is simpler.** No multi-row collapse logic needed (§2). One-to-one `INSERT INTO paliad.procedural_events SELECT submission_code, name, name_en, description, event_type AS event_kind, primary_party, ... FROM paliad.deadline_rules WHERE submission_code IS NOT NULL` against 153 rows.
|
||||
2. **The 78 NULL-submission_code rows need an explicit decision in B.1.** Either:
|
||||
- (a) Skip them — they remain `deadline_rules`-only and become orphan-once-deadline_rules-is-dropped. Not acceptable; B.4 would lose them.
|
||||
- (b) Mint synthetic codes (`null.<uuid8>` or similar) for the structural rows and create `procedural_events` for them.
|
||||
- (c) Treat them as "sequencing-rule-only" (a `sequencing_rules` row with NULL `procedural_event_id`) — would require `sequencing_rules.procedural_event_id` to be nullable, which contradicts §4.1's NOT NULL FK.
|
||||
- Default recommendation: **(b)** — mint codes, preserve every row. B.1 must document the mint rule in the `.up.sql`. Surface this to head before scheduling B.1.
|
||||
3. **concept_id stays N:1 on procedural_events.** No UNIQUE constraint. §4.1's sketch already does this; just don't accidentally tighten it.
|
||||
4. **Use migration number 134** (or whatever's the live `MAX(version)+1` at B.1-write-time; re-check at the moment of writing the file).
|
||||
5. **Snapshot before drop in B.4:** `CREATE TABLE paliad.deadline_rules_pre_<N> AS TABLE paliad.deadline_rules;` per §4 precedent. **This is the hard-stop pre-condition for B.4 entering the deploy queue.**
|
||||
6. **Submission_drafts.submission_code → procedural_events.code text join** continues to work unchanged through B.1-B.3 because both names match. No B.5 dual-write needed for `submission_drafts`. (The design's §6.3 already noted this.)
|
||||
|
||||
None of these change the **shape** of the design — they tighten the backfill SQL and surface one explicit decision (point 2) for head.
|
||||
|
||||
---
|
||||
|
||||
## §8 Outputs of this slice (B.0)
|
||||
|
||||
| Artifact | Status |
|
||||
|---|---|
|
||||
| `docs/design-procedural-events-b0-findings-2026-05-26.md` (this file) | created on `mai/curie/researcher-slice-b-zero` |
|
||||
| `docs/design-procedural-events-model-2026-05-25.md` | cherry-picked from `mai/cronus/inventor-procedural` onto this branch (design doc was never merged to main; B.0 brings it onto a branch off main so the doc bug fix has somewhere to land) |
|
||||
| m/paliad#93 issue body — `deadline_rule_id` → `rule_id` correction | patched via Gitea API |
|
||||
| Gitea comment on m/paliad#93 summarizing this report | posted (see §6 trailing summary on the issue) |
|
||||
|
||||
**Nothing migrated, nothing written to `paliad.deadline_rules` or any other live data table.** Only `mai.reports` (progress) and the GitHub issue body / repo files were touched.
|
||||
|
||||
---
|
||||
|
||||
## §9 Hard-stop status
|
||||
|
||||
**B.0 COMPLETE. AWAITING B.1 GREENLIGHT.**
|
||||
|
||||
Per the original instruction:
|
||||
|
||||
- B.1 (additive migration creating `paliad.procedural_events`, `paliad.sequencing_rules`, `paliad.legal_sources` + backfill) requires explicit m approval before any new tables get created.
|
||||
- B.4 (destructive drop of `paliad.deadline_rules` + `paliad.deadlines.rule_id`) requires m's downtime-window approval AND a `paliad.deadline_rules_pre_<N>` snapshot table in the same migration.
|
||||
- This researcher (curie) stays parked until head re-hires.
|
||||
|
||||
---
|
||||
|
||||
## §10 Decisions worth surfacing to m before B.1 starts
|
||||
|
||||
1. **NULL-submission_code rows (78 of them) — what to do during backfill?** Recommendation (b): mint synthetic codes. m should confirm or pick (a)/(c).
|
||||
2. **B.5 deprecation header window length** — the design (§8.2) says "one slice". For 7 active submission_drafts that's safe; the question is whether external integrations (Word templates with `{{rule.X}}`) need a longer window. The variable-bag alias contract (`submission_vars.go`) covers Word templates without a wire-format change, so "one slice" is defensible. m should confirm.
|
||||
3. **Migration number reservation** — by the time B.1 ships, the live head may be 135+. The B.1 coder must re-check `MAX(version)` at write-time. (Not a decision; just a process note.)
|
||||
|
||||
These are the only open questions the B.0 audit surfaced. Everything else in the design holds.
|
||||
@@ -1,571 +0,0 @@
|
||||
# Design — Procedural-Events Data Model (t-paliad-262)
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-05-25
|
||||
**Issue:** m/paliad#93 (mai task t-paliad-262)
|
||||
**Branch:** `mai/cronus/inventor-procedural`
|
||||
**Status:** DESIGN — read-only, no schema or code changes in this branch.
|
||||
**B.0 re-validation:** see `docs/design-procedural-events-b0-findings-2026-05-26.md` (curie, 2026-05-26) for the live-DB premise re-check. Numeric §1 claims drifted; Q5 multi-row collapse premise is moot (no `_archived_litigation.*` rows remain); Q6 N:1 attachment confirmed; mig number target updated 124 → 134.
|
||||
**Prior art read:**
|
||||
- `docs/design-deadline-data-model-2026-05-08.md` (einstein, t-paliad-158) — proposed `proceeding_event_types` + `proceeding_event_edges`; the **graph-shape recommendation has not been built** (no `proceeding_event*` tables exist in the live DB as of 2026-05-25, verified via `information_schema.tables`).
|
||||
- `docs/design-fristen-phase2-2026-05-15.md` (Phase 2/3 unified-rule columns — migs 078/079/091, **shipped**).
|
||||
- `docs/design-submission-generator-2026-05-19.md` and `docs/design-submission-page-2026-05-22.md` (Slice 1 → Slice A of the Schriftsätze stack — shipped on top of today's `deadline_rules`).
|
||||
|
||||
This doc names a single conflation in the schema and proposes a two-slice fix (cosmetic immediate, structural follow-up). It is intentionally narrower than einstein's 2026-05-08 graph proposal — it does **not** re-litigate the proceeding-as-DAG question.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
`paliad.deadline_rules` today is **one row that wears three hats**:
|
||||
|
||||
1. **The procedural-event template** — `submission_code`, `name`, `name_en`, `description`, `event_type`, `primary_party`. This is "what kind of step is this in the proceeding": Rechtsbeschwerdebegründung, mündliche Verhandlung, Entscheidung, etc.
|
||||
2. **The legal-norm citation** — `legal_source`, `rule_code`, `alt_rule_code`, `rule_codes[]`. This is "the source-of-law anchor": § 102 PatG, UPC RoP R.220(1).
|
||||
3. **The sequencing rule** — `parent_id`, `trigger_event_id`, `duration_value`, `duration_unit`, `timing`, `alt_duration_*`, `combine_op`, `condition_expr`, `is_spawn`, `spawn_*`, `sequence_order`, `is_court_set`, `priority`, `anchor_alt`, `proceeding_type_id`. This is "how and when does it fire relative to other events".
|
||||
|
||||
The conflation surfaces most painfully in the submission-draft editor's variable sidebar (m's report 2026-05-25 15:02), where the lawyer sees field labels like `{{rule.submission_code}}` for what is plainly a *procedural-event code*, `{{rule.event_type}}` for what is plainly the *procedural-event kind*, and `{{rule.legal_source_pretty}}` for what is plainly the *legal norm* — all under a `rule.*` namespace that reads as if the lawyer were filling in arithmetic.
|
||||
|
||||
**Recommendation = Q1 option (C):**
|
||||
|
||||
- **Slice A (immediate, this design's coder shift):** cosmetic rename — placeholders, i18n labels, Go struct-comment naming, admin-UI page titles all shift to `procedural_event.*` as the canonical name. **Database schema, table name, column names, FK directions, JSON envelope keys on the wire all stay exactly as they are.** Old `{{rule.*}}` placeholders remain emitted in the variable bag as legacy aliases so existing Word templates and saved drafts keep working.
|
||||
- **Slice B (planned follow-up, separate mai task, separate slice plan):** structural rework — extract `paliad.procedural_events`, `paliad.sequencing_rules`, `paliad.legal_sources`, with a phased dual-write migration. **Not shipped here.** This doc defines the target shape (§4) and the migration shape (§5) so the eventual coder has a brief, not so the eventual coder is hired today.
|
||||
|
||||
**Umbrella term lock = Q2 option (R):** **"procedural event"** (DE: **"Verfahrensschritt"**) as the umbrella covering filings, hearings, decisions, orders. Justification in §2.
|
||||
|
||||
Both Slice A and the eventual Slice B preserve the Schriftsätze surface (t-paliad-238/242/243): the submissions list query changes its predicate from `dr.event_type = 'filing'` to `pe.event_kind IN ('filing', 'reply')` (Slice B only) — same rows, cleaner predicate.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-25)
|
||||
|
||||
Every load-bearing claim was checked against the running paliad codebase + youpc Supabase. Numbers and schema facts are point-in-time as of 2026-05-25 15:30.
|
||||
|
||||
| Claim | Verification |
|
||||
|---|---|
|
||||
| `paliad.deadline_rules` carries the 38 columns listed in §0's three-hats decomposition. | `information_schema.columns WHERE table_schema='paliad' AND table_name='deadline_rules'` — 38 rows; columns confirmed verbatim. |
|
||||
| Live row count = 254. | `SELECT COUNT(*) FROM paliad.deadline_rules` → 254. |
|
||||
| 177 rows carry a `submission_code` (procedural-event identity); 158 distinct values. | `COUNT(*) FILTER (WHERE submission_code IS NOT NULL)` → 177; `COUNT(DISTINCT submission_code)` → 158. |
|
||||
| 102 rows carry a `legal_source`; 70 distinct citations. | Same query, `legal_source` column. |
|
||||
| 125 rows are linked to a `deadline_concepts` row via `concept_id`. | `COUNT(*) FILTER (WHERE concept_id IS NOT NULL)` → 125 (49 % of the corpus). |
|
||||
| `event_type` distribution: 130 `filing` · 77 NULL · 25 `decision` · 21 `hearing` · 1 `order`. | `SELECT event_type, count(*) GROUP BY event_type` — confirmed; the 77 NULL rows are structural / parent-only rows in the proceeding tree. |
|
||||
| 10 `submission_code` values appear on more than one row (jurisdictional / bilateral variants). | All 10 today are `_archived_litigation.*` codes (claimant/defendant splits + multi-stage hearing rows). Live non-archived codes are 1:1 with rows in the current corpus. |
|
||||
| `paliad.deadlines` joins to `deadline_rules` via column `rule_id` (uuid, FK). The text `rule_code` and free-text `custom_rule_text` (mig 122, t-paliad-258) are denormalized for display when the rule row is deleted. | `internal/services/deadline_service.go:69-127`; live column list confirms `rule_id`, `rule_code`, `custom_rule_text` — there is **no** `deadline_rule_id` column on deadlines (issue body called it `deadlines.deadline_rule_id` — that's a doc-side typo; the column is `rule_id`). |
|
||||
| `paliad.submission_drafts` keys to a procedural event via `submission_code` text — **no FK** to `deadline_rules`. | `information_schema.columns` for `submission_drafts`: `submission_code text` plus `(project_id, submission_code)` as the joint identifier. Confirms the Schriftsätze surface filters on the *text key*, not on `deadline_rules.id`. |
|
||||
| The Schriftsätze list (t-paliad-238) filters `deadline_rules` by `event_type='filing'` and `submission_code IS NOT NULL`. | `internal/handlers/submissions.go:193-211` — verbatim. |
|
||||
| The variable bag emits exactly 8 `rule.*` placeholders. | `internal/services/submission_vars.go:349-364` — `rule.submission_code`, `rule.name`, `rule.name_de`, `rule.name_en`, `rule.legal_source`, `rule.legal_source_pretty`, `rule.primary_party`, `rule.event_type`. Frontend i18n labels at `frontend/src/client/submission-draft.ts:158-185`. |
|
||||
| Admin rule-edit form binds the same `rule.X` fields. | `frontend/src/admin-rules-edit.tsx:74-110` + `frontend/src/client/admin-rules-edit.ts:253-278` — same eight columns surfaced as form inputs. |
|
||||
| The Fristenrechner client surface refers to `calc.rule.nameDE` / `calc.rule.nameEN`. | `frontend/src/client/fristenrechner.ts:1592,1655`. |
|
||||
| einstein's 2026-05-08 `proceeding_event_types` + `proceeding_event_edges` are **not** in the DB. | `SELECT table_name FROM information_schema.tables WHERE table_schema='paliad' AND table_name LIKE '%proceeding_event%'` → 0 rows. The graph-shape proposal was never built. |
|
||||
| `paliad.deadline_concepts` (57 rows in the original einstein audit; live count not directly queried this shift) still exists and is referenced via `deadline_rules.concept_id`. | `information_schema.tables` confirms `deadline_concepts`, `deadline_concept_event_types`, `deadline_event_types`, `event_types`, `trigger_events`, `event_categories` all still present — the deadline-knowledge graph from the einstein design lives on alongside the unified rule columns. |
|
||||
| Phase 2/3 columns (`priority`, `condition_expr`, `is_court_set`, `lifecycle_state`, `draft_of`, `published_at`, `rule_codes[]`) are live and load-bearing. | `internal/models/models.go:622-684` + mig 091. Slice B's structural rework must preserve every one of these on the new `sequencing_rules` table — they are not legacy. |
|
||||
| Live `paliad.deadlines` references to rules are sparse (1 row in prod). | `SELECT COUNT(*) FROM paliad.deadlines` → 1. The 4 `submission_drafts` rows reference a procedural event by `submission_code` text only. Tiny live FK surface → migrations can be aggressive without losing user data. |
|
||||
| Migration tracker is `paliad.paliad_schema_migrations`; next available number is 124 (mig 123 = Backup Mode Slice A, just shipped). | `internal/db/migrations/` directory listing; latest applied = 123. |
|
||||
|
||||
**Doc-side bug flagged for this issue's body:** the deliverable spec writes `paliad.deadlines.deadline_rule_id` in §3 (Q3 migration shape). The live column is `paliad.deadlines.rule_id`. Slice B's rename target is therefore `paliad.deadlines.procedural_event_id`, renamed directly from `paliad.deadlines.rule_id` — there is no intermediate `deadline_rule_id` step (no such column exists). Updating the issue body is m's call — flagged here so it doesn't propagate into a coder brief. *(B.0 update 2026-05-26: issue body patched. See `docs/design-procedural-events-b0-findings-2026-05-26.md` §5.)*
|
||||
|
||||
---
|
||||
|
||||
## §2 m's vocabulary call (Q2 — lock the umbrella term)
|
||||
|
||||
m proposed "procedural event" in the report. Options weighed:
|
||||
|
||||
| Option | Reads as | Collisions | Verdict |
|
||||
|---|---|---|---|
|
||||
| **"procedural event"** (DE: "Verfahrensschritt") | Umbrella that naturally covers filings, hearings, decisions, orders. Matches lawyer mental model: "the next thing that happens in the proceeding". | None — no `paliad.procedural_event*` table or column today (verified). | **(R) — adopt as canonical.** |
|
||||
| "submission" | Today the Schriftsätze surface uses this for *filings only* (`event_type='filing'`). Expanding the meaning would silently change Slice A's semantics for an existing UI. | Surface-level collision with the Schriftsätze nomenclature already in production. | Reject — would lose precision for an existing concept. |
|
||||
| "event" / "event_type" | Existing `deadline_rules.event_type` column. | **Hard collision** with `paliad.events` (audit feed, distinct table, distinct meaning). Renaming around it would be worse than the conflation we're trying to fix. | Reject. |
|
||||
| "Verfahrensschritt" only (no English) | Cleanest German but no English fallback. | Bilingual UI (DE primary, EN secondary per project CLAUDE.md) requires both. | Reject in isolation — but **adopt as the canonical German rendering** of "procedural event". |
|
||||
| "Verfahrensereignis" | Closer literal translation of "procedural event". | None. | Reject in favor of "Verfahrensschritt" — m's broader vocabulary uses "Schritt" (e.g. "Antragsschritt") more naturally than "Ereignis", which already maps to `paliad.events` in the audit-feed sense. |
|
||||
|
||||
**Lock:**
|
||||
|
||||
| Surface | Canonical |
|
||||
|---|---|
|
||||
| English | **procedural event** (lowercase except sentence-initial) |
|
||||
| German | **Verfahrensschritt** (m. — der Verfahrensschritt) |
|
||||
| Plural EN | procedural events |
|
||||
| Plural DE | Verfahrensschritte |
|
||||
| Code identifier (Go struct names, TS types) | `ProceduralEvent`, `ProceduralEventKind`, `ProceduralEventTemplate` |
|
||||
| Snake-case (DB columns, JSON keys, i18n keys, placeholders) | `procedural_event`, `procedural_event_kind`, `procedural_events` (table) |
|
||||
| Slice A: variable-bag placeholder namespace | `procedural_event.*` (with `rule.*` kept as legacy alias) |
|
||||
| Slice B: table name (if shipped) | `paliad.procedural_events` |
|
||||
|
||||
`event_type` (the column) becomes `event_kind` in Slice B — using "kind" rather than "type" to free up the word "type" for the proceeding-level taxonomy (`paliad.proceeding_types`, untouched) and to mirror the "event_type vs event_kind" disambiguation einstein hit in the 2026-05-08 doc. In Slice A the column stays `event_type` (no DB change).
|
||||
|
||||
**Q2 is locked by inventor recommendation.** It costs nothing structurally and clears noise across every downstream conversation. If m disagrees in the head round-trip, the only thing that flips is the term — Slice A's scope shape stays.
|
||||
|
||||
---
|
||||
|
||||
## §3 Scope decision (Q1 — A vs B vs C)
|
||||
|
||||
**Recommendation = (C) — cosmetic rename now, structural rework as a planned follow-up.**
|
||||
|
||||
### Why not (A) — cosmetic only and stop
|
||||
|
||||
(A) leaves the model wrong forever. The conflation isn't just a labelling annoyance — it makes future questions harder to answer cleanly:
|
||||
|
||||
- "How many distinct procedural events does paliad model?" Today: ambiguous (rows vs distinct `submission_code`s vs distinct `(submission_code, proceeding_type_id)` tuples).
|
||||
- "Where can we attach a per-procedural-event Word template that's independent of which proceeding it appears in?" Today: nowhere — the FK chain forces a per-row template registry, see `internal/handlers/files.go` template fallback.
|
||||
- "Show me every sequencing rule that triggers a given procedural event across all proceedings." Today: requires joining `deadline_rules` to itself on `submission_code` + `parent_id`, brittle.
|
||||
|
||||
If m signals (A) anyway — fine; the cosmetic-only slice is a strict subset of (C)'s Slice A and ships the same value (label clarity in the editor). But the recommendation is to write down the structural target now while the analysis is fresh.
|
||||
|
||||
### Why not (B) — restructure immediately
|
||||
|
||||
(B) means: one slice plan, one cutover. With:
|
||||
- 254 live rule rows,
|
||||
- 1 live `paliad.deadlines` row,
|
||||
- 4 live `submission_drafts` rows,
|
||||
- 12 Go services + 6 handlers touching `deadline_rules` + 8 placeholder strings on the wire + the admin rule-editor UI bound to the column shape,
|
||||
|
||||
…doing this in one cutover means a big-bang migration during a downtime window. m has granted exactly one such window in recent memory (2026-05-15 for mig 091's destructive drops), and that one was constrained to a 4-column drop. A four-table restructure has a meaningfully larger blast radius; it warrants its own task with its own slice plan and its own risk review.
|
||||
|
||||
### Why (C) — cosmetic-rename Slice A this design, structural Slice B as a separate task
|
||||
|
||||
Three properties of (C) make it the safe call:
|
||||
|
||||
1. **Slice A is reversible at any time** — every change is in i18n strings, Go struct comments, admin-UI page titles, and the variable-bag aliases. No DB migration. No drop. A revert is a `git revert` of the Slice A commit.
|
||||
2. **Slice B is fully designed but uncommitted** — §4 and §5 below define the target shape and migration plan, but the design doc itself ships in Slice A. m can read it, redirect it, or park it without pressure to ship it now.
|
||||
3. **The Schriftsätze surface doesn't care which slice we ship** — Slice A leaves it on `event_type='filing'`; Slice B flips it to `event_kind IN ('filing', 'reply')` over a dual-write window. Either way, the lawyer-facing behavior is unchanged.
|
||||
|
||||
### Slice A's deliverable boundary (what gets renamed, what stays)
|
||||
|
||||
**Renamed in Slice A:**
|
||||
|
||||
- **i18n keys** for the admin rule-editor field labels: `admin.rules.edit.field.submission_code` → `admin.rules.edit.field.procedural_event_code`, etc. (16 keys total — `name`, `name_en`, `description`, `submission_code`, `rule_code`, `legal_source`, `primary_party`, `event_type` × DE/EN — full list in §7.1.)
|
||||
- **Variable-bag placeholder labels** in `submission-draft.ts:158-185`: the *visible label* (`{ de: "Schriftsatz-Code", en: "Submission code" }`) is unchanged for filings (filings are still Schriftsätze on that surface), but the **namespace shown next to the placeholder string** changes: lawyer sees `{{procedural_event.code}}` in the placeholder column with the same Schriftsatz-Code label and same value. The old `{{rule.submission_code}}` stays in the catalog as an "(alt)" entry pointing at the same field.
|
||||
- **Variable-bag emission** (`internal/services/submission_vars.go:351-364`): the bag emits **both** key-names for every value, so any Word template / saved draft holding `{{rule.X}}` keeps working without a touch. New templates and the in-app catalog show the canonical `{{procedural_event.X}}` name.
|
||||
- **Admin page titles + section headings**: "Regel bearbeiten" → "Verfahrensschritt bearbeiten" (DE), "Edit rule" → "Edit procedural event" (EN). "Regeln verwalten" → "Verfahrensschritte verwalten" / "Procedural events". The URL path `/admin/rules` stays — URL renames have downstream cost (bookmarks, audit log entries) and would need their own redirect slice (out of scope here).
|
||||
- **Go struct comments + service docstrings + worker-facing log lines** that refer to "the rule" → "the procedural event" where the referent is the procedural-event aspect (not the sequencing-rule aspect). Function names, type names, table name stay (Slice B handles those).
|
||||
- **The "Submission Code / Einreichung-Kennung" label** itself stays (it's the lawyer's anchor — they recognize it). The framing around it changes: it now reads as "the code that identifies this *procedural event*", not "the code attached to this *rule*".
|
||||
|
||||
**Untouched in Slice A:**
|
||||
|
||||
- Database schema. Table name (`paliad.deadline_rules`). Column names. FK directions. Indexes. RLS policies. Triggers. Audit log column `rule_id`.
|
||||
- Go struct names: `DeadlineRule` stays. The renames here are *prose*, not *code*. Renaming `DeadlineRule` to `ProceduralEvent` couples Slice A to Slice B's table rename — keep them decoupled.
|
||||
- JSON envelope keys on the wire (`POST /api/admin/rules/:id` still accepts `submission_code` in the body — Slice B's API rename is a breaking change with its own deprecation window).
|
||||
- URL paths (`/admin/rules`, `/api/admin/rules/:id`, `/api/projects/:id/submissions` etc.).
|
||||
- `paliad.deadlines.rule_id` FK column name.
|
||||
- The variable-bag's legacy `{{rule.X}}` keys — kept forever as aliases (cheap, zero rot).
|
||||
- The `submission_drafts` table's `submission_code` text key.
|
||||
|
||||
This boundary makes Slice A a one-day coder shift: scoped, reversible, label-only.
|
||||
|
||||
### What Slice B inherits
|
||||
|
||||
Slice B inherits a codebase + a UI where every prose surface already speaks "procedural event". It also inherits a *legacy alias contract* (the dual emission in the variable bag) that gives it freedom to rename the JSON keys on the wire and the Go struct in two separate sub-slices without rushing.
|
||||
|
||||
---
|
||||
|
||||
## §4 Restructure schema (Q3 — if/when we ship Slice B)
|
||||
|
||||
This is the target the eventual Slice B coder would land. **Nothing here ships in this task.**
|
||||
|
||||
### §4.1 Three new tables (plus the rename of `deadline_rules`)
|
||||
|
||||
```sql
|
||||
-- 1. Procedural event templates — one row per (procedural-event identity)
|
||||
-- For now the live corpus is 1:1 with non-archived submission_codes
|
||||
-- (148 of the 158 distinct codes), so we get ~177 rows minus the 10
|
||||
-- multi-row codes' duplicates. Bilateral / jurisdictional variants
|
||||
-- are modeled at the sequencing_rules layer.
|
||||
CREATE TABLE paliad.procedural_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE, -- former submission_code
|
||||
name text NOT NULL, -- DE
|
||||
name_en text NOT NULL,
|
||||
description text,
|
||||
event_kind text NOT NULL, -- filing|reply|hearing|decision|order|other
|
||||
primary_party_default text, -- claimant|defendant|both|court
|
||||
legal_source_id uuid REFERENCES paliad.legal_sources(id),
|
||||
concept_id uuid REFERENCES paliad.deadline_concepts(id),
|
||||
lifecycle_state text NOT NULL DEFAULT 'published', -- draft|published|archived
|
||||
draft_of uuid REFERENCES paliad.procedural_events(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 2. Legal sources — the source-of-law citations the procedural event
|
||||
-- anchors against. ~70 distinct values today (live corpus).
|
||||
CREATE TABLE paliad.legal_sources (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
citation text NOT NULL UNIQUE, -- "DE.PatG.102", "UPC.RoP.220.1", …
|
||||
jurisdiction text NOT NULL, -- DE|UPC|EPA|DPMA|other
|
||||
pretty_de text NOT NULL, -- "§ 102 PatG"
|
||||
pretty_en text NOT NULL, -- "Section 102 PatG"
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 3. Sequencing rules — the timing / trigger / condition mechanics that
|
||||
-- today live alongside the procedural-event identity on deadline_rules.
|
||||
-- One row per (procedural_event × proceeding × variant). The 10
|
||||
-- "_archived_litigation.*" codes that today have 2-5 rows become
|
||||
-- 2-5 sequencing_rules rows for the same procedural_events row.
|
||||
CREATE TABLE paliad.sequencing_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
procedural_event_id uuid NOT NULL REFERENCES paliad.procedural_events(id),
|
||||
proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
parent_id uuid REFERENCES paliad.sequencing_rules(id), -- structural tree, today's parent_id
|
||||
trigger_event_id bigint REFERENCES paliad.trigger_events(id), -- event-rooted variant
|
||||
duration_value integer NOT NULL DEFAULT 0,
|
||||
duration_unit text NOT NULL DEFAULT 'months',
|
||||
timing text DEFAULT 'after',
|
||||
alt_duration_value integer,
|
||||
alt_duration_unit text,
|
||||
alt_rule_code text, -- legacy free-text alt citation, retained
|
||||
anchor_alt text,
|
||||
combine_op text, -- max|min
|
||||
condition_expr jsonb,
|
||||
primary_party text, -- per-rule override of the procedural_event default
|
||||
sequence_order integer NOT NULL DEFAULT 0,
|
||||
is_spawn boolean NOT NULL DEFAULT false,
|
||||
spawn_label text,
|
||||
spawn_proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
is_bilateral boolean NOT NULL DEFAULT false,
|
||||
is_court_set boolean NOT NULL DEFAULT false,
|
||||
priority text NOT NULL DEFAULT 'mandatory',
|
||||
rule_code text, -- legacy short-form citation, retained on the rule
|
||||
rule_codes text[], -- multi-citation array (mig pre-091)
|
||||
deadline_notes text,
|
||||
deadline_notes_en text,
|
||||
lifecycle_state text NOT NULL DEFAULT 'published',
|
||||
draft_of uuid REFERENCES paliad.sequencing_rules(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 4. Rename downstream FK + add the link to procedural_events.
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id),
|
||||
ADD COLUMN sequencing_rule_id uuid REFERENCES paliad.sequencing_rules(id);
|
||||
-- (rule_id stays as a transitional alias during the dual-write window;
|
||||
-- dropped at end of Slice B)
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 5. Submission drafts: add procedural_event_id FK alongside submission_code.
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id);
|
||||
-- (submission_code stays — it's the cosmetic anchor lawyers recognize
|
||||
-- in URLs and chat, and it doubles as the procedural_events.code value)
|
||||
```
|
||||
|
||||
### §4.2 What goes where (column-by-column map)
|
||||
|
||||
Every column on today's `paliad.deadline_rules` lands on exactly one of the three new tables:
|
||||
|
||||
| Today's `deadline_rules` column | Lands on | Notes |
|
||||
|---|---|---|
|
||||
| `id`, `created_at`, `updated_at` | `sequencing_rules` | The current row's identity becomes a sequencing-rule row. `procedural_events.id` is **new** — backfilled from `submission_code`. |
|
||||
| `submission_code` | `procedural_events.code` | Promoted up. Multi-row codes (10 in corpus, all `_archived_litigation.*`) collapse to one row on the new table; the 2-5 sequencing rows hang off it. |
|
||||
| `name`, `name_en`, `description` | `procedural_events` | Procedural-event identity. |
|
||||
| `primary_party` | `procedural_events.primary_party_default` AND `sequencing_rules.primary_party` | Both. The procedural event has a default party (claimant for Klage etc.); the sequencing rule can override per-jurisdiction (bilateral variants — e.g. `litigation.reply` claimant vs defendant become two sequencing rows with overridden party). |
|
||||
| `event_type` | `procedural_events.event_kind` | Hat 1, with rename to `event_kind` (term lock §2). |
|
||||
| `legal_source` | `legal_sources.citation` + FK from `procedural_events.legal_source_id` | The citation moves to its own row; the procedural event points at it. `pretty_de` / `pretty_en` materialize the existing `legalSourcePretty()` function output as columns (with the function retained as the migration source). |
|
||||
| `rule_code`, `alt_rule_code`, `rule_codes[]` | `sequencing_rules` | Short-form citation arrays stay on the sequencing rule — they're rule-specific. |
|
||||
| `proceeding_type_id`, `parent_id`, `trigger_event_id`, `spawn_proceeding_type_id`, `is_spawn`, `spawn_label`, `is_bilateral`, `is_court_set`, `combine_op` | `sequencing_rules` | Hat 3 (mechanics) — exact copies. |
|
||||
| `duration_value`, `duration_unit`, `timing`, `alt_duration_value`, `alt_duration_unit`, `anchor_alt` | `sequencing_rules` | Hat 3 (mechanics). |
|
||||
| `condition_expr` (jsonb) | `sequencing_rules` | Hat 3. The grammar from mig 091 stays. |
|
||||
| `priority`, `sequence_order` | `sequencing_rules` | Hat 3. |
|
||||
| `is_active`, `lifecycle_state`, `draft_of`, `published_at` | **BOTH** `procedural_events` AND `sequencing_rules` | A procedural event can be retired independently of any one of its sequencing variants. Backfill: copy onto both during dual-write; new rows go through the rule-editor service which writes both sides together. |
|
||||
| `concept_id` (FK to `deadline_concepts`) | `procedural_events.concept_id` | The concept layer (einstein 2026-05-08) attaches to the procedural event, not the sequencing rule. |
|
||||
| `deadline_notes`, `deadline_notes_en` | `sequencing_rules` | They're rule-specific notes ("filing the appeal in DE costs €X if you also did Y") — not procedural-event-wide. |
|
||||
|
||||
Three columns disappear:
|
||||
|
||||
- The semantically-overloaded part of `event_type` (renamed to `event_kind` and moved).
|
||||
- The "what is this thing" vs "how does it fire" name conflict — gone by construction.
|
||||
- Any column that exists only because of the conflation (none of today's columns are pure overhead — they all carry data — so the count stays at 38 across the three new tables).
|
||||
|
||||
### §4.3 Indexes + RLS
|
||||
|
||||
`paliad.can_see_project()` is the canonical RLS predicate (mig 055). None of the three new tables hold project-scoped data — they're firm-wide reference tables. RLS = none, same posture as today's `deadline_rules` (which is firm-wide and unrestricted at the row level; access control is via the `lifecycle_state='published'` filter in the read paths).
|
||||
|
||||
Indexes inherited from today:
|
||||
|
||||
- `paliad.legal_sources(citation)` — UNIQUE.
|
||||
- `paliad.procedural_events(code)` — UNIQUE.
|
||||
- `paliad.procedural_events(concept_id)` — for the deadline-concept join.
|
||||
- `paliad.sequencing_rules(procedural_event_id, proceeding_type_id, lifecycle_state)` — primary read path for the calculator.
|
||||
- `paliad.sequencing_rules(parent_id)` — tree walk.
|
||||
- `paliad.sequencing_rules(trigger_event_id)` — event-rooted variant.
|
||||
|
||||
---
|
||||
|
||||
## §5 Migration plan (Slice B — when it ships, not in this task)
|
||||
|
||||
Phased dual-write, so the cutover is **never** a single instant where the wire format flips. m gets to roll back any one phase with a `git revert` + an `ALTER TABLE` if a phase misbehaves in prod.
|
||||
|
||||
### §5.1 Phase 1 — Additive (no down-time)
|
||||
|
||||
1. Create `procedural_events`, `sequencing_rules`, `legal_sources`.
|
||||
2. Backfill `legal_sources` from `DISTINCT legal_source` on `deadline_rules` (70 rows). Populate `pretty_de`/`pretty_en` by calling the existing `legalSourcePretty()` function in a one-shot SQL/Go shim during the migration. Verify `COUNT(DISTINCT legal_source FROM deadline_rules) = COUNT(*) FROM legal_sources`.
|
||||
3. Backfill `procedural_events` from `DISTINCT submission_code` on `deadline_rules WHERE submission_code IS NOT NULL`. Take `name`, `name_en`, `event_type → event_kind`, `primary_party`, `concept_id`, `description` from the lowest-`id` rule row for each code (tie-breaker: lowest `sequence_order`). Verify `COUNT(*) FROM procedural_events = COUNT(DISTINCT submission_code FROM deadline_rules WHERE submission_code IS NOT NULL)` (= 158).
|
||||
4. Backfill `sequencing_rules` 1:1 from `deadline_rules` (254 rows). FK `procedural_event_id` resolved by code lookup; sequencing-rule row inherits the `deadline_rules.id` (so existing `deadlines.rule_id` FKs continue to resolve via the new column for the dual-write window — see Phase 3).
|
||||
5. Add `paliad.deadlines.procedural_event_id` + `sequencing_rule_id` columns, backfill from `deadlines.rule_id` join.
|
||||
6. Add `paliad.submission_drafts.procedural_event_id`, backfill from `submission_code` join.
|
||||
|
||||
This phase ships behind a feature flag (or just behind unused code) — readers + writers stay on `deadline_rules`. No behavior change.
|
||||
|
||||
### §5.2 Phase 2 — Dual-write (no down-time)
|
||||
|
||||
7. Update `RuleEditorService` to write to both `deadline_rules` (legacy) and (`procedural_events`, `sequencing_rules`, `legal_sources`) on every Create/Update/Publish/Archive. Audit log writes one row per side.
|
||||
8. Update read paths to **read from the new tables**, falling back to `deadline_rules` if the new row is missing (defense-in-depth during backfill catch-up).
|
||||
9. Run for ≥ 1 week (m's call on length). Compare row counts and a hash digest of the union daily — if drift, surface.
|
||||
|
||||
### §5.3 Phase 3 — Cutover (no down-time, but reversible only via re-application of the dual-write)
|
||||
|
||||
10. Flip read paths to **only** the new tables (`SubmissionVarsService.loadPublishedRule`, `DeadlineRuleService.*`, `SubmissionService.list`, `ProjectionService`, `FristenrechnerCalc`, etc.).
|
||||
11. Stop writing to `deadline_rules`.
|
||||
12. `paliad.deadlines.rule_id` is kept as a no-op alias for one more week; new writes go to `procedural_event_id` + `sequencing_rule_id`.
|
||||
13. `submission_drafts.submission_code` is kept as the URL anchor; the FK `procedural_event_id` is the primary join key going forward.
|
||||
|
||||
### §5.4 Phase 4 — Drop legacy (downtime window, destructive)
|
||||
|
||||
14. `paliad.deadline_rules_pre_<slice-B-mig>` snapshot of the entire table.
|
||||
15. DROP TABLE paliad.deadline_rules (after CASCADE-safe FK rewires).
|
||||
16. DROP COLUMN paliad.deadlines.rule_id (keep `rule_code` + `custom_rule_text` as the human-readable denormalized columns — they're the safety net for orphaned deadlines per t-paliad-258).
|
||||
|
||||
m grants this destructive phase its own window (precedent: mig 091 on 2026-05-15). Until then, the legacy table sits dormant.
|
||||
|
||||
### §5.5 Migration tracker
|
||||
|
||||
- Slice B uses migration numbers 124 (Phase 1 — create tables + backfill) and onward — a 4-5 migration sequence, one per phase boundary, mirroring the Phase 2/3 slicing that shipped under t-paliad-195.
|
||||
- Each migration includes a `paliad.audit_reason = 'mig <n>: <slice-B-phase>'` set_config like mig 091 did, so the audit log captures the schema journey.
|
||||
|
||||
---
|
||||
|
||||
## §6 Service-layer impact
|
||||
|
||||
### §6.1 Slice A — prose-only changes
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `internal/services/submission_vars.go` | `addRuleVars` → also emit `procedural_event.code`, `procedural_event.name`, `procedural_event.name_de`, `procedural_event.name_en`, `procedural_event.legal_source`, `procedural_event.legal_source_pretty`, `procedural_event.primary_party`, `procedural_event.event_kind` (8 new keys, 1:1 with the 8 existing `rule.*` keys, same values). Rename docstrings + the package-level placeholder map comment ("`rule.*`" → "`procedural_event.*` (with legacy alias `rule.*`)"). |
|
||||
| `internal/services/deadline_rule_service.go` | Top-of-file comment + struct comment renames only. Method names stay (`DeadlineRuleService`, `GetByID`, etc.). |
|
||||
| `internal/services/rule_editor_service.go` | Same. |
|
||||
| `internal/services/projection_service.go`, `deadline_service.go`, `fristenrechner.go`, `submission_draft_service.go`, `event_trigger_service.go`, `event_deadline_service.go`, `proceeding_mapping.go`, `export_service.go` | No code changes. Comments mentioning "the rule"/"rules" stay accurate as long as the file is about sequencing — only services that surface the **identity** aspect of the rule (`submission_vars.go`) need a prose pass. |
|
||||
| `internal/handlers/submissions.go` | No SQL change. Type+comment renames: the catalog response type stays `submissionListEntry` (it's still a Schriftsatz-level list); doc comments speak of "procedural events whose kind is filing" instead of "rules of type filing". |
|
||||
| `internal/handlers/admin_rules.go` | URL path stays. JSON envelope stays. Page-render comments + log-line text shift to "procedural event". |
|
||||
| `internal/handlers/submission_drafts.go`, `deadlines.go`, `fristenrechner.go` | No service-layer change. |
|
||||
|
||||
### §6.2 Slice B — structural
|
||||
|
||||
Mostly load-bearing; not enumerated here in detail (out of scope per (R)=C). The shape:
|
||||
|
||||
- `RuleEditorService` splits into `ProceduralEventService` + `SequencingRuleService` + `LegalSourceService`. The Save / Publish / Archive flow on the editor coordinates all three.
|
||||
- `DeadlineRuleService.GetByID` becomes `SequencingRuleService.GetByID`; the `submission_code` lookup moves to `ProceduralEventService.GetByCode`.
|
||||
- `SubmissionVarsService.loadPublishedRule` becomes `loadPublishedProceduralEvent` and returns a triple (`event`, `defaultSequencingRule`, `legalSource`); the variable-bag emission consumes all three.
|
||||
- `ProjectionService` and the Fristenrechner calculator read from `sequencing_rules` (same column set, same logic — only the table name changes).
|
||||
- `SubmissionService.list` (handlers/submissions.go) filters `procedural_events.event_kind IN ('filing', 'reply')`.
|
||||
- Backfill orphans + audit triggers (mig 079 / 089) are re-pointed at `sequencing_rules` + a new `procedural_events_audit`.
|
||||
|
||||
---
|
||||
|
||||
## §7 UI / i18n impact
|
||||
|
||||
### §7.1 i18n keys (Slice A)
|
||||
|
||||
Existing keys (DE + EN) at `frontend/src/client/i18n.ts` lines ~2834-2920 and ~5800-5890 — surface area is *labels*, not *placeholders-in-Word*:
|
||||
|
||||
| Old key | New key (Slice A) | DE label | EN label |
|
||||
|---|---|---|---|
|
||||
| `admin.rules.list.title` | `admin.procedural_events.list.title` | "Verfahrensschritte verwalten — Paliad" | "Manage procedural events — Paliad" |
|
||||
| `admin.rules.list.heading` | `admin.procedural_events.list.heading` | "Verfahrensschritte verwalten" | "Manage procedural events" |
|
||||
| `admin.rules.list.subtitle` | `admin.procedural_events.list.subtitle` | "Verfahrensschritte anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived." | "Create, edit and publish procedural events. Lifecycle: draft → published → archived." |
|
||||
| `admin.rules.list.new` | `admin.procedural_events.list.new` | "+ Neuer Verfahrensschritt" | "+ New procedural event" |
|
||||
| `admin.rules.col.submission_code` | `admin.procedural_events.col.code` | "Code" (drop "/ Einreichung-Kennung" — the new heading already disambiguates) | "Code" |
|
||||
| `admin.rules.col.legal_citation` | `admin.procedural_events.col.legal_source` | "Rechtsgrundlage" | "Legal source" |
|
||||
| `admin.rules.col.name` | `admin.procedural_events.col.name` | "Bezeichnung" | "Name" |
|
||||
| `admin.rules.col.proceeding` | `admin.procedural_events.col.proceeding` | "Verfahrenstyp" | "Proceeding" |
|
||||
| `admin.rules.col.priority` | `admin.procedural_events.col.priority` | "Priorität" | "Priority" |
|
||||
| `admin.rules.col.lifecycle` | `admin.procedural_events.col.lifecycle` | "Lifecycle" | "Lifecycle" |
|
||||
| `admin.rules.col.modified` | `admin.procedural_events.col.modified` | "Zuletzt geändert" | "Last modified" |
|
||||
| `admin.rules.edit.title` | `admin.procedural_events.edit.title` | "Verfahrensschritt bearbeiten — Paliad" | "Edit procedural event — Paliad" |
|
||||
| `admin.rules.edit.heading.loading` | `admin.procedural_events.edit.heading.loading` | "Verfahrensschritt laden…" | "Loading procedural event…" |
|
||||
| `admin.rules.edit.breadcrumb` | `admin.procedural_events.edit.breadcrumb` | "← Verfahrensschritte verwalten" | "← Manage procedural events" |
|
||||
| `admin.rules.edit.field.submission_code` | `admin.procedural_events.edit.field.code` | "Code (Schriftsatz-Code / Einreichung-Kennung)" — keep the parenthetical so lawyers familiar with the old label know what they're looking at. | "Code (submission / procedural-event identifier)" |
|
||||
| `admin.rules.edit.field.rule_code` | `admin.procedural_events.edit.field.short_citation` | "Rechtsgrundlage (Kurzform)" | "Legal source (short form)" |
|
||||
| `admin.rules.edit.field.legal_source` | `admin.procedural_events.edit.field.legal_source` | "Rechtsgrundlage (Langform)" | "Legal source (long form)" |
|
||||
| `admin.rules.edit.field.name` | `admin.procedural_events.edit.field.name` | "Bezeichnung (DE)" | "Name (DE)" |
|
||||
| `admin.rules.edit.field.name_en` | `admin.procedural_events.edit.field.name_en` | "Bezeichnung (EN)" | "Name (EN)" |
|
||||
| `admin.rules.edit.field.proceeding` | `admin.procedural_events.edit.field.proceeding` | "Verfahrenstyp" | "Proceeding type" |
|
||||
| `admin.rules.edit.field.trigger` | `admin.procedural_events.edit.field.trigger` | "Trigger-Ereignis" | "Trigger event" |
|
||||
| `admin.rules.edit.field.parent` | `admin.procedural_events.edit.field.parent` | "Übergeordneter Verfahrensschritt (UUID)" | "Parent procedural event (UUID)" |
|
||||
| `admin.rules.edit.field.concept` | `admin.procedural_events.edit.field.concept` | "Konzept (UUID)" | "Concept (UUID)" |
|
||||
| `admin.rules.edit.field.sequence_order` | `admin.procedural_events.edit.field.sequence_order` | "Reihenfolge" | "Order" |
|
||||
| `admin.rules.edit.field.duration_value` | `admin.procedural_events.edit.field.duration_value` | "Dauer" | "Duration" |
|
||||
| `admin.rules.edit.field.primary_party` | `admin.procedural_events.edit.field.primary_party` | "Partei (typisch)" | "Primary party" |
|
||||
| `admin.rules.edit.field.event_type` | `admin.procedural_events.edit.field.event_kind` | "Art des Verfahrensschritts" | "Procedural-event kind" |
|
||||
| `admin.rules.edit.field.description` | `admin.procedural_events.edit.field.description` | "Beschreibung" | "Description" |
|
||||
|
||||
**Legacy keys retained as aliases** so any existing translation imports or external integrations keep working — old keys point at the same DE/EN values during a deprecation window of one full Slice B cycle.
|
||||
|
||||
### §7.2 Variable-bag placeholders (Slice A)
|
||||
|
||||
`frontend/src/client/submission-draft.ts:155-185` — the catalog of placeholders the lawyer sees in the sidebar:
|
||||
|
||||
| Old placeholder (kept as legacy alias) | New canonical placeholder | DE label | EN label |
|
||||
|---|---|---|---|
|
||||
| `{{rule.submission_code}}` | `{{procedural_event.code}}` | "Code (Verfahrensschritt)" | "Code (procedural event)" |
|
||||
| `{{rule.name}}` | `{{procedural_event.name}}` | "Bezeichnung" | "Name" |
|
||||
| `{{rule.name_de}}` | `{{procedural_event.name_de}}` | "Bezeichnung (DE)" | "Name (DE)" |
|
||||
| `{{rule.name_en}}` | `{{procedural_event.name_en}}` | "Bezeichnung (EN)" | "Name (EN)" |
|
||||
| `{{rule.legal_source}}` | `{{procedural_event.legal_source}}` | "Rechtsgrundlage (Code)" | "Legal source (code)" |
|
||||
| `{{rule.legal_source_pretty}}` | `{{procedural_event.legal_source_pretty}}` | "Rechtsgrundlage" | "Legal source" |
|
||||
| `{{rule.primary_party}}` | `{{procedural_event.primary_party}}` | "Partei (typisch)" | "Primary party" |
|
||||
| `{{rule.event_type}}` | `{{procedural_event.event_kind}}` | "Art des Verfahrensschritts" | "Procedural-event kind" |
|
||||
|
||||
The catalog renders the canonical name in the "copy-this-placeholder" button. The variable bag (`submission_vars.go`) emits both names with identical values, so any Word template the lawyer already has continues to work; new templates are encouraged to use the canonical name.
|
||||
|
||||
### §7.3 Admin rule-editor form (Slice A)
|
||||
|
||||
`frontend/src/admin-rules-edit.tsx:74-110` — i18n key rebinds + heading text update. The DOM `id` attributes (`f-submission-code`, `f-rule-code`, `f-legal-source`, …) stay — they're internal, the rename here is cosmetic, the form still POSTs the same JSON envelope (Slice A doesn't touch the API). The fieldset `legend` for the "Identität" section changes to "Verfahrensschritt-Identität" (DE) / "Procedural-event identity" (EN). The "Verfahren & Trigger" section heading stays — that section is about sequencing, and Slice A doesn't rename sequencing-level labels (those are Slice B).
|
||||
|
||||
### §7.4 Project-detail Schriftsätze tab + dashboard
|
||||
|
||||
`frontend/src/client/submissions.ts`, `submissions-index.ts`: no surface-level label change in Slice A. The Schriftsätze tab continues to show Schriftsätze (the lawyer's preferred term for *filings specifically*). The tab is a filtered view onto procedural events of kind `filing`/`reply` — that distinction surfaces only in admin contexts.
|
||||
|
||||
### §7.5 Help text + docs
|
||||
|
||||
A short addition to the in-app help: "What is a procedural event?" — one-paragraph definition explaining the umbrella term, with examples (Klage, Klageerwiderung, mündliche Verhandlung, Endurteil). Stored in `frontend/src/client/i18n.ts` under `help.procedural_events.intro`. Out of scope for the URL/router changes — added as static copy where it fits naturally.
|
||||
|
||||
---
|
||||
|
||||
## §8 Slice plan
|
||||
|
||||
### §8.1 Slice A (this design's downstream task)
|
||||
|
||||
**Scope:** prose-only rename per §3 ("renamed in Slice A" list).
|
||||
|
||||
**Mechanics:**
|
||||
|
||||
1. Add 8 new placeholder keys to the variable bag in `submission_vars.go` (1:1 with the existing 8 `rule.*` keys). Keep the legacy keys.
|
||||
2. Update `frontend/src/client/submission-draft.ts` placeholder catalog labels.
|
||||
3. Rebind admin i18n keys per §7.1 (with legacy keys retained).
|
||||
4. Update admin page titles + section headings.
|
||||
5. Update Go struct comments + service docstrings in `submission_vars.go`, `deadline_rule_service.go`, `rule_editor_service.go`, `submission_draft_service.go`, `submissions.go` handler. No code-flow change.
|
||||
6. Update `internal/handlers/submissions.go` doc comments.
|
||||
7. Add a short `docs/glossary.md` entry (or extend an existing one) for "procedural event" / "Verfahrensschritt" — single source of truth for the term.
|
||||
8. Tests: rename strings in existing test fixtures + add a regression test that the variable bag emits **both** the legacy `rule.X` and the canonical `procedural_event.X` keys with the same value. (Critical — without this test, a future commit could drop the legacy alias and silently break user templates.)
|
||||
9. Manual smoke: open the admin rule editor, confirm the new title appears. Open the submission-draft editor, confirm both `{{rule.X}}` and `{{procedural_event.X}}` placeholders are listed (with canonical first). Generate a `.docx` from a project using each placeholder name — both render identically.
|
||||
|
||||
**Risk:** very low. No DB change, no API change, fully reversible.
|
||||
|
||||
**No hours estimate per project CLAUDE.md.**
|
||||
|
||||
### §8.2 Slice B (separate mai task — designed here, hired later)
|
||||
|
||||
**Scope:** structural rework per §4 + §5.
|
||||
|
||||
**Mechanics:** Phase 1 → Phase 4 per §5.
|
||||
|
||||
**Prerequisite:** m greenlights via a new mai task with this doc + §11's open items addressed. **Not part of Slice A.**
|
||||
|
||||
**Sub-slices (suggested for Slice B's own task):**
|
||||
|
||||
- **B.0** — Re-validate this doc's premises against live DB (numbers shift over weeks).
|
||||
- **B.1** — Phase 1 additive migration + backfill (mig 124).
|
||||
- **B.2** — Phase 2 dual-write + read-fallback.
|
||||
- **B.3** — Phase 3 read cutover (no schema change).
|
||||
- **B.4** — Phase 4 destructive drop (downtime window).
|
||||
- **B.5** — Rename Go types `DeadlineRule` → `SequencingRule` + `ProceduralEvent`; rename JSON API envelope keys with a deprecation header. Independent of B.4.
|
||||
- **B.6** — Rename admin URL paths `/admin/rules` → `/admin/procedural-events` with redirects. Optional / low-priority.
|
||||
|
||||
### §8.3 Why splitting is the right call
|
||||
|
||||
The conflation is real, but the *fix* for the most-painful surface (the editor sidebar) is independent of the table restructure. Splitting lets m ship the fix this week, see whether the prose change alone resolves enough of the cognitive friction, and then decide whether the structural rework is still worth the migration cost. If after Slice A m says "this reads fine now, B isn't worth it", that's a legitimate outcome — Slice B is a *good* refactor, not an *urgent* one.
|
||||
|
||||
---
|
||||
|
||||
## §9 Risk assessment
|
||||
|
||||
### §9.1 Slice A risks
|
||||
|
||||
| Risk | Likelihood | Severity | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Lawyer's existing Word template has `{{rule.submission_code}}` baked in; a future commit drops the legacy alias and breaks templates. | Low (Slice A keeps the alias) | High if it happens | Regression test (§8.1 step 8) asserts both keys emit. Add an audit-log line on every variable-bag call recording which keys were consumed by the merge engine — gives a 30-day window of evidence before we'd consider deprecating the legacy keys. |
|
||||
| i18n key rename misses a binding, leaving an English string visible to a DE user. | Medium | Low | The build pipeline (`bun test` / `bun build`) fails on missing i18n keys in `i18n-keys.ts`. Add the new keys to the type union; leave the old keys in the union with `@deprecated` JSDoc. |
|
||||
| Renamed admin page heading confuses returning admin users ("Where did 'Regeln verwalten' go?"). | Medium | Low | One-time changelog entry; the URL `/admin/rules` is unchanged so muscle memory still lands them on the page. Internal users only (whitelist-gated). |
|
||||
| Slice A reads as "we're done" and Slice B never ships. | Medium | Medium (the model stays wrong) | This doc files the Slice B design as a separate task entry **before** Slice A merges, so the to-do is visible. m's call whether to schedule it. |
|
||||
|
||||
### §9.2 Slice B risks (deferred; recorded for the future task)
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Backfill collapses too eagerly: 10 multi-row submission_codes today are `_archived_litigation.*` — confirm they should collapse into one procedural event with 2-5 sequencing variants, vs. each row becoming its own procedural event. | The `_archived_litigation.*` codes are archived per their prefix — collapse is safe. **Decision-flag for Slice B's own design pass.** |
|
||||
| `deadline_concepts` linkage (125 of 254 rules link to a concept) — does the concept attach to the procedural event or the sequencing rule? §4.2 says procedural event; verify this is right when re-validating premises in B.0. | Read-path audit: every consumer that joins `deadline_rules.concept_id` (rule_editor, projection, fristenrechner) operates on the rule-level today. Reconfirm none of them depend on per-jurisdiction concept-attachment. |
|
||||
| The dual-write window introduces drift if a write hits one side and fails on the other. | Atomicity via single transaction per write in `RuleEditorService`. Daily drift-check job (one SELECT pair, alert if mismatched). |
|
||||
| `paliad.deadlines.rule_id` (1 live row, but more in future) — backfilling `procedural_event_id` + `sequencing_rule_id` must not orphan the live row. | The 1 live row joins cleanly. Backfill in the same migration that adds the new columns. |
|
||||
| The submission-draft `submission_code` text key — what if two `procedural_events.code` values collide post-rename (e.g. a draft was saved against a code that we then archive)? | Slice B Phase 1 enforces `procedural_events.code UNIQUE`; the backfill verifies no collision on the existing 158 distinct values. Drafts with codes that no longer exist as published procedural events are handled by the existing `submission_drafts.submission_code` text fallback (no FK enforcement). |
|
||||
| Slice B's API-key rename (`submission_code` → `code` in JSON) breaks external integrations. | None exist today (paliad is internal-only); add a one-Slice deprecation header (`X-Deprecated-Field: submission_code`) before flipping. |
|
||||
| **Coordination risk with future fristen/calculator work.** The Fristenrechner calculator reads `deadline_rules` directly today. Slice B Phase 2's read-fallback handles this, but a parallel calculator feature in flight could land changes that need re-merging. | B.0's job: confirm no in-flight task touches `deadline_rules` table shape before scheduling. |
|
||||
|
||||
### §9.3 What rolls Slice A back
|
||||
|
||||
`git revert <slice-a-commit>` + reload. Zero data side-effects (no DB writes). 30 seconds.
|
||||
|
||||
### §9.4 What rolls Slice B back
|
||||
|
||||
Per phase — Phases 1-3 reversible via reverting code + `DROP TABLE`. Phase 4 reversible only by restoring `deadline_rules` from the `_pre_<n>` snapshot taken at the start of Phase 4. Same posture as mig 091 — m's call when to commit to this point.
|
||||
|
||||
---
|
||||
|
||||
## §10 Out of scope
|
||||
|
||||
- **Renaming `paliad.events`** (the audit feed). Distinct table, distinct concept. The umbrella-term lock (§2) deliberately uses "procedural event" not "event" to avoid colliding with it.
|
||||
- **Renaming `paliad.deadline_concepts`** to align with the procedural-event taxonomy. The concept layer is the cross-proceeding semantic bridge (einstein 2026-05-08 Q5); the relationship "procedural event has-a concept" already reads cleanly under the new term.
|
||||
- **Per-jurisdiction variations of the same procedural event** (issue body's explicit out-of-scope). The 10 multi-row codes in the corpus today stay multi-row.
|
||||
- **Multi-tenant / cross-firm sharing of procedural events** — paliad is single-tenant per deploy via `FIRM_NAME`; cross-firm is a separate design.
|
||||
- **einstein's `proceeding_event_edges` graph proposal.** That design proposed a graph of typed event-types connected by typed edges. This design's procedural-events / sequencing-rules split is **compatible** with that graph shape (the edges would attach to procedural-event-IDs rather than sequencing-rule-IDs), but the graph layer is a Slice C, not Slice B. Flagged for future continuity, not part of either slice here.
|
||||
- **Renaming Go type `DeadlineRule` to `SequencingRule` or `ProceduralEvent` in Slice A.** Slice A is prose; Slice B's B.5 sub-slice handles the type rename. Coupling them costs the reversibility property.
|
||||
- **API-envelope key renames** (`submission_code` → `code`, `event_type` → `event_kind` on the wire). Slice B only.
|
||||
- **URL path renames** (`/admin/rules` → `/admin/procedural-events`). Slice B.6, optional.
|
||||
- **Touching `paliad.trigger_events`** beyond keeping the FK path open (today `deadline_rules.trigger_event_id`; Slice B maps to `sequencing_rules.trigger_event_id`).
|
||||
- **Touching `paliad.event_categories` / Pathway-B navigation.** Independent layer.
|
||||
|
||||
---
|
||||
|
||||
## §11 Open questions for m (escalated via `mai instruct head` per project CLAUDE.md)
|
||||
|
||||
Per project CLAUDE.md "Head answers questions — NO AskUserQuestion" rule, these are surfaced to head, not picked-as-chip with the user.
|
||||
|
||||
| ID | Question | Inventor recommendation | Material to head? |
|
||||
|---|---|---|---|
|
||||
| **Q1** | Scope: cosmetic-only (A) · full restructure (B) · cosmetic now + B as planned follow-up (C). | **(R) = C** | Yes — material. Defines whether Slice B is hired today or filed as a future task. |
|
||||
| **Q2** | Umbrella term: "procedural event" (DE: Verfahrensschritt) · "submission" (filings only) · "Verfahrensereignis" · other. | **(R) = procedural event / Verfahrensschritt** | Yes — material. The term ripples through every label in §7. Inventor's pick is the canonical choice; head can override with a single message. |
|
||||
| **Q3** | Slice B migration shape: confirmed (§4 + §5) or rescope. | **(R) = §4 + §5 as written, decision deferred until Slice B is hired** | No — informational. Locked when Slice B's own design pass runs. |
|
||||
| **Q4** | Effect on Schriftsätze surface: filter `procedural_events.event_kind IN ('filing', 'reply')` is acceptable replacement for today's `event_type='filing'`. | **(R) = yes, semantically equivalent under Slice B; no behavior change to lawyer.** | No — informational. |
|
||||
| **Q5** | Are the 10 archived multi-row submission_codes (`_archived_litigation.*`) safe to collapse into single procedural events with multiple sequencing variants in Slice B? | **(R) = yes, prefix indicates archival; collapse-safe.** | No — informational, defers to Slice B. |
|
||||
| **Q6** | `concept_id` attaches to procedural event, not sequencing rule. Confirmable? | **(R) = yes, per §4.2 (one concept per identity, not per jurisdiction).** | No — informational, defers to Slice B. |
|
||||
| **Q7** | Keep the legacy `{{rule.X}}` placeholder aliases **forever**, or set a deprecation horizon (e.g. 1 year)? | **(R) = forever, with `@deprecated` annotation in the catalog. Removing them risks breaking lawyer-authored templates that paliad doesn't see.** | Yes — material to Slice A's contract (test in §8.1 step 8 asserts both keys emit). |
|
||||
| **Q8** | Document side: update m/paliad#93 issue body to fix the `deadlines.deadline_rule_id` → `deadlines.rule_id` typo (§1 last paragraph). | **(R) = yes, head's call when to edit.** | No — informational, doc hygiene. |
|
||||
| **Q9** | After Slice A ships, do we file Slice B as a new mai task **now** (so it's visible), or wait for m to ask? | **(R) = file now, status:planning, no owner. Visibility >> deferred surprise.** | Yes — material to "does the model stay wrong forever". |
|
||||
|
||||
Q1, Q2, Q7, Q9 are the four head needs to answer before the coder shift. Q3-Q6, Q8 defer cleanly.
|
||||
|
||||
---
|
||||
|
||||
## §12 Appendix — verbatim m quote
|
||||
|
||||
From m's report 2026-05-25 15:02 (paliad#93 body):
|
||||
|
||||
> This shows how our 'rule' table system may need a revision?! It feels like we are rule based not submission based. But here we have a specific submission that is connected to a rule (as in: legal norm). And of course also connected to other 'procedural events' (which is a good term for it all) by rules how they are sequenced. But it makes it sound weird in the fields...
|
||||
|
||||
The design above takes m's three-way split — *the procedural event* / *the legal norm* / *the rule by which they are sequenced* — at face value and turns it into a column-level map (§4.2), a slice plan (§8), and a deprecation contract (§9.1).
|
||||
|
||||
---
|
||||
|
||||
*End of design.*
|
||||
@@ -1,510 +0,0 @@
|
||||
# Design — `/tools/procedures` workflow tracker (m/paliad#152)
|
||||
|
||||
**Task:** t-paliad-337
|
||||
**Gitea:** m/paliad#152
|
||||
**Inventor:** atlas (shift-1, fresh — name-recycle, not the atlas from earlier today)
|
||||
**Date:** 2026-05-27
|
||||
**Branch:** `mai/atlas/inventor-extend-tools`
|
||||
**Status:** Draft — coder gate held; m to ratify the remaining open questions via `AskUserQuestion` before any coder shift.
|
||||
|
||||
**Builds on:**
|
||||
- `docs/design-unified-procedural-events-tool-2026-05-27.md` (cronus's U0-U4 design, shipped today as `/tools/procedures`)
|
||||
- `docs/design-deadline-system-revision-2026-05-27.md` §3.3 + §3.3a (atlas Phase 2 model layer + view-mode toggle)
|
||||
- `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26 Mode A+B+result, shipped via t-paliad-322)
|
||||
|
||||
**Reframe note (2026-05-27 21:01):** the first draft of this doc overengineered the surface — three-view toggle, separate compound drawer, separate Konstellationen drawer. m re-anchored: "clean display of timelines that have potential forks the user can select. UX should be key. It should be easy to find your thing." This rewrite collapses to a single canonical shape and folds the zoom / constellation / cross-cut concepts into it. The pre-grilling §13 + the 11-Q batch in §14 of the first draft are gone — superseded by m's 4 answers in §0.2 and the smaller open-question set in §10.
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises
|
||||
|
||||
### §0.1 What shipped today and what m hit
|
||||
|
||||
`/tools/procedures` (U0-U4, knuth, m/paliad#151) is a **catalog browser**:
|
||||
- 4 always-visible tabs (Verfahren wählen / Direkt suchen / Geführt / Aus Akte).
|
||||
- Shared filter strip + search box at the top (markup-only in U0).
|
||||
- Two output shapes — TREE (Verfahrensablauf) and LINEAR (Mode A/B result view) — bound to specific entry tabs.
|
||||
|
||||
m's bugs (2026-05-27 20:43 / 20:46):
|
||||
|
||||
1. 4 tabs visible → pre-form leaks across them, page feels like 4 disjoint workflows.
|
||||
2. Result view fires too many rules incl. conditional-flag-off + curie's 7 compound rules.
|
||||
3. Proaktiv/Gericht/Reaktiv columns are a stance grouping, not a sequence anchor.
|
||||
4. No "you are here" marker.
|
||||
5. Sequence isn't visualised as a sequence — flat priority groups, not chained.
|
||||
|
||||
m's reframe (verbatim, 20:43): "view proceedings with all possible constellations and the sequences and determine **where we are** in that sequence and **what steps are coming next** for any given procedural event."
|
||||
|
||||
Tightened by m on 21:01:
|
||||
|
||||
> "clean display of timelines that have potential forks the user can select. procedural_events that act as triggers for mandatory or optional events. And there is a limited type of proceedings — a sequence of the events builds the proceeding. Some aux proceedings, some main… but a lot is connected. UX should be key. It should be easy to find your thing."
|
||||
|
||||
### §0.2 The four m-answers that lock the architecture
|
||||
|
||||
Asked back during the grilling round at 20:57, answered 21:01:
|
||||
|
||||
| | inventor's grilling question | m's answer | what it locks |
|
||||
|---|---|---|---|
|
||||
| 1 | One canonical shape or still 3 views? | "I still want zoomability for one event and all events it triggers. But that can be from within the full timeline/tree as well." | **One canonical view** (full timeline/tree); zoom is an *interaction* on it, not a separate view. The Anchor / Verfahren / Konstellationen toggle is dropped. |
|
||||
| 2 | What's a "fork" — scenario flags only / +optionals / everything? | "c" (everything: flags + optionals + appeal-target + court-set picks) | **Every choice point in the data is a fork.** Optionals (priority='optional') + conditional flags + appeal-target + perspective + court-set scheduling. Inline pickers. |
|
||||
| 3 | "Easy to find" — timeline-as-index / search box / proceeding picker first? | "all of these — text search, filter pills, a display of the resulting proceedings timelines" | **Find = combined affordance.** Text search + filter pills + the displayed result *is* the matched proceeding timelines. The page never has chrome that isn't either the find affordance or the timelines themselves. |
|
||||
| 4 | Aux proceedings inline or drillable? | "inline" | **Aux proceedings draw inline as expandable child timelines** hanging off the spawn point in the parent timeline. The full connected graph is one visible thing. |
|
||||
|
||||
### §0.3 Live data the tracker has to work against
|
||||
|
||||
Verified 2026-05-27 against `paliad.sequencing_rules` (231 published / 242 total):
|
||||
- 110 chained (parent_id not null) — most rules in a chain.
|
||||
- 78 trigger-rooted, 4 spawns (cross-PT), 47 court-set, 18 conditional (6 `with_ccr` / 4 `with_amend` / 4 `with_cci` / 4 compound `with_ccr AND with_amend`).
|
||||
- Biggest single proceeding: `upc.inf.cfi` (50 rules).
|
||||
- ~46 proceeding types total (UPC 35 / DE 5 / EPA 3 / DPMA 3).
|
||||
- `paliad.deadlines` carries both `procedural_event_id` and `sequencing_rule_id` → Akte actuals overlay is a direct join.
|
||||
|
||||
### §0.4 Scope
|
||||
|
||||
**In:** redesign the `/tools/procedures` surface as a single timeline-tree view with inline forks + a combined find affordance.
|
||||
|
||||
**Out:**
|
||||
- Calculator changes.
|
||||
- Editorial backfill (curie's t-paliad-333 owns the 7 compound rules + R.109 chain). This design is *independent* of curie's column-shape work; compound rules surface inline via parent_id like any other rule, with whatever annotation curie ships.
|
||||
- `/admin/procedural-events` write surface.
|
||||
- `/projects/{id}` Verlauf / SmartTimeline — cross-link only.
|
||||
- youpc.org cross-repo / Outlook sync / PDF export.
|
||||
|
||||
---
|
||||
|
||||
## §1 The single canonical shape
|
||||
|
||||
One page. One view. Top section = find affordance. Below = matched proceeding timelines, each as an inline-forked tree, vertically stacked.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ [🔍 Suche: Klageerwiderung_____________________] │
|
||||
│ Forum: [● UPC] [DE] [EPA] [DPMA] │
|
||||
│ Verfahren: [● Verletzung] [● Widerklage] [Berufung] [Nichtigkeit] … │
|
||||
│ Partei: [Klägerseite] [● Beklagtenseite] │
|
||||
│ Akte: HL-2024-001 ▼ Stichtag: 2026-04-01 │
|
||||
│ │
|
||||
│ 2 Verfahren passen — Anker: Klageerwiderung (HL-2024-001) │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ upc.inf.cfi · Verletzungsverfahren UPC ─────────────────────────┐
|
||||
│ │
|
||||
│ ● Klageerhebung (R.13) 2026-01-15 · Klg · M │
|
||||
│ │ │
|
||||
│ ▼ ● Klageerwiderung (R.23.1) 2026-04-01 · Bekl · M │
|
||||
│ │ ━━━━ DU BIST HIER ━━━━ │
|
||||
│ │ Optionen für dieses Ereignis: │
|
||||
│ │ ☑ Widerklage auf Nichtigkeit │
|
||||
│ │ ☐ Antrag Patentänderung (R.30) │
|
||||
│ │ ☐ Vorläufige Einwendungen │
|
||||
│ │ │
|
||||
│ ├─● Replik (R.29.a/b) 2026-06-01 · Klg · M │
|
||||
│ │ ├─● Duplik (R.29.c) 2026-07-01 · Bekl · M │
|
||||
│ │ └─● Replik auf Defence to CCR (R.29.d) 2026-08-01 · Klg · M │
|
||||
│ │ └─● Rejoinder (R.29.e) 2026-09-01 · Bekl · M │
|
||||
│ │ │
|
||||
│ ├─● Widerklage auf Nichtigkeit ✓ │
|
||||
│ │ └─▼ Tochterverfahren upc.rev.cfi ▾ │
|
||||
│ │ │ │
|
||||
│ │ ├─● Antrag Patentänderung (R.50) optional ☐ │
|
||||
│ │ ├─● Hauptverhandlung [Gericht] │
|
||||
│ │ └─● Entscheidung [Gericht] │
|
||||
│ │ │
|
||||
│ └─● Vorläufige Einwendungen ☐ (optional, ausgewählt: nein) │
|
||||
│ │
|
||||
│ ● Mündliche Verhandlung [Gericht bestimmt] │
|
||||
│ │ │
|
||||
│ └─● Urteil [Gericht] │
|
||||
│ └─▼ Berufungsverfahren upc.apl ▸ (auf Endentscheidung) │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ upc.ccr.cfi · Widerklage auf Nichtigkeit (Tochter, oben verlinkt) ┐
|
||||
│ … │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
No tabs. No view toggle. The output reacts to the find affordance, the anchor pin, and per-node fork selections.
|
||||
|
||||
### §1.1 The shape's components
|
||||
|
||||
1. **Find header** (sticky at top): search input + filter pills + Akte/date row + a one-line result summary. §2.
|
||||
2. **Timeline-trees** (the page body): one block per matched proceeding, full chain + inline forks + inline aux branches. §3-§5.
|
||||
3. **Anchor pin** (when set): the "DU BIST HIER" band on a specific node, optionally with zoom mode collapsing everything else. §6.
|
||||
|
||||
That's the entire UI surface. No drawers, no separate drillable panes, no constellation viewer. Forks are inline checkboxes; aux proceedings are inline expandable subtrees; zoom is an interaction on the existing rendering.
|
||||
|
||||
---
|
||||
|
||||
## §2 The find affordance
|
||||
|
||||
m's #3 answer makes this load-bearing: text + pills + result-timelines are all the same affordance. As the user narrows, the timelines below filter; as the timelines change, the result-count summary updates; clicking a node in a timeline auto-narrows the filter pills to that proceeding (optional sugar).
|
||||
|
||||
### §2.1 Composition
|
||||
|
||||
| Control | Source | Composes via | Persists in |
|
||||
|---|---|---|---|
|
||||
| Free-text search | input box, debounced 200ms | OR-against (procedural_event.name DE/EN, rule_code, aliases) | `?q=<text>` |
|
||||
| Forum pill row | static enum (UPC/DE/EPA/DPMA), single-select | AND | `?forum=<id>` |
|
||||
| Verfahren pill row | proceeding_type chips, multi-select (deduped from active forum) | AND (any-of) | `?procs=<csv>` |
|
||||
| Partei pill row | claimant / defendant / both / — (or auto from Akte) | AND | `?party=<x>` |
|
||||
| Akte picker | dropdown of user's projects | seeds Verfahren + Partei + scenario_flags + anchor | `?project=<uuid>` |
|
||||
| Stichtag (date) | date input, defaults today | feeds computed dates throughout the timelines | `?trigger_date=<iso>` |
|
||||
|
||||
All controls live in one sticky header. The header keeps its height stable so the timelines below don't reflow on every keystroke.
|
||||
|
||||
### §2.2 Cold open behaviour
|
||||
|
||||
No URL params, no Akte:
|
||||
- Search box empty, all forums neutral, all proceeding pills neutral. Show a curated default of the most-common proceedings: `upc.inf.cfi`, `upc.rev.cfi`, `upc.apl.merits`, `de.inf.lg`, `epa.opp.opd`, `dpma.opp.dpma`. (See Q4 below.)
|
||||
- A hint above the timelines: "Suche oder filtere, um andere Verfahren einzublenden."
|
||||
|
||||
With a `?project=` param: filters pre-fill from the Akte, anchor pins to the latest completed deadline.
|
||||
|
||||
With a `?q=` or `?event=` param: filters pre-fill to match, single matched proceeding renders pinned.
|
||||
|
||||
### §2.3 What the search matches
|
||||
|
||||
Free-text search hits the same corpus the existing `/api/tools/fristenrechner/search?kind=events` endpoint covers — procedural_events by name + code + aliases. Spawn-only events stay filtered out (per atlas P0 §2.2). Hits surface in two ways simultaneously:
|
||||
|
||||
- The matched proceeding(s) render expanded with the hit event(s) anchor-pinned.
|
||||
- A small "Treffer: 3 Ereignisse in 2 Verfahren" summary above the timelines.
|
||||
|
||||
If the user types something narrow enough to match a single event, the page auto-pins that event (auto-anchor). If multiple events match, the user picks via a small dropdown under the search input — picking sets the anchor.
|
||||
|
||||
### §2.4 Why pills, not chips-with-sub-modes
|
||||
|
||||
The shipped 4-tab UI tried to express "what kind of question are you asking" via tabs. m's answer #3 collapses that — the find affordance doesn't care which "kind" of question; it cares about the active filter set. A user with a search + a forum + an Akte set gets the right timelines regardless of which tab they "came from". The mental model is: narrow the set; the timelines arrive.
|
||||
|
||||
---
|
||||
|
||||
## §3 Timelines and forks
|
||||
|
||||
Each matched proceeding renders as one card. Inside the card: the proceeding's name + jurisdiction badge in a thin header strip, then the chain.
|
||||
|
||||
### §3.1 The chain
|
||||
|
||||
Vertical, top-to-bottom = chronological. Each node = one procedural_event (the rule that fires it lives inside). Edges = parent_id. Per node:
|
||||
|
||||
- **Bullet style** by priority: solid filled (mandatory), solid outline (recommended), dotted (optional), dashed (conditional-flag-off and hidden).
|
||||
- **Bullet colour**: priority band — black/grey/blue/light depending on the scale we end up picking. Lime accent (`#c6f41c`) reserved for the anchor pin.
|
||||
- **Inline metadata**: name, rule code, computed date, party badge, priority badge. Stripped to one line.
|
||||
- **Court-set events**: render with `[Gericht bestimmt]` in date column.
|
||||
- **Spawn nodes**: terminate the bullet with `▼ Tochterverfahren <code> ▾` — expandable inline; collapsed by default unless the spawn flag is on. §5.
|
||||
|
||||
### §3.2 Forks — every choice point is one
|
||||
|
||||
A "fork" is anywhere the user can flip the proceeding's shape:
|
||||
|
||||
1. **Scenario flags** (`with_ccr`, `with_amend`, `with_cci`) — currently 3, extensible via curie's `scenario_flag_catalog`.
|
||||
2. **Optional rules** (`priority='optional'`) — each is a "do I do this?" pick.
|
||||
3. **Appeal-target picks** — `applies_to_target` array on appeal proceedings (endentscheidung / kostenentscheidung / anordnung / schadensbemessung / bucheinsicht). Per-card chip group at the appeal root.
|
||||
4. **Perspective** — claimant / defendant per proceeding (mostly comes from Akte's `our_side`, picker overrides).
|
||||
5. **Court-set timing choices** — `choices_offered` JSON on `sequencing_rules` (`appellant` / `include_ccr` / `skip` shapes from einstein). Per-card chip set.
|
||||
|
||||
**Where forks render.** Inline, *on the node where the fork's effect begins.* Not in a top-of-page flag strip (m's bug #5 — sequences should be visualised as sequences; flags above the tree decouple cause from effect).
|
||||
|
||||
Concretely: the `with_ccr` fork renders as a checkbox **on the Klageerwiderung node**, because that's where the user decides "we are filing a Widerklage with our KEW". Toggling it lights up the CCR child branches below. Similarly:
|
||||
|
||||
- `with_amend` renders on the KEW node *and* on the Antrag-Patentänderung node it gates.
|
||||
- `with_cci` renders on the Defence-to-Revocation node.
|
||||
- Optional rules render as a checkbox on their own card.
|
||||
- Appeal-target picks render on the appeal root.
|
||||
|
||||
If multiple forks share a node, they cluster as a small "Optionen für dieses Ereignis" mini-strip *below* the node header:
|
||||
|
||||
```
|
||||
▼ ● Klageerwiderung (R.23.1) 2026-04-01 · Bekl · M
|
||||
│ Optionen:
|
||||
│ ☑ Widerklage auf Nichtigkeit
|
||||
│ ☐ Antrag Patentänderung (R.30)
|
||||
│ ☐ Vorläufige Einwendungen einlegen
|
||||
```
|
||||
|
||||
### §3.3 Default rendering ("Gewählt" semantics)
|
||||
|
||||
Each node renders iff:
|
||||
- It's mandatory (priority='mandatory'), OR
|
||||
- It's selected per current scenario state (priority='recommended' unless explicitly deselected; priority='optional' iff explicitly selected; conditional iff flag is on).
|
||||
- Same as atlas P3's "Gewählt" view-mode.
|
||||
|
||||
Conditional rules whose flag is off **do not render at all** by default. The fork checkbox to *turn the flag on* still appears on the gating node — turning it on causes the dependent branch to render.
|
||||
|
||||
This is m's bug #2 fix: no more dump of every-rule including flag-off conditional. The forks themselves are the affordance that brings hidden branches into view.
|
||||
|
||||
### §3.4 Optional reveal — "Alle Optionen"
|
||||
|
||||
A single toggle at the top of each proceeding card (NOT page-wide):
|
||||
|
||||
```
|
||||
[· Gewählt ·] [ Alle Optionen ]
|
||||
```
|
||||
|
||||
"Alle Optionen" renders every rule including flag-off conditionals (greyed with their flag hint) and unselected optionals (dotted with `[Aufnehmen]` chip). Useful when the user wants to see the whole shape. Per-proceeding so a page with 3 proceedings can have one in "Alle Optionen" mode without affecting the others. State persists in `localStorage` per proceeding code.
|
||||
|
||||
### §3.5 Why dropping "Nur Pflicht"
|
||||
|
||||
Atlas P3's three-way toggle had Nur Pflicht / Gewählt / Alle Optionen. With forks made inline + per-node, "Nur Pflicht" loses load-bearing — it was useful when the page had no fork interactivity (you wanted to dial down clutter). Now the user can just leave all forks off and see the mandatory-only chain in Gewählt mode. The two-way Gewählt ↔ Alle Optionen is enough.
|
||||
|
||||
### §3.6 Cross-party rows
|
||||
|
||||
Per atlas §2.4 / m's lock: every rule for the proceeding renders, with rows where the user is *not* the primary_party muted + carrying a "Gegenseitig" badge. Same treatment in this tracker. Not hidden by perspective; just visually deemphasised.
|
||||
|
||||
---
|
||||
|
||||
## §4 Court-set events & date rendering
|
||||
|
||||
`is_court_set=true` rules don't compute a date — the court picks it on the day. Two display options that interact:
|
||||
|
||||
- Render with `[Gericht bestimmt]` in the date column, no date. Standard.
|
||||
- When the user has scheduled the actual date (an `appointments` row on the project or a manual override), the actual date replaces the badge. Akte-only.
|
||||
|
||||
If the date is "vom Gericht" and matters as a trigger for downstream events, downstream events render `[abhängig von Verhandlung]` instead of a date, and recompute live once the court date is known.
|
||||
|
||||
`choices_offered` per-rule (the 3 known shapes today: `appellant`, `include_ccr`, `skip`) — also inline per-node, treated as forks (§3.2 #5).
|
||||
|
||||
---
|
||||
|
||||
## §5 Aux proceedings inline
|
||||
|
||||
Per m's #4 answer: spawned proceedings draw inline as expandable subtrees, not as drillable separate pages.
|
||||
|
||||
### §5.1 Render
|
||||
|
||||
A spawn node renders as a leaf chip terminating the parent's chain segment:
|
||||
|
||||
```
|
||||
●─● Widerklage auf Nichtigkeit ✓
|
||||
└─▼ Tochterverfahren upc.rev.cfi ▾
|
||||
│
|
||||
├─● Antrag Patentänderung (R.50) optional ☐
|
||||
├─● Hauptverhandlung [Gericht]
|
||||
└─● Entscheidung [Gericht]
|
||||
└─▼ Berufungsverfahren upc.apl ▸
|
||||
```
|
||||
|
||||
- Collapsed by default unless the gating fork is on (e.g. `with_ccr` ticked → CCR's spawn into upc.rev.cfi auto-expands).
|
||||
- Expanding writes nothing — pure UI state in `sessionStorage["procedures:expanded_spawns"]`.
|
||||
- The aux subtree renders with the same node vocabulary as the parent; forks inside the aux are independently editable.
|
||||
- Aux subtrees can themselves have aux subtrees (e.g. CCR → Berufung). Depth is bounded by the data — today 2 levels deep at most.
|
||||
|
||||
### §5.2 Cross-references
|
||||
|
||||
When two paths converge on the same aux proceeding (e.g. CCR triggers from a couple of places), the aux renders inline at the first path's spawn point and renders as a back-reference at subsequent spawn points: `▸ (siehe oben: Tochterverfahren upc.rev.cfi)`. Single source of truth in the rendered tree, even when the graph has multiple edges.
|
||||
|
||||
### §5.3 Akte mode
|
||||
|
||||
In Akte mode, if the spawn was actualised (a child project exists linked via `parent_project_id`), the aux subtree shows the child project's badge: `📁 HL-2024-001-CCR · Tochterakte`. Clicking the badge navigates to that child project. The subtree itself still renders inline.
|
||||
|
||||
---
|
||||
|
||||
## §6 Anchor pin & zoom
|
||||
|
||||
m's #1 answer: "zoomability for one event and all events it triggers, from within the full timeline".
|
||||
|
||||
### §6.1 The anchor pin
|
||||
|
||||
Any node can be pinned as the anchor. Pinning sources:
|
||||
- URL `?event=<sequencing_rule_id>` (deep link).
|
||||
- Search box auto-pin when the search narrows to a single hit.
|
||||
- Click-to-pin on any node (small pin icon in the node's metadata row).
|
||||
- Akte landing: auto-pin to latest completed deadline.
|
||||
|
||||
The pinned node renders with a 4px lime-coloured left band + a `━━ DU BIST HIER ━━` divider above its successors. The pin is also reflected in the find-header's result summary: "Anker: Klageerwiderung (HL-2024-001)".
|
||||
|
||||
### §6.2 Zoom mode
|
||||
|
||||
A small `[ 🔍 Fokus ]` chip on the anchored node toggles zoom mode for that anchor. When zoom is on:
|
||||
|
||||
- The anchored node's ancestors collapse to a single breadcrumb at the top of the proceeding card:
|
||||
```
|
||||
upc.inf.cfi ▸ Klageerhebung ▸ ━ Klageerwiderung ━
|
||||
```
|
||||
- The anchored node renders full.
|
||||
- Successors render fully (the forward subtree under the anchor).
|
||||
- Sibling branches at every ancestor depth collapse to a single-line summary card: `… 4 weitere Schritte verborgen — [zeigen]`.
|
||||
|
||||
This is what m means by "zoom into one event from within the timeline" — the *same view*, just with non-relevant siblings collapsed. Toggle off → full timeline restored, anchor still pinned.
|
||||
|
||||
Zoom is page-scoped (one anchor per page, one zoom state). State in URL: `?event=<id>&zoom=1`.
|
||||
|
||||
### §6.5 Multi-proceeding anchor scope (m's Q3 divergence)
|
||||
|
||||
When the page shows >1 matched proceeding *and* an anchor is pinned, the non-anchored proceedings auto-collapse to a header-only one-line card:
|
||||
|
||||
```
|
||||
┌─ upc.inf.cfi · Verletzungsverfahren UPC ─────┐
|
||||
│ … full timeline … │
|
||||
│ ━━ DU BIST HIER: Klageerwiderung ━━ │
|
||||
└──────────────────────────────────────────────┘
|
||||
|
||||
┌─ upc.rev.cfi ▸ ausblenden — [zeigen] ────────┐
|
||||
└──────────────────────────────────────────────┘
|
||||
|
||||
┌─ upc.apl.merits ▸ — [zeigen] ────────────────┐
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Clicking a header card's `[zeigen]` link restores that proceeding's full timeline (the header stays as a per-card affordance for re-collapse). The collapsed state persists in `sessionStorage["procedures:collapsed_proceedings"]`. Un-pinning the anchor restores all visible proceedings to full-render automatically.
|
||||
|
||||
The rule applies regardless of how the anchor was pinned (URL, search-auto, click-to-pin, Akte landing). The find-header result count still shows N proceedings matched — header cards are present, just collapsed.
|
||||
|
||||
### §6.3 The "where I came from" question
|
||||
|
||||
m's brief asked for backward-walk visualisation. Without zoom: the chain above the anchor is the backward walk — it's the same tree. With zoom: the breadcrumb at the top of the proceeding card is the backward walk in compact form. No separate concept; backward walk = upward in the tree.
|
||||
|
||||
### §6.4 Akte mode: actuals overlay
|
||||
|
||||
When `?project=<uuid>` is set, each node in the chain queries `paliad.deadlines WHERE project_id = $p AND sequencing_rule_id = $r` and overlays:
|
||||
|
||||
- `status='done'` → ✓ in the node bullet area + actual completed date in the date column. Greyed slightly to read as "past".
|
||||
- `status='open'` and `due_date < today` → ⚠ overdue.
|
||||
- `status='open'` and `due_date >= today` → 📅 actual due date if differs from computed; ◇ marker.
|
||||
- No deadline row → render as template (current behaviour).
|
||||
|
||||
The anchor auto-pins to the latest `status='done'` deadline by default — the natural reading is "we just finished this".
|
||||
|
||||
---
|
||||
|
||||
## §7 What lives where: the find header vs the timelines
|
||||
|
||||
A short table to make the responsibility boundary explicit:
|
||||
|
||||
| Concern | Find header | Timeline body |
|
||||
|---|---|---|
|
||||
| Pick proceeding(s) | Filter pill row | (auto-rendered after) |
|
||||
| Pick anchor | Search-narrow → auto-pin / URL `?event=` | Click pin icon on any node |
|
||||
| Pick perspective | Pill (or auto from Akte) | (read-only — feeds rendering) |
|
||||
| Pick scenario flags | (no) | Inline fork checkboxes on gating nodes |
|
||||
| Pick optional rules | (no) | Inline fork checkboxes on each optional node |
|
||||
| Pick appeal target | (no) | Inline chip group on appeal root |
|
||||
| Pick date | Stichtag input | (read-only — feeds computed dates) |
|
||||
| Toggle Alle Optionen / Gewählt | (no) | Per-proceeding 2-way toggle |
|
||||
| Zoom on anchor | (no) | `[Fokus]` chip on anchored node |
|
||||
| Akte select | Akte picker | (read-only — feeds actuals overlay) |
|
||||
|
||||
Find header = "narrow the set + global context". Timelines = "everything per-event". No drawers, no overlays.
|
||||
|
||||
---
|
||||
|
||||
## §8 Cold open + empty state
|
||||
|
||||
Cold open with no Akte, no URL params (Q4 below): show a curated default of 6 most-common proceedings (`upc.inf.cfi`, `upc.rev.cfi`, `upc.apl.merits`, `de.inf.lg`, `epa.opp.opd`, `dpma.opp.dpma`), each rendered with default Gewählt + no forks selected + no anchor. Hint text above: "Suche oder filtere, um andere Verfahren zu sehen."
|
||||
|
||||
Empty filter result (e.g. user types nonsense): zero timelines render, with a helper card: "Keine Treffer. Filter zurücksetzen ▸"
|
||||
|
||||
---
|
||||
|
||||
## §9 Migration (direct replace per m's Q7)
|
||||
|
||||
4 slices + 1 cleanup, all surface, no DB mig, no `?tracker=1` flag. Each slice ships visibly to users at `/tools/procedures`. T1 must be at least as functional as today's catalog browser — so the find header + multi-proceeding render + inline forks + aux inline all front-load there. T2-T4 layer the remaining behaviour.
|
||||
|
||||
All independent of curie's editorial work — compound rules render inline via parent_id like any other rule; if curie ships a `compound_predecessors uuid[]` column later, those rules can render at multiple positions (one inline per predecessor) without tracker code changes beyond the join.
|
||||
|
||||
| Slice | What ships | Notes |
|
||||
|---|---|---|
|
||||
| **T1 — Tracker shell replaces the catalog page** | `/tools/procedures` now renders: sticky find header (search + Forum/Verfahren/Partei pills + Akte picker + global Stichtag), N-proceeding render (one card per matched proceeding), inline forks (scenario flags + optionals visible as checkboxes on the gating node), aux proceedings inline-expandable at spawn points, cold-open with 6 curated defaults (Q4), default = Gewählt. The 4 entry-mode tabs are deleted in the same PR; URL params `?mode=proceeding\|search\|wizard\|akte` 301-redirect or drop. URL anchor `?event=<rule_id>` scroll-highlights the matching node (no zoom yet). | Replaces catalog UI; users see the new tracker immediately. |
|
||||
| **T2 — Anchor pin + zoom + multi-proceeding scope** | Anchor pin (lime band + DU BIST HIER divider), `[Fokus]` chip on anchored node toggles zoom (§6.2), URL state `?event=…&zoom=1`. Multi-proceeding auto-collapse rule (§6.5) kicks in when an anchor is set. Click-to-pin on any node. | Layered on T1's existing render. |
|
||||
| **T3 — Akte landing + actuals overlay** | `?project=<uuid>` derives anchor from latest `status='done'` deadline (Q5), backward walk overlays `paliad.deadlines` actuals as status badges (§6.4), scenario_flags load from project, fork write-back via existing `PATCH /api/projects/{id}/scenario-flags` + `POST /api/projects/{id}/deadlines/bulk`. | The first slice that exercises the project hookup end-to-end. |
|
||||
| **T4 — Appeal-target + court-set choices + polish** | Wire `applies_to_target` array forks on appeal proceedings, `choices_offered` shapes (`appellant`, `include_ccr`, `skip`), court-set date override from `appointments` table, cross-party muted treatment per §3.6. Per-proceeding "Alle Optionen" toggle (§3.4). | Polish + the edge-case fork shapes. |
|
||||
| **T5 — Cleanup** | Dead-code removal: legacy `procedures.ts` tab toggling, `fristenrechner-mode-a.ts` / `fristenrechner-wizard.ts` / `fristenrechner-result.ts` / `verfahrensablauf.ts` if no longer referenced (verify with grep before deletion). Sidebar/cmd-K unchanged (URL same). | No user-visible change. |
|
||||
|
||||
### §9.1 Constraint: T1 is the new floor
|
||||
|
||||
Because there's no flag, **T1 must not regress** from today's catalog UI in any non-trivial way. The catalog today serves four user workflows:
|
||||
|
||||
1. **Pick a proceeding, see its full Verfahrensablauf** → T1 covers via "Verfahren" pill click → that proceeding renders alone.
|
||||
2. **Search for an event** → T1 covers via search input + auto-pin.
|
||||
3. **Wizard from R1-R5** → T1 covers via Forum/Verfahren/Partei pills + search (the wizard's narrowing is just a sequence of filter applications).
|
||||
4. **Enter via Akte** → T1 covers via the Akte picker; full actuals overlay arrives in T3 (open/done badges may render partial in T1, but the Akte's scenario_flags + proceeding pre-load works).
|
||||
|
||||
If T1 reviewing exposes a regression, T1 holds (the issue blocks merge) — m's PR review gates the slice landing.
|
||||
|
||||
### §9.2 What stays unchanged
|
||||
|
||||
- URL: `/tools/procedures` keeps it.
|
||||
- Sidebar entry "Verfahren & Fristen" keeps it.
|
||||
- cmd-K palette keeps it.
|
||||
- All other tools, calendar, projects, deadlines surfaces — untouched.
|
||||
- Calculator (`pkg/litigationplanner.CalculateRule`) — untouched.
|
||||
|
||||
### §9.3 Out-of-band dependencies
|
||||
|
||||
- The compound-predecessors editorial column is owned by curie's t-paliad-333. Tracker reads whatever lands. If it slips past T4, compound rules render via their primary parent_id only (today's shape) — degraded but still correct on that path. No tracker re-render needed when curie ships.
|
||||
- The Akte actuals overlay (T3) reads `paliad.deadlines.sequencing_rule_id` — column exists, nothing new.
|
||||
|
||||
### §9.4 Test surface per slice
|
||||
|
||||
- **T1**: cold-open 6 curated defaults render; search narrows to single proceeding; pill toggles change render; `?project=` loads Akte filters (no actuals yet); URL deep-link `?event=` highlights matching node; legacy `?mode=` redirects.
|
||||
- **T2**: click-to-pin sets anchor with lime band; `[Fokus]` zoom collapses siblings; un-zoom restores; multi-proceeding auto-collapse when anchor active; URL state survives reload.
|
||||
- **T3**: Akte landing auto-pins latest done deadline; status badges render on each node from `paliad.deadlines`; fork tick writes to `scenario_flags`; "In Akte speichern" persists.
|
||||
- **T4**: appeal-target chips switch the rule set rendered on appeal proceedings; `choices_offered` per-node chip groups visible + functional; "Alle Optionen" reveals hidden conditional rules with greyed state.
|
||||
- **T5**: production deploy unchanged surface; no live regression; deleted files don't break build.
|
||||
|
||||
---
|
||||
|
||||
## §10 Open questions for m
|
||||
|
||||
Seven questions in 2 batches (4 + 3) for `AskUserQuestion`. Tier 1 = how the per-node fork UI feels + how zoom interacts with multi-proceeding pages. Tier 2 = cold-open content + Akte default + Stichtag scope + migration cadence.
|
||||
|
||||
m's picks fold back into §11 below before the "TRACKER DESIGN READY FOR REVIEW" signal.
|
||||
|
||||
### Batch 1 — fork UI + zoom + cross-party
|
||||
- **Q1 (Fork-cluster shape on a node)** — when a node has 2-4 forks (e.g. Klageerwiderung: `with_ccr` + `with_amend` + Vorl. Einwend.) — (a) inline checkbox list below the node header (current sketch), (b) collapsed "Optionen (3) ▾" affordance that expands on hover/click, (c) chip strip on the same line as the node header.
|
||||
- **Q2 (Zoom interaction)** — `[Fokus]` chip on the anchored node — (a) collapses siblings to one-line summaries (current sketch), (b) outright hides siblings, breadcrumb-only, (c) split-view (zoomed pane below original full tree).
|
||||
- **Q3 (Anchor scope on a multi-proceeding page)** — when 3 timelines are visible and the user pins an anchor in one — (a) the other 2 timelines stay expanded normally (no zoom effect on them), (b) the other 2 timelines auto-collapse to header-only ("upc.rev.cfi ▸ ausblenden — [zeigen]"), (c) the other 2 timelines reorder to bottom of page (anchored proceeding floats to top).
|
||||
- **Q4 (Cold-open default content)** — opening `/tools/procedures` with no URL params and no Akte — (a) the 6-curated-default-proceedings sketch (Verletzung UPC + DE LG, Nichtigkeit UPC, Berufung UPC, EPA-Einspruch, DPMA-Einspruch), (b) all ~46 proceedings rendered with all forks off (lots of scrolling), (c) empty state with a "Filter wählen, um Verfahren einzublenden" prompt.
|
||||
|
||||
### Batch 2 — Akte semantics + Stichtag + migration
|
||||
- **Q5 (Akte landing — default anchor)** — `?project=<uuid>` — (a) auto-pin to latest `status='done'` deadline (current sketch), (b) auto-pin to next-open deadline (forward-looking), (c) no auto-pin, just pre-fill filters + actuals overlay, user picks anchor.
|
||||
- **Q6 (Stichtag scope)** — date input in the find header — (a) global, feeds all visible proceedings' computed dates (current sketch — useful for browsing "if today were the trigger"), (b) per-proceeding (each timeline carries its own date input), (c) only valid in single-proceeding mode (hidden when the page shows >1 proceeding).
|
||||
- **Q7 (Migration cadence)** — (a) flag-gated dev under `?tracker=1`, T1-T4 ship, T5 hard-cut (current sketch, cronus precedent), (b) direct replace at T1 (no flag — every slice ships visibly to users), (c) parallel URL `/tools/procedures-v2` until hard-cut.
|
||||
|
||||
---
|
||||
|
||||
## §11 m's decisions (2026-05-27)
|
||||
|
||||
All 7 questions answered via `AskUserQuestion` in 2 batches (4 + 3) at 21:0?. 5 picks on-recommendation, 2 diverged. Decisions below; the underlying question list lives in §10 above as the historical record.
|
||||
|
||||
### Tier 1 — fork UI + zoom + cross-party
|
||||
|
||||
- **Q1 (Fork cluster on a node): Inline checkbox list below node header.** [= REC] **Locks §3.2.** Every fork on a given node renders as a checkbox in an "Optionen:" cluster line below the node header. Always visible, no hover, no extra click. Vertical real estate per node is acceptable because the default `Gewählt` mode keeps the tree compact (most events have zero forks).
|
||||
- **Q2 (Zoom interaction): Collapse siblings to one-line summaries.** [= REC] **Locks §6.2.** `[Fokus]` chip on the anchored node folds sibling branches at each ancestor depth to a `… 4 weitere Schritte verborgen — [zeigen]` line. The anchored node's subtree renders full. Breadcrumb at the top of the proceeding card. Toggle off restores everything.
|
||||
- **Q3 (Multi-proceeding anchor scope): Other timelines auto-collapse to header-only.** [≠ REC; m diverged from "stay expanded"] **Locks new §6.5.** When an anchor is pinned on a multi-proceeding page, the non-anchored proceedings fold to a one-line header card (`upc.rev.cfi ▸ ausblenden — [zeigen]`). Clicking the header line restores that proceeding's full timeline. Rationale (interpreted): with an anchor pinned, the page is *about* that anchor — having other proceedings full-render in parallel competes for attention without earning it. The header card preserves the find-header result count and offers a one-click escape if the user wants to compare.
|
||||
- **Q4 (Cold open content): 6 curated default proceedings.** [= REC] **Locks §8.** No URL params + no Akte → render `upc.inf.cfi`, `upc.rev.cfi`, `upc.apl.merits`, `de.inf.lg`, `epa.opp.opd`, `dpma.opp.dpma` stacked vertically, all forks off, no anchor. Hint: "Suche oder filtere, um andere Verfahren zu sehen."
|
||||
|
||||
### Tier 2 — Akte + Stichtag + migration
|
||||
|
||||
- **Q5 (Akte default anchor): Latest `status='done'` deadline.** [= REC] **Locks §6.4 + §9.** `?project=<uuid>` → derive anchor by `SELECT … FROM paliad.deadlines WHERE project_id=$p AND sequencing_rule_id IS NOT NULL ORDER BY completed_at DESC NULLS LAST LIMIT 1`. Fallback: next open deadline → proceeding root. The backward chain reads as Akte history; the anchor itself is the most recently completed work; forward is upcoming.
|
||||
- **Q6 (Stichtag scope): Global, feeds all visible proceedings.** [= REC] **Locks §2.1 + §7.** One date input in the find header. All visible proceedings compute dates against it. When the user has an Akte loaded, the Stichtag pre-fills from the project's latest trigger date but is overrideable. When the anchor is pinned to a `status='done'` deadline, the date input shows that deadline's `completed_at` but can still be overridden for "what-if" exploration.
|
||||
- **Q7 (Migration cadence): Direct replace at T1 — no flag.** [≠ REC; m diverged from flag-gated dev] **Rewrites §9.** Every slice ships visibly to users at /tools/procedures. T1 must be at minimum equivalent to today's catalog browser (so the slicing has to front-load find header + multi-proceeding render + forks inline + aux inline). The flag-gated dev plan is dropped. cronus's Q11 hard-cut precedent extends here: m would rather ship per-slice visibly than carry a parallel surface during dev. Rationale (interpreted): partial-tracker > no-tracker, and ~50 internal lawyers absorb the per-slice deltas through release comms.
|
||||
|
||||
### §11.1 Changes triggered by m's divergences
|
||||
|
||||
**Q3 divergence — multi-proceeding anchor scope.** New §6.5 added below. The header-card-only render for non-anchored proceedings preserves filter compose (you can still see "upc.rev.cfi matched the filter") while clearing the page's vertical real estate for the anchor's full context.
|
||||
|
||||
**Q7 divergence — direct replace.** §9 rewritten end-to-end. T1 now ships the minimum-viable tracker (find header + multi-proceeding render + forks inline + aux subtrees inline + URL-anchor highlight), replacing the catalog UI at /tools/procedures from the moment it merges. T2-T4 layer zoom, Akte semantics, polish. T5 ("cleanup only") is now just dead-code removal.
|
||||
|
||||
### §11.2 What stays unchanged
|
||||
|
||||
The other 5 picks (Q1, Q2, Q4, Q5, Q6) ratified the inventor proposal. Inline checkbox forks per node, breadcrumb-collapse zoom, 6-curated cold open, latest-done-deadline Akte anchor, global Stichtag — all locked as drafted in §1-§8.
|
||||
|
||||
---
|
||||
|
||||
## §12 Out of scope
|
||||
|
||||
- Calculator changes.
|
||||
- Editorial backfill (curie's t-paliad-333). Compound rules render inline as parent_id-chained rules with curie's annotation; no special tracker treatment.
|
||||
- `/admin/procedural-events`, `/projects/{id}` Verlauf / SmartTimeline.
|
||||
- youpc.org / Outlook / PDF export.
|
||||
- Multi-project anchor comparison.
|
||||
- Free-text scenario flag i18n.
|
||||
|
||||
---
|
||||
|
||||
## §13 Synthesis links
|
||||
|
||||
- **mBrian** (after m's ratification): file as `[synthesis]` linked `triggered_by` t-paliad-337; `related_to` cronus's unified-procedural-events-tool design + atlas's deadline-system-revision + cronus's earlier Fristenrechner overhaul.
|
||||
- **Cross-refs in this repo**: `docs/design-unified-procedural-events-tool-2026-05-27.md` (cronus, U0-U4 shipped today), `docs/design-deadline-system-revision-2026-05-27.md` (atlas Phase 2), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26).
|
||||
- **Gitea**: m/paliad#152 (this design), m/paliad#151 (cronus U0-U4), m/paliad#149 (atlas Phase 2).
|
||||
- **Coder phase** (deferred per inventor SKILL): runs after m ratifies §10 + §11. Slice ordering per §9. NOT atlas (parked at "TRACKER DESIGN READY FOR REVIEW"). Pattern-fluent Sonnet coder picks up T1 first.
|
||||
@@ -1,580 +0,0 @@
|
||||
# Design — `paliad.proceeding_types` taxonomy cleanup: primary proceedings vs phases vs side-actions vs meta
|
||||
|
||||
**Task:** t-paliad-324
|
||||
**Gitea:** m/paliad#147
|
||||
**Inventor:** atlas (shift-1)
|
||||
**Date:** 2026-05-26
|
||||
**Status:** Draft — coder gate held until m ratifies the 10 design questions in §9
|
||||
**Branch:** `mai/atlas/inventor-proceeding`
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live (before designing)
|
||||
|
||||
Verified against live youpc Postgres (port 11833, `paliad` schema) on 2026-05-26 22:05. Findings supersede the audit grouping in m/paliad#147 wherever they diverge — the issue body was correct on shape but conservative on counts.
|
||||
|
||||
### 0.1 The 46-row table, fully classified by usage
|
||||
|
||||
`paliad.proceeding_types` has 49 rows total; 46 active, 3 inactive (`upc.apl.merits/cost/order` — superseded by `upc.apl.unified`, id 160) plus 1 archive bucket (`_archived_litigation`, id 32). Cross-references against the four downstream consumers:
|
||||
|
||||
| Consumer | Column | Active rows that point at the 46 active types |
|
||||
|---|---|---|
|
||||
| `paliad.sequencing_rules.proceeding_type_id` | rule's anchor proceeding | **18 distinct rows used** — the primaries with corpus. 28 rows have 0 rules. |
|
||||
| `paliad.sequencing_rules.spawn_proceeding_type_id` | cross-proceeding spawn target | **1 distinct row used** — `upc.apl.merits` (id=11, **inactive!**). 0 active types are spawn targets. |
|
||||
| `paliad.projects.proceeding_type_id` | project's primary type | **6 distinct rows used** (across 18 projects). All 6 are in the 18 primaries. |
|
||||
| `paliad.event_category_concepts.proceeding_type_code` | concept's owning proceeding | **18 distinct codes used.** 3 of those codes (`upc.apl.merits`, `upc.apl.order`, `upc.apl.cost`) point at **inactive** rows — pre-existing data drift from the `upc.apl.unified` merger (flagged §8, out of scope here). |
|
||||
|
||||
The audit answer in one sentence: **of the 46 active rows, only 18 have any downstream consumer pointing at them today** (the 18 primaries with corpus). The remaining 28 rows are decorative — they exist in the table but nothing references them.
|
||||
|
||||
This makes reparenting **trivially safe**: no FK invariant breaks, no SQL update touches existing data, no migration risk.
|
||||
|
||||
### 0.2 The 18 primaries with corpus (rules + concepts)
|
||||
|
||||
Ordered by `paliad.sequencing_rules` count (descending), with `event_category_concepts` count alongside:
|
||||
|
||||
| id | code | jurisdiction | rules | concepts | projects |
|
||||
|---:|---|---|---:|---:|---:|
|
||||
| 8 | `upc.inf.cfi` | UPC | 25 | 14 | 1 |
|
||||
| 9 | `upc.rev.cfi` | UPC | 17 | 10 | 0 |
|
||||
| 160 | `upc.apl.unified` | UPC | 16 | 0 *(see drift note)* | 0 |
|
||||
| 12 | `de.inf.lg` | DE | 11 | 4 | 1 |
|
||||
| 13 | `de.null.bpatg` | DE | 10 | 4 | 1 |
|
||||
| 14 | `epa.opp.opd` | EPA | 8 | 7 | 1 |
|
||||
| 15 | `epa.opp.boa` | EPA | 8 | 12 | 0 |
|
||||
| 16 | `epa.grant.exa` | EPA | 8 | 0 | 0 |
|
||||
| 17 | `upc.dmgs.cfi` | UPC | 8 | 1 | 0 |
|
||||
| 26 | `de.inf.bgh` | DE | 8 | 17 | 0 |
|
||||
| 25 | `de.inf.olg` | DE | 7 | 8 | 0 |
|
||||
| 10 | `upc.pi.cfi` | UPC | 7 | 3 | 0 |
|
||||
| 27 | `de.null.bgh` | DE | 6 | 10 | 0 |
|
||||
| 29 | `dpma.appeal.bpatg` | DPMA | 5 | 6 | 0 |
|
||||
| 30 | `dpma.appeal.bgh` | DPMA | 4 | 8 | 0 |
|
||||
| 28 | `dpma.opp.dpma` | DPMA | 4 | 3 | 1 |
|
||||
| 18 | `upc.disc.cfi` | UPC | 4 | 1 | 0 |
|
||||
| 35 | `upc.ccr.cfi` | UPC | 1 | 0 | 1 |
|
||||
|
||||
These 18 are unambiguously **primary proceedings** in the m/paliad#147 sense — self-contained matters, own filing, own deadline cascade, own ablauf. They survive every model.
|
||||
|
||||
### 0.3 The 4 unloaded primaries (Group A continued)
|
||||
|
||||
Four more active rows are conceptually primaries but carry **zero rules and zero concepts today** — seeded for catalog completeness, waiting for corpus:
|
||||
|
||||
| id | code | jurisdiction | what it is |
|
||||
|---:|---|---|---|
|
||||
| 171 | `upc.dni.cfi` | UPC | Negative Feststellungsklage — standalone declaratory action |
|
||||
| 172 | `upc.epo.review` | UPC | Überprüfung von EPA-Entscheidungen — standalone review action |
|
||||
| 179 | `upc.bsv.cfi` | UPC | Beweissicherung / saisie — standalone evidence-preservation order |
|
||||
| 188 | `upc.pl.cfi` | UPC | Schutzschrift — pre-litigation defensive filing |
|
||||
|
||||
These are **primary** by character (each has its own RoP-defined filing pathway and its own deadline tree once rules get seeded) but **unloaded** today. Decision: keep them as `kind='proceeding'` so Mode B R3 surfaces them for future rule attachment and `pkg/litigationplanner` accepts them as valid catalog codes.
|
||||
|
||||
§9 Q3.b discusses `upc.pl.cfi` (it's the only borderline — Schutzschrift is technically a pre-action filing, not a proceeding at the time of filing). m's call.
|
||||
|
||||
### 0.4 The 28 non-primary rows
|
||||
|
||||
The 28 active rows that have **zero rules + zero concepts + zero projects pointing at them** group cleanly into three categories:
|
||||
|
||||
#### Group B — Phases of a primary CFI proceeding (5 rows)
|
||||
|
||||
These describe stages *within* an existing CFI proceeding, not standalone matters. A `upc.inf.cfi` action passes through interim → oral → decision phases; the phase isn't a separately-elected proceeding type.
|
||||
|
||||
| id | code | name |
|
||||
|---:|---|---|
|
||||
| 173 | `upc.cfi.interim` | CFI - Zwischenverfahren |
|
||||
| 174 | `upc.cfi.oral` | CFI - Mündliche Verhandlung |
|
||||
| 175 | `upc.cfi.decision` | CFI - Endentscheidung |
|
||||
| 176 | `upc.costs.cfi` | Separate Kostenentscheidung *(post-decision sub-phase)* |
|
||||
| 185 | `upc.default.cfi` | Versäumnisentscheidung *(alt. decision outcome)* |
|
||||
|
||||
The "phase" concept already has a natural home in the data model: `paliad.procedural_events.event_kind` (filing/hearing/decision/order). What `upc.cfi.interim` actually represents is "all events with kind=filing under upc.inf.cfi/upc.rev.cfi/upc.pi.cfi/etc."; `upc.cfi.oral` is "all events with kind=hearing"; `upc.cfi.decision` is "all events with kind=decision". The proceeding-type row buys nothing the event_kind already carries.
|
||||
|
||||
#### Group C — Side-actions inside a proceeding (10 rows)
|
||||
|
||||
Applications and court orders that arise *inside* a primary proceeding. They could each become a `condition_expr`-gated rule on the parent proceeding when corpus arrives; they don't need their own proceeding row.
|
||||
|
||||
| id | code | name |
|
||||
|---:|---|---|
|
||||
| 178 | `upc.evidence.cfi` | Beweisanordnungen (allgemein) |
|
||||
| 182 | `upc.experiments.cfi` | Gerichtlich angeordnete Versuche |
|
||||
| 177 | `upc.security.cfi` | Sicherheitsleistung |
|
||||
| 184 | `upc.intervention.rop` | Streitbeitritt |
|
||||
| 165 | `upc.parties.change` | Parteiwechsel / Patentübergang |
|
||||
| 170 | `upc.optout.cfi` | Antrag auf Opt-out |
|
||||
| 180 | `upc.inspection.cfi` | Besichtigungsantrag |
|
||||
| 181 | `upc.freezing.cfi` | Anordnung zur Vermögenssperre |
|
||||
| 187 | `upc.withdrawal.rop` | Klagerücknahme |
|
||||
| 183 | `upc.rehearing.coa` | Wiederaufnahmeantrag |
|
||||
|
||||
A subtle distinction: `upc.bsv.cfi` (Beweissicherung) IS a standalone primary (its own RoP filing) whereas `upc.evidence.cfi` (Beweisanordnungen allgemein) is a side-action class (orders the court makes inside any proceeding). The two are not duplicates; the categorisation is structural, not nominal.
|
||||
|
||||
#### Group D — Cross-cutting administrative / meta (8 rows)
|
||||
|
||||
These describe rules-of-procedure mechanics, not matters a lawyer takes on. None of them is a "Verfahren" in any user-facing sense.
|
||||
|
||||
| id | code | name |
|
||||
|---:|---|---|
|
||||
| 162 | `upc.case.mgmt` | Verfahrensverwaltung |
|
||||
| 161 | `upc.general.rop` | Allgemeine Bestimmungen |
|
||||
| 163 | `upc.service.rop` | Zustellung von Schriftsätzen |
|
||||
| 168 | `upc.language.rop` | Verfahrenssprache |
|
||||
| 164 | `upc.representation.rop` | Vertretung / Anwaltsprivileg |
|
||||
| 166 | `upc.fees.court` | Gerichtsgebühren |
|
||||
| 167 | `upc.legalaid.cfi` | Prozesskostenhilfe |
|
||||
| 186 | `upc.special.cfi` | Besondere Verfahrenslagen |
|
||||
| 169 | `upc.reestablishment.rop` | Wiedereinsetzung in den vorigen Stand *(cross-cutting; applies to every proceeding)* |
|
||||
|
||||
`upc.reestablishment.rop` lands in Group D because **every** proceeding has a Wiedereinsetzung path — it isn't a kind-of-proceeding, it's a cross-cutting remedy. Today's rules already model it correctly (it's a `condition_expr`-gated rule on each primary, not a separately-elected proceeding type).
|
||||
|
||||
### 0.5 Counts reconciled
|
||||
|
||||
| Group | Count | Total of 46 |
|
||||
|---|---:|---:|
|
||||
| A.1 Primary with corpus (18 rows) | 18 | |
|
||||
| A.2 Primary, unloaded (4 rows) | 4 | |
|
||||
| B Phases (5 rows) | 5 | |
|
||||
| C Side-actions (10 rows) | 10 | |
|
||||
| D Meta / cross-cutting (9 rows) | 9 | |
|
||||
| **Total** | | **46 ✓** |
|
||||
|
||||
m/paliad#147's audit listed 8 Group-D rows; live data shows 9 once `upc.reestablishment.rop` is moved into the meta bucket (it appeared as ambiguous "cross-cutting admin / meta" — confirming this design's read).
|
||||
|
||||
---
|
||||
|
||||
## 1. Categorization — ratified
|
||||
|
||||
The taxonomy proposal: a row in `paliad.proceeding_types` has exactly one of four **structural kinds**.
|
||||
|
||||
| `kind` | What it is | Visible in Mode B R3 wizard? | In `pkg/litigationplanner` catalog? | Eligible for `projects.proceeding_type_id`? |
|
||||
|---|---|---|---|---|
|
||||
| `proceeding` | A self-contained matter with its own filing pathway and its own deadline tree | **Yes** | **Yes** (filtered by `kind='proceeding' AND is_active=true`) | **Yes** |
|
||||
| `phase` | A stage *within* a primary proceeding | No | No | No |
|
||||
| `side_action` | An application/order that arises inside a primary proceeding | No | No | No |
|
||||
| `meta` | RoP mechanics, cross-cutting rules, court administration | No | No | No |
|
||||
|
||||
This is **Model 1 from m/paliad#147** (kind discriminator on `proceeding_types`). §2 explains why it beats Models 2-4 for the actual data.
|
||||
|
||||
The 46 active rows map to the 4 kinds as follows:
|
||||
|
||||
- **`proceeding` (22 rows):** all 18 primaries-with-corpus + the 4 unloaded primaries from §0.3. Specifically the union of §0.2 + §0.3.
|
||||
- **`phase` (5 rows):** the §0.4 Group B list.
|
||||
- **`side_action` (10 rows):** the §0.4 Group C list.
|
||||
- **`meta` (9 rows):** the §0.4 Group D list (incl. `upc.reestablishment.rop`).
|
||||
|
||||
### 1.1 Edge calls
|
||||
|
||||
- **`upc.ccr.cfi` (id 35)** — stays `kind='proceeding'` with the existing routing-to-`upc.inf.cfi` from t-paliad-204 §0.3 S1 (the determinator surfaces it, the mapping returns inf.cfi's id with `with_ccr=true`). Rationale: the routing layer is already built and m ratified it 2026-05-18. This design does not re-open that decision. §9 Q7 lets m revisit.
|
||||
- **`upc.pl.cfi` (Schutzschrift, id 188)** — borderline. Schutzschrift is filed *before* a proceeding exists; it's a defensive pre-litigation filing. Recommendation: keep as `kind='proceeding'` (it has its own RoP path + its own deadlines once seeded). The alternative — calling it `side_action` of a not-yet-existing inf.cfi — is semantically backwards. §9 Q3.b lets m revisit.
|
||||
- **`upc.bsv.cfi` (saisie, id 179)** vs **`upc.evidence.cfi` (id 178)** — bsv stays `kind='proceeding'` (own RoP filing under R.192-198), evidence stays `kind='side_action'` (the orders a court makes inside any proceeding under R.190). The codes are not duplicates.
|
||||
|
||||
### 1.2 What the categorisation buys
|
||||
|
||||
- **Mode B R3 (Fristenrechner overhaul, t-paliad-322)** queries `proceeding_types WHERE is_active AND kind='proceeding'` and gets a clean 22-row pick list — no phase/side-action/meta noise.
|
||||
- **`projects.proceeding_type_id` integrity** is enforceable: an FK + CHECK (or a triggered constraint, see §3.3) blocks setting a project's type to anything except `kind='proceeding'`.
|
||||
- **`pkg/litigationplanner` snapshot generator** filters identically; youpc.org's catalog stays UPC-primary-only with no leakage of phase/admin rows.
|
||||
- **Determinator + dropdowns** get a forward-compatible filter; future feature work (e.g. "show me all side-actions available in this proceeding") becomes a different query against the same table.
|
||||
- **Forward-compatibility for new rows** — when corpus for a side-action arrives (e.g. `upc.evidence.cfi` gains 4 sequencing_rules with `condition_expr='evidence_order_issued'`), the rules anchor on the *parent* primary, not on the side-action row. The kind classification stays correct; the side-action row remains a taxonomic label.
|
||||
|
||||
---
|
||||
|
||||
## 2. Model choice — Model 1 (kind discriminator)
|
||||
|
||||
### 2.1 The four candidate models, scored
|
||||
|
||||
| Model | Schema churn | Models phase parentage? | Mode B R3 filter | Migration risk | Verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| **1. `kind` discriminator on `proceeding_types`** | One column + CHECK constraint | No, but doesn't need to | `WHERE kind='proceeding'` | Trivial — UPDATE only | **Recommended** |
|
||||
| 2. Self-referencing `parent_id` | One column + FK + CHECK | Yes, but parentage is wrong shape (phases are phase-of-EVERY-CFI, not of one) | `WHERE parent_id IS NULL` | Trivial | Over-modelled |
|
||||
| 3. Separate tables | Three new tables + view/JOINs | Yes, fully | Just query `proceeding_types` | Migration churn + every consumer query learns a new shape | Overkill for 28 unused rows |
|
||||
| 4. Move phases into `procedural_events` | One mass row-move + DELETE | n/a (phases vanish from `proceeding_types`) | Trivial | Highest — would touch event_kind taxonomy and Fristenrechner result-view structure | Wrong shape (phases ≠ events) |
|
||||
|
||||
### 2.2 Why Model 1 wins
|
||||
|
||||
The fundamental observation: **the 28 non-primary rows have zero downstream pressure**. No rule, no project, no concept, no spawn FK references them. They exist in the table as taxonomic placeholders — names someone wrote down so future corpus could attach. We don't need to physically restructure the table; we just need to label what's what so consumers can filter correctly.
|
||||
|
||||
Model 1 gives us exactly that with one column. The other models pay schema/migration cost to model a parent-child relationship that **no consumer queries**. Mode B R3 doesn't ask "what are the phases of upc.inf.cfi?" — it asks "what are the proceedings I can pick?". The Fristenrechner result view doesn't ask the proceeding-types table about phases — phases live inside `procedural_events.event_kind` and the priority-bucket sub-sections in the §4.2 of the Fristenrechner overhaul doc.
|
||||
|
||||
Model 2's `parent_id` is wrong in shape: `upc.cfi.interim` doesn't have ONE parent (`upc.inf.cfi`), it has SEVEN parents (every CFI proceeding). Modelling that as a self-reference would force either (a) duplicating the phase rows per primary, or (b) using NULL parent_id for "applies to all". Both options are uglier than just dropping parent_id and trusting `kind='phase'`.
|
||||
|
||||
Model 3's separate tables would create rich relations that no consumer reads. Premature relational normalisation.
|
||||
|
||||
Model 4 would force phases into `procedural_events`, but phases aren't events. A phase is a *bucket of events*. The bucket is already implicit in the `event_kind` column (filing → interim, hearing → oral, decision → decision). If anything, Model 4 is *backwards* — phases should disappear into `event_kind`, not become event rows. The way to "delete" the phase rows from proceeding_types is just to deactivate them (or mark them `kind='phase'`); we don't need to re-locate them into another table to claim that conceptual move.
|
||||
|
||||
### 2.3 What we don't do — physical deletion
|
||||
|
||||
The 28 non-primary rows are NOT dropped from the table. They:
|
||||
|
||||
- Get tagged with the right `kind` value.
|
||||
- Optionally get `is_active=false` flipped (m's call, §9 Q9).
|
||||
- Stay in the table so consumers that historically referenced them by id (admin tools, audit logs, future schema-rescue scripts) keep working.
|
||||
|
||||
`DROP` is a one-way door we don't need to walk through. The CHECK constraint + kind tagging gives us the same logical cleanliness with none of the irreversibility risk.
|
||||
|
||||
---
|
||||
|
||||
## 3. Schema sketch + migration plan
|
||||
|
||||
### 3.1 DDL — the new column
|
||||
|
||||
```sql
|
||||
-- Migration NNN_proceeding_types_kind.up.sql
|
||||
-- (NNN = whatever MAX(version) + 1 is at write time; see project-status.md
|
||||
-- for the live numbering. As of 2026-05-26 the head is mig 152 per the
|
||||
-- recent dedupe of identical sequencing_rule clones.)
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN kind text NOT NULL DEFAULT 'proceeding'
|
||||
CHECK (kind IN ('proceeding', 'phase', 'side_action', 'meta'));
|
||||
|
||||
COMMENT ON COLUMN paliad.proceeding_types.kind IS
|
||||
'Structural classification — see docs/design-proceeding-types-taxonomy-2026-05-26.md §1. '
|
||||
'proceeding = self-contained matter (own filing + deadline tree); '
|
||||
'phase = stage inside a primary CFI proceeding; '
|
||||
'side_action = application/order inside a proceeding; '
|
||||
'meta = RoP mechanics, court admin, cross-cutting remedies.';
|
||||
|
||||
CREATE INDEX proceeding_types_kind_active_idx
|
||||
ON paliad.proceeding_types(kind, is_active)
|
||||
WHERE is_active = true;
|
||||
```
|
||||
|
||||
The DEFAULT keeps existing inserts (admin tooling, snapshot tests) safe: any new row defaults to `proceeding`. The CHECK enforces the vocabulary at write time.
|
||||
|
||||
### 3.2 Data move — UPDATE statements, no INSERT/DELETE
|
||||
|
||||
```sql
|
||||
-- Phases (per m's Q2 carve-out: upc.costs.cfi (176) is NOT a phase, it stays primary)
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'phase'
|
||||
WHERE id IN (173, 174, 175, 185); -- §0.4 Group B minus 176
|
||||
|
||||
-- Side-actions
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'side_action'
|
||||
WHERE id IN (178, 182, 177, 184, 165, 170, 180, 181, 187, 183); -- §0.4 Group C
|
||||
|
||||
-- Meta / cross-cutting
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'meta'
|
||||
WHERE id IN (162, 161, 163, 168, 164, 166, 167, 186, 169); -- §0.4 Group D
|
||||
|
||||
-- Primaries (incl. m's Q2 carve-out for upc.costs.cfi) stay on the DEFAULT
|
||||
-- 'proceeding' value — no UPDATE needed.
|
||||
|
||||
-- Per m's Q9: deactivate the non-primary rows so the admin list surfaces only
|
||||
-- primaries. The kind column carries the semantic info; is_active controls UI
|
||||
-- visibility. Reversible — flip is_active back on if a row gains corpus.
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = false
|
||||
WHERE kind IN ('phase', 'side_action', 'meta');
|
||||
```
|
||||
|
||||
Per m's Q9, the `is_active=false` flip is mandatory in this mig. After it: 23 active rows (all `kind='proceeding'`), 23 inactive rows (the phase/side_action/meta set), in addition to the pre-existing inactive appeal-triplet + archived bucket. The `kind` column tells consumers what each row IS; `is_active` tells consumers whether to show it.
|
||||
|
||||
### 3.3 Optional integrity constraints
|
||||
|
||||
If m wants stronger guarantees that `projects.proceeding_type_id` can only point at primaries, add a deferrable FK validator. Cleanest pattern in Postgres:
|
||||
|
||||
```sql
|
||||
-- Option A: trigger-based check (works for any kind set, deferred-friendly).
|
||||
CREATE OR REPLACE FUNCTION paliad.assert_project_type_is_proceeding()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.proceeding_type_id IS NOT NULL THEN
|
||||
PERFORM 1 FROM paliad.proceeding_types
|
||||
WHERE id = NEW.proceeding_type_id AND kind = 'proceeding';
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'projects.proceeding_type_id must reference a kind=proceeding row, got id=%', NEW.proceeding_type_id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE TRIGGER projects_proceeding_type_kind_check
|
||||
BEFORE INSERT OR UPDATE OF proceeding_type_id ON paliad.projects
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.assert_project_type_is_proceeding();
|
||||
```
|
||||
|
||||
Per m's Q8: **trigger on `projects` only**, no symmetric enforcement on `sequencing_rules`. Projects are written via the public app (the surface most exposed to operator error); rules are edited via the admin `/admin/procedural-events` surface which already validates against active+published lifecycle. The single trigger is enough.
|
||||
|
||||
### 3.4 Migration sequencing — single self-contained mig
|
||||
|
||||
One migration file:
|
||||
|
||||
```
|
||||
internal/db/migrations/153_proceeding_types_kind.up.sql
|
||||
internal/db/migrations/153_proceeding_types_kind.down.sql
|
||||
```
|
||||
|
||||
Up does ALTER + UPDATE + (optional) trigger creation. Down does DROP COLUMN (cascading the trigger if present). No data loss on either direction — the kind column is purely additive.
|
||||
|
||||
Mig number depends on what knuth lands first; the coder reads `MAX(version)` at write time per the project's mig conventions.
|
||||
|
||||
---
|
||||
|
||||
## 4. FK reparenting tables
|
||||
|
||||
There is no reparenting to do. Below for completeness:
|
||||
|
||||
| Source table.column | Pointing at non-primary rows? | Action |
|
||||
|---|---|---|
|
||||
| `sequencing_rules.proceeding_type_id` | **0 active rules** (verified §0.1) | None |
|
||||
| `sequencing_rules.spawn_proceeding_type_id` | **0 active rules** point at non-primaries; 4 active rules point at id=11 (inactive `upc.apl.merits`) | Pre-existing drift, out of scope (§8) |
|
||||
| `projects.proceeding_type_id` | **0 projects** (all 6 distinct values are primaries) | None |
|
||||
| `event_category_concepts.proceeding_type_code` | **0 concepts** point at non-primary codes; 30 concepts point at `upc.apl.merits/order/cost` codes (which are inactive but conceptually primaries) | Pre-existing drift, out of scope (§8) |
|
||||
|
||||
The "FK reparent" section of the acceptance criteria in m/paliad#147 is a no-op for this design: the 28 rows being re-classified have **no incoming references** to reparent. The migration is pure relabelling.
|
||||
|
||||
---
|
||||
|
||||
## 5. Worked example — `upc.cfi.interim` after the mig
|
||||
|
||||
### 5.1 Today (broken)
|
||||
|
||||
Someone created the row `upc.cfi.interim` (id 173, name "CFI - Zwischenverfahren") in `paliad.proceeding_types` with `category='fristenrechner'`. The intent was probably "we'll attach interim-phase rules here later". Result:
|
||||
|
||||
- The row appears in the Mode B R3 wizard chip strip (if R3 queries `WHERE is_active=true AND jurisdiction='UPC'`) — confusing to the user, because "Zwischenverfahren" is not a proceeding they pick; it's a stage their proceeding passes through.
|
||||
- The row could be set as `projects.proceeding_type_id` (no FK constraint forbids it today) — corrupting the SmartTimeline's lane logic, which assumes the project's type is a primary.
|
||||
- The row appears in admin /admin/proceeding-types lists, polluting the primary-proceedings overview.
|
||||
|
||||
### 5.2 After mig 153
|
||||
|
||||
The migration runs:
|
||||
|
||||
```sql
|
||||
UPDATE paliad.proceeding_types SET kind = 'phase' WHERE id = 173;
|
||||
-- Optionally: UPDATE paliad.proceeding_types SET is_active = false WHERE id = 173;
|
||||
```
|
||||
|
||||
Now:
|
||||
|
||||
- Mode B R3 query becomes `WHERE is_active=true AND jurisdiction = $1 AND kind='proceeding'`. `upc.cfi.interim` is filtered out — it is not a "Verfahren" the user can pick.
|
||||
- A future admin who tries to set a project's `proceeding_type_id = 173` either fails the optional trigger from §3.3 (with a clear error) or gets a code-level rejection from `ProjectService.SetProceedingType` (which the coder will harden to filter by `kind='proceeding'`).
|
||||
- The `pkg/litigationplanner` snapshot generator filter becomes `WHERE is_active=true AND category='fristenrechner' AND kind='proceeding' AND jurisdiction IN ('UPC')`. The row never makes it into the youpc.org catalog.
|
||||
|
||||
The row itself stays in the database. Its id is stable. Future work that wants to *use* the phase row as a taxonomic label (e.g. "show me which event_kinds map to which UPC phases") gets a clean shape: query `WHERE kind='phase' AND code LIKE 'upc.cfi.%'`.
|
||||
|
||||
### 5.3 Where interim-phase deadlines actually live
|
||||
|
||||
The user-facing concept "interim phase" is already modelled correctly, just elsewhere:
|
||||
|
||||
- A `procedural_events` row like `upc.inf.cfi.soc` (Statement of Claim) has `event_kind='filing'`. The Fristenrechner overhaul (t-paliad-322 §4.2) groups follow-ups by priority + presents them under the trigger card. There is no UI element that needs a "Zwischenverfahren" proceeding-type label to operate.
|
||||
- A future "show me the full ablauf of UPC inf, broken down by phase" feature can derive phases from `procedural_events.event_kind` ordering + the rule sequence_order. The `proceeding_types` table doesn't need to carry the phase labels.
|
||||
|
||||
---
|
||||
|
||||
## 6. Consumer impact
|
||||
|
||||
### 6.1 `projects.proceeding_type_id`
|
||||
|
||||
| Concern | Before | After mig 153 |
|
||||
|---|---|---|
|
||||
| Valid values | Any active proceeding_types row | Any `kind='proceeding'` active row (22 rows) |
|
||||
| Enforcement | None at DB level | Optional trigger (§3.3 / §9 Q8) |
|
||||
| Code-level filter in ProjectService | No filter on kind | Filter to `kind='proceeding'` when listing pickable types |
|
||||
| Existing data | 6 distinct values (all in 22) | No change — all 6 are kind='proceeding' |
|
||||
| SmartTimeline lane logic | Assumes primary-proceeding shape | Assumption now FK-enforceable |
|
||||
|
||||
**No data migration on existing projects.** The 6 currently-used proceeding types are all in the primary set.
|
||||
|
||||
### 6.2 `sequencing_rules.proceeding_type_id` + `spawn_proceeding_type_id`
|
||||
|
||||
| Concern | Before | After mig 153 |
|
||||
|---|---|---|
|
||||
| `proceeding_type_id` valid values | Any active row | Any active row (no enforcement change; admin curation suffices) |
|
||||
| `spawn_proceeding_type_id` valid values | Any active row | Same — spawns conceptually must point at a primary, but enforcement stays in admin tooling |
|
||||
| Existing data | 157 rules anchored on 18 primaries | No change — all 157 already on `kind='proceeding'` rows |
|
||||
| `id=11 spawn pressure` (`upc.apl.merits`, inactive) | 4 active spawn rules point here | Pre-existing drift, out of scope (§8) |
|
||||
|
||||
No `sequencing_rules` table changes accompany this mig. The post-mig invariant **"every active rule's `proceeding_type_id` is a `kind='proceeding'` row"** holds without any UPDATE.
|
||||
|
||||
### 6.3 Fristenrechner Mode B R3 (t-paliad-322, knuth's S3+)
|
||||
|
||||
§3.2 R3 of the Fristenrechner overhaul says:
|
||||
|
||||
> Chips: every active `proceeding_type` whose jurisdiction matches R2 AND whose event roster contains at least one event with R1's kind.
|
||||
|
||||
After mig 153, the R3 query gains one more AND-clause:
|
||||
|
||||
```sql
|
||||
SELECT pt.id, pt.code, pt.name, pt.name_en, pt.sort_order
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.is_active = true
|
||||
AND pt.kind = 'proceeding' -- NEW
|
||||
AND pt.jurisdiction = $1 -- from R2
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE sr.proceeding_type_id = pt.id
|
||||
AND pe.event_kind = $2 -- from R1
|
||||
AND sr.is_active = true
|
||||
)
|
||||
ORDER BY pt.sort_order, pt.code;
|
||||
```
|
||||
|
||||
The `kind='proceeding'` filter is the only line that changes. Knuth's S3 implementation reads from this query; the chip pool shrinks from "all 35 active UPC types" to "the 14 primary UPC types that have rules" (still narrowed further by R1's event_kind via the EXISTS subquery).
|
||||
|
||||
No coder churn beyond adding the AND-clause. The mig 153 lands either alongside knuth's S3 work or independently (§7 sequencing decision).
|
||||
|
||||
### 6.4 Litigation Planner suite (t-paliad-292)
|
||||
|
||||
The package's catalog snapshot generator (`pkg/litigationplanner/scripts/snapshot/main.go`) currently filters:
|
||||
|
||||
```go
|
||||
// scripts/snapshot/main.go
|
||||
const proceedingTypesQuery = `
|
||||
SELECT id, code, name, name_en, jurisdiction, default_color, sort_order, display_order,
|
||||
trigger_event_label_de, trigger_event_label_en
|
||||
FROM paliad.proceeding_types
|
||||
WHERE is_active = true
|
||||
AND category = 'fristenrechner'
|
||||
AND jurisdiction = $1
|
||||
`
|
||||
```
|
||||
|
||||
After mig 153, this query gains the same `AND kind = 'proceeding'` line. The UPC snapshot shrinks from "potentially 35 rows" to a clean primary-only set. Today's snapshot probably already includes the phase/side-action/meta rows (since `is_active=true` is true for all of them) — depending on whether a snapshot has been regenerated since the 161-188 rows landed, the embedded JSON may be carrying decorative rows that the youpc.org catalog never resolves to rules. Mig 153 + a snapshot regen cleans this up.
|
||||
|
||||
The package's `Catalog.Proceeding(ctx, code, hint)` interface stays unchanged. A youpc-side call asking for `code='upc.cfi.interim'` previously returned the row + zero rules (technically valid but useless); after mig 153 the snapshot doesn't include it and the call returns `ErrUnknownProceedingType`. That's the correct shape — youpc users never had a reason to ask for a phase row.
|
||||
|
||||
The scenarios design (`paliad.scenarios.spec.proceedings[].code`) gains an integrity check at write time: the validator already asserts every code resolves to an active proceeding; now it additionally asserts `kind='proceeding'`. A user trying to compose a scenario with `code='upc.cfi.interim'` gets a clear error. (The validator is paliad-side, not library-side — see Litigation Planner doc §5 "Validatable at write time".)
|
||||
|
||||
### 6.5 Admin /admin/procedural-events list (recently shipped, t-paliad-321)
|
||||
|
||||
The proceeding-type column in the admin list (m/paliad#144 follow-up, just landed) renders one of the 46 active codes per row. Post-mig 153, the admin filter dropdown can:
|
||||
|
||||
- Default to showing only `kind='proceeding'` rows (clean primary view).
|
||||
- Offer a "show all kinds" toggle for admins triaging the non-primary rows.
|
||||
|
||||
This is presentation-only — the underlying admin queries don't need to change immediately. The kind column is a forward-compat hook.
|
||||
|
||||
### 6.6 Knowledge-platform pages (Gerichtsverzeichnis, Patentglossar)
|
||||
|
||||
Untouched. None of those pages query `proceeding_types` directly.
|
||||
|
||||
### 6.7 Fristen export / paliad data export (t-paliad-279)
|
||||
|
||||
Untouched. The exporter dumps `proceeding_types` as a whole (no kind-filter); after mig 153 it dumps the same rows with the new kind column. Forward-compat by default.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration sequencing decision vs m/paliad#146
|
||||
|
||||
m/paliad#146 (Fristenrechner overhaul, t-paliad-322 / 323) is on the S1-S6 train under knuth. m's directive at task brief time: **knuth pauses at the S1+S2 seam waiting for this taxonomy decision**.
|
||||
|
||||
Three options were on the table:
|
||||
|
||||
(a) **Pause #146 until taxonomy clean** — knuth blocked, this design lands first, then knuth resumes S3+.
|
||||
(b) **Land #146 against current shape, migrate later** — knuth ships S3-S6 against the current 46-row table, taxonomy mig follows.
|
||||
(c) **Land taxonomy in parallel, knuth re-targets if needed** — both run, knuth's S3 picks up the new filter when mig 153 is ready.
|
||||
|
||||
**Recommendation: (c) parallel-land** with the following caveats:
|
||||
|
||||
- The taxonomy mig is **additive** (ADD COLUMN with safe DEFAULT, no DROP, no data move beyond UPDATEs that touch unreferenced rows). Knuth's S3 implementation can be written with or without the `kind='proceeding'` filter — adding the filter is a one-line patch the moment mig 153 lands.
|
||||
- The R3 chip-pool query in knuth's S3 PR should be **future-proofed by also adding the `kind='proceeding'` filter behind a feature flag or an env-time SQL constant**, defaulting to "no filter" pre-mig and "filter" post-mig. (Or simpler: knuth writes the filter unconditionally; the migration lands first; ordering is mechanical.)
|
||||
- The mig 153 PR should land **before** knuth's S3 PR ships to main, so the filter is never false-positive (chipping phase rows users can't actually pick). Both PRs can be drafted in parallel; the squeeze happens at merge time.
|
||||
- Sequence on main: mig 153 → knuth S3 (with filter) → knuth S4-S6.
|
||||
|
||||
Option (c) keeps knuth productive (S3 work can start immediately after this design ratifies; doesn't have to wait for the mig to merge) and avoids the option (a) idle cost.
|
||||
|
||||
Option (b) was rejected because it leaves the Mode B R3 wizard chipping 35 UPC rows on initial release — exactly the bug m flagged in m/paliad#147 ("half of the 46 active proceeding_types are not primary proceedings"). The user would see phase rows in R3 day one of the Fristenrechner overhaul shipping; we'd be shipping the bug.
|
||||
|
||||
Option (a) was rejected as the safest but slowest path. The taxonomy mig is trivial enough (one ALTER + four UPDATE statements + optional trigger) that parallel-running has no real risk.
|
||||
|
||||
§9 Q10 gives m the chance to pick differently.
|
||||
|
||||
---
|
||||
|
||||
## 8. Out of scope (flagged for separate work)
|
||||
|
||||
- **`upc.apl.*` data drift.** 30 rows in `paliad.event_category_concepts` reference the inactive `upc.apl.merits` / `upc.apl.order` / `upc.apl.cost` codes (the pre-`upc.apl.unified` triplet). 4 active sequencing_rules reference `spawn_proceeding_type_id=11` (the inactive `upc.apl.merits` row). This is a pre-existing inconsistency from the appeal unification mig — needs its own follow-up ticket. Not blocking this design; can be cleaned up in a separate migration that retargets concepts + spawn FKs to `upc.apl.unified` (id=160).
|
||||
- **Renaming or relabelling primary proceedings.** Out per m/paliad#147 acceptance — editorial work, not structural.
|
||||
- **Adding new proceeding types beyond the existing corpus.** Out per m/paliad#147 acceptance.
|
||||
- **The Fristenrechner UI overhaul itself (m/paliad#146).** Separate track; this design only tells knuth's S3 what set to chip.
|
||||
- **The scenarios design (m/paliad#124).** Already ratified in `docs/design-litigation-planner-2026-05-26.md` §5; this design only refines the spec validator's "every code resolves to a primary" check.
|
||||
- **DROPing the non-primary rows physically.** Reversible deactivation via `kind=...` + optional `is_active=false` is enough; physical deletion adds irreversibility risk for no functional gain.
|
||||
- **Migration of `event_category_concepts.proceeding_type_code` to a real FK.** It's text today, joined softly; converting to FK is a separate hardening task.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions for m (10 decision questions)
|
||||
|
||||
Sent via `AskUserQuestion` in 3 batches per inventor SKILL contract (4+3+3). m's picks land in §10 below after the round-trip.
|
||||
|
||||
| # | Topic | Recommended pick |
|
||||
|---|---|---|
|
||||
| Q1 | Model choice | Model 1 (kind discriminator) |
|
||||
| Q2 | Phases — linear sub-phases of every CFI, or separately-elected? | Implicit: phases live in `procedural_events.event_kind`, not as proceeding_types |
|
||||
| Q3.a | Side-actions — triggered by parent event, or initiated out-of-band? | Mixed; today's data has no rules, future rules anchor on the parent primary with `condition_expr` |
|
||||
| Q3.b | `upc.pl.cfi` (Schutzschrift) — primary or side-action? | Primary (own RoP filing pathway) |
|
||||
| Q4 | Collapse `de.inf.lg`/`olg`/`bgh` into one `de.inf` with instance_level qualifier? | No — keep discrete |
|
||||
| Q5 | Collapse `de.null.bpatg`/`bgh` into one `de.null` with instance_level qualifier? | No — keep discrete |
|
||||
| Q6 | Should DE follow the `upc.apl.unified` pattern? | No (= keep discrete, locks Q4+Q5) |
|
||||
| Q7 | `upc.ccr.cfi` — proceeding row with routing (status quo), or `with_ccr` flag on `upc.inf.cfi`? | Keep as proceeding (status quo per t-paliad-204 S1) |
|
||||
| Q8 | Enforce `projects.proceeding_type_id` → `kind='proceeding'` at the DB level? | Yes, via trigger (§3.3) |
|
||||
| Q9 | Set `is_active=false` on the 28 non-primary rows after mig 153? | Yes (cleanest admin UX) |
|
||||
| Q10 | Sequencing vs m/paliad#146 — pause / parallel / re-target? | (c) parallel-land — mig first, then knuth S3 with filter |
|
||||
|
||||
Q11 in the issue body ("how many rules need new condition_expr disambiguation?") is **empirically answered, no decision needed**: 0 rules need new condition_expr — every active rule is already correctly anchored to a primary. Surfaced in §4 + §6.2.
|
||||
|
||||
---
|
||||
|
||||
## 10. m's decisions (2026-05-27)
|
||||
|
||||
All 11 questions answered via `AskUserQuestion` on 2026-05-27 09:52 (3 batches of 4+4+3). 10 of 11 picks = recommendation; Q9 diverged at the chip-picker but m's follow-up instruction ("I follow your recommendation") flips Q9 to the recommendation as well. Q2 carries a precise carve-out captured verbatim below.
|
||||
|
||||
- **Q1 (Model): Model 1 — kind discriminator.** [= recommendation] One column + CHECK constraint + UPDATE statements. **Locks §1, §2, §3.1, §3.2.**
|
||||
- **Q2 (Phases): Generally option 1 (implicit via `procedural_events.event_kind`), with carve-outs.** [≈ option 1 with carve-out] m's verbatim call:
|
||||
> Generally 1, but I agree with costs which are not only a phase but also "standalone" side proceedings. But default decision application is not.
|
||||
Concretely:
|
||||
- `upc.cfi.interim` (173) → `kind='phase'`
|
||||
- `upc.cfi.oral` (174) → `kind='phase'`
|
||||
- `upc.cfi.decision` (175) → `kind='phase'`
|
||||
- `upc.default.cfi` (185) → `kind='phase'` (m: "default decision application is not [a standalone side proceeding]")
|
||||
- **`upc.costs.cfi` (176) → `kind='proceeding'`** (m: "costs are not only a phase but also standalone side proceedings"). The Separate Kostenentscheidung can be filed as its own application under R.151 RoP independently of the parent decision; m's read is that the standalone-application character outweighs the phase-of-CFI character.
|
||||
Net: 4 phase rows (not 5 as in the strawman), 23 primary-proceeding rows (not 22). **Updates §0.4 Group B count, §0.5 totals row, §1 categorisation, §3.2 UPDATE statement IDs (drop 176 from the phase UPDATE).**
|
||||
- **Q3.a (Side-actions): kind='side_action', rules anchor on parent primary.** [= recommendation] All 10 §0.4 Group C rows get `kind='side_action'`. When corpus arrives, rules attach to the parent primary with a `condition_expr` flag. **Locks §1.1, §3.2 side-action UPDATE.**
|
||||
- **Q3.b (Schutzschrift): kind='proceeding'.** [= recommendation] `upc.pl.cfi` (188) stays in the primary set on the strength of its own RoP filing pathway. **Locks §0.3 unloaded-primary list.**
|
||||
- **Q4 (DE inf collapse): Keep discrete.** [= recommendation] `de.inf.lg/olg/bgh` stay as 3 separate primaries. No collapse, no instance_level qualifier introduction. **Locks §0.2 + §1 DE-side categorisation.**
|
||||
- **Q5 (DE null collapse): Keep discrete.** [= recommendation] `de.null.bpatg/bgh` stay separate. Symmetric with Q4. **Locks §0.2 + §1 DE-side categorisation.**
|
||||
- **Q6 (DE follow upc.apl pattern): No — keep DE discrete.** [= recommendation] Locks Q4+Q5. The `upc.apl.unified` consolidation was about same-court appeal variants; DE appeals are different-court-instance appeals — different problem. **No code-rename work falls out of this design.**
|
||||
- **Q7 (CCR shape): Keep status quo.** [= recommendation] `upc.ccr.cfi` stays as `kind='proceeding'` with the existing routing-to-`upc.inf.cfi` from t-paliad-204 §0.3 S1. **Locks §1.1.**
|
||||
- **Q8 (DB trigger): Trigger on `projects` only.** [= recommendation] BEFORE INSERT/UPDATE trigger on `paliad.projects` enforces `proceeding_type_id → kind='proceeding'`. No trigger on `sequencing_rules` (admin tooling already gates). **Locks §3.3 — keep the `projects` trigger DDL, drop the optional `sequencing_rules` variant.**
|
||||
- **Q9 (Deactivate non-primaries): Yes — deactivate.** [m's chip-pick was "keep active"; flipped to recommendation per m's "I follow your recommendation" instruction] All `kind IN ('phase', 'side_action', 'meta')` rows get `is_active=false` in mig 153. The admin `/admin/proceeding-types` list shows only the 23 active primaries. Rows stay in the table with their `kind` tag so future tooling that wants to surface them can flip `is_active` back on. **Updates §3.2 — uncomment the optional `UPDATE … SET is_active=false` block.**
|
||||
- **Q10 (Sequencing vs #146): Parallel-land.** [= recommendation] Mig 153 + knuth's S3 PR drafted in parallel; mig merges first; knuth's S3 includes the `kind='proceeding'` filter in R3's chip query from day one. No idle cost; no bug shipped. **Locks §7.**
|
||||
|
||||
### 10.1 What changed from the strawman as a result
|
||||
|
||||
Two material edits flow from m's picks:
|
||||
|
||||
1. **§0.4 Group B (Phases) drops `upc.costs.cfi` (id 176)** — moved into the primary set. Phase count: 5 → 4. Primary count: 22 → 23. §0.2 picks up id 176 as an unloaded primary (zero rules today; future corpus will attach).
|
||||
2. **§3.2 migration includes the `is_active=false` UPDATE** (was optional in the strawman, now mandatory):
|
||||
|
||||
```sql
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = false
|
||||
WHERE kind IN ('phase', 'side_action', 'meta');
|
||||
```
|
||||
|
||||
This is what the post-mig 153 cleanup looks like: 23 active rows (all `kind='proceeding'`), 23 inactive rows (4 phase + 10 side_action + 9 meta + the pre-existing 3 inactive appeal-triplet + 1 archived bucket = 27 inactive total, but 23 of those are the freshly-deactivated taxonomy rows).
|
||||
|
||||
These edits don't change the §7 sequencing decision or the §6 consumer-impact analysis. They tighten the mig file and shift one row's classification.
|
||||
|
||||
### 10.2 Final categorisation (post-decisions)
|
||||
|
||||
| `kind` | Count | Codes |
|
||||
|---|---:|---|
|
||||
| `proceeding` | **23** | upc.inf.cfi, upc.rev.cfi, upc.pi.cfi, upc.dmgs.cfi, upc.disc.cfi, upc.ccr.cfi, upc.apl.unified, upc.dni.cfi, upc.epo.review, upc.bsv.cfi, upc.pl.cfi, **upc.costs.cfi** (m's Q2 carve-out), de.inf.lg, de.inf.olg, de.inf.bgh, de.null.bpatg, de.null.bgh, epa.opp.opd, epa.opp.boa, epa.grant.exa, dpma.opp.dpma, dpma.appeal.bpatg, dpma.appeal.bgh |
|
||||
| `phase` | **4** | upc.cfi.interim, upc.cfi.oral, upc.cfi.decision, upc.default.cfi |
|
||||
| `side_action` | **10** | upc.evidence.cfi, upc.experiments.cfi, upc.security.cfi, upc.intervention.rop, upc.parties.change, upc.optout.cfi, upc.inspection.cfi, upc.freezing.cfi, upc.withdrawal.rop, upc.rehearing.coa |
|
||||
| `meta` | **9** | upc.case.mgmt, upc.general.rop, upc.service.rop, upc.language.rop, upc.representation.rop, upc.fees.court, upc.legalaid.cfi, upc.special.cfi, upc.reestablishment.rop |
|
||||
| **Total** | **46** | ✓ |
|
||||
|
||||
Post-mig 153: 23 active (all `kind='proceeding'`), 23 deactivated (the phase/side_action/meta set).
|
||||
|
||||
---
|
||||
|
||||
## 11. Synthesis links
|
||||
|
||||
- mBrian topic: `topic-fristenrechner` — file this design as a `[synthesis]` node, link `related_to` the proceeding-code-taxonomy doc (2026-05-18) and the Fristenrechner overhaul (2026-05-26), `triggered_by` t-paliad-324.
|
||||
- Related design docs: `docs/design-proceeding-code-taxonomy-2026-05-18.md` (the code-shape doc), `docs/design-fristenrechner-overhaul-2026-05-26.md` (knuth's parent design), `docs/design-litigation-planner-2026-05-26.md` §5 (scenarios spec validator).
|
||||
- Related migrations: 095 (fristen gap-fill, spawn FK invariant), 096 (proceeding code rename), 152 (sequencing_rule dedupe + admin column).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,568 +0,0 @@
|
||||
# Design — Unified procedural-events tool (m/paliad#151)
|
||||
|
||||
**Task:** t-paliad-334
|
||||
**Gitea:** m/paliad#151
|
||||
**Inventor:** cronus (shift-1, fresh context — name-recycled, not the cronus from earlier today)
|
||||
**Date:** 2026-05-27
|
||||
**Branch:** `mai/cronus/inventor-unified`
|
||||
**Status:** Draft — coder gate held; awaiting m's go on the unification approach
|
||||
|
||||
**Builds on:**
|
||||
- `docs/assessment-deadline-system-2026-05-27.md` (athena, Phase 1 audit — premises)
|
||||
- `docs/design-deadline-system-revision-2026-05-27.md` (atlas, model + per-surface revisions — pre-locked decisions)
|
||||
- `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26 inventor, Mode A + B + result shipped via t-paliad-322 / m/paliad#146 S1-S6)
|
||||
- `docs/design-event-card-choices-2026-05-25.md`, `docs/design-determinator-row-cascade-2026-05-13.md` (per-card choice + determinator routing — current Verfahrensablauf state)
|
||||
|
||||
m's framing (2026-05-27 19:13):
|
||||
|
||||
> There are many dimensions by which we can display and filter our procedural events. Maybe we should hire an inventor to find out the best methods from the ones we already have? It makes sense to narrow things, display them in sequence and context, make selections etc. It just needs to be done well, preferably in a unitary tool. There should be alternative means to derive at what you want to derive at.
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises — what the inventor is and isn't doing
|
||||
|
||||
This is a **surface-layer** design. The **model layer** is locked by atlas's `design-deadline-system-revision-2026-05-27.md` (Q1-Q12 + 14:34/14:40 post-ratification additions, all m-decided 2026-05-27). The shipped Fristenrechner Mode A + Mode B + result view is the model-side foundation; the in-flight atlas P0-P5 train extends Verfahrensablauf and the scenario SSoT.
|
||||
|
||||
The inventor's question is **not** "how should the rule graph be modelled" — that's settled. It's: **of the 6 surfaces that read this model, do we have the right *surfaces*? Should they unify into one tool, two, or stay as today's set?**
|
||||
|
||||
Out of scope per the issue + paliadin/head brief:
|
||||
|
||||
- Calculator (`pkg/litigationplanner.CalculateRule`) — working.
|
||||
- `/admin/procedural-events` as an editorial **write** surface — different audience, different action set, must stay separate.
|
||||
- `/projects/{id}` Verlauf — per-Akte **actuals** surface, not the ablauf-tool. Sister tool, not subsumable.
|
||||
- SmartTimeline projection — per-project read view that composes actuals + projections; sister to Verlauf, project-bound. Not subsumable.
|
||||
- youpc.org/deadlines — cross-repo public surface. Snapshot consumer.
|
||||
- Outlook / Calendar sync UI.
|
||||
|
||||
In-scope unification candidates (4 surfaces): the three Fristenrechner modes (A search + B wizard + result) **and** Verfahrensablauf — these read the *same model* (sequencing_rules + procedural_events + scenario_flags) to answer questions about the *same underlying graph*. The question is whether they're best presented as one URL with multi-mode entry, two URLs with shared vocabulary, or as today's split.
|
||||
|
||||
---
|
||||
|
||||
## §1 Audit of the 6 surfaces
|
||||
|
||||
For each surface: question it answers, dimensions it filters/anchors on, what it does well, what it does poorly, overlap with neighbours.
|
||||
|
||||
### §1.1 `/tools/fristenrechner` Mode A — "Direkt suchen" (shipped t-paliad-322 S3)
|
||||
|
||||
**Question shape:** "I know a procedural event happened (e.g. *Klageerwiderung*). What follow-ups come next?"
|
||||
|
||||
**Dimensions used:**
|
||||
- *Filters* (top strip): `forum` (UPC/DE/EPA/DPMA), `proceeding_type`, `event_kind` (filing/hearing/decision/order), `primary_party`.
|
||||
- *Anchor* (the search result): one `procedural_event` row → lock as trigger.
|
||||
- *Inbox* secondary chip (CMS / beA / postal): auto-derives forum.
|
||||
|
||||
**Path:** Filter strip → free-text search → result row click → linear follow-up view (handed off to §1.3).
|
||||
|
||||
**Strengths:** Power-user surface; one box does everything; forgiving to misspellings via pg_trgm; deep-linkable via `?mode=search&q=…&forum=…`.
|
||||
|
||||
**Weaknesses:** Search returns *every* event including spawn-only and leaves (atlas §2.2 P0 fix in flight); no visualisation of *where* the picked event sits in the proceeding tree.
|
||||
|
||||
**Overlap:** Picks the same `procedural_event` rows that Mode B R4 lands on; picks the same proceeding chips that Verfahrensablauf shows. Filter strip is a subset of Verfahrensablauf's filter chips.
|
||||
|
||||
### §1.2 `/tools/fristenrechner` Mode B — "Geführt" wizard (shipped t-paliad-322 S4)
|
||||
|
||||
**Question shape:** Same as Mode A but for users who don't know how to phrase the question. Narrows by Q&A.
|
||||
|
||||
**Dimensions used:** All five Mode A filters reframed as wizard rows:
|
||||
- R1 `event_kind` (Filter badge)
|
||||
- R2 `forum` / jurisdiction (Filter, skipped if R1 narrows)
|
||||
- R3 `proceeding_type` (Qualifier, auto-skipped on single match)
|
||||
- R4 `procedural_event` (Qualifier — the landing question)
|
||||
- R5 `primary_party` (Qualifier, only when follow-ups differ by side)
|
||||
|
||||
**Path:** Q-by-Q chip pick → R4 lock → linear follow-up view (handed off to §1.3).
|
||||
|
||||
**Strengths:** Onboarding-friendly; auto-prefills from Akte (`projects.proceeding_type_id` → R3, `projects.our_side` → R5); preserves compatible downstream picks on back-nav.
|
||||
|
||||
**Weaknesses:** No tree-context view of the answer; the user lands on a flat result with no zoom-out.
|
||||
|
||||
**Overlap:** Same R4 event set as Mode A's search results. Same downstream result view.
|
||||
|
||||
### §1.3 `/tools/fristenrechner` result view (shipped t-paliad-322 S2)
|
||||
|
||||
**Question shape:** Given a locked event + trigger date, what dated follow-ups exist?
|
||||
|
||||
**Dimensions used:**
|
||||
- *Anchor:* one `sequencing_rule` (the trigger's anchor rule).
|
||||
- *Linear walk:* one hop down via `parent_id` — children of the anchor, grouped by priority.
|
||||
- *Display axes:* priority (4 groups: mandatory / recommended / optional / conditional), party, condition flag, court-set, spawn.
|
||||
- *Persistent state:* per-rule checkboxes (selection for write-back), per-rule date overrides.
|
||||
- *Write-back:* `POST /api/projects/{id}/deadlines/bulk` with audit_reason.
|
||||
|
||||
**Strengths:** Clear list + write-back footer; sticky trigger card; deep-linkable; cross-party detection in atlas P0 (S1 from t-paliad-327).
|
||||
|
||||
**Weaknesses:** Only shows *direct* children of the anchor. No visibility of where this slice fits in the proceeding's wider graph. No way to pivot to "show the whole ablauf around this".
|
||||
|
||||
**Overlap:** Selection state UI vocabulary (per-rule checkbox + chip) is conceptually identical to Verfahrensablauf's per-rule selection chips that atlas's P3 will ship.
|
||||
|
||||
### §1.4 `/tools/verfahrensablauf` (current state + atlas P3 in flight)
|
||||
|
||||
**Question shape (today):** "What does proceeding-type X look like in full?"
|
||||
|
||||
**Dimensions used:**
|
||||
- *Anchor:* one `proceeding_type` (chip-picked).
|
||||
- *Filters:* `side` (claimant/defendant), `target` (appeal-target — endentscheidung / kostenentscheidung / anordnung / schadensbemessung / bucheinsicht), `trigger_date`.
|
||||
- *Scenario flags:* CCR / inf_amend / rev_amend / rev_cci, plus per-card choices (appellant / include_ccr / skip).
|
||||
- *View toggle:* `columns` (3-column swimlane: Unsere Seite | Gericht | Gegnerseite) vs `timeline` (single-column chronological).
|
||||
- *Detail-mode toggle (shipped today via m/paliad#149 P3):* `mandatory_only` / `selected` / `all_options`.
|
||||
- *Per-card affordances:* `[Aufnehmen]` / `[Entfernen]` chips for optional/recommended rules, dotted-border for unselected, greyed for conditional-with-flag-off.
|
||||
|
||||
**Strengths:** The most data-rich surface — every rule for the proceeding rendered with computed dates against `trigger_date`. View-mode toggle gives detail-level control. URL params are clean (proceeding/side/target/trigger_date); noisy scenario flags live in localStorage (per `verfahrensablauf-state.ts`).
|
||||
|
||||
**Weaknesses:** The user must already know which proceeding to look at — no entry path from "an event happened" or "search by name". 3-column swimlane reads dense on desktop and unmanageably wide on mobile. Trigger-date is per-page (not per-rule), so the entire ablauf computes from one anchor — fine for kontextfrei browse, awkward for Akte where different rules have different real triggers.
|
||||
|
||||
**Overlap:** Detail-mode + per-rule selection chips share the design vocabulary that result view §1.3 *should* eventually adopt. Filter dimensions are a superset of Mode A's filter strip.
|
||||
|
||||
### §1.5 `/admin/procedural-events` (shipped, Slice B.5)
|
||||
|
||||
**Question shape:** "I need to edit / publish / audit rules."
|
||||
|
||||
**Dimensions used:** Lifecycle filter (draft/published/archived), proceeding chip, trigger-event filter, free-text. Per-row click → editor form. Separate tab for orphans (Slice 10 fuzzy-match staging).
|
||||
|
||||
**Strengths:** Lifecycle-aware; clone-publish workflow; audit log; orphan resolution.
|
||||
|
||||
**Weaknesses:** None for editors. *For readers,* it's the wrong tool — too much editor-state metadata in the table; no tree / sequence / dates / scenario filtering.
|
||||
|
||||
**Overlap:** None functional. Shares the rule corpus but its *action set* (edit/publish/audit/resolve-orphan) is disjoint from the reader surfaces.
|
||||
|
||||
**Verdict: keep separate.** Different audience (editors only — m today, the partner team eventually), different action set, different lifecycle vocabulary. Cross-linking is sufficient: every reader-surface row should have a "Diese Regel bearbeiten" link to `/admin/procedural-events/{id}/edit` for editor users.
|
||||
|
||||
### §1.6 `/projects/{id}` Verlauf — out of scope per brief
|
||||
|
||||
Project-bound timeline of *actual* deadlines + appointments + project_events for one Akte. Composes with SmartTimeline projections.
|
||||
|
||||
**Question shape:** "What's happened on my Akte and what's next *for this specific case*?"
|
||||
|
||||
This is conceptually downstream of the ablauf-tool: the ablauf-tool answers "what's the *shape* of proceeding X"; Verlauf answers "what's the *state* of *my Akte* that happens to be proceeding X". The shape becomes the actuals through user actions (write-back from Mode A result view, manual entry, CMS sync).
|
||||
|
||||
**Verdict: keep separate.** Different question, different data shape (instances vs templates).
|
||||
|
||||
### §1.7 SmartTimeline / `ProjectionService` — out of scope per brief
|
||||
|
||||
Per-project read view via `GET /api/projects/{id}/timeline` that returns merged actuals + projected future rows (via FristenrechnerService) + parent-node lane aggregation. The render shape is project-bound and lookahead-capped; the model knows about levels (Case / Patent / Litigation / Client) and bubble-up events.
|
||||
|
||||
**Verdict: keep separate.** SmartTimeline composes the ablauf-tool's output with project actuals; it's a consumer, not a peer.
|
||||
|
||||
### §1.8 `youpc.org/deadlines` — out of scope (cross-repo)
|
||||
|
||||
Public surface backed by the offline UPC snapshot (`cmd/gen-upc-snapshot`). Snapshot consumer only.
|
||||
|
||||
**Verdict: keep separate.** Different repo, different deploy.
|
||||
|
||||
---
|
||||
|
||||
## §2 The question→surface→dimension matrix
|
||||
|
||||
The single source of truth for "which dimension lives where". Two questions answer "which view does this surface show":
|
||||
|
||||
| User question | Today's surface | Anchor input | Output shape | Output detail |
|
||||
|---|---|---|---|---|
|
||||
| "What's the typical ablauf of upc.inf.cfi?" | Verfahrensablauf | `proceeding_type` | Tree-or-columns of all rules | Whole ablauf |
|
||||
| "Was passiert nach Klageerhebung?" | Fristenrechner Mode A | `procedural_event` | Linear follow-ups (priority groups) | Slice through tree |
|
||||
| "Was passiert nach… (don't know the event name)?" | Fristenrechner Mode B | Q&A → `procedural_event` | Same as Mode A | Same |
|
||||
| "Welche Fristen für meine Akte ergeben sich?" | Fristenrechner Mode A/B + `?project=` | Akte + `procedural_event` | Linear follow-ups + write-back | Same + actions |
|
||||
| "Wie sieht der gesamte Ablauf für meine Akte aus?" | Verfahrensablauf + `?project=` | Akte (derives `proceeding_type`) | Tree-or-columns + scenario | Whole ablauf + state |
|
||||
| "Welche Regeln gibt's? Wie bearbeite ich sie?" | /admin/procedural-events | — | Editor table | Editor metadata |
|
||||
| "Was steht auf meinem Akten-Plan?" | /projects/{id} Verlauf | Akte | Actuals timeline | Per-instance state |
|
||||
|
||||
Dimensions matrix — same dimension axis, varied surface presentation:
|
||||
|
||||
| Dimension | Cardinality | Mode A | Mode B | Result | Verfahrensablauf | Admin |
|
||||
|---|--:|---|---|---|---|---|
|
||||
| `forum` (jurisdiction) | 4 | top-chip filter | R2 | trigger-card badge | — (anchored by PT) | search facet |
|
||||
| `proceeding_type` | 23 | top-chip filter | R3 (auto-skip on single) | trigger-card chip | chip strip (the anchor) | dropdown filter |
|
||||
| `event_kind` | 5 | top-chip filter | R1 | trigger-card badge | — (in cards) | search facet |
|
||||
| `primary_party` | 5 | top-chip filter | R5 (when needed) | per-rule chip | swimlane column / per-card | — |
|
||||
| `priority` | 4 | — | — | group header | view-mode toggle + card style | column |
|
||||
| `condition_expr` (gating) | bool | — | — | conditional group | greyed cards + flag strip | rule editor field |
|
||||
| `is_spawn` | bool | hidden (atlas filter) | hidden | "⇲ Verfahren öffnen" CTA | leaf with ⇲ icon | column |
|
||||
| `is_court_set` | bool | — | — | "vom Gericht" badge | greyed-date card | column |
|
||||
| `parent_id` (chain depth) | derived | "Folgen: N" count | — | depth-1 only (children of anchor) | depth-N indentation / tree walk | "abhängig von" chip |
|
||||
| selection state (scenario_flags `rule:<uuid>`) | per-rule | — | — | checkbox (write-back) | `[Aufnehmen]`/`[Entfernen]` chips | — |
|
||||
| scenario flags (named: with_ccr, with_amend, …) | 3 | — | — | bound checkboxes (read-only) | flag strip (canonical edit surface) | rule editor field |
|
||||
| view-mode (detail level) | 3 | — | — | — (always "selected") | top toggle | — |
|
||||
| `trigger_date` | date | result view input | result view input | top of card | per-page input | — |
|
||||
|
||||
**Reading the matrix.** Every dimension lives at least two surfaces over. The user's mental model has to translate "the proceeding chip on Verfahrensablauf" to "R3 in Mode B" to "the proceeding filter strip in Mode A" — three names, same dimension. Same for forum, event_kind, party.
|
||||
|
||||
This is the friction m's framing pointed at: **the dimensions are shared, but the surface vocabulary is not.**
|
||||
|
||||
---
|
||||
|
||||
## §3 Consolidation proposal
|
||||
|
||||
### §3.1 The honest answer first
|
||||
|
||||
Of the 6 surfaces:
|
||||
|
||||
- **2 stay separate, correctly** — `/admin/procedural-events` (editorial audience) and `/projects/{id}` Verlauf + SmartTimeline (per-Akte actuals). They serve different question shapes and audiences. Cross-link liberally; do not merge.
|
||||
- **4 are candidates for unification** — Fristenrechner Mode A + Mode B + result + Verfahrensablauf. Same underlying data, same dimensions, two zoom levels on one graph. Today they sit at two URLs (`/tools/fristenrechner` + `/tools/verfahrensablauf`) with separate filter vocabularies.
|
||||
|
||||
### §3.2 The unified surface: `/tools/procedures`
|
||||
|
||||
**Proposal:** consolidate the 4 reader surfaces into one page at `/tools/procedures` (the more general name; both "Fristenrechner" and "Verfahrensablauf" are sub-modes inside).
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ /tools/procedures │
|
||||
│ ┌─ Akte / kontextfrei ─┐ ┌─ Filterleiste ────────────────────────────┐│
|
||||
│ │ HL-2024-001 ▼ ohne │ │ Forum • Verfahren • event_kind • Partei ││
|
||||
│ └──────────────────────┘ └───────────────────────────────────────────┘│
|
||||
│ ┌─ Wie willst du einsteigen? ──────────────────────────────────────────┐│
|
||||
│ │ (•) Verfahren wählen ( ) Direkt suchen ( ) Geführt ( ) Aus Akte ││
|
||||
│ └─────────────────────────────────────────────────────────────────────┘│
|
||||
│ ┌─ Ausgabe ── (Anzeige: Gewählt) ──────────────────────────────────────┐│
|
||||
│ │ Either: TREE (proceeding-anchored) ││
|
||||
│ │ │ 📥 Klageerhebung [claimant · M] ││
|
||||
│ │ │ ├─ Klageerwiderung [defendant · M] ││
|
||||
│ │ │ │ └─ Replik [claimant · M · ?with_ccr] ││
|
||||
│ │ │ ├─ Widerklage [defendant · O · ?with_ccr] ││
|
||||
│ │ │ └─ ⇲ Berufungsverfahren öffnen [SPAWN] ││
|
||||
│ │ ││
|
||||
│ │ Or: LINEAR (event-anchored, after locking) ││
|
||||
│ │ │ 🎯 Klageerwiderung (defendant, 2026-04-01) ││
|
||||
│ │ │ ───────────────────────────────────────────── ││
|
||||
│ │ │ Pflicht: Replik (1 Monat) ☑ ││
|
||||
│ │ │ Empfohlen: Vorl. Einwendungen ☑ ││
|
||||
│ │ │ Optional: … ││
|
||||
│ │ │ Bedingt: … ││
|
||||
│ │ │ [In Akte speichern] ││
|
||||
│ │ ││
|
||||
│ │ Pivot: every card has "Im Ablauf zeigen" ↔ "Folge-Fristen anzeigen" ││
|
||||
│ └─────────────────────────────────────────────────────────────────────┘│
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The page carries **one URL**, **one filter strip**, **one Akte picker**, **one selection-state store** (scenario_flags), **one view-mode toggle**, and **two output shapes** the user can toggle between:
|
||||
|
||||
1. **Tree output** (proceeding-anchored): the current Verfahrensablauf rendering — every rule of a proceeding, depth-indented via `parent_id`, with per-rule chips for selection and the three view-modes (Nur Pflicht / Gewählt / Alle Optionen).
|
||||
2. **Linear output** (event-anchored): the current Mode A/B result view — sticky trigger card + 4 priority groups of follow-ups + write-back footer.
|
||||
|
||||
The **entry mode** selects *which output you land on*:
|
||||
- "Verfahren wählen" + chip → tree of that proceeding.
|
||||
- "Direkt suchen" + search → linear follow-ups of the picked event.
|
||||
- "Geführt" wizard → linear follow-ups of the wizarded event.
|
||||
- "Aus Akte" → tree of the Akte's proceeding, with scenario_flags pre-loaded.
|
||||
|
||||
The two outputs **share** the filter strip, the Akte context, the scenario state, the per-card UI vocabulary. Cross-pivoting is one click: from any rule card in the tree, "Folge-Fristen anzeigen" pivots to linear-from-that-anchor; from the linear view, "Im Ablauf zeigen" pivots back to the tree with the anchor highlighted.
|
||||
|
||||
### §3.3 Alternative — keep the URLs split, tighten alignment
|
||||
|
||||
The *minimum* unification, if m balks at folding two pages into one: keep `/tools/fristenrechner` and `/tools/verfahrensablauf` as distinct URLs but:
|
||||
|
||||
- Standardise the filter strip vocabulary (same chip names, same order, same colour coding).
|
||||
- Share the entry-mode dropdown / tab UI components.
|
||||
- Mutual deep-links: every result-view row has "Im Ablauf zeigen" → Verfahrensablauf URL with anchor; every Verfahrensablauf tree node has "Folge-Fristen" → Fristenrechner URL with event locked.
|
||||
- Selection state already shared via `projects.scenario_flags` from atlas P0.
|
||||
|
||||
This is the conservative path. It preserves URL stability but accepts that "which tool for which question" remains a learned concept rather than a single-doorway tool.
|
||||
|
||||
### §3.4 Inventor's recommendation
|
||||
|
||||
**Unify (§3.2)** — m's framing ("preferably in a unitary tool") + the dimension matrix showing 6+ shared filters argue strongly. The cost of two URLs is two filter vocabularies, two mental models, two cmd-K targets. Folding them is a few weeks of frontend work after atlas's P3 lands; the data layer is already ready.
|
||||
|
||||
The risk is *not* the merge — it's the rename. `/tools/fristenrechner` is the name lawyers know. Naming choices in §11.Q2 below.
|
||||
|
||||
---
|
||||
|
||||
## §4 Multi-dimensional filter spec
|
||||
|
||||
Where each dimension lives in the unified surface. Categories: **anchor** (the thing the output is rooted on), **filter** (narrows what's rendered), **qualifier** (refines the anchor), **display** (per-card affordance), **state** (persists across surface).
|
||||
|
||||
| Dimension | Category | Where (entry mode) | Where (tree output) | Where (linear output) |
|
||||
|---|---|---|---|---|
|
||||
| `forum` | filter | top strip chip | top strip chip (narrows PT chips) | top strip chip + trigger-card badge |
|
||||
| `proceeding_type` | anchor (tree) / filter (linear) | "Verfahren wählen" chip-grid; "Direkt"/"Geführt" filter strip | The anchor — header above tree | trigger-card chip |
|
||||
| `event_kind` | filter | "Geführt" R1; Mode A filter chip | per-card icon | per-rule row icon |
|
||||
| `primary_party` | filter | "Geführt" R5; Mode A filter chip; Akte (`our_side`) | swimlane column OR per-card chip (view-mode-dependent) | per-rule chip + Gegenseitig badge |
|
||||
| `priority` | display | — | view-mode toggle + per-card style | group header (4 groups) |
|
||||
| `condition_expr` (gating) | state | — | greyed + flag-strip activation | conditional group + read-only checkbox |
|
||||
| `is_spawn` | display | filtered out of pickers (atlas §2.2) | leaf with ⇲ icon | "⇲ Verfahren öffnen" CTA, no date |
|
||||
| `is_court_set` | display | — | greyed-date card with "vom Gericht" badge | "vom Gericht" badge, no date |
|
||||
| `parent_id` (chain depth) | display | — | tree indentation | hidden (linear shows depth-1 only) |
|
||||
| selection state `rule:<uuid>` | state | — | `[Aufnehmen]`/`[Entfernen]` chips | checkbox (write-back) |
|
||||
| named scenario flags (`with_ccr`, …) | state | — | flag strip above tree | read-only mirror in conditional group |
|
||||
| view-mode (detail level) | display | — | three-way segmented top toggle | — (always Gewählt) |
|
||||
| `trigger_date` | anchor (linear) / display (tree) | linear: result view date input | tree: optional per-page input, defaults today | linear: top-card date input (canonical) |
|
||||
| `is_cross_party` (derived) | display | — | muted style + Gegenseitig badge | muted style + Gegenseitig badge |
|
||||
|
||||
**Design principle:** dimensions stay in the **same chip / control**, regardless of which output is showing. The user learns the filter strip once. The output reacts.
|
||||
|
||||
---
|
||||
|
||||
## §5 Alternative paths spec — four ways to derive at the same outcome
|
||||
|
||||
m's "alternative means to derive at what you want" rendered explicitly. All four paths converge on the same underlying rule-set view; only the *entry experience* differs.
|
||||
|
||||
```
|
||||
Path 1: PROCEEDING-FIRST (German-lawyer approach)
|
||||
"Ich öffne ein UPC-Verletzungsverfahren — wie sieht das aus?"
|
||||
1. Page open → "Verfahren wählen" tab (default if no Akte)
|
||||
2. Chip-grid: pick `upc.inf.cfi`
|
||||
3. Tree renders. User sees full ablauf.
|
||||
4. (Optional) Click rule → drill to linear follow-ups of that rule.
|
||||
|
||||
Path 2: EVENT-FIRST (UPC-lawyer / paralegal)
|
||||
"Das Gericht hat einen Hinweisbeschluss erlassen — was bedeutet das?"
|
||||
1. Page open → "Direkt suchen" tab
|
||||
2. Filter strip: Forum=UPC + event_kind=order
|
||||
3. Search "Hinweis" → 3 hits
|
||||
4. Click `upc.inf.cfi.cmo_review` → linear follow-ups (Antrag CMO-Überprüfung etc.)
|
||||
|
||||
Path 3: GUIDED (trainee PA)
|
||||
"Es ist etwas passiert; ich weiß nicht wie die Frist heißt"
|
||||
1. Page open → "Geführt" tab
|
||||
2. R1 event_kind: filing
|
||||
3. R2 forum: UPC (or skipped if R1 narrowed)
|
||||
4. R3 proceeding_type: upc.inf.cfi (auto-skipped if only one)
|
||||
5. R4 event chip-strip: pick the relevant event
|
||||
6. R5 perspective (only if follow-ups differ)
|
||||
7. Linear follow-ups render.
|
||||
|
||||
Path 4: AKTE-FIRST (senior partner / paralegal with project context)
|
||||
"Auf HL-2024-001 ist heute Klageerwiderung zugegangen — was nun?"
|
||||
1. Page open → Akte picker → HL-2024-001
|
||||
2. Page auto-derives `proceeding_type` + `our_side` + `scenario_flags`
|
||||
3. Default landing: TREE of upc.inf.cfi, scenario flags pre-loaded
|
||||
4. Click "Klageerwiderung" card → linear follow-ups, write-back footer enabled
|
||||
5. Tick rules → "In Akte speichern" → POST /api/projects/.../deadlines/bulk
|
||||
```
|
||||
|
||||
All four paths share:
|
||||
- the same filter strip (forum / proceeding / event_kind / party — values persist across paths in URL)
|
||||
- the same view-mode toggle (when tree is showing)
|
||||
- the same scenario_flags (when Akte is loaded)
|
||||
- the same per-card vocabulary (`[Aufnehmen]` / `[Entfernen]` / `[Bedingt]` / `[Gegenseitig]` / `⇲`)
|
||||
- the same cross-pivot affordance ("Im Ablauf zeigen" / "Folge-Fristen anzeigen")
|
||||
|
||||
The user can switch paths mid-task: started in Path 4, lost in the Akte's tree, jump to Path 2 (search) to find a specific event, then jump back to the tree via the cross-pivot. Tab state preserved.
|
||||
|
||||
---
|
||||
|
||||
## §6 Selection state spec
|
||||
|
||||
Already locked by atlas's `design-deadline-system-revision-2026-05-27.md` §2.3 + §2.4a. Briefly, in the unified tool's context:
|
||||
|
||||
- **Named flags** (`with_ccr`, `with_amend`, `with_cci`, plus catalog extensions) — top "Szenario-Flags" strip when proceeding is locked. Edits write to `projects.scenario_flags` (Akte) or localStorage (kontextfrei) and dispatch `scenario-flag-changed` CustomEvent. Both tree and linear views listen and re-render.
|
||||
- **Per-rule deviations** (`rule:<uuid> = true|false`) — `[Aufnehmen]` / `[Entfernen]` chips on each tree card; identical to the result-view checkboxes in linear mode (linear's "checked" state literally is `rule:<uuid>=true`).
|
||||
- **Default population:** none on project create. The flat-map only stores deviations from priority defaults.
|
||||
|
||||
**Cross-view sync.** When the user toggles "Klageerwiderung" in linear write-back, the tree's corresponding card immediately re-renders with the chip state updated — same CustomEvent. When the user clicks `[Aufnehmen]` on the tree's "Antrag CMO-Überprüfung", switching to linear shows it pre-checked.
|
||||
|
||||
**Kontextfrei vs Akte:** kontextfrei writes to `localStorage["paliad.verfahren.scenario.<proceeding_code>"]` (per-proceeding key — different proceedings have different selection sets, matching the existing `paliad.verfahrensablauf.scenario.*` convention). Akte writes to the DB column.
|
||||
|
||||
---
|
||||
|
||||
## §7 Sequence visualisation
|
||||
|
||||
Three candidate shapes. Issue brief lists "vertical tree, horizontal timeline, collapsible groups, per-priority lanes" as options. Today's surfaces use:
|
||||
|
||||
| Shape | Where today | What it does well | What it does poorly |
|
||||
|---|---|---|---|
|
||||
| **3-column swimlane** (Unsere / Gericht / Gegenseite) | Verfahrensablauf default view | Reads side-of-table cleanly; left = our action, right = opponent's | Dense at depth; mobile-hostile; cross-party hops zig-zag across columns |
|
||||
| **Single-column linear timeline** | Verfahrensablauf alt view | Mobile-friendly; chronological | Loses parent-chain structure visually |
|
||||
| **Vertical tree (indented)** | atlas P3 proposal; ASCII trees in design docs | Shows chain depth; clean on desktop + mobile; matches mental model | Less easy to read date-order at a glance |
|
||||
| **Priority groups** | Mode A/B result view | Highlights what's urgent | Loses sequence; only works for one anchor |
|
||||
|
||||
**Recommendation:** make the tree the canonical desktop shape (atlas P3); the 3-column swimlane becomes an optional view ("Schwimmbahnen") when the user wants side-comparison; mobile defaults to the single-column linear timeline collapsed by depth. Per-priority groups stay as the linear-output sub-shape (only when an event is locked).
|
||||
|
||||
This is a strict superset of today's options — no shape is removed.
|
||||
|
||||
**Concrete rendering rules:**
|
||||
- Each card carries 4 axes: priority, selection state, conditional gate, cross-party. Visual style composes them: priority = colour stripe; selection = solid vs dotted border; conditional-flag-off = greyed; cross-party = muted + Gegenseitig badge.
|
||||
- Spawn rules render as **leaf chips** with `⇲` icon. In Akte mode, the chip becomes a CTA: click → create child project of the spawn target's PT, link via `parent_project_id`. Already wired via `/api/projects/{id}/timeline/counterclaim` for the CCR case.
|
||||
- Court-set rules carry a "vom Gericht bestimmt" badge in place of the computed date. The card is still rendered (it's still part of the ablauf), just without a date column entry.
|
||||
- Chain depth is rendered via **indentation + connector lines**, capped at depth-5 (today's max is 4 for the upc.inf.cfi CCR branch). Beyond depth-3 the lines fold to a "in 3 weiteren Schritten" collapsible hint — keeps long chains from running off the screen.
|
||||
|
||||
---
|
||||
|
||||
## §8 Context preservation when drilling
|
||||
|
||||
m: "when a user drills into a single rule from one entry, how to keep the surrounding sequence visible".
|
||||
|
||||
Three options:
|
||||
|
||||
1. **Split-pane** — left: tree of the proceeding; right: linear follow-ups of the focused rule. Tree highlights the focused node.
|
||||
2. **Inline drawer** — clicking a rule expands an inline drawer beneath it showing follow-ups; tree stays in place; drawer is collapsible.
|
||||
3. **Breadcrumb pivot** — single output shape at a time; pivoting linear→tree shows a breadcrumb chain "upc.inf.cfi > Klageerhebung > Klageerwiderung > [Klageerwiderung is here]"; tree renders with the breadcrumb highlighted.
|
||||
|
||||
**Inventor pick: option 2 (inline drawer)** for desktop, **option 3 (breadcrumb)** for mobile. Reasons:
|
||||
- Split-pane (option 1) is the cleanest visualisation but burns half the screen on context the user might not want. Optional via a "Zwei Spalten" toggle for power users.
|
||||
- Inline drawer (option 2) keeps everything in one column with progressive disclosure; the user scrolls through the tree, expands the rule they care about, sees follow-ups, collapses, moves on. Matches how the existing `<details>` flow already works on /admin pages.
|
||||
- Breadcrumb (option 3) is the only sensible mobile pattern — split panes can't, drawers nest awkwardly.
|
||||
|
||||
When in the inline drawer, the focused rule's follow-ups render in the same priority-group shape as the linear view; the per-rule `[Aufnehmen]` / `[Entfernen]` chips work identically; write-back to Akte works identically. The drawer is the linear view embedded.
|
||||
|
||||
---
|
||||
|
||||
## §9 Mobile / narrow viewport
|
||||
|
||||
Today's Verfahrensablauf 3-column swimlane is desktop-heavy. The tree-output proposal collapses better, but still needs careful narrow-viewport rules.
|
||||
|
||||
Layout breakpoints:
|
||||
|
||||
- **< 640px (phone):** single-column. Filter strip collapses to a sticky "Filter" button → bottom-sheet panel with the same chips. Entry-mode picker collapses to a sticky dropdown ("Verfahren wählen ▾"). Tree renders with no indentation lines; depth-N items get a leading "└ ".indent decoration only. Per-card chips ([Aufnehmen] etc.) move to a "..." menu on each card. View-mode toggle moves to a single icon button cycling Pflicht→Gewählt→Alle.
|
||||
- **640-1024px (tablet):** filter strip stays at top but wraps; entry-mode picker becomes tabs; tree renders with proper indentation. View-mode toggle and Akte picker stay inline.
|
||||
- **> 1024px (desktop):** full layout per §3.2. Optional "Zwei Spalten" toggle for the split-pane variant (§8.1).
|
||||
|
||||
**Mobile drill-down (§8 option 3):** clicking a card on phone pushes a new route `?focus=<rule_id>` and renders the linear follow-up view full-screen with a back-arrow breadcrumb. Back arrow restores the tree at the previous scroll position.
|
||||
|
||||
**Filter persistence across viewports:** URL params survive resize, the bottom-sheet panel reflects the same state as the desktop top-strip — same state machine.
|
||||
|
||||
---
|
||||
|
||||
## §10 Worked examples — 3 personas
|
||||
|
||||
### §10.1 Trainee PA — "what's next after Klageerwiderung?"
|
||||
|
||||
Persona: Anna, 6-month PA trainee, doesn't know which proceeding "Klageerwiderung" belongs to.
|
||||
|
||||
1. Opens `/tools/procedures`. No Akte. Lands on "Verfahren wählen" tab (default) but she doesn't want to browse — she wants to find one event.
|
||||
2. Clicks "Geführt" tab. R1: was hat sich ereignet → **filing**. R2: forum → **UPC**. R3: proceeding_type → **upc.inf.cfi** (the only filing-forum option that has "Klage" in its events). R4: event chip-strip → **Klageerwiderung**. R5: perspective — wizard asks because the follow-ups differ → **defendant**.
|
||||
3. Lands on linear follow-ups view. Sees: Pflicht: Replik (claimant, 1 Monat); Empfohlen: Vorl. Einwendungen; Optional: Widerklage; Bedingt: Antrag auf Patentänderung (greyed, with_amend off).
|
||||
4. Wants to know: where does Klageerwiderung sit in the bigger picture? Clicks "Im Ablauf zeigen". Tree renders, with Klageerwiderung highlighted; she sees the SoC root above it, the CCR branch beside it, the cascade of Replik/Duplik below.
|
||||
5. Anna learns the shape. Back to her task — she copies the Replik date into her notes.
|
||||
|
||||
### §10.2 Senior partner — brief client on full upc.inf.cfi ablauf
|
||||
|
||||
Persona: Dr. Becker, senior litigator, briefing a client on Friday about a new UPC matter that hasn't been filed yet.
|
||||
|
||||
1. Opens `/tools/procedures`. No Akte (matter not in Paliad yet).
|
||||
2. Tab: "Verfahren wählen" → clicks `upc.inf.cfi` chip.
|
||||
3. Tree renders. View-mode at default **Gewählt** — shows mandatory + recommended. Becker flips to **Alle Optionen** to brief the client on the full set including conditional branches.
|
||||
4. CCR branch greyed (with_ccr off by default in kontextfrei). Becker ticks `with_ccr` in the flag strip. Tree re-renders; CCR branch lights up.
|
||||
5. Becker wants to print this. Cmd-P / "PDF exportieren" (out of scope for this design but flagged). Tree-with-current-state renders cleanly because nothing depends on viewport hover.
|
||||
6. After the call, Becker creates the Akte in Paliad. Returns to the page with `?project=HL-2025-031`. Same state preserved into the new project — `scenario_flags = {with_ccr: true}` writes to DB on first PATCH.
|
||||
|
||||
### §10.3 Paralegal — enter CMS-received Hinweisbeschluss into Akte
|
||||
|
||||
Persona: Sandra, paralegal, daily CMS triage. Today: a Hinweisbeschluss arrived on HL-2024-001 (upc.inf.cfi).
|
||||
|
||||
1. Opens `/tools/procedures` → picks HL-2024-001 from Akte picker.
|
||||
2. Page auto-derives proceeding = upc.inf.cfi, our_side = claimant, scenario_flags = {with_ccr: true} (already on this matter).
|
||||
3. Default landing: TREE of upc.inf.cfi, scenario state loaded. Sandra sees the full ablauf with the matter's actual selections.
|
||||
4. She knows the event is a Hinweisbeschluss → uses the search box (top right corner of the unified page, available in any mode) → types "Hinweis".
|
||||
5. Search popover shows 1 result: `upc.inf.cfi.cmo_review` (Antrag auf CMO-Überprüfung). Sandra clicks → tree scrolls + highlights the rule; drawer expands beneath it showing the follow-up rule `upc.inf.cfi.cmo_review_resp` with computed date (today + R.333.2 duration).
|
||||
6. Drawer footer has "In Akte speichern" button. Sandra ticks the follow-up rule, sets trigger date = today, audit reason = "CMS-Hinweisbeschluss eingegangen", saves.
|
||||
7. Deadline inserted into HL-2024-001. Sandra returns to her queue.
|
||||
|
||||
Total clicks: 5 (open tool, search, click result, tick, save). No mode-switching, no URL-jumping, no two-tab juggling.
|
||||
|
||||
---
|
||||
|
||||
## §11 Migration plan
|
||||
|
||||
Five-slice train. Each slice ships as one PR. P0 is the model layer atlas already designed; everything below is surface-layer on top.
|
||||
|
||||
| Slice | Mig | What ships | Reversible? |
|
||||
|---|---|---|---|
|
||||
| **U0 — Shared filter-strip component** | — | Extract Mode A's filter strip + Verfahrensablauf's filter chips into one `<FilterStrip>` component used by both pages (still two URLs). Standardise chip names, order, colour. Cross-link buttons in both directions. | Yes — code-only |
|
||||
| **U1 — New unified page at `/tools/procedures`** | — | New route + page shell. Carries Akte picker, filter strip, entry-mode tab control. Initially shows TREE view only (lifts from /tools/verfahrensablauf without removing the original). | Yes — route addition |
|
||||
| **U2 — Linear output + drawer + cross-pivot** | — | Embed the Mode A/B result-view rendering as an inline drawer in U1. Cross-pivot "Im Ablauf zeigen" / "Folge-Fristen anzeigen" wired. Search box top-right available in all modes. | Yes — code-only |
|
||||
| **U3 — Entry mode tabs (Direkt / Geführt / Verfahren / Aus Akte)** | — | Wire Mode A search + Mode B wizard as additional entry tabs on `/tools/procedures`. All four entry paths converge on either tree or linear output depending on what the user picked. | Yes — code-only |
|
||||
| **U4 — Redirects + deprecation** | — | **Per m's Q11 (§11.5): hard cut, no dual-shipping.** `/tools/fristenrechner?…` → 301 → `/tools/procedures?mode=direkt&…` (preserve query params). `/tools/verfahrensablauf?…` → 301 → `/tools/procedures?mode=ablauf&…`. Sidebar + cmd-K updated in the same PR. Old `*.tsx` files deleted. No `?legacy=1` escape. | Reversible only by revert PR |
|
||||
|
||||
**Constraint:** U0-U3 are independent of atlas P0-P3 and can ship in parallel (different files). U4 should land after atlas P3 (`/tools/verfahrensablauf` tree) so the redirect target carries the full tree shape from day 1. If atlas P3 slips, U4 stays in the queue.
|
||||
|
||||
**No DB migration.** All state lives in `projects.scenario_flags` (atlas P0) + localStorage. URL param schema is additive.
|
||||
|
||||
**Pre-deploy gauntlet:** kontextfrei + Akte modes × each entry path × tree + linear output = 16 path/output combinations. Plus mobile narrow viewport for all 4 entry paths. Plus URL deep-link restore for each saved-state shape.
|
||||
|
||||
---
|
||||
|
||||
## §11.5 m's decisions (2026-05-27)
|
||||
|
||||
All 12 questions answered via `AskUserQuestion` in 3 batches of 4. 9 picks on-recommendation; 3 diverged from the inventor pick. Decisions below; raw question list preserved in §12 as the historical record.
|
||||
|
||||
### Tier 1 — does the unification happen at all & what does it look like?
|
||||
|
||||
- **Q1 (Unify vs Align): Full unification — one URL.** [= recommendation] **Locks §3.2.** The four reader surfaces (Fristenrechner Mode A + Mode B + result + Verfahrensablauf) fold into a single page with entry-mode tabs and two output shapes. Aligned-but-separate (§3.3) is dropped from the plan.
|
||||
- **Q2 (URL/Name): `/tools/procedures` — English.** [≠ recommendation; m diverged from inventor's `/tools/verfahren` pick] m's verbatim:
|
||||
> just one, but english name - call it tools/procedures ...
|
||||
**Locks §3.2 + §11 (renames `/tools/verfahren` → `/tools/procedures` throughout).** Rationale: the codebase convention is "English in code, German in UI" (project CLAUDE.md: "All code, table names, Go types, service names, URL paths, API endpoints, file names — English"). `/tools/procedures` follows that rule; the inventor's `/tools/verfahren` strawman broke it. The German sidebar entry stays "Verfahren & Fristen" (Q12) — the URL is the developer surface, the label is the user surface.
|
||||
- **Q3 (Default entry / search shape): All entry modes as tabs + text search combined with dimension filters.** [≠ recommendation; m reframed the question] m's verbatim:
|
||||
> yeah, different tabs, right?! I think we need to have all of the named ones. And we can combine a text search with filters for the dimensions of the event
|
||||
**Locks §3.2 + §5 + reshapes §4.** All four named entry paths (Verfahren wählen / Direkt suchen / Geführt / Aus Akte) are visible as tabs simultaneously. The search box is part of the filter strip at the top of the page and composes with the chip filters (Forum / Verfahren / event_kind / Partei) at all times. The "Direkt suchen" tab still exists for the explicit search-first workflow, but the search input is also live in tree mode (top-of-page filter strip) — meaning a user browsing a proceeding can refine the tree's rendered set by typing into the same search box that filters Mode A. The default landing question ("which tab is active first") becomes a secondary concern: any of the four tabs is one click away. Default behaviour: first tab in the strip ("Verfahren wählen") is selected on cold open with no Akte, but the URL preserves the user's last-active tab if returning via a deep-link.
|
||||
- **Q4 (Akte default behaviour): TREE of the Akte's proceeding.** [= recommendation] **Locks §3.2 + §10.3.** Akte picker triggers auto-derivation of `proceeding_type` + `our_side` + `scenario_flags`, lands on the tree view with the matter's state loaded.
|
||||
|
||||
### Tier 2 — tree mechanics + visual style
|
||||
|
||||
- **Q5 (Tree shape): Both vertical tree + 3-column swimlane, with a toggle.** [= recommendation] **Locks §7.** Default desktop = vertical indented tree (clean chain depth, mobile-translatable); "Schwimmbahnen" toggle reveals the 3-column swimlane (Unsere Seite | Gericht | Gegnerseite) for side-comparison. Toggle state in `localStorage["procedures:tree_shape"]` (per-user, not per-Akte).
|
||||
- **Q6 (Cross-pivot): Inline drawer beneath the card.** [= recommendation] **Locks §8.** Clicking a rule card expands an inline drawer with the linear follow-up view (priority groups + write-back footer). Tree stays in place above. Multiple drawers can be open. Drawer carries the same per-rule selection chips as the tree, so writes propagate to scenario_flags identically.
|
||||
- **Q7 (Search position): Always-visible search bar in the filter strip.** [= recommendation] **Locks §4 + §3.2.** Search input lives in the top filter strip next to the chip groups; available in every output mode. Composes with chip filters via AND semantics (chip filters narrow the corpus, search ranks within the narrowed set). This is what m's Q3 reframe asked for.
|
||||
- **Q8 (Cross-party rows in tree): Show with Gegenseitig badge + muted style.** [= recommendation] **Locks §7.** Tree renders the full graph including opponent rows, muted + badged consistently with the linear view. Identical to atlas's locked treatment for the linear view (`design-deadline-system-revision-2026-05-27.md` §2.4).
|
||||
|
||||
### Tier 3 — mobile + migration
|
||||
|
||||
- **Q9 (Mobile tree shape): Single-column with `└` indent decorator.** [= recommendation] **Locks §9.** Phone-narrow render keeps depth via leading-marker indentation; SVG connector lines drop; cards stack vertically. Resize back to tablet/desktop restores the full tree with connector lines.
|
||||
- **Q10 (Mobile drill): Push new route with breadcrumb back.** [= recommendation] **Locks §9.** Clicking a card on phone pushes `?focus=<rule_id>` and renders the full-screen linear follow-up view with a back-arrow breadcrumb. Tree scroll position preserved on back. Inline drawer is desktop-only.
|
||||
- **Q11 (Migration window): Hard cut — no dual-shipping window.** [≠ recommendation; m diverged from "2 weeks 302"] m's verbatim:
|
||||
> not at all
|
||||
**Locks §11 (rewrites the U4 slice).** When `/tools/procedures` ships, `/tools/fristenrechner` and `/tools/verfahrensablauf` flip directly to redirects (301 permanent, no `?legacy=1` escape hatch). Sidebar entries swap to the new entry in the same release. cmd-K palette swaps to the new entry. No 2-week dual-shipping window. Rationale (interpreted): the audience is internal HLC lawyers (~50 users, all on the same release rhythm). A 2-week dual ship adds complexity for almost no benefit; m would rather flip and fix any broken bookmark via direct comm.
|
||||
- **Q12 (Sidebar): One entry "Verfahren & Fristen".** [= recommendation] **Locks §11.** Single sidebar item (German label) pointing at `/tools/procedures` (English URL). cmd-K palette updated to one entry "Verfahren & Fristen" with `/tools/procedures` as the action.
|
||||
|
||||
### §11.5.1 Changes triggered by m's divergences
|
||||
|
||||
Three picks changed the design beyond ratification. Summarised here so the coder reads the *current* design, not the pre-grilling strawman.
|
||||
|
||||
1. **URL rename `/tools/verfahren` → `/tools/procedures`** (Q2). Replaces every URL reference in §3.2, §4, §5, §10, §11, §14. Page name in the codebase: `frontend/src/procedures.tsx`. Sidebar label stays German ("Verfahren & Fristen"). Internal Go types stay English (`ProceduresPage`, etc.).
|
||||
2. **All-tabs-visible + search-as-filter** (Q3). Replaces the strawman's "pick a single default tab" wording in §3.2 + §4. The unified page now renders all four entry-mode tabs at all times (Verfahren wählen / Direkt suchen / Geführt / Aus Akte). The search box is in the filter strip alongside the chip filters and composes with them in every output mode (tree + linear). The "Direkt suchen" tab remains, but its function shifts: it's the *search-first cold start* tab; once the user has any output (tree or linear), the search box at the top of the page is the canonical re-narrowing affordance. The wizard tab ("Geführt") and the Akte tab still exist as explicit workflows.
|
||||
3. **Hard cut, no dual-ship** (Q11). Slice U4 in §11 is rewritten: 301 redirects on `/tools/fristenrechner` + `/tools/verfahrensablauf` to the new page; no `?legacy=1` escape; the old `*.tsx` files are deleted in the same PR. Bookmarks resolve via the 301; no in-product affordance points at the legacy URL after the merge.
|
||||
|
||||
### §11.5.2 What stays unchanged
|
||||
|
||||
The other 9 picks (Q1, Q4-Q10, Q12) ratified the inventor proposal. The full unification at a single URL with two output shapes (tree + linear drawer), four entry paths, shared selection state via `projects.scenario_flags`, vertical tree + swimlane toggle, mobile `└` decorator + breadcrumb-back drill-down, single sidebar entry — all locked as drafted in §1-§11.
|
||||
|
||||
---
|
||||
|
||||
## §12 Open questions for m
|
||||
|
||||
Twelve questions, batched 4 + 4 + 4 for `AskUserQuestion`. The first batch is **must-answer** (decides the unification's existence + URL shape); the second is **shape** (tree mechanics + visual style); the third is **mobile + migration** (operational).
|
||||
|
||||
Will be answered via `AskUserQuestion` per the inventor SKILL; m's picks fold back into a `§12.5 m's decisions (2026-05-27)` section at the top of this file before the "DESIGN READY FOR REVIEW" signal.
|
||||
|
||||
### Batch 1 — does the unification happen at all & what does it look like?
|
||||
|
||||
- **Q1 (Unify vs Align):** Fold the four reader surfaces into `/tools/procedures` (full unification §3.2), or keep `/tools/fristenrechner` and `/tools/verfahrensablauf` as separate URLs and just tighten alignment (§3.3)?
|
||||
- **Q2 (Naming):** If unifying — what's the page name? `/tools/verfahren` (generic German, my original pick), `/tools/fristenrechner` (lawyers know this one — repurpose as the supermarket), or `/tools/ablauf` (closest to what it does)? (m diverged with `/tools/procedures` — see §11.5.)
|
||||
- **Q3 (Default entry mode):** When the user opens `/tools/procedures` with no URL params and no Akte, which entry tab is active? "Verfahren wählen" (browse, my pick), "Direkt suchen" (power), "Geführt" (onboarding).
|
||||
- **Q4 (Akte default behaviour):** When user picks an Akte from the picker, default landing — TREE of the Akte's proceeding (my pick) or "remember last view" per-user.
|
||||
|
||||
### Batch 2 — tree mechanics + visual style
|
||||
|
||||
- **Q5 (Tree shape):** Desktop tree rendering — vertical indented tree (my pick), 3-column swimlane (current Verfahrensablauf default), or both with a "Schwimmbahnen" toggle.
|
||||
- **Q6 (Cross-pivot affordance):** When clicking a rule card in the tree to see its follow-ups — inline drawer beneath the card (my pick), split-pane (tree left + linear right), or full-page push (replaces tree, breadcrumb back).
|
||||
- **Q7 (Mode A search location):** The free-text "Direkt suchen" entry — only as a top-tab (my pick, with a small search icon always available in tree mode), always-visible search bar at top, or only inside the "Direkt" tab.
|
||||
- **Q8 (Cross-party rows in linear):** Atlas locked "show with Gegenseitig badge, unchecked default, unconditionally excluded from write-back". In tree mode, same treatment (my pick) or hide cross-party rows entirely by default and surface via "Gegenseite einblenden" toggle.
|
||||
|
||||
### Batch 3 — mobile + migration
|
||||
|
||||
- **Q9 (Mobile tree shape):** On phones (< 640px) — single-column indented list with leading "└" decorator (my pick), single-column flat list (no indentation), or chronological-timeline view (auto-pivots when narrow).
|
||||
- **Q10 (Mobile drill-down):** Clicking a card on phone — push new route with breadcrumb-back (my pick), inline drawer (cramped on small screens), or modal sheet.
|
||||
- **Q11 (Migration window):** After the unified page ships — 2-week dual-shipping with 302 redirects (my pick, matches t-paliad-322 S5 pattern), 1-week, or 4-week.
|
||||
- **Q12 (Sidebar entries):** Sidebar today has "Fristenrechner" + "Verfahrensablauf" as separate items. Post-merge — one entry "Verfahren & Fristen" (my pick), keep both with both → same URL, or pick one ("Fristenrechner" or "Verfahrensablauf") as the canonical name.
|
||||
|
||||
---
|
||||
|
||||
## §13 Out of scope
|
||||
|
||||
- Calculator changes (`pkg/litigationplanner.CalculateRule`). Working.
|
||||
- Editorial backfill (curie owns t-paliad-333 in parallel).
|
||||
- /admin/procedural-events as a read surface — different audience.
|
||||
- /projects/{id} Verlauf — per-Akte actuals; sister tool.
|
||||
- SmartTimeline / `ProjectionService` — per-project read view, downstream consumer.
|
||||
- youpc.org/deadlines — cross-repo snapshot consumer.
|
||||
- Outlook / Calendar sync UI.
|
||||
- PDF export of the tree (mentioned in §10.2 but not designed here).
|
||||
- Bulk-write affordances beyond the existing `/deadlines/bulk` endpoint.
|
||||
- Multi-project comparison views (would belong in SmartTimeline at Patent / Litigation / Client level, not in `/tools/procedures`).
|
||||
- Translation between languages of free-text scenario flag names.
|
||||
|
||||
---
|
||||
|
||||
## §14 Synthesis links
|
||||
|
||||
- mBrian: file as `[synthesis]` linked `triggered_by` t-paliad-334; `related_to` athena's assessment + atlas's deadline-system-revision design + cronus's earlier Fristenrechner overhaul design.
|
||||
- Cross-refs in this repo: `docs/assessment-deadline-system-2026-05-27.md` (athena), `docs/design-deadline-system-revision-2026-05-27.md` (atlas), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26), `docs/design-event-card-choices-2026-05-25.md` (existing per-card choice).
|
||||
- Gitea: m/paliad#151 (this design), m/paliad#149 (atlas Phase 2), m/paliad#146 (cronus 2026-05-26 Fristenrechner overhaul, S1-S6 shipped).
|
||||
- Coder phase (deferred per inventor SKILL): runs after m ratifies via AskUserQuestion. Slice ordering per §11. NOT cronus (parked at "DESIGN READY FOR REVIEW"). A pattern-fluent Sonnet coder picks up U0 first; U1-U3 sequential; U4 gated on atlas P3 landing.
|
||||
@@ -1,495 +0,0 @@
|
||||
# PRD — `docforge`: a modular document-generator engine
|
||||
|
||||
**Task:** t-paliad-349 (m/paliad#157) · **Author:** leibniz (inventor) · **Date:** 2026-05-29
|
||||
**Status:** DESIGN — awaiting head's go/no-go on the coder shift.
|
||||
**Supersedes nothing.** Extends and re-homes the submission generator designed in
|
||||
`docs/design-submission-generator-2026-05-19.md`, `…-v2-2026-05-26.md`, and
|
||||
`docs/design-submission-page-2026-05-22.md`.
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises
|
||||
|
||||
### 0.1 What this is
|
||||
|
||||
m wants the paliad "doc generator" pulled apart into a clean, reusable engine.
|
||||
Verbatim direction (2026-05-29):
|
||||
|
||||
> I want to be able to create and modify word documents, using variables inside
|
||||
> the documents, "editing them live" and preview the results, export in the end.
|
||||
> We should have all that modular to keep it clean. The editor is something else
|
||||
> than the importing, exporting, variable exchange, data fetching etc.
|
||||
>
|
||||
> Currently I can't upload the base document to insert variables into to create a
|
||||
> template — and then later I want to fill the template using data, modifying it
|
||||
> manually where necessary, then exporting.
|
||||
|
||||
Two distinct user surfaces fall out of that:
|
||||
|
||||
- **Authoring** — upload a base `.docx` → place variable slots into it → save as a
|
||||
reusable template. *This is the gap that does not exist today.*
|
||||
- **Generation** — pick a template → bind variables to project data → manually edit
|
||||
where needed (live editor + preview) → export `.docx`.
|
||||
|
||||
### 0.2 Today's state (audited 2026-05-29, verified against the live tree)
|
||||
|
||||
The current submission generator is ~250 KB of Go plus a 115 KB editor bundle:
|
||||
|
||||
- `internal/services/submission_vars.go` — variable resolution across **7 namespaces**
|
||||
(`firm.*`, `today.*`, `user.*`, `project.*`, `parties.*`, `procedural_event.*`
|
||||
+ `rule.*` legacy aliases, `deadline.*`). Resolution is a **push** model: each
|
||||
namespace is a hardcoded `addXxxVars(bag PlaceholderMap, …)` function mutating a
|
||||
shared `map[string]string`. There is **no interface and no registry** — adding a
|
||||
namespace means hand-editing `Build` to call a new function.
|
||||
- `internal/services/submission_merge.go` — placeholder substitution. The regex
|
||||
(line 95, verified) is `\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`.
|
||||
Two-pass: single-run replace inside each `<w:t>`, then
|
||||
cross-run merge for fragmented placeholders. HTML preview wraps `(key,value)` in
|
||||
Private-Use-Area sentinels so `emitTextWithDraftVars` can reconstruct
|
||||
`<span class="draft-var" data-var="key">…</span>` for click-to-jump.
|
||||
- `internal/services/submission_md.go` — Markdown → OOXML runs. `parseInlineSpans`
|
||||
(lines 393–446) tokenises bold/italic and **preserves `{{…}}` verbatim**.
|
||||
- `internal/services/submission_compose.go` — assembles the final `.docx`: unzip base,
|
||||
render each included section's Markdown to OOXML, splice between
|
||||
`{{#section:KEY}}…{{/section:KEY}}` anchors, patch hyperlink rels, repack, then run
|
||||
the placeholder pass.
|
||||
- `internal/services/submission_{draft,section,building_block,base}_service.go` — the
|
||||
draft/section/building-block/base data model + CRUD.
|
||||
- `internal/handlers/submission_{drafts,sections,building_blocks,bases}.go` — the HTTP
|
||||
wire (the 53 KB `submission_drafts.go` is the bulk).
|
||||
- `frontend/src/client/submission-draft.ts` — the editor UI (**one `.ts` bundle; there is
|
||||
no `submission-draft.tsx`** — the brief was wrong on this point).
|
||||
|
||||
**OOXML approach (verified):** pure `archive/zip` + string manipulation of
|
||||
`word/document.xml`. **No third-party docx library** — `go.mod` has none.
|
||||
`lukasjarosch/go-docx` appears *only in a comment* (`submission_merge.go:13`)
|
||||
documenting why it was rejected (it refuses sibling placeholders in one run). The base
|
||||
stays byte-for-byte identical outside the regions we touch.
|
||||
|
||||
**Reference model:** `pkg/litigationplanner/` (t-paliad-292). The package **owns its
|
||||
types** and exposes **interfaces for stateful inputs** (`Catalog`, `HolidayCalendar`,
|
||||
`CourtRegistry`); paliad implements them against Postgres, youpc.org against an embedded
|
||||
JSON snapshot. `doc.go` is the package doc; `types_wire_test.go` locks the JSON contract.
|
||||
**docforge mirrors this packaging discipline exactly.**
|
||||
|
||||
### 0.3 Premise correction (load-bearing)
|
||||
|
||||
The brief lists **two consumers in scope: paliad + upc-commentary**. Verified against the
|
||||
live repo: **`UPCommentary/upc-kommentar` is Bun + SvelteKit + TypeScript + PLpgSQL —
|
||||
zero Go.** A SvelteKit app cannot `import` a Go `pkg/`. m's resolution (2026-05-29):
|
||||
**upc-kommentar is out of scope as a live consumer for now.** docforge is a pure Go
|
||||
package; paliad imports it in-process like `litigationplanner`. The interfaces are
|
||||
designed so an HTTP veneer (for a future TS consumer) is *addable later* without rework —
|
||||
but none is built now. See §4 D-P1 and §8.
|
||||
|
||||
### 0.4 Locked constraints (m, confirmed)
|
||||
|
||||
- One Go module: `pkg/docforge`. Same packaging model as `pkg/litigationplanner`.
|
||||
- docforge **owns no database tables** — data flows in via interfaces.
|
||||
- `.docx` first; engine designed format-pluggable for `.pdf`/`.html`/`.md` later.
|
||||
- Authoring and Generation are **distinct pages**, but share the engine + the generic
|
||||
editor plumbing.
|
||||
- Generation must support **minor manual content edits** (live editor, not just
|
||||
data-binding).
|
||||
- Editor stays per-consumer; the **generic UX plumbing** is extracted into a reusable UI
|
||||
package now.
|
||||
- The neutral model must be **lossless for our own `.docx`** (the uploaded base is an
|
||||
opaque carrier, preserved byte-for-byte outside touched regions).
|
||||
|
||||
### 0.5 Contracts that MUST survive the refactor
|
||||
|
||||
These are invariants. The migration (§6) protects each by moving it *with its file and its
|
||||
test*, unchanged:
|
||||
|
||||
1. **`placeholderRegex`** = `` `\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}` `` — underscores
|
||||
and dots legal in keys; whitespace inside braces trimmed; case-sensitive.
|
||||
2. **Last night's underscore fix** (commit `b78a984`): `parseInlineSpans` short-circuits
|
||||
the inline scanner on `{{` and copies the placeholder literally to `}}`, so
|
||||
`{{project.case_number}}` is never mangled to `{{project.casenumber}}`.
|
||||
3. **`data-var` contract** — `data-var="<key>"` on both `.draft-var` preview spans and
|
||||
`.submission-draft-var-input` sidebar inputs; the click-to-jump and focus-highlight are
|
||||
bijective across repaints.
|
||||
4. **Missing-value markers** — `[KEIN WERT: key]` (DE) / `[NO VALUE: key]` (EN) render
|
||||
inline, never an error.
|
||||
5. **Legacy aliases** — `procedural_event.X ≡ rule.X` resolve identically
|
||||
(`submission_vars_aliases_test.go`); party variables emit comma-joined, indexed, and
|
||||
flat-legacy forms (`submission_vars_parties_test.go`).
|
||||
6. **Section anchor syntax** — `{{#section:KEY}}…{{/section:KEY}}`, `KEY` matched against
|
||||
`[A-Za-z0-9_]+`.
|
||||
7. **No binary retention** — exported `.docx` is regenerable from inputs; only audit rows
|
||||
persist (`system_audit_log` `submission.exported` + `project_events`).
|
||||
8. **V1 fallback path** — pre-Composer drafts (`base_id IS NULL`, no section rows) render
|
||||
via the pure-placeholder path. No auto-upgrade.
|
||||
9. **`{{…}}` pass-through** — the Markdown walker emits placeholders verbatim; the merge
|
||||
pass substitutes them afterward. Order is load-bearing (substitution runs *inside*
|
||||
compose, after section splicing).
|
||||
|
||||
---
|
||||
|
||||
## §1 Goals
|
||||
|
||||
**G1.** Extract the format-neutral document machinery (Markdown→OOXML walker, OOXML
|
||||
merge/compose, placeholder engine, `.dotm`→`.docx`) into `pkg/docforge` with a clean
|
||||
public surface and zero behavior change at the extraction step.
|
||||
|
||||
**G2.** Introduce a **neutral document/template model** so importers produce it, the engine
|
||||
binds variables on it, and exporters render it out — with `.docx` as the first
|
||||
importer+exporter pair, not the universe. Lossless for our own `.docx`.
|
||||
|
||||
**G3.** Replace the hardcoded `addXxxVars` push with a **`VariableResolver` interface per
|
||||
namespace** + a `ResolverSet` that composes them, preserves aliases, and exposes the key
|
||||
catalogue (label + group) so the frontend variable form/palette becomes data-driven
|
||||
instead of hardcoded in TS.
|
||||
|
||||
**G4.** Build the **Authoring surface**: upload `.docx` → WYSIWYG render → click/select →
|
||||
insert `{{slot}}` → save template. Closes the gap m named.
|
||||
|
||||
**G5.** Refactor **Generation** onto docforge + uploaded templates, preserving the live
|
||||
editor, preview, manual-edit, and export — and every contract in §0.5.
|
||||
|
||||
**G6.** Extract the **generic editor UX** into `frontend/src/lib/docforge-editor/`,
|
||||
consumed by both the generation and authoring shells.
|
||||
|
||||
**Non-goals (this PRD):** implementation, migration SQL, code. Formats beyond `.docx`
|
||||
(interface only). Live upc-kommentar integration. Multi-user concurrent editing of one
|
||||
draft. An HTTP service veneer.
|
||||
|
||||
---
|
||||
|
||||
## §2 User journeys
|
||||
|
||||
### 2.1 Authoring (new)
|
||||
|
||||
1. m opens **`/admin/templates`** (or `/templates/new`) and uploads a base `.docx`
|
||||
(firm letterhead with caption layout, signature block, etc.).
|
||||
2. docforge's `.docx` importer parses the upload into a **carrier** (opaque OOXML kept
|
||||
intact) + a renderable preview. The page shows a **WYSIWYG-ish render** of the document.
|
||||
3. m highlights a piece of text — e.g. `Az. 4c O 12/23` — and a **variable palette**
|
||||
(sourced from the `ResolverSet.Keys()` catalogue, grouped DE/EN) lets him pick
|
||||
`project.case_number`. The selection is **replaced with a `{{project.case_number}}`
|
||||
slot**; a `template_slots` row records the slot key + its anchor position.
|
||||
4. He repeats for every variable region, saves, and the template becomes pickable in
|
||||
Generation. (Editing the template later creates a new **version** — see §4 D-A3.)
|
||||
|
||||
**Scope guard:** v1 authoring places **text-level slots in body paragraphs**. Slots in
|
||||
headers/footers/tables/text-boxes are a flagged follow-up (§7 note), because the
|
||||
click→OOXML-run mapping there is materially harder.
|
||||
|
||||
### 2.2 Generation (refactor of today)
|
||||
|
||||
1. Lawyer picks a template (uploaded template *or* a legacy Gitea base — both supported
|
||||
during transition) for a submission code, optionally project-scoped.
|
||||
2. A **draft** is created. Its template **structure is snapshotted** at create
|
||||
(§4 D-A3) so later template edits don't shift an in-flight draft.
|
||||
3. The sidebar shows the variable form (data-driven from `ResolverSet.Keys()`); the
|
||||
resolved bag is merged with the lawyer's overrides; the live preview renders with
|
||||
`data-var` click-to-jump; manual prose edits autosave (500 ms debounce).
|
||||
4. Export → docforge binds the model + carrier + resolved variables → `.docx` bytes
|
||||
stream as a download. Audit rows written. No binary retained.
|
||||
|
||||
### 2.3 upc-kommentar parallel journey (deferred — validates the abstractions)
|
||||
|
||||
Not built now, but the abstractions are sized for it: upc-kommentar authors work in
|
||||
**Markdown** (and want to import **foreign doc/docx** as input — m, 2026-05-29 Q4). When
|
||||
it becomes a consumer, it would: implement its own `VariableResolver`(s) over its Postgres
|
||||
(commentary metadata), feed Markdown through docforge's **markdown importer** into the
|
||||
neutral model, edit live in its own Svelte shell (reusing the *wire contract*, not Go
|
||||
code), and export. The Go engine is reached over an HTTP veneer added at that point. This
|
||||
journey is the litmus test for §3's seams: **a new consumer adds resolvers + a transport,
|
||||
touches no engine internals.**
|
||||
|
||||
---
|
||||
|
||||
## §3 Module shape
|
||||
|
||||
### 3.1 Package tree
|
||||
|
||||
```
|
||||
pkg/docforge/
|
||||
doc.go // package doc (litigationplanner-style)
|
||||
model.go // neutral model: Document, Block, InlineSpan, Slot
|
||||
template.go // Template, TemplateSlot, Carrier
|
||||
variables.go // VariableResolver interface, VariableKey, ResolverSet, alias registry
|
||||
bind.go // binding engine: walk model, resolve slots, apply missing-marker policy
|
||||
render.go // RenderHTML (preview w/ data-var spans) — format-neutral entry
|
||||
importer.go // Importer interface
|
||||
exporter.go // Exporter interface
|
||||
store.go // TemplateStore interface (carrier bytes + slot persistence contract)
|
||||
errors.go // sentinel errors (ErrUnknownTemplate, ErrUnboundSlot, …)
|
||||
placeholder.go // placeholderRegex + substitution primitives (THE locked grammar)
|
||||
types_wire_test.go // locks the JSON wire shape consumed by the TS editor
|
||||
docx/ // the .docx adapter — first importer + exporter
|
||||
importer.go // DocxImporter: parse .docx -> Carrier + detect/locate slots
|
||||
exporter.go // DocxExporter: (model + carrier + vars) -> .docx bytes [today's compose+merge]
|
||||
ooxml.go // archive/zip + document.xml manipulation [today's submission_merge/compose internals]
|
||||
md_to_ooxml.go // Markdown -> OOXML runs [today's submission_md walker + the b78a984 fix]
|
||||
dotm.go // ConvertDotmToDocx [today's pre-pass]
|
||||
markdown/ // markdown importer (input content; foreign-docx import is a later sibling)
|
||||
importer.go // parse Markdown -> neutral blocks
|
||||
```
|
||||
|
||||
**What lives in docforge vs paliad:**
|
||||
|
||||
| Concern | Home | Why |
|
||||
|---|---|---|
|
||||
| Neutral model, binding, preview-render | `docforge` | format-neutral core |
|
||||
| `VariableResolver` interface + `ResolverSet` | `docforge` | the seam m wants clean |
|
||||
| Placeholder grammar + substitution | `docforge` | shared invariant (§0.5.1) |
|
||||
| `.docx` importer + exporter, MD→OOXML walker | `docforge/docx` | first format adapter (ships *inside* the pkg, like litigationplanner's embedded snapshot) |
|
||||
| Markdown importer | `docforge/markdown` | input-format adapter |
|
||||
| Concrete resolvers (`project`, `parties`, `firm`, `user`, `today`, `deadline`, `procedural_event`) | **paliad** `internal/…` | they read paliad's DB/services |
|
||||
| `TemplateStore` impl (Postgres bytea) | **paliad** | docforge owns no tables |
|
||||
| Section / building-block model, submission codes | **paliad** | consumer-specific composition concepts |
|
||||
| HTTP handlers, editor UI, authoring page | **paliad** | wire + per-consumer UI |
|
||||
|
||||
### 3.2 The neutral model + the carrier (resolving "intermediate, but lossless docx")
|
||||
|
||||
```go
|
||||
// A Document is the format-neutral content model importers produce and exporters consume.
|
||||
type Document struct {
|
||||
Blocks []Block
|
||||
}
|
||||
type Block struct {
|
||||
Kind BlockKind // paragraph | heading | list_item | blockquote | section_marker
|
||||
Style string // logical style key (mapped to a base stylemap on export)
|
||||
Spans []InlineSpan // text runs (bold/italic/link) + Slots
|
||||
// …list level, section key, etc.
|
||||
}
|
||||
type InlineSpan struct {
|
||||
Text string
|
||||
Bold bool
|
||||
Italic bool
|
||||
Link string
|
||||
Slot *Slot // non-nil => this span is a variable slot, not literal text
|
||||
}
|
||||
type Slot struct {
|
||||
Key string // e.g. "project.case_number" — the placeholder grammar key
|
||||
}
|
||||
```
|
||||
|
||||
**The carrier keeps the lossless guarantee.** The uploaded `.docx` chrome
|
||||
(letterhead, styles, caption, signature) is **never round-tripped through `Document`**.
|
||||
It is held as an opaque `Carrier` (the original OOXML), and the exporter splices the
|
||||
rendered neutral content into the carrier's named anchors, then substitutes slots — exactly
|
||||
today's compose mechanism, now formalised:
|
||||
|
||||
```go
|
||||
type Carrier struct {
|
||||
Format string // "docx"
|
||||
Bytes []byte // original upload, preserved byte-for-byte outside anchor regions
|
||||
Anchors []Anchor // {{#section:KEY}}…{{/section:KEY}} positions + slot positions
|
||||
}
|
||||
```
|
||||
|
||||
So **two layers**: editable content = `Document` (neutral, format-pluggable); base chrome =
|
||||
`Carrier` (opaque, lossless). Foreign-docx *import as input content* (Q4) does parse into
|
||||
`Document` and **is inherently lossy** — flagged as a boundary (§8), distinct from the
|
||||
lossless export of *our* templates.
|
||||
|
||||
### 3.3 The variable resolver seam (G3)
|
||||
|
||||
```go
|
||||
// VariableResolver answers keys within one dotted namespace.
|
||||
type VariableResolver interface {
|
||||
Namespace() string // e.g. "project"
|
||||
Resolve(key string) (value string, ok bool)// ok=false => unknown key => missing marker
|
||||
Keys() []VariableKey // catalogue for the palette + sidebar form
|
||||
}
|
||||
type VariableKey struct {
|
||||
Key, LabelDE, LabelEN, Group string
|
||||
}
|
||||
|
||||
// ResolverSet composes namespaced resolvers, registers canonical<->legacy aliases,
|
||||
// and offers BOTH a pull path (Resolve, used during binding) and a push path
|
||||
// (BuildBag, preserving today's resolved_bag/merged_bag wire).
|
||||
type ResolverSet struct{ /* … */ }
|
||||
func (s *ResolverSet) Resolve(key string) (string, bool)
|
||||
func (s *ResolverSet) BuildBag() map[string]string // == today's PlaceholderMap
|
||||
func (s *ResolverSet) Catalogue() []VariableKey // drives the data-driven form/palette
|
||||
func (s *ResolverSet) RegisterAlias(canonical, legacy string)
|
||||
```
|
||||
|
||||
paliad's seven `addXxxVars` functions become seven resolver types implementing this
|
||||
interface. `BuildBag()` reproduces today's flat map exactly (alias parity tests pin it).
|
||||
`Catalogue()` kills the hardcoded `VARIABLE_GROUPS`/`VARIABLE_LABELS` in the TS bundle.
|
||||
**Resolver model = hybrid** (pull-capable interface, push-driven `BuildBag` default —
|
||||
inventor pick, §4 D-I1).
|
||||
|
||||
### 3.4 Wire contract (Go ↔ TS) — preserved, locked by test
|
||||
|
||||
The editor wire stays as-is; `types_wire_test.go` pins it:
|
||||
|
||||
- `GET draft` → `{ draft, resolved_bag, merged_bag, preview_html, rule, parties, sections }`
|
||||
- preview HTML carries `<span class="draft-var" data-var="<key>">…</span>` (built by
|
||||
docforge's `RenderHTML`, today's `emitTextWithDraftVars`).
|
||||
- `PATCH draft` ← `{ variables: PlaceholderMap, … }` (presence-tracked optional fields).
|
||||
- export/preview endpoints unchanged.
|
||||
- **New (authoring):** `POST /api/templates` (upload), `GET /api/templates/:id` (carrier
|
||||
preview + slots), `POST /api/templates/:id/slots` (place slot), `GET /api/docforge/variables`
|
||||
(the `Catalogue()`).
|
||||
|
||||
---
|
||||
|
||||
## §4 Decisions (m's picks, 2026-05-29)
|
||||
|
||||
### Prose-grill resolutions (core metaphor)
|
||||
|
||||
| # | Question | m's decision | Note |
|
||||
|---|---|---|---|
|
||||
| P1 | Cross-language sharing model | **Go pkg only; upc-kommentar out of scope for now, "reuse later somehow"** | Interfaces sized so an HTTP veneer is addable without rework. No service built. |
|
||||
| P2 | Intermediate model? | **Yes — but lossless for our .docx** | → carrier (opaque OOXML) + neutral Document (editable content). §3.2. |
|
||||
| P3 | Authoring slot mechanic | **(b) click-to-insert** | Upload → render → click/select → inject `{{…}}`. |
|
||||
| P4 | Input formats | **Markdown primary; foreign doc/docx import later** | Markdown importer first; foreign-docx import is lossy (§8). |
|
||||
| P5 | Editor sharing | **Build paliad's UI; extract generic UX into a UI package** | `frontend/src/lib/docforge-editor/`. |
|
||||
|
||||
### Structured decisions
|
||||
|
||||
| # | Decision | m's pick | Rationale / divergence |
|
||||
|---|---|---|---|
|
||||
| A1 | Authoring UX | **WYSIWYG inline** | Matches "insert variables into the document". Hardest part — render fidelity + click→run mapping — flagged §7. |
|
||||
| A2 | Template storage | **Postgres bytea (interface-backed)** | m leans (1); flagged Supabase Storage as viable. Resolved: behind a `TemplateStore` interface, bytea impl now, Supabase Storage a one-impl swap later. No schema churn either way. |
|
||||
| A3 | Versioning of existing drafts | **Snapshot at draft-create** | Lawyer's in-flight draft won't shift under them; matches today's section-seeding. |
|
||||
| A4 | Migration strategy | **Extract-in-place, then extend** | Lowest risk to the recent fixes — they move with their files + tests; behavior identical at each step. |
|
||||
| B1 | Package name | **`docforge`** | — |
|
||||
| B2 | Schema scope | **New generic tables** (`templates`, `template_slots`, `template_versions`) | Authoring is domain-neutral; submission_bases (Gitea/section_spec) stays for legacy bases with a converge path. |
|
||||
| B3 | UI package extraction | **Extract now** | Authoring reuses it this cycle — earns its keep, not speculative. |
|
||||
| B4 | Exporter pluggability | **Interface now, docx-only impl** | Cheap insurance; matches "pluggable for later". |
|
||||
|
||||
### Inventor picks (m delegated — "whatever works best")
|
||||
|
||||
| # | Pick | Reasoning |
|
||||
|---|---|---|
|
||||
| I1 | `VariableResolver` = pull-capable interface, push `BuildBag()` default | Preserves today's flat-map wire while enabling on-demand resolution + the `Catalogue()` that data-drives the form. |
|
||||
| I2 | `.docx` adapter ships **inside** `pkg/docforge/docx` | Mirrors litigationplanner shipping its embedded snapshot in-package; keeps the first adapter co-located with the engine it proves. |
|
||||
| I3 | Carrier-vs-Document split (§3.2) | Only way to satisfy "intermediate model" AND "lossless our .docx" simultaneously. |
|
||||
|
||||
---
|
||||
|
||||
## §5 Data model deltas (paliad-side — docforge owns none)
|
||||
|
||||
**New tables** (additive; SQL drafted by the coder, not here):
|
||||
|
||||
- **`paliad.templates`** — `id`, `slug`, `name_de/en`, `kind` (`'submission'` | generic),
|
||||
`source_format` (`'docx'`), `firm`, `is_active`, `created/updated_by`, timestamps,
|
||||
`current_version_id` FK.
|
||||
- **`paliad.template_versions`** — immutable snapshots: `id`, `template_id` FK,
|
||||
`version` int, `carrier_blob` bytea (the `.docx`; or storage ref via `TemplateStore`),
|
||||
`created_at`, `created_by`. Editing a template inserts a new version row.
|
||||
- **`paliad.template_slots`** — `id`, `template_version_id` FK, `slot_key` (the variable
|
||||
key, e.g. `project.case_number`), `anchor` (position encoding — see flag below),
|
||||
`label`, `order_index`. Versioned alongside the carrier.
|
||||
|
||||
**Snapshot semantics (A3):** a draft pins `template_version_id`. Template edits create a
|
||||
new version; existing drafts keep their pinned version. *(Flag for coder: pin
|
||||
`template_version_id` on the draft vs. copy a `template_snapshot` jsonb onto the draft —
|
||||
both satisfy A3; the version-table approach is preferred for auditability but the coder
|
||||
picks based on query ergonomics.)*
|
||||
|
||||
**Touched existing tables:**
|
||||
|
||||
- `submission_drafts` — add nullable `template_version_id` for uploaded-template drafts;
|
||||
**legacy `base_id` path preserved** (extract-in-place ⇒ no data migration of the 11
|
||||
existing drafts; §0.5.8 fallback intact).
|
||||
- `submission_bases`, `submission_sections`, `submission_building_blocks` — **unchanged**.
|
||||
They remain paliad consumer-specific concepts that map onto docforge's neutral model at
|
||||
render time. submission_bases (Gitea-backed) coexists with the new uploaded-template
|
||||
tables during transition; convergence is a later, separate task.
|
||||
|
||||
**Slot anchor encoding (flag for coder):** how a `template_slots.anchor` records *where*
|
||||
in the carrier OOXML the slot sits (run index + offset, vs. a stable sentinel token
|
||||
injected into the carrier at authoring time). The sentinel-token approach is likely
|
||||
simpler and reuses the existing cross-run substitution machinery — resolve in
|
||||
implementation chat.
|
||||
|
||||
---
|
||||
|
||||
## §6 Migration plan (protects working code + the recent fixes)
|
||||
|
||||
**Principle:** extract-in-place (A4). Each step **compiles, passes the moved tests, and
|
||||
leaves observable behavior identical.** The recent fixes travel *with their files*:
|
||||
|
||||
- The **b78a984 underscore fix** → `pkg/docforge/docx/md_to_ooxml.go` (was
|
||||
`submission_md.go` `parseInlineSpans`), `submission_md_test.go` moves alongside.
|
||||
- **`placeholderRegex`** → `pkg/docforge/placeholder.go`; its tests move.
|
||||
- **`data-var` / `emitTextWithDraftVars`** → `pkg/docforge/render.go` (`RenderHTML`);
|
||||
wire test moves and is pinned in `types_wire_test.go`.
|
||||
- **Cross-run merge, `.dotm`→`.docx`, anchor splicing** → `pkg/docforge/docx/`; tests move.
|
||||
- **Building-block + section model, submission codes, the 7 concrete resolvers** stay in
|
||||
`internal/` (consumer-specific) — now calling into docforge.
|
||||
|
||||
**Safety rails per step:** (1) `go build ./...` green; (2) the moved test files green; (3)
|
||||
a golden-export check — generate a known draft before and after the step, assert byte-equal
|
||||
`.docx`; (4) the live preview HTML for a fixture draft is string-equal (the `data-var`
|
||||
contract). No step ships until all four hold.
|
||||
|
||||
**What is explicitly NOT migrated:** the 11 pre-Composer drafts (`base_id IS NULL`) keep
|
||||
the v1 fallback render path; no auto-upgrade (§0.5.8).
|
||||
|
||||
---
|
||||
|
||||
## §7 Slice train
|
||||
|
||||
Tracer-bullet vertical slices, each independently shippable. Slices 1–3 are pure
|
||||
behavior-preserving refactors (the risky-to-working-code part, front-loaded under golden
|
||||
checks); 4–7 build the new capability; 8 sets up the future.
|
||||
|
||||
1. **Extract the docx engine** — move MD→OOXML walker, OOXML merge/compose, placeholder
|
||||
grammar, `.dotm`→`.docx` into `pkg/docforge/{placeholder.go, render.go, docx/}`.
|
||||
paliad's `submission_*` services become thin adapters. Golden-export + preview checks
|
||||
green. *Protects b78a984, the regex, the data-var contract.*
|
||||
2. **Neutral model + binding** — introduce `Document`/`Block`/`Slot`/`Carrier` + `bind.go`;
|
||||
refactor the docx exporter to consume the neutral model (sections → blocks → OOXML
|
||||
spliced into carrier). Behavior identical (golden checks).
|
||||
3. **`VariableResolver` interface** — refactor the 7 `addXxxVars` into resolver types +
|
||||
`ResolverSet`; `BuildBag()` reproduces today's map (alias-parity tests pin it);
|
||||
`Catalogue()` exposed. Frontend form switched to consume `Catalogue()` (kills hardcoded
|
||||
`VARIABLE_GROUPS`).
|
||||
4. **Template store + schema** — `templates`/`template_versions`/`template_slots` +
|
||||
Postgres-bytea `TemplateStore` impl. No UI yet. Additive migrations.
|
||||
5. **UI package extraction** — pull generic plumbing (debounced autosave, data-var wiring,
|
||||
preview/export round-trip, focus preservation, sticky collapse) into
|
||||
`frontend/src/lib/docforge-editor/`; submission editor consumes it. Refactor, behavior
|
||||
identical.
|
||||
6. **Authoring page** — upload `.docx` → docforge docx-importer → WYSIWYG render → select
|
||||
text → pick variable from `Catalogue()` palette → inject slot (writes
|
||||
`template_slots` + new `template_version`). Reuses the UI package + docforge importer.
|
||||
*(v1: body-paragraph text slots only.)*
|
||||
7. **Generation on uploaded templates** — generation page picks an uploaded template
|
||||
(`template_version_id` path) alongside legacy bases; snapshot-at-create; data-bind +
|
||||
manual edit + export via docforge. Legacy base path still works.
|
||||
8. **Markdown importer + exporter-interface finalisation** — `docforge/markdown` importer
|
||||
as input; `Exporter` interface locked (docx-only impl). Sets up future formats +
|
||||
eventual upc-kommentar reuse.
|
||||
|
||||
**Flagged follow-ups (post-train, separate tasks):** slots in headers/footers/tables;
|
||||
foreign-docx import fidelity; the HTTP veneer + a TS consumer; submission_bases →
|
||||
templates convergence; auto-upgrade of pre-Composer drafts.
|
||||
|
||||
---
|
||||
|
||||
## §8 Out of scope
|
||||
|
||||
- **Implementation, migration SQL, code.** PRD only.
|
||||
- **upc-kommentar as a live consumer** — deferred; abstractions sized for it, nothing built.
|
||||
- **An HTTP service veneer** — addable later without engine rework; not now.
|
||||
- **Formats beyond `.docx`** — `Exporter` interface defined (B4), only the docx impl built.
|
||||
- **Lossless import of *foreign* `.docx`** — our own templates export losslessly via the
|
||||
carrier; importing an arbitrary third-party Word doc as input content is best-effort and
|
||||
inherently lossy. Distinct guarantee.
|
||||
- **Multi-user concurrent editing** of one draft.
|
||||
- **Re-proposing the current `submission_*.go` shape** — the point is to extract + clean it.
|
||||
- **Slots outside body paragraphs** (headers/footers/tables/text-boxes) in authoring v1.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — open flags for the coder (resolve in implementation chat)
|
||||
|
||||
1. **Slot anchor encoding** — run-index+offset vs. injected sentinel token (§5). Lean
|
||||
sentinel.
|
||||
2. **Snapshot mechanism** — pinned `template_version_id` vs. `template_snapshot` jsonb on
|
||||
the draft (§5). Lean version-pin.
|
||||
3. **Authoring render fidelity** — reuse the existing lossy `docXMLToHTML` preview for the
|
||||
WYSIWYG surface, or invest in higher fidelity. Lean reuse for v1, accept that
|
||||
complex layouts render approximately while slots still anchor correctly.
|
||||
4. **Storage backend** — Postgres bytea now; Supabase Storage is a clean `TemplateStore`
|
||||
swap if template volume/size grows.
|
||||
@@ -1,685 +0,0 @@
|
||||
# PRD — Procedures: Litigation Builder (m/paliad#153)
|
||||
|
||||
**Task:** t-paliad-339
|
||||
**Gitea:** m/paliad#153
|
||||
**Inventor:** edison (shift-1, Opus)
|
||||
**Date:** 2026-05-27
|
||||
**Branch:** `mai/edison/inventor-prd-columnar`
|
||||
**Status:** Draft — DESIGN READY FOR REVIEW. Coder gate held.
|
||||
|
||||
**Builds on (read before extending this PRD):**
|
||||
|
||||
- `docs/design-procedures-workflow-tracker-2026-05-27.md` — atlas's reverted tracker design (m/paliad#152). The anchor+scope idea did not land; understand *why* before re-proposing.
|
||||
- `docs/design-unified-procedural-events-tool-2026-05-27.md` — cronus's U0-U4 catalog, currently live on main @ `ed3c5d1` post-revert. Visual baseline for filter strip + tab control.
|
||||
- `docs/design-deadline-system-revision-2026-05-27.md` — atlas Phase 2 model layer (scenario_flags SSoT, view-mode toggle, per-rule selection chips). Model layer is locked; this PRD is purely surface + new persistence tables.
|
||||
- `docs/design-fristenrechner-overhaul-2026-05-26.md` — cronus 2026-05-26 inventor-pass (Mode A + B + result, shipped via t-paliad-322).
|
||||
|
||||
**Predecessor takeaway (atlas's debrief on #152):**
|
||||
|
||||
> "When the architecture is novel, default to grilling m in prose FIRST. The doc rewrites cost a commit; the bigger cost would have been wasting m's question-batch on the wrong architecture."
|
||||
|
||||
Followed here. This PRD captures the architecture m chose through **20 chip-picker decisions across 5 batches**, not an inventor-first strawman.
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises
|
||||
|
||||
### §0.1 What is `/tools/procedures` today (live, post-revert)
|
||||
|
||||
The current page is cronus's 4-tab catalog (U0-U4, shipped via m/paliad#151):
|
||||
|
||||
- Sticky filter strip (search box + 4 chip rows: Forum / Verfahren / Ereignisart / Partei).
|
||||
- 4 solid tabs: `Verfahren wählen` / `Direkt suchen` / `Geführt` / `Aus Akte`.
|
||||
- Default-active tab = "Verfahren wählen" renders `VerfahrensablaufBody` (the legacy Verfahrensablauf wizard: proceeding picker → perspective + date → 3-step wizard → result in 3-column "Spalten" or single-column "Zeitstrahl").
|
||||
- Other 3 tab panels are stubs (search/wizard/akte never wired in U0-U3).
|
||||
|
||||
m's blocking feedback (verbatim, 2026-05-27 22:18):
|
||||
|
||||
> I like to keep our current columnar layout with proactive / court / reactive. And it is good if we can select which side we want to simulate. […] There are basically three main approaches I see to this: Get an overview over proceedings, play around with options, build Scenarios. Another one where something specific happened and we just want to know what deadlines we need to note […]. A third one from a specific proceeding / case file where things take place / have taken place.
|
||||
|
||||
And the architecture-shifting follow-ups (2026-05-27 22:35-22:36, mid-grilling):
|
||||
|
||||
> I would prefer to have an interface where not every constellation is in the URL by the way. That seems limiting.
|
||||
> We could just have a litigation builder. Sometimes we build a full scenario with multiple instances etc, sometimes we just want the next step.
|
||||
> we should have ways to save these "litigation constellations" where we save which proceedings we have and which state they are in, which submissions were or were not filed. A small Scenario DB could work, dont you think?
|
||||
|
||||
These three statements upgraded the brief from "redesign a catalog" to "build a Litigation Builder backed by a Scenario DB". The PRD below is shaped by them.
|
||||
|
||||
### §0.2 Locked constraints (m's words, brief in #153)
|
||||
|
||||
- Columnar layout: `proaktiv | court | reaktiv` (perspective-flippable).
|
||||
- Three approaches as entry modes: overview/scenarios, event-triggered, case-file driven.
|
||||
- Filtering across all dimensions + text search.
|
||||
- Optional follow-ups: toggleable, highlightable, with display-count setting.
|
||||
- Modular *where it actually helps* (m: "I don't know — generally does not super apply here." — drop modular as a load-bearing goal).
|
||||
- UPC v1, expand later.
|
||||
|
||||
### §0.3 Live data the builder works against
|
||||
|
||||
Verified 2026-05-27 against `paliad.sequencing_rules` (231 published / 242 total):
|
||||
|
||||
- 110 chained (`parent_id` not null).
|
||||
- 78 trigger-rooted, 4 spawns (cross-PT), 47 court-set, 18 conditional.
|
||||
- ~46 proceeding types total (UPC 35 / DE 5 / EPA 3 / DPMA 3). v1 focuses on UPC.
|
||||
- `paliad.proceeding_types.kind` discriminator (atlas's t-paliad-324) filters non-proceeding rows (phases/side_actions/meta) from the picker.
|
||||
- `paliad.deadlines` carries both `procedural_event_id` and `sequencing_rule_id` → Akte actuals overlay is a direct join.
|
||||
- `paliad.projects.scenario_flags` jsonb (atlas P0) is the SSoT for project-level scenario state; the new `paliad.scenario_proceedings.scenario_flags` mirrors this shape per-proceeding-per-scenario.
|
||||
|
||||
### §0.4 Scope (in / out)
|
||||
|
||||
**In:**
|
||||
|
||||
- Replace `/tools/procedures` with the Litigation Builder.
|
||||
- New `paliad.scenarios` + `paliad.scenario_proceedings` + `paliad.scenario_events` + `paliad.scenario_shares` tables.
|
||||
- Promote-to-project flow (scenario → `paliad.projects` row).
|
||||
- Bidirectional link from `/projects/{id}` (button: "Im Builder öffnen" — exports project state to a builder session).
|
||||
|
||||
**Out (deferred or owned elsewhere):**
|
||||
|
||||
- Calculator (`pkg/litigationplanner.CalculateRule`) — working.
|
||||
- Editorial backfill (curie's t-paliad-333 owns the 7 compound rules + R.109).
|
||||
- `/admin/procedural-events` (editor surface; different audience).
|
||||
- `/projects/{id}` Verlauf / SmartTimeline (per-Akte actuals; sister tool).
|
||||
- youpc.org / Outlook / PDF export.
|
||||
- Multi-jurisdiction expansion (DE/EPA/DPMA) — UPC v1 first.
|
||||
- Cross-proceeding peer triggers (UPC-inf judgment → EPA opp choice deadline) — v1.1.
|
||||
- Multi-user concurrent editing on the same scenario (out of scope; sharing is read-only).
|
||||
|
||||
---
|
||||
|
||||
## §1 Goals
|
||||
|
||||
1. **One canvas, three entry modes.** Unify the 3 approaches into a single Litigation Builder surface. The entry modes (`Übersicht / Ereignis / Aus Akte`) shape the *initial* state of the canvas; once the user is working, the canvas itself is what they interact with.
|
||||
2. **Persisted constellations.** A user can save a "litigation constellation" — multiple parallel proceedings with their flags, filed/skipped/planned event states, dates, and notes — as a named scenario. Scenarios live in the DB (not the URL).
|
||||
3. **Auto-save by default.** No "unsaved changes" modals. The active scenario auto-persists. Anonymous scratch scenarios convert to named ones when the user clicks "Benennen".
|
||||
4. **Promote-to-project.** A scenario can be turned into a real `paliad.projects` row via a 3-step wizard. Procedural shape, placeholder parties, notes, and filed-state all carry over; the user fleshes out client-bound metadata during the wizard.
|
||||
5. **Share read-only with the team.** Each scenario is private by default; explicit "An Team teilen" grants named HLC users read-only access. Original owner stays sole editor.
|
||||
6. **Columnar geometry restored.** The current "Spalten" view (claimant | court | defendant) returns as the canonical render — but now per-proceeding-triplet within a scenario, with perspective ("our side") flippable per proceeding so `proaktiv | court | reaktiv` reads correctly across multi-proceeding constellations.
|
||||
7. **Per-event-card optional horizon.** Each event card on the canvas can dial in how many optional follow-ups to surface. Cards are the unit of optional-display control.
|
||||
|
||||
---
|
||||
|
||||
## §2 User journeys
|
||||
|
||||
### §2.1 Journey A — Cold-open builder ("Übersicht / Scenarios")
|
||||
|
||||
**Persona:** Dr. Becker, senior partner. Friday afternoon. New UPC matter not yet committed; she's briefing a client on Monday on the full procedural shape.
|
||||
|
||||
1. Opens `/tools/procedures`. No `?scenario` param. Cold-open canvas: empty workbench with a "Neues Szenario starten" CTA and a short list of her 5 most-recent scenarios.
|
||||
2. Clicks the CTA → inline picker (Forum chip row → Verfahren chip row → `Hinzufügen`). Picks UPC + `upc.inf.cfi`.
|
||||
3. Canvas now renders one proceeding triplet (`proaktiv | court | reaktiv`). Default perspective is empty (no party selected) — both sides render equally; the perspective radio in the page header sits unset.
|
||||
4. She picks defendant perspective at the page header → triplet flips. The defendant column becomes `proaktiv` (her side); claimant becomes `reaktiv`.
|
||||
5. She adds a second proceeding via `+ Verfahren hinzufügen` at the bottom: EPA `epa.opp.opd`. New triplet stacks below the first. New triplet's perspective defaults to "patentee" inheriting from her client's role across the two; she flips per-proceeding via the triplet header.
|
||||
6. She turns on `with_ccr` on the UPC inf triplet's per-proceeding flag strip. The CCR child triplet auto-expands inline below the parent at the spawn node.
|
||||
7. Auto-save kicks in (debounced 500ms). The page header shows "Gespeichert in Scratch · Benennen".
|
||||
8. She clicks "Benennen", enters "Becker — UPC + EPA defensive". Side panel "Meine Szenarien" updates.
|
||||
9. On Monday she opens the scenario from her recent list, walks the client through it, hits "Als Projekt anlegen" (when the client commits). 3-step wizard fires (§5.4).
|
||||
|
||||
### §2.2 Journey B — Event-triggered lookup ("Ereignis")
|
||||
|
||||
**Persona:** Sandra, paralegal. Today: a Hinweisbeschluss arrived on a CMS queue. She doesn't know yet which Akte it belongs to.
|
||||
|
||||
1. Opens `/tools/procedures`. Picks "Ereignis" entry mode at the top.
|
||||
2. Page-header search box auto-focuses. She types "Hinweis" → universal search drops down: `5 Ereignisse · 1 Szenario · 0 Akten`. Picks the event `upc.inf.cfi.cmo_review` (Antrag CMO-Überprüfung).
|
||||
3. Canvas renders one triplet of `upc.inf.cfi` with the Hinweisbeschluss event card auto-anchored (lime band + `━━ DU BIST HIER ━━` divider above the next-coming events).
|
||||
4. She reads the follow-ups: "Antrag auf CMO-Überprüfung (claimant, R.333.2 · 1 Monat)" and 2 optional follow-ups. The Stichtag input in the page header defaults to today; she leaves it.
|
||||
5. She doesn't save anything — this was a quick lookup. Scratch scenario auto-persists but she doesn't name it; it'll fall off her recent list after a while.
|
||||
6. Later she identifies the matter (HL-2024-001), switches to "Aus Akte" mode, and continues there.
|
||||
|
||||
### §2.3 Journey C — Case-file driven ("Aus Akte")
|
||||
|
||||
**Persona:** Anna, senior associate. Working on HL-2024-001 (UPC infringement). The client just confirmed they want to file a CCR.
|
||||
|
||||
1. Opens `/tools/procedures`. Page-header Akte picker shows recent projects; she picks HL-2024-001.
|
||||
2. Page header auto-fills: proceeding = `upc.inf.cfi`, perspective = defendant (from `projects.our_side`), scenario_flags = `{with_ccr: false}` (current state).
|
||||
3. Builder loads: one `upc.inf.cfi` triplet, perspective-flipped. Event cards overlay actuals from `paliad.deadlines` — `Klageerhebung` is filed (2026-01-15), `Klageerwiderung` is planned (2026-04-01, computed), others are planned.
|
||||
4. She turns on `with_ccr` on the triplet's flag strip. The CCR child triplet expands inline. **Crucially:** the scenario is *project-backed* — the flag write also patches `projects.scenario_flags` (via existing `PATCH /api/projects/{id}/scenario-flags` from atlas P0). When she walks away, the project's deadlines + flags reflect the builder's state.
|
||||
5. She marks the `Widerklage auf Nichtigkeit` event card as "filed" with today's date. Builder writes a `paliad.deadlines` row with `status='done'` + `completed_at=today`, audit_reason "via Litigation Builder". Project's Verlauf reflects this.
|
||||
6. The CCR child triplet's `Antrag Patentänderung (R.30)` event card surfaces. She marks it "planned" and ticks the per-card optional horizon to "+2" → 2 more optional R.30-adjacent rules surface.
|
||||
7. Exit: she closes the tab. Project state persists in `paliad.projects` + `paliad.deadlines` as before; the scenario row tracks the builder-session view (so when she returns, the canvas state is restored — including her per-card optional-horizon picks).
|
||||
|
||||
### §2.4 Journey D — Promote scratch to a real project
|
||||
|
||||
**Persona:** Dr. Becker, follow-up from Journey A. The client committed; she wants to convert the scenario into a real matter.
|
||||
|
||||
1. With "Becker — UPC + EPA defensive" loaded, she clicks "Als Projekt anlegen" in the page header.
|
||||
2. **Wizard step 1: Bestätigen.** Read-only summary of what's about to be promoted: 2 proceedings (UPC inf + EPA opp), CCR child, 3 scenario flags set, 0 events filed, 5 events planned, 2 notes. "Weiter".
|
||||
3. **Wizard step 2: Parteien ergänzen.** Each proceeding's parties section shows whatever placeholder names she sketched in the scenario ("Klg X" / "Bekl Y"). She edits each into the real names. (Per m's Q11 pick — full carry — placeholder strings come in; the wizard's job is to clean them.)
|
||||
4. **Wizard step 3: Akte-Metadaten.** Case number, client, litigation parent project (optional), our_side (auto-set from the scenario's primary triplet), team selection. "Anlegen".
|
||||
5. New `paliad.projects` row written with `origin_scenario_id = <scenario.id>`. Scenario row's `status` flips to `promoted`, `promoted_project_id` points back. Builder navigates to `/projects/<new-id>`.
|
||||
6. The scenario stays read-only in her "Meine Szenarien" list under "Promoted", reachable for historical reference (cf. "this is what we planned at briefing time").
|
||||
|
||||
### §2.5 Journey E — Share a scenario with a colleague
|
||||
|
||||
**Persona:** Anna shares the HL-2024-001 builder session with Dr. Becker (her supervising partner) for review before committing to the CCR strategy.
|
||||
|
||||
1. Anna opens the scenario, clicks "Teilen" in the page header.
|
||||
2. Side panel slides in with a user-picker (HLC user search). She picks "Dr. Becker", clicks "Schreibgeschützt teilen".
|
||||
3. `paliad.scenario_shares` row written. Anna remains sole editor.
|
||||
4. Dr. Becker opens the tool. Her side panel "Meine Szenarien" has a new bucket "Geteilt mit mir"; Anna's scenario is listed. She opens it: canvas renders the same view but every mutating affordance (add proceeding, flag toggle, file/skip, promote, share) is disabled. Watermark: "Geteilt von Anna · schreibgeschützt".
|
||||
5. Becker reads, drops Anna a note via existing comment infrastructure (out of scope — separate ticket). Decision made out-of-band. Anna proceeds.
|
||||
|
||||
---
|
||||
|
||||
## §3 The canvas shape
|
||||
|
||||
### §3.1 ASCII sketch
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Paliad · Verfahren & Fristen — Litigation Builder [Mein Konto ▾] │
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Szenario: [Becker — UPC + EPA def. ▼] Gespeichert ✓ · [Benennen] [Teilen] [Als Projekt] │
|
||||
│ Akte: [— ohne — ▼] Stichtag: [2026-04-01] │
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Filter: [🔍 Klageerwiderung, Hinweis, HL-2024… ] │
|
||||
│ Forum [● UPC] [DE] [EPA] [DPMA] Verfahren [● upc.inf.cfi …] │
|
||||
│ Partei [Klg] [● Bekl] Ereignisart [filing] [hearing] [decision] │
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Einstieg: [ Übersicht ● ][ Ereignis ○ ][ Aus Akte ○ ] │
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─ upc.inf.cfi · Verletzungsverfahren UPC Bekl-Sicht [▾] [Detailgrad: Gewählt ▾]│
|
||||
│ │ Optionen: ☑ with_ccr ☐ with_amend ☐ with_cci [─][×] │
|
||||
│ ├─────────────────┬────────────────────┬─────────────────────────────────────────┤
|
||||
│ │ Proaktiv (Bekl) │ Gericht │ Reaktiv (Klg) │
|
||||
│ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
||||
│ │ │ Klageerw. │ │ │ │ Klageerh. │ │
|
||||
│ │ │ R.23 │ │ │ │ R.13 │ │
|
||||
│ │ │ planned │ │ │ │ filed │ │
|
||||
│ │ │ 2026-04-01 │ │ │ │ 2026-01-15 │ │
|
||||
│ │ │ +3 Optionen ▾│ │ │ │ │ │
|
||||
│ │ └─────────────┘ │ │ └─────────────┘ │
|
||||
│ │ │ ┌──────────────┐ │ │
|
||||
│ │ │ │ Mündl. Verh. │ │ │
|
||||
│ │ │ │ planned │ │ │
|
||||
│ │ │ │ [Gericht] │ │ │
|
||||
│ │ │ └──────────────┘ │ │
|
||||
│ │ ━━━━━━━━━ DU BIST HIER (Klageerwiderung) ━━━━━━━━━ │
|
||||
│ └─────────────────┴────────────────────┴─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌── (spawn child) upc.ccr.cfi · Widerklage auf Nichtigkeit Klg-Sicht [▾] ───────┐│
|
||||
│ │ Optionen: ☐ with_amend [─][×]││
|
||||
│ ├────────────────────┬─────────────────┬───────────────────────────────────────┐ ││
|
||||
│ │ Proaktiv (Klg) │ Gericht │ Reaktiv (Bekl) │ ││
|
||||
│ │ ┌─────────────┐ │ │ │ ││
|
||||
│ │ │ CCR-Antrag │ │ │ │ ││
|
||||
│ │ │ R.49 │ │ │ │ ││
|
||||
│ │ │ planned │ │ │ │ ││
|
||||
│ │ └─────────────┘ │ │ │ ││
|
||||
│ └────────────────────┴─────────────────┴───────────────────────────────────────┘ ││
|
||||
│ │
|
||||
│ ┌─ epa.opp.opd · Einspruchsverfahren EPA PatInh-Sicht [▾] [Detailgrad: Gewählt ▾]│
|
||||
│ │ Optionen: (keine flags für EPA Opp) [─][×] │
|
||||
│ ├─────────────────┬────────────────────┬─────────────────────────────────────────┤
|
||||
│ │ Proaktiv │ EPA │ Reaktiv (Einsprechende) │
|
||||
│ │ ┌─────────────┐ │ │ │
|
||||
│ │ │ Erwiderung │ │ │ │
|
||||
│ │ │ R.79(1) EPÜ │ │ │ │
|
||||
│ │ │ planned │ │ │ │
|
||||
│ │ └─────────────┘ │ │ │
|
||||
│ └─────────────────┴────────────────────┴─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [ + Verfahren hinzufügen ] │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Side panel (collapsible, right-edge):
|
||||
┌──── Meine Szenarien ────┐
|
||||
│ ● Aktiv │
|
||||
│ ▸ Becker — UPC+EPA def │ ← current
|
||||
│ ▸ Test-CCR-Patent-X │
|
||||
│ ○ Geteilt mit mir │
|
||||
│ ▸ Becker UPC ply │
|
||||
│ ○ Promoted │
|
||||
│ ▸ HL-2023-118 │
|
||||
│ ○ Archiviert (3) │
|
||||
│ [+ Neues Szenario] │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
### §3.2 What each element does
|
||||
|
||||
| Element | Read | Write | Persists in |
|
||||
|---|---|---|---|
|
||||
| Page-header scenario picker | Current `scenarios.id` + `name` | Switch scenarios | URL `?scenario=<id>` + DB |
|
||||
| `[Benennen]` button | Anonymous → named | `scenarios.name`, `status='active'` | DB |
|
||||
| `[Teilen]` button | — | `scenario_shares` row(s) | DB |
|
||||
| `[Als Projekt]` button | — | Opens promote wizard | (wizard → DB on commit) |
|
||||
| Akte picker | User's projects | Loads project state into builder | URL `?project=<id>` + DB |
|
||||
| Stichtag input | Scenario-level default | `scenarios.stichtag` | DB |
|
||||
| Filter strip (search + chips) | Free-text + dimension filters | UI state | URL `?q`, `?forum`, … per-mode |
|
||||
| Einstieg mode radio | Current entry mode | Resets filter strip on change | URL `?mode=` |
|
||||
| Triplet header (jurisdiction badge + name + perspective + Detailgrad) | `scenario_proceedings.{primary_party, detailgrad}` | Edit | DB |
|
||||
| Triplet flag strip | `scenario_proceedings.scenario_flags` | Toggle flags | DB |
|
||||
| Event card (state, date, notes, optional-horizon) | `scenario_events.*` | Edit per-card | DB |
|
||||
| `+ Verfahren hinzufügen` | — | New `scenario_proceedings` row | DB |
|
||||
| Side panel | User's scenarios + shared scenarios | Switch + create + archive | DB |
|
||||
|
||||
### §3.3 Columns: `proaktiv | court | reaktiv`
|
||||
|
||||
The 3-column layout returns as the canonical desktop shape. Per m's locked constraint (and brief #153), it is a **stance grouping**, not a sequence anchor — time flows top-to-bottom (chronological), columns express *who is acting*.
|
||||
|
||||
- **Proaktiv**: the column for events the active perspective's party initiates (their `primary_party` matches the event's `primary_party`).
|
||||
- **Court**: court-set events (`is_court_set=true`), neutral column.
|
||||
- **Reaktiv**: the column for events the opposing party initiates.
|
||||
|
||||
The perspective is per-proceeding (per-triplet, via `scenario_proceedings.primary_party`). When no perspective is set (`null`), both party columns render equally with their natural party labels (Klg / Bekl), not Proaktiv / Reaktiv. This means kontextfrei browsing reads as "claimant column | court | defendant column" until the user picks a side.
|
||||
|
||||
This addresses m's reverted-design bug #3 verbatim: "Proaktiv/Gericht/Reaktiv columns are a stance grouping, not a sequence anchor." Time = vertical. Stance = horizontal. The triplet is the unit; multiple proceedings stack vertically.
|
||||
|
||||
### §3.4 Event card anatomy
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Klageerwiderung │ ← event name (procedural_event.name)
|
||||
│ R.23 │ ← rule code
|
||||
│ planned │ ← state: planned / filed / skipped
|
||||
│ 2026-04-01 │ ← date (computed for planned, actual for filed)
|
||||
│ +3 Optionen ▾ │ ← per-card optional horizon (only when card has optionals)
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
State machine (m's Q10 pick — 3-state):
|
||||
|
||||
- `planned` (default): future event, date is computed from anchor + duration_value + duration_unit. Click → choose `filed` or `skipped`.
|
||||
- `filed`: past event, `actual_date` is set (defaults to computed, user can override). Visual: ✓ checkmark, slightly muted "past" tone.
|
||||
- `skipped`: user chose not to file. Visual: strikethrough text + optional `skip_reason` (textarea). Optional rules are commonly skipped without rationale; mandatory rules with `skipped` state flag the scenario as "non-standard" but don't block.
|
||||
|
||||
No `overdue` state — user does the date arithmetic by eye against today. (Mandatory cards rendered in red when `actual_date < today AND state=planned` is a **render hint**, not a stored state.)
|
||||
|
||||
**Per-card optional horizon (m's Q4 pick).** Each card with children at `priority IN ('optional','recommended-skip-by-default')` carries a chip `+N Optionen ▾`. Default N=0 (hidden). Clicking opens an inline list of the optional children with `+`/`-` controls to surface/hide them on the canvas. Per-card horizon persists as `scenario_events.horizon_optional int`.
|
||||
|
||||
Filed-state cards persist the date in `scenario_events.actual_date date`. The card's notes field (textarea, lazy-loaded) lives in `scenario_events.notes text`.
|
||||
|
||||
### §3.5 Court-set events
|
||||
|
||||
`is_court_set=true` rules don't compute a date until the court picks one. Card renders with `[Gericht]` badge in place of the date and a small "Datum eintragen" affordance. Clicking `filed` opens a date picker (date is required for `filed` state when `is_court_set=true` — the user is asserting "the court set this date").
|
||||
|
||||
Downstream events that anchor on a court-set event render their dates as `[abhängig von <event>]` until the court date is filed, then auto-recompute.
|
||||
|
||||
### §3.6 Spawn (child) proceedings
|
||||
|
||||
When a triplet has a `with_<flag>` enabled and the flag's gating rule has `is_spawn=true`, the child proceeding (e.g. `upc.ccr.cfi` for `with_ccr` on `upc.inf.cfi`) renders inline as a child triplet **immediately below the parent triplet** in the canvas stack — visually nested via the spawn note in the parent triplet's header band.
|
||||
|
||||
`scenario_proceedings.parent_scenario_proceeding_id` FK self-references for the nesting; `scenario_proceedings.spawn_anchor_event_id` points at the gating sequencing_rule so the UI knows where in the parent the spawn happened.
|
||||
|
||||
The child triplet has its own perspective, scenario flags, Stichtag override, Detailgrad. It can itself spawn (depth N supported; today's data is 2-deep at most).
|
||||
|
||||
Cross-proceeding peer triggers (`upc.inf judgment → epa.opp choice deadline`) are **out of scope for v1** (m's Q14 pick). v1 ships independent triplets stacked vertically; the user mentally tracks cross-dependencies. A future `scenario_event_links` table is the path to peer triggers in v1.1.
|
||||
|
||||
---
|
||||
|
||||
## §4 Hard decisions table — m's 20 picks
|
||||
|
||||
| # | Topic | Pick | Locks |
|
||||
|---|---|---|---|
|
||||
| Q1 | Modular meaning | "doesn't super apply" — drop modular as a load-bearing goal | §0.2 |
|
||||
| Q2 | Tab state semantics | Shared anchor + Akte across modes; filters reset per mode | §3.1, §3.2, §6 |
|
||||
| Q3 | Case-file integration | Page-header Akte picker, persistent across modes | §3.1, §3.2, §2.3 |
|
||||
| Q4 | Optional-display horizon | Per-event-card | §3.4 |
|
||||
| Q5 | Builder shape | Unified builder, 3 entry modes (cold-open / event-triggered / Akte) | §0, §1, §2, §3 |
|
||||
| Q6 | Scenario↔project relationship | Separate `paliad.scenarios` table + promote-to-project action | §5, §2.4 |
|
||||
| Q7 | Scenario contents | Multi-proceeding constellation per scenario | §3, §5 |
|
||||
| Q8 | Save model | Auto-save active scenario + "Meine Szenarien" list | §1, §3, §6.4 |
|
||||
| Q9 | Multi-proceeding render | Vertical stacked column-triplets | §3 |
|
||||
| Q10 | Per-event state | 3-state: planned / filed / skipped (no `overdue` state) | §3.4 |
|
||||
| Q11 | Promote-to-project carry | Everything (incl. placeholder parties + free-form notes) | §2.4, §5.4 |
|
||||
| Q12 | Sharing model | Private by default + explicit team-share (read-only) | §1, §5, §2.5 |
|
||||
| Q13 | Scenario flags placement | Per-proceeding (each triplet owns its `scenario_flags`) | §5.1 |
|
||||
| Q14 | Cross-proceeding peer triggers | Out of scope for v1 (defer to v1.1) | §3.6, §7 |
|
||||
| Q15 | Perspective scope | Per-proceeding (each triplet has its own `primary_party`) | §3.3, §5.1 |
|
||||
| Q16 | Add-proceeding flow | `+ Verfahren hinzufügen` button below the last triplet, inline picker | §3, §3.1 |
|
||||
| Q17 | Cold-open canvas | Empty canvas + "Neues Szenario" CTA + recent-list | §2.1, §3 |
|
||||
| Q18 | Search scope | Universal: events + scenarios + Akten, scoped by result type | §3.1, §6 |
|
||||
| Q19 | Promote-to-project flow | 3-step wizard (Bestätigen → Parteien ergänzen → Akte-Metadaten) | §2.4, §5.4 |
|
||||
| Q20 | Mobile treatment | Desktop v1, mobile basic-read (mutating actions prompt "Auf größerem Bildschirm öffnen") | §3, §7 |
|
||||
|
||||
### §4.1 Divergences from inventor recommendations
|
||||
|
||||
Three picks diverged from my recommendation. Captured here so future readers (m, the coder) see the *current* design, not the strawman.
|
||||
|
||||
- **Q1 — Modular.** Inventor recommended "plug-in widgets". m: "I don't know — generally does not super apply here." Modular is dropped as a goal; the natural decomposition (BuilderCanvas → ProceedingTriplet → EventCard → ScenarioListPanel → PromoteWizard) is documented in §6.2 as build hygiene, not as a load-bearing constraint.
|
||||
- **Q10 — Event state.** Inventor recommended 4-state (planned / filed / skipped / overdue). m picked 3-state — no `overdue` enum. Rationale (interpreted): `overdue` is derived from `date < today AND state=planned`, not stored; this avoids stale state when the date is edited.
|
||||
- **Q11 — Promote carry.** Inventor recommended carrying procedural shape + flags + filed-state + notes but **not** placeholder parties/case_number/billing. m picked "everything carries" — placeholder parties come in. Mitigation: Q19's 3-step wizard's step 2 (Parteien ergänzen) gives the user a chance to clean placeholders before commit, so the safety net m wanted on Q11 is folded into Q19.
|
||||
|
||||
### §4.2 Inventor picks not formally asked
|
||||
|
||||
A few decisions are inventor-set because they're either: (a) implementation details that don't change the architecture, or (b) clean defaults that match existing patterns. Listed here so they're visible; m can flag any.
|
||||
|
||||
- **Detailgrad ("Gewählt" / "Alle Optionen") scope**: per-proceeding (matches today's Verfahrensablauf pattern). State in `scenario_proceedings.detailgrad`.
|
||||
- **Akte picker shape**: flat dropdown sorted by recently-viewed first, with a typeahead filter for case numbers/names. Same shape as today's project picker on /agenda.
|
||||
- **Notes**: per-event-card (textarea on each card, lazy-loaded). Scenario-level notes also exist (`scenarios.notes text`) for cross-cutting commentary.
|
||||
- **Read-only shared state UI**: every mutating affordance is disabled (greyed, no click handlers). Watermark "Geteilt von <X> · schreibgeschützt" at the top of the canvas. No "Fork to my workspace" affordance in v1.
|
||||
- **URL contract**: minimal, view-state only — `?scenario=<id>&mode=<entry>&event=<sequencing_rule_id>` (deep-link to a specific anchor). Filter pills + chip state get URL params *per active entry mode* but explicitly NOT the constellation data (per m's "not every constellation in URL" guidance). The constellation lives in `paliad.scenario_*` tables.
|
||||
- **Auto-save granularity**: debounced 500ms on every change. Indicator near scenario name: `Gespeichert ✓` (last successful save < 5s ago), `Speichert…` (in flight), `Letzte Speicherung fehlgeschlagen — erneut versuchen` (on error).
|
||||
- **Soft delete**: archived scenarios stay in DB with `status='archived'`. No hard delete in v1.
|
||||
- **Audit**: no audit log on scenario edits (they're exploratory). Audit on promote-to-project goes via the existing `projects.audit_log`.
|
||||
- **Concurrent editing**: single-editor model. Owner is sole editor; shares are read-only. No locking / merge conflict UI needed in v1.
|
||||
- **Bilingual**: German primary, English via existing `i18n.ts`. Scenario names: user-chosen, any language. Skip reasons + notes: free-text, any language.
|
||||
|
||||
---
|
||||
|
||||
## §5 Data model deltas
|
||||
|
||||
All new tables live in `paliad.*` schema, alongside existing `paliad.projects` / `paliad.deadlines` / `paliad.sequencing_rules`.
|
||||
|
||||
### §5.1 New tables
|
||||
|
||||
```sql
|
||||
-- Scenario header. One row per saved scenario (named or scratch).
|
||||
CREATE TABLE paliad.scenarios (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL DEFAULT 'Unbenanntes Szenario',
|
||||
status text NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active','archived','promoted')),
|
||||
origin_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
|
||||
-- set when scenario was exported from a project
|
||||
promoted_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
|
||||
-- set when scenario was promoted to a project
|
||||
stichtag date NULL,
|
||||
-- scenario-level default Stichtag; per-triplet overrides take precedence
|
||||
notes text NULL,
|
||||
-- free-form scenario-level commentary
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX scenarios_owner_status_idx ON paliad.scenarios(owner_id, status);
|
||||
CREATE INDEX scenarios_updated_idx ON paliad.scenarios(owner_id, updated_at DESC);
|
||||
|
||||
-- One row per proceeding inside a scenario. Multiple per scenario for
|
||||
-- multi-proceeding constellations. parent_scenario_proceeding_id self-refs
|
||||
-- for spawned children (CCR child of UPC inf etc.).
|
||||
CREATE TABLE paliad.scenario_proceedings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scenario_id uuid NOT NULL REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
|
||||
proceeding_type_id uuid NOT NULL REFERENCES paliad.proceeding_types(id),
|
||||
primary_party text NULL
|
||||
CHECK (primary_party IN ('claimant','defendant')),
|
||||
-- per-proceeding perspective; null = no perspective picked yet
|
||||
scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
-- per-proceeding flags: {with_ccr: true, with_amend: false, …}
|
||||
parent_scenario_proceeding_id uuid NULL REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
|
||||
-- self-ref for spawned children (CCR child of UPC inf etc.)
|
||||
spawn_anchor_event_id uuid NULL REFERENCES paliad.sequencing_rules(id),
|
||||
-- which rule of the parent caused this spawn (for UI placement)
|
||||
ordinal int NOT NULL DEFAULT 0,
|
||||
-- stack order on canvas (top to bottom)
|
||||
stichtag date NULL,
|
||||
-- per-proceeding Stichtag override; falls back to scenarios.stichtag
|
||||
detailgrad text NOT NULL DEFAULT 'selected'
|
||||
CHECK (detailgrad IN ('selected','all_options')),
|
||||
appeal_target text NULL,
|
||||
-- applies_to_target for appeal proceedings; null for non-appeal triplets
|
||||
collapsed boolean NOT NULL DEFAULT false,
|
||||
-- user-collapsed triplet header (UI state)
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX scenario_proceedings_scenario_idx ON paliad.scenario_proceedings(scenario_id, ordinal);
|
||||
CREATE INDEX scenario_proceedings_parent_idx ON paliad.scenario_proceedings(parent_scenario_proceeding_id);
|
||||
|
||||
-- One row per event card on the canvas. Captures the card's state +
|
||||
-- per-card attributes (filed date, skip reason, notes, optional horizon).
|
||||
-- Most cards are sequencing-rule-backed; free-form events have a null
|
||||
-- sequencing_rule_id and a non-null procedural_event_id (or text label).
|
||||
CREATE TABLE paliad.scenario_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scenario_proceeding_id uuid NOT NULL REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
|
||||
sequencing_rule_id uuid NULL REFERENCES paliad.sequencing_rules(id),
|
||||
procedural_event_id uuid NULL REFERENCES paliad.procedural_events(id),
|
||||
-- one of {sequencing_rule_id, procedural_event_id, custom_label} must be set
|
||||
custom_label text NULL,
|
||||
-- free-form event name when neither sequencing_rule nor procedural_event apply
|
||||
state text NOT NULL DEFAULT 'planned'
|
||||
CHECK (state IN ('planned','filed','skipped')),
|
||||
actual_date date NULL,
|
||||
-- set when state='filed'; can also be set for state='planned' (court-set override)
|
||||
skip_reason text NULL,
|
||||
-- optional rationale when state='skipped'
|
||||
notes text NULL,
|
||||
-- per-card free-form
|
||||
horizon_optional int NOT NULL DEFAULT 0,
|
||||
-- per-card "show N more optionals" affordance
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (scenario_proceeding_id, sequencing_rule_id) WHERE sequencing_rule_id IS NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX scenario_events_proceeding_idx ON paliad.scenario_events(scenario_proceeding_id);
|
||||
|
||||
-- Read-only team shares. Owner is sole editor; shares grant view-only.
|
||||
CREATE TABLE paliad.scenario_shares (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scenario_id uuid NOT NULL REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
|
||||
shared_with_user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by uuid NOT NULL REFERENCES auth.users(id),
|
||||
UNIQUE (scenario_id, shared_with_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX scenario_shares_user_idx ON paliad.scenario_shares(shared_with_user_id);
|
||||
```
|
||||
|
||||
### §5.2 Additions to existing tables
|
||||
|
||||
```sql
|
||||
-- One nullable FK on paliad.projects to track which scenario spawned this
|
||||
-- project (set on promote-to-project). Auditable origin trail.
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN origin_scenario_id uuid NULL
|
||||
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX projects_origin_scenario_idx ON paliad.projects(origin_scenario_id)
|
||||
WHERE origin_scenario_id IS NOT NULL;
|
||||
```
|
||||
|
||||
No other changes to existing schema. `paliad.deadlines` continues to be the authoritative source for project-bound actuals; the builder writes to `paliad.deadlines` (not `scenario_events`) when working in Akte mode against a project-backed scenario.
|
||||
|
||||
### §5.3 RLS
|
||||
|
||||
Same pattern as existing `paliad.projects`:
|
||||
|
||||
- `scenarios` readable by `owner_id` OR by users with a matching `scenario_shares.shared_with_user_id` row.
|
||||
- `scenarios` writable only by `owner_id` (and only when `status != 'promoted'`).
|
||||
- `scenario_proceedings` + `scenario_events` cascade from scenario visibility.
|
||||
- `scenario_shares` readable by `shared_with_user_id` or `created_by`; writable only by the scenario owner.
|
||||
|
||||
Helper function `paliad.can_see_scenario(scenario_id)` mirrors the existing `paliad.can_see_project(project_id)` shape.
|
||||
|
||||
### §5.4 Promote-to-project: data flow
|
||||
|
||||
```
|
||||
[Wizard step 1: Bestätigen]
|
||||
Read: scenarios + scenario_proceedings + scenario_events
|
||||
Action: none (read-only summary)
|
||||
|
||||
[Wizard step 2: Parteien ergänzen]
|
||||
Read: scenario_proceedings.scenario_flags (for hints about placeholder party names)
|
||||
Action: builds an in-memory parties payload (per proceeding, per role)
|
||||
|
||||
[Wizard step 3: Akte-Metadaten]
|
||||
Read: user's clients + litigations + project tree (existing /projects API)
|
||||
Action: builds an in-memory project metadata payload
|
||||
|
||||
[Commit]
|
||||
Transaction:
|
||||
1. INSERT into paliad.projects (carrying step-2 + step-3 payloads, + scenario notes)
|
||||
SET origin_scenario_id = <scenario.id>
|
||||
2. INSERT into paliad.parties from step-2 payload
|
||||
3. For each scenario_proceeding (depth-first, parent before child):
|
||||
a. INSERT scenario_flags as projects.scenario_flags (parent-level only;
|
||||
children become sub-projects via parent_project_id)
|
||||
b. For each filed scenario_event: INSERT paliad.deadlines row with
|
||||
status='done', completed_at=actual_date, audit_reason='via Litigation Builder promotion'
|
||||
c. For each planned scenario_event: INSERT paliad.deadlines row with
|
||||
status='pending', due_date=computed (or actual_date override)
|
||||
d. Skipped events: not inserted (no deadline row)
|
||||
4. UPDATE paliad.scenarios SET status='promoted', promoted_project_id=<new>
|
||||
5. Navigate to /projects/<new>
|
||||
```
|
||||
|
||||
The deadlines write uses existing `POST /api/projects/{id}/deadlines/bulk` semantics under the hood — no new bulk-deadline-from-scenario endpoint needed.
|
||||
|
||||
---
|
||||
|
||||
## §6 Modular boundaries (light)
|
||||
|
||||
m said modular "doesn't super apply" — dropped as a load-bearing goal. The natural decomposition below is build-hygiene documentation, not a constraint the coder must enforce.
|
||||
|
||||
### §6.1 Front-end components
|
||||
|
||||
| Component | File | Responsibility |
|
||||
|---|---|---|
|
||||
| `BuilderCanvas` | `frontend/src/components/BuilderCanvas.tsx` | Root render of the builder. Receives the active scenario, renders triplet stack + cold-open empty state |
|
||||
| `ProceedingTriplet` | `frontend/src/components/ProceedingTriplet.tsx` | One proceeding's render: header strip (jurisdiction + name + perspective + Detailgrad + collapse + remove) + flag strip + 3 columns + spawn child triplets recursively |
|
||||
| `EventCard` | `frontend/src/components/EventCard.tsx` | One card in a column lane. State / date / optional-horizon / notes affordances |
|
||||
| `ScenarioFlagsStrip` | `frontend/src/components/ScenarioFlagsStrip.tsx` | Per-triplet flag toggles. Reads scenario_flag_catalog, applies to scenario_proceedings.scenario_flags |
|
||||
| `AddProceedingPicker` | `frontend/src/components/AddProceedingPicker.tsx` | Inline picker triggered by `+ Verfahren hinzufügen`. Forum chip row → Verfahren chip row → `Hinzufügen` |
|
||||
| `ScenarioListPanel` | `frontend/src/components/ScenarioListPanel.tsx` | Side panel: Aktiv / Geteilt / Promoted / Archiviert buckets + new-scenario CTA |
|
||||
| `PromoteToProjectWizard` | `frontend/src/components/PromoteToProjectWizard.tsx` | 3-step modal: Bestätigen / Parteien / Metadaten |
|
||||
| `PageHeaderControls` | `frontend/src/components/PageHeaderControls.tsx` | Scenario picker + Benennen/Teilen/Promote buttons + Akte picker + Stichtag input |
|
||||
| `EntryModeChrome` | `frontend/src/components/EntryModeChrome.tsx` | Cold-open / event-triggered / Akte mode radio; ephemeral UI affordance that fades into canvas state |
|
||||
|
||||
### §6.2 Client TS files
|
||||
|
||||
Mirror the React-ish component split:
|
||||
|
||||
- `frontend/src/client/builder.ts` — root orchestrator (auto-save loop, URL state, mode routing, scenario fetch)
|
||||
- `frontend/src/client/builder-scenario.ts` — scenario CRUD against `/api/scenarios`
|
||||
- `frontend/src/client/builder-event-card.ts` — per-card state machine + optional-horizon control
|
||||
- `frontend/src/client/builder-promote-wizard.ts` — 3-step wizard state machine
|
||||
- `frontend/src/client/builder-search.ts` — universal search (events + scenarios + Akten)
|
||||
- `frontend/src/client/builder-shares.ts` — share-with-team UI
|
||||
|
||||
### §6.3 Backend services + routes
|
||||
|
||||
| Service | File | Endpoints |
|
||||
|---|---|---|
|
||||
| `ScenarioService` | `internal/services/scenario_service.go` | List / Get / Create / Update / Archive / Promote |
|
||||
| `ScenarioProceedingService` | `internal/services/scenario_proceeding_service.go` | Add / Remove / Update (flags, perspective, ordinal, detailgrad) |
|
||||
| `ScenarioEventService` | `internal/services/scenario_event_service.go` | List / Update state / Set date / Set notes / Set horizon |
|
||||
| `ScenarioShareService` | `internal/services/scenario_share_service.go` | List / Add / Remove shares |
|
||||
| `ScenarioPromoteService` | `internal/services/scenario_promote_service.go` | Wizard-driven transactional promote |
|
||||
|
||||
Routes (added under existing API namespace):
|
||||
|
||||
```
|
||||
GET /api/scenarios — list user's scenarios (filtered by status)
|
||||
POST /api/scenarios — create new scenario
|
||||
GET /api/scenarios/{id} — get scenario + proceedings + events (deep)
|
||||
PATCH /api/scenarios/{id} — update name / stichtag / notes / status
|
||||
DELETE /api/scenarios/{id} — archive (soft delete; status='archived')
|
||||
POST /api/scenarios/{id}/proceedings — add proceeding to scenario
|
||||
PATCH /api/scenarios/{id}/proceedings/{pid} — update flags / perspective / ordinal / detailgrad
|
||||
DELETE /api/scenarios/{id}/proceedings/{pid} — remove proceeding (cascades to events)
|
||||
PATCH /api/scenarios/{id}/events/{eid} — update state / date / notes / horizon
|
||||
POST /api/scenarios/{id}/shares — share with user (read-only)
|
||||
DELETE /api/scenarios/{id}/shares/{sid} — revoke share
|
||||
POST /api/scenarios/{id}/promote — promote to project (3-step wizard payload)
|
||||
POST /api/scenarios/from-project/{project_id} — export project to a new scenario (what-if)
|
||||
GET /api/search — universal search (events + scenarios + Akten)
|
||||
```
|
||||
|
||||
Existing endpoints used unchanged:
|
||||
|
||||
- `GET /api/tools/fristenrechner/search?kind=events` — for the events corpus.
|
||||
- `GET /api/projects` — Akte picker source.
|
||||
- `POST /api/projects/{id}/deadlines/bulk` — promotion writes deadlines through this.
|
||||
- `PATCH /api/projects/{id}/scenario-flags` — Akte-mode flag sync.
|
||||
|
||||
---
|
||||
|
||||
## §7 Migration plan from current live shape
|
||||
|
||||
Current live (`/tools/procedures` on main @ `ed3c5d1`) = cronus's U0-U4 4-tab catalog. Migration is a 6-slice train, every slice ships visibly. No feature flag (m's pattern preference per #152 Q7).
|
||||
|
||||
### §7.1 Slice train
|
||||
|
||||
| Slice | What ships | DB | Visible to user |
|
||||
|---|---|---|---|
|
||||
| **B0 — Scenario DB foundation** | New tables (scenarios + scenario_proceedings + scenario_events + scenario_shares) + RLS + minimal API (list / create / get). Scenarios writable from a developer-only test route at first. | Mig #N (new tables + RLS + `paliad.projects.origin_scenario_id`) | No user-visible change. |
|
||||
| **B1 — Builder shell + cold-open mode** | New `/tools/procedures` page replaces the 4-tab catalog. Renders: page header (scenario picker + Akte picker + Stichtag + search), entry-mode radio (cold-open active), filter strip, empty canvas + "Neues Szenario starten" CTA + recent list. Add-proceeding picker works; first triplet renders with the existing Verfahrensablauf-core calc. Auto-save active scenario. Side panel "Meine Szenarien" with Aktiv bucket only. | — | New page visible. Single triplet works end-to-end. |
|
||||
| **B2 — Multi-triplet + spawn nesting + per-event state** | Vertical multi-triplet stack with `+ Verfahren hinzufügen`. Per-triplet perspective + flag strip. Spawn child triplets render inline. Event cards get the 3-state machine (planned/filed/skipped) + date editor + per-card optional horizon chip. Page-header Stichtag drives default dates. | — | Full scenario builder works without Akte integration. |
|
||||
| **B3 — Event-triggered mode + universal search** | "Ereignis" entry mode wires the search box to land on a single-triplet anchored view (scratch scenario). Universal search returns events + scenarios + Akten with type-scoped result groups. Filter pills (forum/proc/party/kind) reset on mode switch. | — | Event lookup works. |
|
||||
| **B4 — Akte mode + project-backed scenarios** | "Aus Akte" entry mode + page-header Akte picker. Loads project state into the builder (proceeding + perspective + scenario_flags + deadlines actuals). Akte-backed scenarios write through to `paliad.deadlines` + `paliad.projects.scenario_flags`; non-Akte scenarios write to `paliad.scenario_events`. Cross-surface scenario-flag-changed event listener reused from #152 T3. | — | Akte integration works end-to-end. |
|
||||
| **B5 — Share + Promote-to-project wizard** | "Teilen" button + user picker + share row. "Geteilt mit mir" bucket in side panel. "Als Projekt anlegen" opens the 3-step wizard (Bestätigen → Parteien ergänzen → Akte-Metadaten). Successful commit creates project + cascades deadlines + sets `origin_scenario_id`, navigates to /projects/{id}. "Promoted" bucket in side panel. | — | Sharing + promotion work. |
|
||||
| **B6 — Mobile basic-read + cleanup + i18n polish** | Mobile (<640px) shows scenarios + cards read-only; mutating affordances prompt "Auf größerem Bildschirm öffnen". Cleanup: delete dead U0-U4 catalog code (4-tab control, legacy `verfahrensablauf.ts`, etc.). All i18n keys finalised (DE + EN). | — | Mobile works; codebase cleaner. |
|
||||
|
||||
### §7.2 Why this train shape
|
||||
|
||||
- **B0 is DB-only**. The schema can land independently and be exercised via test routes / Supabase MCP before any UI sees it. Keeps mig risk isolated.
|
||||
- **B1-B2 are the MVP**. After B2, a user can build and save a multi-proceeding scenario fully kontextfrei. That alone replaces 60% of today's catalog use.
|
||||
- **B3 adds the lookup path**. After B3, "what's next after Klageerwiderung?" works without saving.
|
||||
- **B4 makes it real**. Akte integration is the load-bearing piece for daily use; ships once the foundation is stable.
|
||||
- **B5 unlocks team value**. Sharing + promotion are the difference between "personal tool" and "team tool". Ship after the core works.
|
||||
- **B6 is cleanup**. Mobile read + dead code removal land last to avoid coupling to in-flight features.
|
||||
|
||||
### §7.3 What stays unchanged
|
||||
|
||||
- URL `/tools/procedures` keeps it (the new builder lives there).
|
||||
- Sidebar entry "Verfahren & Fristen" keeps it.
|
||||
- cmd-K palette keeps it.
|
||||
- `/tools/fristenrechner` + `/tools/verfahrensablauf` legacy redirects (from cronus's U4) stay alive: 301 → `/tools/procedures` (the builder).
|
||||
- `pkg/litigationplanner.CalculateRule` — untouched.
|
||||
- `/admin/procedural-events` — untouched.
|
||||
- `/projects/{id}` Verlauf — untouched (new "Im Builder öffnen" button is the only addition).
|
||||
|
||||
### §7.4 Cleanup at B6
|
||||
|
||||
Dead code to delete (verify with grep before deletion):
|
||||
|
||||
- `frontend/src/components/VerfahrensablaufBody.tsx` (replaced by ProceedingTriplet)
|
||||
- `frontend/src/client/verfahrensablauf.ts` (replaced by builder.ts orchestration)
|
||||
- `frontend/src/client/views/verfahrensablauf-state.ts` (replaced by scenario-backed state)
|
||||
- `frontend/src/client/views/verfahrensablauf-state.test.ts`
|
||||
- ~~`frontend/src/client/verfahrensablauf-detail-mode.ts`~~ — KEEP. Builder imports `filterByDetailMode` from it; per-triplet Detailgrad reuses this module.
|
||||
- Existing scratch tab content in `frontend/src/client/procedures.ts` (4-tab toggling logic, mode routing)
|
||||
|
||||
**Kept**:
|
||||
|
||||
- `frontend/src/client/views/verfahrensablauf-core.ts` (calculation engine; reused by EventCard + ProceedingTriplet)
|
||||
- Legacy URL redirects in Go (`/tools/fristenrechner` + `/tools/verfahrensablauf` → `/tools/procedures`)
|
||||
|
||||
---
|
||||
|
||||
## §8 Open follow-ups (out of scope for v1)
|
||||
|
||||
Tracked for v1.1 / future tickets:
|
||||
|
||||
- **Cross-proceeding peer triggers** (UPC-inf judgment → EPA opp choice deadline). New `paliad.scenario_event_links` table. UI: trigger-picker chip on event cards.
|
||||
- **DE / EPA / DPMA full expansion**. v1 supports EPA + DPMA proceedings at the data layer (calc engine handles them), but the spawn flags and CCR-style nestings are UPC-specific. Other jurisdictions get proper coverage in v1.1.
|
||||
- **Scenario versioning / snapshots**. m's Q8 alternative ("versioned snapshots") deferred. Add when scenarios start driving client briefings.
|
||||
- **Multi-user concurrent editing**. Out of scope. Single-editor model with read-only shares is sufficient until usage shows otherwise.
|
||||
- **Fork-a-shared-scenario**. Read-only sharing in v1 doesn't expose "fork into my workspace". Add when team usage demands it.
|
||||
- **Comments on scenarios / event cards**. Out of scope (separate ticket).
|
||||
- **PDF export of a scenario for client briefings**. Out of scope.
|
||||
- **Mobile-parity edits**. v1.1 — full mobile interaction loop.
|
||||
- **Audit log on scenario edits**. Out of scope (exploratory data).
|
||||
- **Cross-scenario comparison view**. ("Compare planned vs actual" lives on the project page via promote-then-compare; explicit comparison tool is v2.)
|
||||
|
||||
---
|
||||
|
||||
## §9 Synthesis links
|
||||
|
||||
- **mBrian**: file as `[synthesis]` linked `triggered_by` t-paliad-339; `related_to` atlas's reverted tracker design, cronus's unified-procedural-events-tool design, atlas's deadline-system-revision.
|
||||
- **Cross-refs in this repo**: `docs/design-procedures-workflow-tracker-2026-05-27.md` (atlas, reverted), `docs/design-unified-procedural-events-tool-2026-05-27.md` (cronus, live), `docs/design-deadline-system-revision-2026-05-27.md` (atlas Phase 2), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26).
|
||||
- **Gitea**: m/paliad#153 (this PRD), m/paliad#152 (atlas's tracker, reverted), m/paliad#151 (cronus U0-U4 shipped), m/paliad#149 (atlas Phase 2 in flight).
|
||||
- **Coder phase** (deferred per inventor SKILL): runs after m ratifies this PRD. Slice ordering per §7.1. NOT edison (parked at DESIGN READY FOR REVIEW). NOT atlas (just-rejected tracker → framing bias). NOT cronus (parked on Fristenrechner inventor branch). A pattern-fluent Sonnet coder picks up B0 first.
|
||||
|
||||
---
|
||||
|
||||
## §10 Coder hand-off notes
|
||||
|
||||
(Pre-emptive — for whoever picks up B0.)
|
||||
|
||||
- **Migration number**: check `internal/db/migrations/` for the max slot at coder shift start. Two recent migrations (curie's t-paliad-336, ritchie's t-paliad-149 P0) are in flight; coordinate via paliadin/head before claiming a slot.
|
||||
- **Akte integration nuance**: when the builder is in Akte mode and the scenario is project-backed, writes flow to `paliad.deadlines` / `paliad.projects.scenario_flags` instead of `paliad.scenario_*` tables — the scenario row itself just records the canvas view-state (which triplets are visible, ordinal, collapsed state, per-card horizon). This dual-write rule is the load-bearing complexity of B4; design tests for it explicitly.
|
||||
- **Auto-save throttling**: 500ms debounce per change. Avoid PATCH-per-keystroke on notes textareas (use blur-trigger + 2s debounce there).
|
||||
- **Search performance**: universal search (events + scenarios + Akten) needs to stay snappy. Events corpus is ~3000 rows; scenarios/Akten are per-user. Use existing trgm indexes; avoid joining across all three for ranking.
|
||||
- **B5 transactional promotion**: do the wizard's commit in a single Postgres transaction. If any of (project insert / parties / deadlines / scenario status update) fails, roll back atomically. No partial promotions.
|
||||
- **Mobile rendering**: B6 is meant to be cheap. Column-triplet → CSS grid that collapses to single-column at `@media (max-width: 640px)`. Mutating affordances get `pointer-events: none` + a click-handler that surfaces the "Auf größerem Bildschirm öffnen" toast — keeps the desktop interaction code paths unchanged.
|
||||
- **i18n keys**: every user-facing string gets `data-i18n` from B1. Don't accumulate i18n debt across slices.
|
||||
@@ -1,280 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a markdown deadline-list export for UPC PA training (work/head delegation #2572).
|
||||
|
||||
Sorts by proceeding-type display_order then sequence_order. Sections by proceeding.
|
||||
|
||||
t-paliad-348 / yoUPC#178 update: matches the engine's `IncludeOptional=false`
|
||||
default (`pkg/litigationplanner/engine.go`). Optional rules (priority='optional')
|
||||
are SUPPRESSED by default so the manuscript shows the same "naked proceeding
|
||||
backbone" the UI now renders. Pass `--include-optional` to opt back in for an
|
||||
exhaustive catalog dump.
|
||||
|
||||
Usage:
|
||||
uv run exports/gen-deadline-list.py [--include-optional] [-o OUT]
|
||||
"""
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["psycopg2-binary"]
|
||||
# ///
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
DSN = os.environ.get(
|
||||
"PALIAD_DEADLINE_EXPORT_DSN",
|
||||
"postgres://postgres:rpsak3yf4lu1izgefx9p9xweg3qroojw@100.99.98.201:11833/postgres?sslmode=disable",
|
||||
)
|
||||
|
||||
# `priority` filter is wired at the SQL level (not post-filter in Python) so
|
||||
# the row counter in the markdown header reflects what's actually in the
|
||||
# manuscript — matching what the lawyer sees on /tools/procedures.
|
||||
SQL_TEMPLATE = """
|
||||
SELECT
|
||||
pt.code AS pt_code,
|
||||
pt.display_order,
|
||||
COALESCE(pt.name_en, pt.name) AS pt_label_en,
|
||||
pt.name AS pt_label_de,
|
||||
COALESCE(pe.name_en, pe.name) AS event_en,
|
||||
pe.name AS event_de,
|
||||
sr.duration_value,
|
||||
sr.duration_unit,
|
||||
sr.timing,
|
||||
sr.alt_duration_value,
|
||||
sr.alt_duration_unit,
|
||||
sr.combine_op,
|
||||
sr.rule_code,
|
||||
COALESCE(te.name, te.name_de) AS trigger_label,
|
||||
te.code AS trigger_code,
|
||||
sr.primary_party,
|
||||
sr.is_court_set,
|
||||
sr.is_spawn,
|
||||
sr.priority,
|
||||
sr.deadline_notes_en,
|
||||
sr.deadline_notes,
|
||||
sr.condition_expr,
|
||||
sr.sequence_order
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
|
||||
LEFT JOIN paliad.trigger_events te ON te.id = sr.trigger_event_id
|
||||
WHERE sr.lifecycle_state = 'published'
|
||||
AND sr.is_active = true
|
||||
AND pt.id IS NOT NULL
|
||||
{priority_filter}
|
||||
ORDER BY pt.display_order NULLS LAST, pt.code, sr.sequence_order NULLS LAST, sr.rule_code, pe.name;
|
||||
"""
|
||||
|
||||
|
||||
def format_frist(duration_value, duration_unit, timing, alt_value, alt_unit, combine_op):
|
||||
"""Format the deadline duration cleanly."""
|
||||
if duration_value is None or duration_unit is None:
|
||||
return ""
|
||||
unit_map = {
|
||||
"days": "d",
|
||||
"weeks": "w",
|
||||
"months": "M",
|
||||
"years": "y",
|
||||
"calendar_days": "CD",
|
||||
"working_days": "WD",
|
||||
}
|
||||
unit = unit_map.get(duration_unit, duration_unit)
|
||||
main = f"{duration_value} {unit}"
|
||||
if alt_value is not None and alt_unit is not None:
|
||||
alt_unit_short = unit_map.get(alt_unit, alt_unit)
|
||||
op = combine_op or "or"
|
||||
main = f"{main} {op} {alt_value} {alt_unit_short}"
|
||||
if timing == "before":
|
||||
main = f"{main} before"
|
||||
elif timing == "after":
|
||||
main = f"{main} after"
|
||||
return main
|
||||
|
||||
|
||||
def format_party(primary_party, is_court_set):
|
||||
if is_court_set:
|
||||
return "court-set"
|
||||
if primary_party == "claimant":
|
||||
return "claimant"
|
||||
if primary_party == "defendant":
|
||||
return "defendant"
|
||||
if primary_party == "both":
|
||||
return "either"
|
||||
if primary_party == "court":
|
||||
return "court"
|
||||
return primary_party or "—"
|
||||
|
||||
|
||||
def detect_r94(notes_en, notes_de):
|
||||
"""Flag R.9.4 non-extendable from notes text (heuristic — no DB field)."""
|
||||
blobs = " ".join(filter(None, [notes_en or "", notes_de or ""])).lower()
|
||||
if "r.9.4" in blobs or "r 9.4" in blobs or "r9.4" in blobs:
|
||||
return "✗"
|
||||
if "non-extendable" in blobs or "nicht verlängerbar" in blobs or "nicht verlaengerbar" in blobs:
|
||||
return "✗"
|
||||
return ""
|
||||
|
||||
|
||||
def conditional_marker(condition_expr):
|
||||
if condition_expr in (None, "", {}):
|
||||
return ""
|
||||
# condition_expr is JSONB → returns dict
|
||||
if isinstance(condition_expr, dict):
|
||||
if "flag" in condition_expr:
|
||||
return f"if `{condition_expr['flag']}`"
|
||||
if condition_expr.get("op") == "and" and "args" in condition_expr:
|
||||
flags = [a.get("flag", "?") for a in condition_expr["args"]]
|
||||
return "if " + " & ".join(f"`{f}`" for f in flags)
|
||||
if condition_expr.get("op") == "or" and "args" in condition_expr:
|
||||
flags = [a.get("flag", "?") for a in condition_expr["args"]]
|
||||
return "if " + " | ".join(f"`{f}`" for f in flags)
|
||||
return "cond"
|
||||
|
||||
|
||||
def md_escape(s):
|
||||
if s is None:
|
||||
return ""
|
||||
return str(s).replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
|
||||
def render(rows, *, include_optional: bool, generated_for: str) -> str:
|
||||
by_pt = {}
|
||||
for r in rows:
|
||||
key = (r["display_order"] or 9999, r["pt_code"], r["pt_label_de"], r["pt_label_en"])
|
||||
by_pt.setdefault(key, []).append(r)
|
||||
|
||||
out = []
|
||||
today = date.today().isoformat()
|
||||
out.append(f"# UPC + DE/EP Deadline Catalog — Stand {today}")
|
||||
out.append("")
|
||||
out.append(f"Source: `paliad.sequencing_rules` (lifecycle_state=published, is_active=true).")
|
||||
out.append(f"Generated for {generated_for}. {len(rows)} rules across {len(by_pt)} proceedings.")
|
||||
if include_optional:
|
||||
out.append("")
|
||||
out.append(
|
||||
"**Mode:** `--include-optional` — every published rule, including "
|
||||
"`priority='optional'` rules suppressed by the engine's default "
|
||||
"(`IncludeOptional=false`). This is the exhaustive catalog dump."
|
||||
)
|
||||
else:
|
||||
out.append("")
|
||||
out.append(
|
||||
"**Mode:** default — matches the engine's `IncludeOptional=false` "
|
||||
"behaviour (pkg/litigationplanner/engine.go). `priority='optional'` "
|
||||
"rules are suppressed; the manuscript shows only the mandatory "
|
||||
"backbone the lawyer sees by default on /tools/procedures. "
|
||||
"Re-run with `--include-optional` for the full catalog. "
|
||||
"(t-paliad-348 / yoUPC#178)"
|
||||
)
|
||||
out.append("")
|
||||
out.append("**Spalten:**")
|
||||
out.append("- **Phase/Event** = procedural event (German primary)")
|
||||
out.append("- **Frist** = duration + timing (`d` days, `w` weeks, `M` months, `CD` calendar days, `WD` working days; `before` = relative to anchor)")
|
||||
out.append("- **Rule** = legal source (RoP / § ZPO / § PatG / Art. EPÜ)")
|
||||
out.append("- **Anchor** = trigger event the deadline runs from")
|
||||
out.append("- **Seite** = filing party (claimant / defendant / either / court-set)")
|
||||
out.append("- **Priorität** = mandatory / recommended / optional / informational (only when `--include-optional`)")
|
||||
out.append("- **R.9.4** = ✗ marked non-extendable in notes (heuristic — confirm against rule text)")
|
||||
out.append("- **Bedingung** = scenario flag(s) that must be set for the rule to fire (blank = always)")
|
||||
out.append("")
|
||||
out.append("---")
|
||||
out.append("")
|
||||
|
||||
for (order, pt_code, pt_de, pt_en) in sorted(by_pt.keys()):
|
||||
prules = by_pt[(order, pt_code, pt_de, pt_en)]
|
||||
out.append(f"## {pt_de} · `{pt_code}`")
|
||||
out.append("")
|
||||
if pt_en and pt_en != pt_de:
|
||||
out.append(f"*{pt_en}*")
|
||||
out.append("")
|
||||
if include_optional:
|
||||
out.append("| # | Phase / Event | Frist | Rule | Anchor | Seite | Priorität | R.9.4 | Bedingung |")
|
||||
out.append("|---:|---|---|---|---|---|---|:---:|---|")
|
||||
else:
|
||||
out.append("| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |")
|
||||
out.append("|---:|---|---|---|---|---|:---:|---|")
|
||||
for i, r in enumerate(prules, 1):
|
||||
event = md_escape(r["event_de"] or r["event_en"] or "")
|
||||
frist = md_escape(
|
||||
format_frist(
|
||||
r["duration_value"], r["duration_unit"], r["timing"],
|
||||
r["alt_duration_value"], r["alt_duration_unit"], r["combine_op"],
|
||||
)
|
||||
)
|
||||
rule = md_escape(r["rule_code"] or "")
|
||||
anchor = md_escape(r["trigger_label"] or "")
|
||||
party = format_party(r["primary_party"], r["is_court_set"])
|
||||
r94 = detect_r94(r["deadline_notes_en"], r["deadline_notes"])
|
||||
cond = md_escape(conditional_marker(r["condition_expr"]))
|
||||
spawn_marker = " ⤴" if r["is_spawn"] else ""
|
||||
if include_optional:
|
||||
priority = md_escape(r["priority"] or "")
|
||||
out.append(
|
||||
f"| {i} | {event}{spawn_marker} | {frist} | {rule} | {anchor} | {party} | {priority} | {r94} | {cond} |"
|
||||
)
|
||||
else:
|
||||
out.append(
|
||||
f"| {i} | {event}{spawn_marker} | {frist} | {rule} | {anchor} | {party} | {r94} | {cond} |"
|
||||
)
|
||||
out.append("")
|
||||
|
||||
out.append("---")
|
||||
out.append("")
|
||||
out.append("**Lesehilfe:**")
|
||||
out.append("- ⤴ Spawn-Marker: event opens a sub-proceeding (e.g. CCR forks revocation track)")
|
||||
out.append("- `with_ccr` = Widerklage auf Nichtigkeit gefilt | `with_amend` = Patentänderungsantrag | `with_cci` = Widerklage auf Verletzung (in rev.cfi)")
|
||||
out.append("- Catalog ist work-in-progress: 7 compound-name rules + Patentänderung-Duplikation noch in m's split-review backlog (m/paliad#149).")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--include-optional",
|
||||
action="store_true",
|
||||
help="Include priority='optional' rules. Default false matches the engine's IncludeOptional=false default.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--out",
|
||||
default="exports/upc-deadlines-2026-05-28.md",
|
||||
help="Output path (relative to repo root).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--generated-for",
|
||||
default="PA-Schulung 2026-05-28",
|
||||
help="Free-text label rendered in the markdown header.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
priority_filter = "" if args.include_optional else "AND sr.priority != 'optional'"
|
||||
sql = SQL_TEMPLATE.format(priority_filter=priority_filter)
|
||||
|
||||
conn = psycopg2.connect(DSN)
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(sql)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
md = render(rows, include_optional=args.include_optional, generated_for=args.generated_for)
|
||||
# Resolve out path relative to the repo root (= the script's grandparent).
|
||||
out_path = Path(args.out)
|
||||
if not out_path.is_absolute():
|
||||
out_path = Path(__file__).resolve().parent.parent / out_path
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(md)
|
||||
n_pt = len({(r["display_order"] or 9999, r["pt_code"]) for r in rows})
|
||||
print(
|
||||
f"WROTE {out_path} ({len(rows)} rules, {n_pt} proceedings, "
|
||||
f"include_optional={args.include_optional})"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 156 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 490 KiB |
@@ -1,378 +0,0 @@
|
||||
# UPC + DE/EP Deadline Catalog — Stand 2026-05-28
|
||||
|
||||
Source: `paliad.sequencing_rules` (lifecycle_state=published, is_active=true).
|
||||
Generated for PA-Schulung 2026-05-28. 178 rules across 25 proceedings.
|
||||
|
||||
**Mode:** default — matches the engine's `IncludeOptional=false` behaviour (pkg/litigationplanner/engine.go). `priority='optional'` rules are suppressed; the manuscript shows only the mandatory backbone the lawyer sees by default on /tools/procedures. Re-run with `--include-optional` for the full catalog. (t-paliad-348 / yoUPC#178)
|
||||
|
||||
**Spalten:**
|
||||
- **Phase/Event** = procedural event (German primary)
|
||||
- **Frist** = duration + timing (`d` days, `w` weeks, `M` months, `CD` calendar days, `WD` working days; `before` = relative to anchor)
|
||||
- **Rule** = legal source (RoP / § ZPO / § PatG / Art. EPÜ)
|
||||
- **Anchor** = trigger event the deadline runs from
|
||||
- **Seite** = filing party (claimant / defendant / either / court-set)
|
||||
- **Priorität** = mandatory / recommended / optional / informational (only when `--include-optional`)
|
||||
- **R.9.4** = ✗ marked non-extendable in notes (heuristic — confirm against rule text)
|
||||
- **Bedingung** = scenario flag(s) that must be set for the rule to fire (blank = always)
|
||||
|
||||
---
|
||||
|
||||
## Verletzungsverfahren · `upc.inf.cfi`
|
||||
|
||||
*Infringement Action*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Klageerhebung | 0 M after | RoP.013.1 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 3 M after | RoP.023 | | defendant | | |
|
||||
| 3 | Replik | 2 M or 2 M after | RoP.029.b | | claimant | | if `with_ccr` |
|
||||
| 4 | Duplik | 1 M or 2 M after | RoP.029.c | | defendant | | if `with_ccr` |
|
||||
| 5 | Erwiderung auf Nichtigkeitswiderklage | 2 M after | RoP.029.a | | claimant | | if `with_ccr` |
|
||||
| 6 | Replik auf Erwiderung zur Nichtigkeitswiderklage | 2 M after | RoP.029.d | | defendant | | if `with_ccr` |
|
||||
| 7 | Duplik auf Replik zur Erwiderung Nichtigkeitswiderklage | 1 M after | RoP.029.e | | claimant | | if `with_ccr` |
|
||||
| 8 | Antrag auf Patentänderung | 2 M after | RoP.030.1 | | claimant | | if `with_ccr` & `with_amend` |
|
||||
| 9 | Erwiderung auf Patentänderungsantrag | 2 M after | RoP.032.1 | | defendant | | if `with_ccr` & `with_amend` |
|
||||
| 10 | Replik auf Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | | claimant | | if `with_ccr` & `with_amend` |
|
||||
| 11 | Duplik auf Replik zum Patentänderungsantrag | 1 M after | RoP.032.3 | | defendant | | if `with_ccr` & `with_amend` |
|
||||
| 12 | Zwischenanhörung | 0 M after | RoP.105 | | court-set | | |
|
||||
| 13 | Mitteilung Dolmetscherkosten | 2 w before | RoP.109.4 | Oral hearing | court | | |
|
||||
| 14 | Übersetzungen einreichen | 2 w after | RoP.109.5 | | either | | |
|
||||
| 15 | Mündliche Verhandlung | 0 M after | RoP.112 | | court-set | | |
|
||||
| 16 | Entscheidung | 0 M after | RoP.118.1 | | court-set | | |
|
||||
| 17 | Duplik zur Replik auf die Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | Reply to the Defence to an Application to amend the patent | defendant | | if `with_ccr` & `with_amend` |
|
||||
| 18 | Einreichung von Übersetzungen von Schriftstücken | 1 M after | RoP.007.4 | Order of the judge-rapporteur to lodge translations | either | | |
|
||||
| 19 | Antrag auf Simultanübersetzung | 1 M before | RoP.109.5 | Oral hearing | either | | |
|
||||
| 20 | Antrag auf Folgemaßnahmen aus einer rechtskräftigen Validitätsentscheidung | 2 M after | RoP.118.4 | Final decision of the central division, Court of Appeal or EPO on the validity of the patent | either | | if `with_ccr` |
|
||||
| 21 | Antrag auf Überprüfung einer verfahrensleitenden Anordnung | 15 d after | RoP.333 | Case management order (Service) | either | | |
|
||||
| 22 | Mängelbeseitigung / Einreichung schriftlicher Stellungnahme | 14 d after | RoP.019 | Preliminary Objection | either | | |
|
||||
| 23 | Mängelbeseitigung / Zahlung | 14 d after | RoP.016 | Notification by the Registry to correct deficiencies | either | | |
|
||||
| 24 | Antrag auf Verweisung an die Zentralkammer | 10 d after | RoP.323 | Information by the Court not to approve Application to use the patent's language as language of the proceedings | either | | |
|
||||
| 25 | Mitteilung über Beauftragung eines Dolmetschers auf Kosten der Partei | 2 w before | RoP.109.5 | Oral hearing | either | | |
|
||||
| 26 | Klärung von Übersetzungsfragen | 2 w after | RoP.109 | Summons to Oral Hearing | court | | |
|
||||
| 27 | Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit | 14 d after | RoP.262.2 | Opponent Submission | either | | |
|
||||
| 28 | Wiedereinsetzungsantrag (UPC R.320) | 2 M after | RoP.320 | Removal of obstacle (UPC R.320) | either | | |
|
||||
|
||||
## Verletzungsverfahren (LG) · `de.inf.lg`
|
||||
|
||||
*Infringement (Regional Court)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Klageerhebung | 0 M after | § 253 ZPO | | claimant | | |
|
||||
| 2 | Anzeige der Verteidigungsbereitschaft | 2 w after | § 276 ZPO | | defendant | | |
|
||||
| 3 | Klageerwiderung | 6 w after | § 276 ZPO | | court-set | | |
|
||||
| 4 | Replik | 4 w after | § 282 ZPO | | court-set | | |
|
||||
| 5 | Duplik | 4 w after | § 282 ZPO | | court-set | | |
|
||||
| 6 | Haupttermin | 0 M after | § 279 ZPO | | court-set | | |
|
||||
| 7 | Urteil | 0 M after | § 300 ZPO | | court-set | | |
|
||||
| 8 | Berufungsfrist | 1 M after | § 517 ZPO | | either | | |
|
||||
| 9 | Berufungsbegründung | 2 M after | § 520 ZPO | | either | | |
|
||||
| 10 | Wiedereinsetzungsantrag (§ 233 ZPO) | 2 w after | § 233 ZPO | Removal of obstacle (ZPO §233) | — | | |
|
||||
| 11 | Einspruch gegen Versäumnisurteil (§ 339 ZPO) | 2 w after | § 339 ZPO | Service of default judgment | — | | |
|
||||
| 12 | Schriftsatznachreichung (§ 296a ZPO) | 3 w after | § 296a ZPO | End of oral hearing | — | | |
|
||||
|
||||
## Nichtigkeitsverfahren · `upc.rev.cfi`
|
||||
|
||||
*Revocation Action*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Nichtigkeitsklage | 0 M after | RoP.044 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | RoP.049.1 | | defendant | | |
|
||||
| 3 | Antrag auf Patentänderung | 0 M after | RoP.049.2.a | | defendant | | if `with_amend` |
|
||||
| 4 | Verletzungswiderklage | 0 M after | RoP.049.2.b | | defendant | | if `with_cci` |
|
||||
| 5 | Replik | 2 M after | RoP.051 | | claimant | | |
|
||||
| 6 | Erwiderung auf Patentänderungsantrag | 2 M after | RoP.043.3 | | claimant | | if `with_amend` |
|
||||
| 7 | Erwiderung auf Verletzungswiderklage | 2 M after | RoP.056.1 | | claimant | | if `with_cci` |
|
||||
| 8 | Duplik | 1 M after | RoP.052 | | defendant | | |
|
||||
| 9 | Replik auf Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | | defendant | | if `with_amend` |
|
||||
| 10 | Replik auf Erwiderung zur Verletzungswiderklage | 1 M after | RoP.056.3 | | defendant | | if `with_cci` |
|
||||
| 11 | Duplik auf Replik zum Patentänderungsantrag | 1 M after | RoP.032.3 | | claimant | | if `with_amend` |
|
||||
| 12 | Duplik auf Replik zur Erwiderung Verletzungswiderklage | 1 M after | RoP.056.4 | | claimant | | if `with_cci` |
|
||||
| 13 | Zwischenanhörung | 0 M after | RoP.105 | | court-set | | |
|
||||
| 14 | Mündliche Verhandlung | 0 M after | RoP.112 | | court-set | | |
|
||||
| 15 | Entscheidung | 0 M after | RoP.118.3 | | court-set | | |
|
||||
| 16 | Duplik zur Replik auf die Erwiderung zur Nichtigkeitsklage | 1 M after | RoP.052 | Reply to the Defence to revocation | — | | |
|
||||
| 17 | Verletzungswiderklage | 2 M after | RoP.053 | Statement for Revocation | — | | |
|
||||
| 18 | Antrag auf Patentänderung | 2 M after | RoP.050 | Statement for Revocation | — | | |
|
||||
|
||||
## Nichtigkeitsverfahren (BPatG) · `de.null.bpatg`
|
||||
|
||||
*Nullity (Federal Patent Court)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Nichtigkeitsklage | 0 M after | § 81 PatG | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | § 82 Abs. 3 PatG | | defendant | | |
|
||||
| 3 | Replik | 2 M after | § 83 PatG | | claimant | | |
|
||||
| 4 | Hinweisbeschluss | 0 M after | § 83 PatG | | court-set | | |
|
||||
| 5 | Stellungnahme zum Hinweisbeschluss | 0 M after | § 83 PatG | | either | | |
|
||||
| 6 | Duplik | 1 M after | § 83 PatG | | defendant | | |
|
||||
| 7 | Mündliche Verhandlung | 0 M after | § 80 PatG | | court-set | | |
|
||||
| 8 | Urteil | 0 M after | § 84 PatG | | court-set | | |
|
||||
| 9 | Berufungsfrist | 1 M after | § 110 PatG | | either | | |
|
||||
| 10 | Berufungsbegründung | 3 M after | § 111 PatG | | either | | |
|
||||
| 11 | Wiedereinsetzungsantrag (§ 123 PatG) | 2 M after | § 123 PatG | Removal of obstacle (PatG §123) | — | | |
|
||||
|
||||
## Einspruchsverfahren · `epa.opp.opd`
|
||||
|
||||
*Opposition Proceedings*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Veröffentlichung der Erteilung | 0 M after | Art. 97 EPÜ | | either | | |
|
||||
| 2 | Einspruchsfrist | 9 M after | Art. 99 EPÜ | | either | | |
|
||||
| 3 | Erwiderung des Patentinhabers | 4 M after | R. 79(1) EPÜ | | court-set | | |
|
||||
| 4 | Entscheidung | 0 M after | Art. 102 EPÜ | | court-set | | |
|
||||
| 5 | Beschwerdefrist | 2 M after | Art. 108 EPÜ | | either | | |
|
||||
| 6 | Beschwerdebegründung | 4 M after | Art. 108 EPÜ | | either | | |
|
||||
| 7 | Stellungnahme weiterer Beteiligter | 0 M after | R. 79 EPÜ | | either | | |
|
||||
| 8 | Eingaben vor mündl. Verhandlung | 0 M after | R. 116 EPÜ | | either | | |
|
||||
| 9 | Wiedereinsetzungsantrag (Art. 122 EPÜ) | 2 M after | Art. 122 EPÜ | Removal of obstacle (EPC Art.122) | — | | |
|
||||
|
||||
## Beschwerdeverfahren · `epa.opp.boa`
|
||||
|
||||
*Appeal Proceedings*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung der Beschwerdeentscheidung | 0 M after | R. 124 EPÜ | | either | | |
|
||||
| 2 | Beschwerdeeinlegung | 2 M after | Art. 108 EPÜ | | either | | |
|
||||
| 3 | Beschwerdebegründung | 4 M after | Art. 108 EPÜ | | either | | |
|
||||
| 4 | Beschwerdeerwiderung | 4 M after | RPBA Art. 12 | | either | | |
|
||||
| 5 | Mündliche Verhandlung | 0 M after | Art. 116 EPÜ | | court-set | | |
|
||||
| 6 | Entscheidung | 0 M after | Art. 111 EPÜ | | court-set | | |
|
||||
| 7 | Eingaben vor mündl. Verhandlung | 0 M after | R. 116 EPÜ | | either | | |
|
||||
| 8 | Antrag auf Überprüfung | 2 M after | Art. 112a EPÜ | | either | | |
|
||||
|
||||
## Einspruchsverfahren DPMA · `dpma.opp.dpma`
|
||||
|
||||
*Opposition DPMA*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Veröffentlichung der Erteilung | 0 M after | § 58 PatG | | either | | |
|
||||
| 2 | Einspruchsfrist | 9 M after | § 59 PatG | | either | | |
|
||||
| 3 | Erwiderung des Patentinhabers | 4 M after | § 59(2) PatG | | court-set | | |
|
||||
| 4 | DPMA-Entscheidung | 0 M after | § 61 PatG | | court-set | | |
|
||||
| 5 | Wiedereinsetzungsantrag (DPMA) | 2 M after | § 123 PatG | Removal of obstacle (DPMA, PatG §123) | — | | |
|
||||
|
||||
## Berufungsverfahren · `upc.apl.merits`
|
||||
|
||||
*Appeal*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Berufungseinlegung | 2 M after | RoP.224.1.a | | either | | |
|
||||
| 2 | Berufungsbegründung | 4 M after | RoP.224.2.a | | either | | |
|
||||
| 3 | Berufungserwiderung | 3 M after | RoP.235.1 | | either | | |
|
||||
| 4 | Mündliche Verhandlung | 0 M after | RoP.240 | | court-set | | |
|
||||
| 5 | Entscheidung | 0 M after | RoP.235.4 | | court-set | | |
|
||||
| 6 | Anschlussberufung | 3 M after | RoP.237 | | either | | |
|
||||
| 7 | Erwiderung Anschlussberufung | 2 M after | RoP.238.1 | | either | | |
|
||||
| 8 | Berufungsschrift gegen eine in Regel 220.1(a) und (b) genannte Entscheidung | 2 M after | RoP.224.1(a) | Decision referred to in Rule 220.1(a) and (b) | — | | |
|
||||
| 9 | Berufungsbegründung gegen eine in Regel 220.1(a) und (b) genannte Entscheidung | 4 M after | RoP.224.1(a) | Decision referred to in Rule 220.1(a) and (b) | — | | |
|
||||
| 10 | Anfechtung einer Entscheidung über die Verwerfung der Berufung als unzulässig | 1 M after | RoP.245 | Decision to reject an appeal as inadmissible | — | | |
|
||||
| 11 | Antrag auf Wiederaufnahme (schwerwiegender Verfahrensmangel) | 2 M after | RoP.247.2 | Final decision (Service) / Discovery of the fundamental defect (whichever is later) | — | | |
|
||||
| 12 | Antrag auf Wiederaufnahme (Straftat) | 2 M after | RoP.247.1 | Final decision (Service) / Court decision on criminal offence (whichever is later) | — | | |
|
||||
|
||||
## Berufungsverfahren OLG (Verletzung) · `de.inf.olg`
|
||||
|
||||
*Appeal OLG (Infringement)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung LG-Urteil | 0 M after | § 540 ZPO | | either | | |
|
||||
| 2 | Berufungsschrift | 1 M after | § 517 ZPO | | either | | |
|
||||
| 3 | Berufungsbegründung | 2 M after | § 520 ZPO | | either | | |
|
||||
| 4 | Berufungserwiderung | 1 M after | § 521 ZPO | | either | | |
|
||||
| 5 | Anschlussberufung | 0 M after | § 524 ZPO | | either | | |
|
||||
| 6 | Mündliche Verhandlung | 0 M after | § 540 ZPO | | court-set | | |
|
||||
| 7 | OLG-Urteil | 0 M after | § 540 ZPO | | court-set | | |
|
||||
|
||||
## Revisions-/NZB-Verfahren BGH (Verletzung) · `de.inf.bgh`
|
||||
|
||||
*Revision / Non-admission Appeal BGH (Infringement)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung OLG-Urteil | 0 M after | § 555 ZPO | | either | | |
|
||||
| 2 | Nichtzulassungsbeschwerde | 1 M after | § 544 ZPO | | either | | |
|
||||
| 3 | Nichtzulassungsbeschwerde-Begründung | 2 M after | § 544 ZPO | | either | | |
|
||||
| 4 | Revisionsfrist | 1 M after | § 548 ZPO | | either | | |
|
||||
| 5 | Revisionsbegründung | 2 M after | § 551 ZPO | | either | | |
|
||||
| 6 | Revisionserwiderung | 1 M after | § 554 ZPO | | either | | |
|
||||
| 7 | Mündliche Verhandlung BGH | 0 M after | § 555 ZPO | | court-set | | |
|
||||
| 8 | BGH-Urteil | 0 M after | § 555 ZPO | | court-set | | |
|
||||
|
||||
## Berufungsverfahren BGH (Nichtigkeit) · `de.null.bgh`
|
||||
|
||||
*Appeal BGH (Nullity)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung BPatG-Urteil | 0 M after | § 110 PatG | | either | | |
|
||||
| 2 | Berufungsschrift | 1 M after | § 110 PatG | | either | | |
|
||||
| 3 | Berufungsbegründung | 3 M after | § 520 Abs. 2 ZPO i.V.m. § 117 PatG | | either | | |
|
||||
| 4 | Berufungserwiderung | 2 M after | § 521 Abs. 2 ZPO i.V.m. § 117 PatG | | court-set | | |
|
||||
| 5 | Mündliche Verhandlung BGH | 0 M after | § 121 PatG | | court-set | | |
|
||||
| 6 | BGH-Urteil | 0 M after | § 122 PatG | | court-set | | |
|
||||
|
||||
## EP-Erteilungsverfahren · `epa.grant.exa`
|
||||
|
||||
*EP Grant Procedure*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Anmeldung | 0 M after | Art. 75 EPÜ | | claimant | | |
|
||||
| 2 | Recherchenbericht | 6 M after | Art. 92 EPÜ | | court-set | | |
|
||||
| 3 | Veröffentlichung (A1) | 18 M after | Art. 93 EPÜ | | court-set | | |
|
||||
| 4 | Prüfungsantrag | 6 M after | R. 70(1) EPÜ | | claimant | | |
|
||||
| 5 | Mitteilung nach R. 71(3) | 0 M after | R. 71(3) EPÜ | | court-set | | |
|
||||
| 6 | Zustimmung + Übersetzung | 4 M after | R. 71(3) EPÜ | | claimant | | |
|
||||
| 7 | Erteilung (B1) | 0 M after | Art. 97 EPÜ | | court-set | | |
|
||||
|
||||
## Beschwerdeverfahren BPatG (DPMA) · `dpma.appeal.bpatg`
|
||||
|
||||
*Appeal BPatG (against DPMA Decision)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung DPMA-Entscheidung | 0 M after | § 65 PatG | | either | | |
|
||||
| 2 | Beschwerde | 1 M after | § 73 PatG | | either | | |
|
||||
| 3 | Beschwerdebegründung | 1 M after | § 75 PatG | | court-set | | |
|
||||
| 4 | Mündliche Verhandlung BPatG | 0 M after | § 78 PatG | | court-set | | |
|
||||
| 5 | BPatG-Entscheidung | 0 M after | § 78 PatG | | court-set | | |
|
||||
|
||||
## Rechtsbeschwerdeverfahren BGH · `dpma.appeal.bgh`
|
||||
|
||||
*Legal Appeal BGH*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung BPatG-Entscheidung | 0 M after | § 100 PatG | | either | | |
|
||||
| 2 | Rechtsbeschwerde | 1 M after | § 100 PatG | | either | | |
|
||||
| 3 | Rechtsbeschwerdebegründung | 1 M after | § 102 PatG | | either | | |
|
||||
| 4 | BGH-Entscheidung | 0 M after | § 100 PatG | | court-set | | |
|
||||
|
||||
## Berufungsverfahren Anordnungen · `upc.apl.order`
|
||||
|
||||
*Order Appeal (15-day track)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Anordnung / angegriffene Entscheidung | 0 M after | RoP.220 | | court-set | | |
|
||||
| 2 | Berufung mit Zulassung | 15 d after | RoP.220.2 | | either | | |
|
||||
| 3 | Antrag auf Ermessensüberprüfung | 15 d after | RoP.220.3 | | either | | |
|
||||
| 4 | Berufungsbegründung (Orders Track) | 15 d after | RoP.224.2.b | | either | | |
|
||||
| 5 | Anschlussberufung | 15 d after | RoP.237 | | either | | |
|
||||
| 6 | Erwiderung Anschlussberufung | 15 d after | RoP.238.2 | | either | | |
|
||||
| 7 | Berufungsschrift gegen eine in Regel 220.1(c) genannte Anordnung oder eine in Regel 220.2 oder 221.3 genannte Entscheidung | 15 d after | RoP.224.1(b) | Order referred to in Rule 220.1(c) or a decision referred to in Rule 220.2 or 221.3 | — | | |
|
||||
| 8 | Berufungsbegründung gegen eine in Regel 220.1(c) genannte Anordnung oder eine in Regel 220.2 oder 221.3 genannte Entscheidung | 15 d after | RoP.224.1(b) | Order referred to in Rule 220.1(c) or a decision referred to in Rule 220.2 or 221.3 | — | | |
|
||||
|
||||
## Schadensbemessungsverfahren · `upc.dmgs.cfi`
|
||||
|
||||
*Damages Determination*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Schadensbemessung | 0 M after | RoP.125 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | RoP.137.2 | | defendant | | |
|
||||
| 3 | Replik | 1 M after | RoP.139 | | claimant | | |
|
||||
| 4 | Duplik | 1 M after | RoP.139 | | defendant | | |
|
||||
|
||||
## Bucheinsichtsverfahren · `upc.disc.cfi`
|
||||
|
||||
*Lay-open Books / Discovery*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Bucheinsicht | 0 M after | RoP.190 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | RoP.142.2 | | defendant | | |
|
||||
| 3 | Replik | 14 d after | RoP.142.3 | | claimant | | |
|
||||
| 4 | Duplik | 14 d after | RoP.142.3 | | defendant | | |
|
||||
|
||||
## Einstweilige Maßnahmen · `upc.pi.cfi`
|
||||
|
||||
*Provisional Measures*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag | 0 M after | RoP.206 | | claimant | | |
|
||||
| 2 | Erwiderung | 0 M after | RoP.211.2 | | court-set | | |
|
||||
| 3 | Mündliche Verhandlung | 0 M after | RoP.195 | | court-set | | |
|
||||
| 4 | Mängelbeseitigung Antrag | 14 d after | RoP.207.6.a | | claimant | | |
|
||||
| 5 | Beschluss | 0 M after | RoP.211 | | court-set | | |
|
||||
| 6 | Klage in der Hauptsache erheben | 31 d max 20 WD after | RoP.213 | | claimant | | |
|
||||
|
||||
## Schutzschrift · `upc.pl.cfi`
|
||||
|
||||
*Protective Letter*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Einreichung der Schutzschrift | 0 M after | RoP.207 | | defendant | | |
|
||||
| 2 | Erneuerung der Schutzschrift | 6 M after | RoP.207.9 | Protective Letter | — | | |
|
||||
|
||||
## Berufungsverfahren Kosten · `upc.apl.cost`
|
||||
|
||||
*Cost-Decision Appeal*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Kostenfestsetzungsbeschluss | 0 M after | RoP.221.4 | | court-set | | |
|
||||
| 2 | Antrag auf Berufungszulassung | 15 d after | RoP.221.1 | | either | | |
|
||||
| 3 | Antrag auf Berufungszulassung gegen Kostenentscheidungen | 15 d after | RoP.220.2 | Decision on fixation of costs (Rule 157) | — | | |
|
||||
|
||||
## Negative Feststellungsklage · `upc.dni.cfi`
|
||||
|
||||
*Declaration of Non-Infringement*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Klage auf negative Feststellung der Nichtverletzung | 0 M after | RoP.063 | | claimant | | |
|
||||
| 2 | Erwiderung auf die negative Feststellungsklage | 2 M after | RoP.066 | Statement for a declaration of non-infringement | — | | |
|
||||
| 3 | Replik auf die Erwiderung zur negativen Feststellungsklage | 1 M after | RoP.067 | Defence to the Statement for a declaration of non-infringement | — | | |
|
||||
| 4 | Duplik zur Replik auf die Erwiderung zur negativen Feststellungsklage | 1 M after | RoP.068 | Reply to the Defence to the Statement for a declaration of non-infringement | — | | |
|
||||
|
||||
## Überprüfung von EPA-Entscheidungen · `upc.epo.review`
|
||||
|
||||
*Review of EPO decisions*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Überprüfung der EPA-Entscheidung | 0 M after | RoP.088 | | — | | |
|
||||
| 2 | Antrag auf Aufhebung einer Entscheidung des EPA, mit der ein Antrag auf einheitliche Wirkung zurückgewiesen wurde | 3 w after | RoP.097 | Decision of the EPO not to grant unitary effect | — | | |
|
||||
|
||||
## Separate Kostenentscheidung · `upc.costs.cfi`
|
||||
|
||||
*Separate Cost Decision*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Kostenfestsetzung | 1 M after | RoP.151 | | claimant | | |
|
||||
|
||||
## Beweissicherung / saisie · `upc.bsv.cfi`
|
||||
|
||||
*Evidence Preservation*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Beweissicherung | 0 M after | RoP.192 | | court-set | | |
|
||||
| 2 | Antrag auf Überprüfung der Beweissicherungsanordnung | 30 d after | RoP.197.3 | Execution of measures to preserve evidence | — | | |
|
||||
| 3 | Beginn des Hauptsacheverfahrens | 31 d max 20 WD after | RoP.198 | Date specified in the Court's order to preserve evidence | — | | |
|
||||
|
||||
## Widerklage auf Nichtigkeit · `upc.ccr.cfi`
|
||||
|
||||
*Counterclaim for Revocation*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Widerklage auf Nichtigkeit | 3 M after | RoP.025 | | defendant | | |
|
||||
|
||||
---
|
||||
|
||||
**Lesehilfe:**
|
||||
- ⤴ Spawn-Marker: event opens a sub-proceeding (e.g. CCR forks revocation track)
|
||||
- `with_ccr` = Widerklage auf Nichtigkeit gefilt | `with_amend` = Patentänderungsantrag | `with_cci` = Widerklage auf Verletzung (in rev.cfi)
|
||||
- Catalog ist work-in-progress: 7 compound-name rules + Patentänderung-Duplikation noch in m's split-review backlog (m/paliad#149).
|
||||
@@ -3,7 +3,8 @@ import { join, relative } from "path";
|
||||
import { renderIndex } from "./src/index";
|
||||
import { renderLogin } from "./src/login";
|
||||
import { renderKostenrechner } from "./src/kostenrechner";
|
||||
import { renderProcedures } from "./src/procedures";
|
||||
import { renderFristenrechner } from "./src/fristenrechner";
|
||||
import { renderVerfahrensablauf } from "./src/verfahrensablauf";
|
||||
import { renderDownloads } from "./src/downloads";
|
||||
import { renderLinks } from "./src/links";
|
||||
import { renderGlossary } from "./src/glossary";
|
||||
@@ -18,7 +19,6 @@ import { renderProjectsNew } from "./src/projects-new";
|
||||
import { renderProjectsDetail } from "./src/projects-detail";
|
||||
import { renderProjectsChart } from "./src/projects-chart";
|
||||
import { renderSubmissionDraft } from "./src/submission-draft";
|
||||
import { renderTemplatesAuthoring } from "./src/templates-authoring";
|
||||
import { renderSubmissionsIndex } from "./src/submissions-index";
|
||||
import { renderSubmissionsNew } from "./src/submissions-new";
|
||||
import { renderEvents } from "./src/events";
|
||||
@@ -40,13 +40,13 @@ import { renderAdminTeam } from "./src/admin-team";
|
||||
import { renderAdminAuditLog } from "./src/admin-audit-log";
|
||||
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
|
||||
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
|
||||
import { renderAdminSubmissionBuildingBlocks } from "./src/admin-submission-building-blocks";
|
||||
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
|
||||
import { renderAdminEventTypes } from "./src/admin-event-types";
|
||||
import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
|
||||
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
|
||||
import { renderAdminRulesList } from "./src/admin-rules-list";
|
||||
import { renderAdminRulesEdit } from "./src/admin-rules-edit";
|
||||
import { renderAdminRulesExport } from "./src/admin-rules-export";
|
||||
import { renderPaliadin } from "./src/paliadin";
|
||||
import { renderAdminPaliadin } from "./src/admin-paliadin";
|
||||
import { renderAdminBackups } from "./src/admin-backups";
|
||||
@@ -241,7 +241,8 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/index.ts"),
|
||||
join(import.meta.dir, "src/client/login.ts"),
|
||||
join(import.meta.dir, "src/client/kostenrechner.ts"),
|
||||
join(import.meta.dir, "src/client/procedures.ts"),
|
||||
join(import.meta.dir, "src/client/fristenrechner.ts"),
|
||||
join(import.meta.dir, "src/client/verfahrensablauf.ts"),
|
||||
join(import.meta.dir, "src/client/downloads.ts"),
|
||||
join(import.meta.dir, "src/client/links.ts"),
|
||||
join(import.meta.dir, "src/client/glossary.ts"),
|
||||
@@ -256,7 +257,6 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/projects-detail.ts"),
|
||||
join(import.meta.dir, "src/client/projects-chart.ts"),
|
||||
join(import.meta.dir, "src/client/submission-draft.ts"),
|
||||
join(import.meta.dir, "src/client/templates-authoring.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-index.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-new.ts"),
|
||||
join(import.meta.dir, "src/client/events.ts"),
|
||||
@@ -279,12 +279,12 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/admin-partner-units.ts"),
|
||||
join(import.meta.dir, "src/client/admin-email-templates.ts"),
|
||||
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
|
||||
join(import.meta.dir, "src/client/admin-submission-building-blocks.ts"),
|
||||
join(import.meta.dir, "src/client/admin-event-types.ts"),
|
||||
join(import.meta.dir, "src/client/admin-approval-policies.ts"),
|
||||
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-list.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-edit.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-export.ts"),
|
||||
join(import.meta.dir, "src/client/paliadin.ts"),
|
||||
// t-paliad-161 — inline Paliadin widget. Loaded via the
|
||||
// PaliadinWidget component on every authenticated page, so the
|
||||
@@ -369,7 +369,8 @@ async function build() {
|
||||
await Bun.write(join(DIST, "index.html"), renderIndex());
|
||||
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
|
||||
await Bun.write(join(DIST, "kostenrechner.html"), renderKostenrechner());
|
||||
await Bun.write(join(DIST, "procedures.html"), renderProcedures());
|
||||
await Bun.write(join(DIST, "fristenrechner.html"), renderFristenrechner());
|
||||
await Bun.write(join(DIST, "verfahrensablauf.html"), renderVerfahrensablauf());
|
||||
await Bun.write(join(DIST, "downloads.html"), renderDownloads());
|
||||
await Bun.write(join(DIST, "links.html"), renderLinks());
|
||||
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
|
||||
@@ -384,7 +385,6 @@ async function build() {
|
||||
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
|
||||
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
|
||||
await Bun.write(join(DIST, "submission-draft.html"), renderSubmissionDraft());
|
||||
await Bun.write(join(DIST, "templates-authoring.html"), renderTemplatesAuthoring());
|
||||
await Bun.write(join(DIST, "submissions-index.html"), renderSubmissionsIndex());
|
||||
await Bun.write(join(DIST, "submissions-new.html"), renderSubmissionsNew());
|
||||
// t-paliad-115 — shared EventsPage at the canonical /events URL.
|
||||
@@ -411,12 +411,12 @@ async function build() {
|
||||
await Bun.write(join(DIST, "admin-partner-units.html"), renderAdminPartnerUnits());
|
||||
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
|
||||
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
|
||||
await Bun.write(join(DIST, "admin-submission-building-blocks.html"), renderAdminSubmissionBuildingBlocks());
|
||||
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
|
||||
await Bun.write(join(DIST, "admin-approval-policies.html"), renderAdminApprovalPolicies());
|
||||
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
|
||||
await Bun.write(join(DIST, "admin-rules-list.html"), renderAdminRulesList());
|
||||
await Bun.write(join(DIST, "admin-rules-edit.html"), renderAdminRulesEdit());
|
||||
await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport());
|
||||
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
|
||||
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
|
||||
await Bun.write(join(DIST, "admin-backups.html"), renderAdminBackups());
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/procedural-events/{id}/edit — Slice 11b (t-paliad-192). Form for the full
|
||||
// /admin/rules/{id}/edit — Slice 11b (t-paliad-192). Form for the full
|
||||
// 37-column rule row plus a side panel with the preview widget and the
|
||||
// audit-log timeline. Lifecycle action bar at the bottom adapts to the
|
||||
// rule's current state (draft/published/archived). Every write goes
|
||||
@@ -26,12 +26,12 @@ export function renderAdminRulesEdit(): string {
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.procedural_events.edit.title">Regel bearbeiten — Paliad</title>
|
||||
<title data-i18n="admin.rules.edit.title">Regel bearbeiten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/procedural-events" />
|
||||
<BottomNav currentPath="/admin/procedural-events" />
|
||||
<Sidebar currentPath="/admin/rules" />
|
||||
<BottomNav currentPath="/admin/rules" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
@@ -39,7 +39,7 @@ export function renderAdminRulesEdit(): string {
|
||||
<div className="tool-header admin-rules-edit-header">
|
||||
<div>
|
||||
<p className="admin-rules-breadcrumb">
|
||||
<a href="/admin/procedural-events" data-i18n="admin.procedural_events.edit.breadcrumb">← Regeln verwalten</a>
|
||||
<a href="/admin/rules" data-i18n="admin.rules.edit.breadcrumb">← Regeln verwalten</a>
|
||||
</p>
|
||||
<h1 id="rules-edit-heading" data-i18n="admin.rules.edit.heading.loading">Regel laden...</h1>
|
||||
<div className="admin-rules-edit-meta">
|
||||
@@ -71,7 +71,7 @@ export function renderAdminRulesEdit(): string {
|
||||
</div>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-submission-code" data-i18n="admin.procedural_events.edit.field.code">Submission Code / Einreichung-Kennung</label>
|
||||
<label htmlFor="f-submission-code" data-i18n="admin.rules.edit.field.submission_code">Submission Code / Einreichung-Kennung</label>
|
||||
<input type="text" id="f-submission-code" className="admin-rules-input" readonly placeholder="z. B. upc.inf.cfi.soc" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
@@ -103,7 +103,7 @@ export function renderAdminRulesEdit(): string {
|
||||
</div>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-parent" data-i18n="admin.procedural_events.edit.field.parent">Parent-Regel (UUID)</label>
|
||||
<label htmlFor="f-parent" data-i18n="admin.rules.edit.field.parent">Parent-Regel (UUID)</label>
|
||||
<input type="text" id="f-parent" className="admin-rules-input" placeholder="UUID oder leer" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
@@ -184,7 +184,7 @@ export function renderAdminRulesEdit(): string {
|
||||
<input type="text" id="f-primary-party" className="admin-rules-input" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-event-type" data-i18n="admin.procedural_events.edit.field.event_kind">Event-Typ (frei)</label>
|
||||
<label htmlFor="f-event-type" data-i18n="admin.rules.edit.field.event_type">Event-Typ (frei)</label>
|
||||
<input type="text" id="f-event-type" className="admin-rules-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
80
frontend/src/admin-rules-export.tsx
Normal file
80
frontend/src/admin-rules-export.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/rules/export — Slice 11b (t-paliad-192). Surfaces the
|
||||
// GET /admin/api/rules/export-migrations endpoint as a SQL preview the
|
||||
// editor can copy or download. Optional ?since=<audit-id> query lets
|
||||
// the editor scope the export to a particular audit window — empty =
|
||||
// every un-exported audit row.
|
||||
export function renderAdminRulesExport(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.rules.export.title">Regel-Migrations exportieren — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/rules" />
|
||||
<BottomNav currentPath="/admin/rules" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<p className="admin-rules-breadcrumb">
|
||||
<a href="/admin/rules" data-i18n="admin.rules.export.breadcrumb">← Regeln verwalten</a>
|
||||
</p>
|
||||
<h1 data-i18n="admin.rules.export.heading">Regel-Migrations exportieren</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.rules.export.subtitle">
|
||||
Generiert ein <code>*.up.sql</code>-Blob mit allen unsynchronisierten Audit-Veränderungen.
|
||||
Manuell in <code>internal/db/migrations/</code> einchecken.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-rules-export-controls">
|
||||
<div className="form-field">
|
||||
<label htmlFor="export-since" data-i18n="admin.rules.export.field.since">Startend ab Audit-ID (optional)</label>
|
||||
<input type="text" id="export-since" className="admin-rules-input" placeholder="UUID, leer = alle un-exportierten" />
|
||||
</div>
|
||||
<button type="button" id="export-run" className="btn-primary" data-i18n="admin.rules.export.run">
|
||||
Export generieren
|
||||
</button>
|
||||
<button type="button" id="export-download" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.download">
|
||||
Als Datei herunterladen
|
||||
</button>
|
||||
<button type="button" id="export-copy" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.copy">
|
||||
In Zwischenablage kopieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="export-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="admin-rules-export-summary" id="export-summary" style="display:none">
|
||||
<span id="export-summary-count" />
|
||||
<span id="export-summary-latest" />
|
||||
</div>
|
||||
|
||||
<pre id="export-output" className="admin-rules-export-pre" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-rules-export.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/procedural-events — Slice 11b (t-paliad-192). Filterable rule table + an
|
||||
// /admin/rules — Slice 11b (t-paliad-192). Filterable rule table + an
|
||||
// Orphans tab that surfaces the Slice 10 fuzzy-match staging rows so an
|
||||
// admin can hand-bind each legacy deadline to one of the candidate
|
||||
// rule_ids. Both surfaces share the same page shell to keep navigation
|
||||
@@ -21,25 +21,28 @@ export function renderAdminRulesList(): string {
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.procedural_events.list.title">Regeln verwalten — Paliad</title>
|
||||
<title data-i18n="admin.rules.list.title">Regeln verwalten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/procedural-events" />
|
||||
<BottomNav currentPath="/admin/procedural-events" />
|
||||
<Sidebar currentPath="/admin/rules" />
|
||||
<BottomNav currentPath="/admin/rules" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.procedural_events.list.heading">Regeln verwalten</h1>
|
||||
<h1 data-i18n="admin.rules.list.heading">Regeln verwalten</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.rules.list.subtitle">
|
||||
Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-rules-header-actions">
|
||||
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.procedural_events.list.new">
|
||||
<a href="/admin/rules/export" className="btn-secondary" data-i18n="admin.rules.list.export">
|
||||
Migrations exportieren
|
||||
</a>
|
||||
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.rules.list.new">
|
||||
+ Neue Regel
|
||||
</button>
|
||||
</div>
|
||||
@@ -77,9 +80,9 @@ export function renderAdminRulesList(): string {
|
||||
<div className="admin-rules-filter admin-rules-filter-chips">
|
||||
<span className="admin-rules-filter-label" data-i18n="admin.rules.filter.lifecycle">Lifecycle</span>
|
||||
<div className="admin-rules-chips" id="rules-filter-lifecycle">
|
||||
<button type="button" className="admin-rules-chip" data-state="" data-i18n="admin.rules.filter.lifecycle.any">Alle</button>
|
||||
<button type="button" className="admin-rules-chip active" data-state="" data-i18n="admin.rules.filter.lifecycle.any">Alle</button>
|
||||
<button type="button" className="admin-rules-chip" data-state="draft" data-i18n="admin.rules.lifecycle.draft">Draft</button>
|
||||
<button type="button" className="admin-rules-chip active" data-state="published" data-i18n="admin.rules.lifecycle.published">Published</button>
|
||||
<button type="button" className="admin-rules-chip" data-state="published" data-i18n="admin.rules.lifecycle.published">Published</button>
|
||||
<button type="button" className="admin-rules-chip" data-state="archived" data-i18n="admin.rules.lifecycle.archived">Archived</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,10 +104,10 @@ export function renderAdminRulesList(): string {
|
||||
<table className="entity-table admin-rules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.procedural_events.col.code">Submission Code</th>
|
||||
<th data-i18n="admin.procedural_events.col.proceeding">Verfahren</th>
|
||||
<th data-i18n="admin.rules.col.submission_code">Submission Code</th>
|
||||
<th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th>
|
||||
<th data-i18n="admin.rules.col.name">Name</th>
|
||||
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>
|
||||
<th data-i18n="admin.rules.col.priority">Priorität</th>
|
||||
<th data-i18n="admin.rules.col.lifecycle">Lifecycle</th>
|
||||
<th data-i18n="admin.rules.col.modified">Zuletzt geändert</th>
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/submission-building-blocks — Composer building-blocks library
|
||||
// editor (t-paliad-315 Slice C). Three-pane layout: list on the left,
|
||||
// edit form in the middle, version log on the right. Hydrated by
|
||||
// client/admin-submission-building-blocks.ts from
|
||||
// GET /api/admin/submission-building-blocks.
|
||||
|
||||
export function renderAdminSubmissionBuildingBlocks(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.building_blocks.title">Bausteine — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/submission-building-blocks" />
|
||||
<BottomNav currentPath="/admin/submission-building-blocks" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.building_blocks.heading">Bausteine</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.building_blocks.subtitle">
|
||||
Wiederverwendbare Textbausteine für Composer-Abschnitte.
|
||||
</p>
|
||||
</div>
|
||||
<div className="tool-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
id="admin-bb-new-btn"
|
||||
className="btn-primary btn-cta-lime"
|
||||
data-i18n="admin.building_blocks.action.new">
|
||||
+ Neuer Baustein
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="admin-bb-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="admin-bb-layout">
|
||||
<aside className="admin-bb-list" id="admin-bb-list">
|
||||
<div className="admin-bb-loading" data-i18n="admin.building_blocks.loading">Lädt…</div>
|
||||
</aside>
|
||||
|
||||
<section className="admin-bb-editor" id="admin-bb-editor">
|
||||
<p className="admin-bb-empty" data-i18n="admin.building_blocks.editor.empty">
|
||||
Wählen Sie einen Baustein aus der Liste — oder erstellen Sie einen neuen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<aside className="admin-bb-versions" id="admin-bb-versions" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-submission-building-blocks.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export function renderAdmin(): string {
|
||||
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
|
||||
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.</p>
|
||||
</a>
|
||||
<a href="/admin/procedural-events" className="card card-link">
|
||||
<a href="/admin/rules" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_TABLE }} />
|
||||
<h2 data-i18n="admin.card.rules.title">Regeln verwalten</h2>
|
||||
<p data-i18n="admin.card.rules.desc">Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-rules-edit.ts — /admin/procedural-events/{id}/edit. Loads a single rule
|
||||
// admin-rules-edit.ts — /admin/rules/{id}/edit. Loads a single rule
|
||||
// row, drives every form field, the preview widget, the audit-log
|
||||
// timeline and the lifecycle action bar. Every write is gated behind
|
||||
// a reason modal — the ≥10-char rule is enforced client-side per
|
||||
@@ -51,10 +51,7 @@ interface Rule {
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
code: string;
|
||||
// `name` is the German display name on the wire; the Go `ProceedingType`
|
||||
// model serialises `db:"name"` as JSON key `name`. Don't reach for
|
||||
// `name_de` — that field does not exist in this payload (m/paliad#113).
|
||||
name: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
}
|
||||
|
||||
@@ -106,10 +103,8 @@ function fmtDateTime(iso: string): string {
|
||||
}
|
||||
|
||||
function parseRuleIDFromPath(): string {
|
||||
// /admin/procedural-events/{uuid}/edit (canonical, post Slice B.6 rename)
|
||||
// /admin/rules/{uuid}/edit (legacy, 301-redirected by the backend but
|
||||
// still matched here in case a stale tab or bookmark hits it).
|
||||
const m = /^\/admin\/(?:procedural-events|rules)\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
|
||||
// /admin/rules/{uuid}/edit
|
||||
const m = /^\/admin\/rules\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
|
||||
return m ? decodeURIComponent(m[1]) : "";
|
||||
}
|
||||
|
||||
@@ -174,14 +169,13 @@ function fillProceedingSelect(selectId: string, list: ProceedingType[]) {
|
||||
for (const pt of list) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(pt.id);
|
||||
const name = getLang() === "en" ? pt.name_en : pt.name;
|
||||
opt.textContent = name ? `${pt.code} · ${name}` : pt.code;
|
||||
opt.textContent = `${pt.code} · ${getLang() === "en" ? pt.name_en : pt.name_de}`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRule(): Promise<void> {
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`);
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`);
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 404) {
|
||||
showFeedback(t("admin.rules.edit.error.not_found") || "Regel nicht gefunden.", true);
|
||||
@@ -200,7 +194,7 @@ async function loadAudit(reset: boolean = true): Promise<void> {
|
||||
auditEntries = [];
|
||||
auditOffset = 0;
|
||||
}
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
|
||||
if (!resp.ok) return;
|
||||
const body = await resp.json();
|
||||
const rows = Array.isArray(body) ? body as AuditEntry[] : [];
|
||||
@@ -510,7 +504,7 @@ async function doSaveDraft(reason: string) {
|
||||
return;
|
||||
}
|
||||
payload.reason = reason;
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`, {
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -532,7 +526,7 @@ async function doSaveDraft(reason: string) {
|
||||
|
||||
async function doLifecycle(op: "publish" | "archive" | "restore", reason: string) {
|
||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/${op}`, {
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/${op}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
@@ -554,7 +548,7 @@ async function doLifecycle(op: "publish" | "archive" | "restore", reason: string
|
||||
|
||||
async function doClone(reason: string) {
|
||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/clone-as-draft`, {
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/clone-as-draft`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
@@ -567,7 +561,7 @@ async function doClone(reason: string) {
|
||||
return;
|
||||
}
|
||||
const newRule = await resp.json() as Rule;
|
||||
window.location.href = `/admin/procedural-events/${encodeURIComponent(newRule.id)}/edit`;
|
||||
window.location.href = `/admin/rules/${encodeURIComponent(newRule.id)}/edit`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
@@ -593,7 +587,7 @@ async function runPreview() {
|
||||
if (flagsRaw) qs.set("flags", flagsRaw);
|
||||
out.innerHTML = `<p class="admin-rules-loading">${esc(t("admin.rules.edit.preview.running") || "Berechne...")}</p>`;
|
||||
out.style.display = "";
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(body.error || (t("admin.rules.edit.preview.error") || "Preview fehlgeschlagen."))}</p>`;
|
||||
|
||||
100
frontend/src/client/admin-rules-export.ts
Normal file
100
frontend/src/client/admin-rules-export.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-rules-export.ts — /admin/rules/export. Calls
|
||||
// GET /admin/api/rules/export-migrations[?since=<uuid>] and renders the
|
||||
// SQL blob server-side. Download builds a Blob URL and triggers a
|
||||
// fake <a> click; copy uses navigator.clipboard.
|
||||
|
||||
interface ExportResult {
|
||||
migration_sql: string;
|
||||
count: number;
|
||||
latest_audit_id: string;
|
||||
}
|
||||
|
||||
let latest: ExportResult | null = null;
|
||||
|
||||
function showFeedback(msg: string, isError: boolean) {
|
||||
const el = document.getElementById("export-feedback") as HTMLElement | null;
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
|
||||
el.style.display = "block";
|
||||
if (!isError) setTimeout(() => { el.style.display = "none"; }, 4000);
|
||||
}
|
||||
|
||||
async function runExport() {
|
||||
const since = (document.getElementById("export-since") as HTMLInputElement).value.trim();
|
||||
const qs = new URLSearchParams();
|
||||
if (since) qs.set("since", since);
|
||||
const url = "/admin/api/rules/export-migrations" + (qs.toString() ? "?" + qs.toString() : "");
|
||||
const out = document.getElementById("export-output") as HTMLElement;
|
||||
const summary = document.getElementById("export-summary") as HTMLElement;
|
||||
const dl = document.getElementById("export-download") as HTMLElement;
|
||||
const cp = document.getElementById("export-copy") as HTMLElement;
|
||||
out.textContent = t("admin.rules.export.running") || "Lade...";
|
||||
summary.style.display = "none";
|
||||
dl.style.display = "none";
|
||||
cp.style.display = "none";
|
||||
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || (t("admin.rules.export.error") || "Export fehlgeschlagen."), true);
|
||||
out.textContent = "";
|
||||
return;
|
||||
}
|
||||
latest = await resp.json() as ExportResult;
|
||||
out.textContent = latest.migration_sql;
|
||||
summary.style.display = "";
|
||||
const countEl = document.getElementById("export-summary-count") as HTMLElement;
|
||||
const latestEl = document.getElementById("export-summary-latest") as HTMLElement;
|
||||
countEl.textContent = (t("admin.rules.export.count") || "Audit-Zeilen: {n}").replace("{n}", String(latest.count));
|
||||
if (latest.latest_audit_id) {
|
||||
latestEl.textContent = (t("admin.rules.export.latest") || "Letzte Audit-ID: {id}").replace("{id}", latest.latest_audit_id);
|
||||
} else {
|
||||
latestEl.textContent = "";
|
||||
}
|
||||
if (latest.count > 0) {
|
||||
dl.style.display = "";
|
||||
cp.style.display = "";
|
||||
showFeedback((t("admin.rules.export.ok") || "{n} Audit-Zeilen exportiert.").replace("{n}", String(latest.count)), false);
|
||||
} else {
|
||||
showFeedback(t("admin.rules.export.no_pending") || "Keine offenen Audit-Zeilen zum Export.", false);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
if (!latest) return;
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const name = `rules-export-${ts}.up.sql`;
|
||||
const blob = new Blob([latest.migration_sql], { type: "application/sql" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!latest) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(latest.migration_sql);
|
||||
showFeedback(t("admin.rules.export.copied") || "In Zwischenablage kopiert.", false);
|
||||
} catch (e) {
|
||||
showFeedback(t("admin.rules.export.copy_failed") || "Kopieren fehlgeschlagen.", true);
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
(document.getElementById("export-run") as HTMLElement).addEventListener("click", runExport);
|
||||
(document.getElementById("export-download") as HTMLElement).addEventListener("click", downloadFile);
|
||||
(document.getElementById("export-copy") as HTMLElement).addEventListener("click", copyToClipboard);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
@@ -1,23 +1,16 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-rules-list.ts — /admin/procedural-events. Drives the rule table (filterable
|
||||
// admin-rules-list.ts — /admin/rules. Drives the rule table (filterable
|
||||
// by proceeding type, trigger event, lifecycle state, free-text query)
|
||||
// plus the Orphans tab (Slice 10 backfill staging rows). Row click on
|
||||
// a rule routes to /admin/procedural-events/{id}/edit; orphan cards have their own
|
||||
// a rule routes to /admin/rules/{id}/edit; orphan cards have their own
|
||||
// "Pick" affordance with an inline reason prompt that posts to
|
||||
// /admin/api/orphans/{id}/resolve.
|
||||
|
||||
interface Rule {
|
||||
id: string;
|
||||
proceeding_type_id?: number | null;
|
||||
// proceeding_type_code is the joined paliad.proceeding_types.code
|
||||
// for proceeding_type_id, populated server-side by the
|
||||
// /admin/api/procedural-events LIST handler (t-paliad-321). Lets the
|
||||
// table show the 3-segment proceeding code (e.g. "upc.inf.cfi") at
|
||||
// a glance without depending on the FILTER-dropdown's limited
|
||||
// proceeding list. NULL on event-rooted rules.
|
||||
proceeding_type_code?: string | null;
|
||||
// submission_code is the proceeding-prefixed identifier of this rule
|
||||
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
|
||||
// rule_code (the legal citation, e.g. `RoP.013.1`).
|
||||
@@ -36,11 +29,7 @@ interface Rule {
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
code: string;
|
||||
// `name` is the German display name on the wire; the Go `ProceedingType`
|
||||
// model serialises `db:"name"` as JSON key `name` (the schema treats DE
|
||||
// as primary). EN lives in `name_en`. Don't reach for `name_de` — that
|
||||
// field does not exist in this payload (cf. m/paliad#113).
|
||||
name: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
category: string;
|
||||
}
|
||||
@@ -80,7 +69,7 @@ let triggerEvents: TriggerEvent[] = [];
|
||||
|
||||
let activeProceeding = "";
|
||||
let activeTrigger = "";
|
||||
let activeLifecycle = "published";
|
||||
let activeLifecycle = "";
|
||||
let activeQuery = "";
|
||||
let searchDebounce: number | undefined;
|
||||
|
||||
@@ -136,28 +125,10 @@ function proceedingLabel(id: number | null | undefined): string {
|
||||
if (id == null) return "—";
|
||||
const pt = proceedings.find((p) => p.id === id);
|
||||
if (!pt) return `#${id}`;
|
||||
const name = getLang() === "en" ? pt.name_en : pt.name;
|
||||
// Guard against a proceeding row that's missing the active-language
|
||||
// name (or against a stale field-name mismatch slipping back in).
|
||||
// Show the code on its own rather than "code · undefined" — that
|
||||
// literal string is the smell that surfaced this bug (m/paliad#113).
|
||||
if (!name) return pt.code;
|
||||
const name = getLang() === "en" ? pt.name_en : pt.name_de;
|
||||
return `${pt.code} · ${name}`;
|
||||
}
|
||||
|
||||
// proceedingCodeCell renders the LIST table's Proceeding column. Uses
|
||||
// the server-side joined proceeding_type_code when available
|
||||
// (t-paliad-321), falling back to the dropdown-lookup proceedingLabel
|
||||
// for older API responses or for rules whose proceeding_type_id
|
||||
// resolves but proceeding_type_code didn't (defence-in-depth). NULL
|
||||
// proceeding_type_id renders as the em-dash placeholder used
|
||||
// elsewhere in the admin table.
|
||||
function proceedingCodeCell(r: Rule): string {
|
||||
if (r.proceeding_type_code) return r.proceeding_type_code;
|
||||
if (r.proceeding_type_id == null) return "—";
|
||||
return proceedingLabel(r.proceeding_type_id);
|
||||
}
|
||||
|
||||
function buildFilterURL(): string {
|
||||
const qs = new URLSearchParams();
|
||||
if (activeProceeding) qs.set("proceeding_type_id", activeProceeding);
|
||||
@@ -165,7 +136,7 @@ function buildFilterURL(): string {
|
||||
if (activeLifecycle) qs.set("lifecycle_state", activeLifecycle);
|
||||
if (activeQuery) qs.set("q", activeQuery);
|
||||
qs.set("limit", "500");
|
||||
return "/admin/api/procedural-events?" + qs.toString();
|
||||
return "/admin/api/rules?" + qs.toString();
|
||||
}
|
||||
|
||||
async function loadProceedings(): Promise<void> {
|
||||
@@ -182,8 +153,7 @@ async function loadProceedings(): Promise<void> {
|
||||
for (const pt of proceedings) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(pt.id);
|
||||
const name = getLang() === "en" ? pt.name_en : pt.name;
|
||||
opt.textContent = name ? `${pt.code} · ${name}` : pt.code;
|
||||
opt.textContent = `${pt.code} · ${getLang() === "en" ? pt.name_en : pt.name_de}`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
@@ -253,9 +223,9 @@ function renderRulesTable() {
|
||||
tbody.innerHTML = rules.map((r) => `
|
||||
<tr data-row-id="${esc(r.id)}" class="admin-rules-row">
|
||||
<td class="admin-rules-col-code"><code>${esc(r.submission_code || "")}</code></td>
|
||||
<td class="admin-rules-col-proceeding"><code>${esc(proceedingCodeCell(r))}</code></td>
|
||||
<td class="admin-rules-col-legal"><code>${esc(r.rule_code || "")}</code></td>
|
||||
<td>${esc(name(r))}</td>
|
||||
<td>${esc(proceedingLabel(r.proceeding_type_id ?? null))}</td>
|
||||
<td><span class="admin-rules-priority admin-rules-priority-${esc(r.priority)}">${esc(priorityLabel(r.priority))}</span></td>
|
||||
<td><span class="${lifecycleClass(r.lifecycle_state)}">${esc(lifecycleLabel(r.lifecycle_state))}</span></td>
|
||||
<td class="admin-rules-col-modified">${esc(fmtDateTime(r.updated_at))}</td>
|
||||
@@ -268,7 +238,7 @@ function renderRulesTable() {
|
||||
if (target && (target.closest("a") || target.closest("button"))) return;
|
||||
const id = row.dataset.rowId;
|
||||
if (!id) return;
|
||||
window.location.href = `/admin/procedural-events/${encodeURIComponent(id)}/edit`;
|
||||
window.location.href = `/admin/rules/${encodeURIComponent(id)}/edit`;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -412,7 +382,7 @@ async function submitReasonModal(ev: Event) {
|
||||
submit.disabled = false;
|
||||
return;
|
||||
}
|
||||
const resp = await fetch("/admin/api/procedural-events", {
|
||||
const resp = await fetch("/admin/api/rules", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -436,7 +406,7 @@ async function submitReasonModal(ev: Event) {
|
||||
return;
|
||||
}
|
||||
const created = await resp.json();
|
||||
window.location.href = `/admin/procedural-events/${encodeURIComponent(created.id)}/edit`;
|
||||
window.location.href = `/admin/rules/${encodeURIComponent(created.id)}/edit`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
import { initI18n, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
function isEN(): boolean { return getLang() === "en"; }
|
||||
|
||||
// /admin/submission-building-blocks — Composer building-blocks admin
|
||||
// editor (t-paliad-315 Slice C). Three-pane layout: list → editor →
|
||||
// version log. CRUD via /api/admin/submission-building-blocks/*.
|
||||
//
|
||||
// Per Q2 ratification (m, 2026-05-26): building blocks are plain text
|
||||
// paste sources. The editor here is curator-only — no per-section
|
||||
// lineage to surface, no "where is this block used" view.
|
||||
|
||||
interface BuildingBlockJSON {
|
||||
id: string;
|
||||
slug: string;
|
||||
firm?: string | null;
|
||||
section_key: string;
|
||||
proceeding_family?: string | null;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
description_de?: string | null;
|
||||
description_en?: string | null;
|
||||
content_md_de: string;
|
||||
content_md_en: string;
|
||||
author_id?: string | null;
|
||||
visibility: string;
|
||||
is_published: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface VersionJSON {
|
||||
id: string;
|
||||
building_block_id: string;
|
||||
content_md_de: string;
|
||||
content_md_en: string;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
edited_by?: string | null;
|
||||
note?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const VISIBILITIES = ["private", "team", "firm", "global"];
|
||||
|
||||
// Section keys must match what the Composer base spec declares for
|
||||
// each section (see internal/db/migrations/146_submission_bases.up.sql).
|
||||
const SECTION_KEYS = [
|
||||
"letterhead", "caption", "introduction", "requests",
|
||||
"facts", "legal_argument", "evidence", "exhibits",
|
||||
"closing", "signature",
|
||||
];
|
||||
|
||||
const state = {
|
||||
blocks: [] as BuildingBlockJSON[],
|
||||
selectedID: null as string | null,
|
||||
versions: [] as VersionJSON[],
|
||||
dirty: false,
|
||||
};
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
await loadList();
|
||||
document.getElementById("admin-bb-new-btn")?.addEventListener("click", onNew);
|
||||
}
|
||||
|
||||
async function loadList(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("/api/admin/submission-building-blocks", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
feedback(`HTTP ${res.status}`, true);
|
||||
return;
|
||||
}
|
||||
const body = await res.json() as { blocks?: BuildingBlockJSON[] };
|
||||
state.blocks = body.blocks ?? [];
|
||||
paintList();
|
||||
} catch (err) {
|
||||
feedback(String(err), true);
|
||||
}
|
||||
}
|
||||
|
||||
function paintList(): void {
|
||||
const host = document.getElementById("admin-bb-list");
|
||||
if (!host) return;
|
||||
host.innerHTML = "";
|
||||
if (state.blocks.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "admin-bb-empty";
|
||||
empty.textContent = isEN() ? "No blocks yet." : "Noch keine Bausteine.";
|
||||
host.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const b of state.blocks) {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "admin-bb-list-row";
|
||||
if (b.id === state.selectedID) row.classList.add("admin-bb-list-row--active");
|
||||
const title = isEN() ? b.title_en : b.title_de;
|
||||
row.innerHTML = `
|
||||
<span class="admin-bb-list-title">${escapeHTML(title || b.slug)}</span>
|
||||
<span class="admin-bb-list-meta">
|
||||
<span class="admin-bb-list-section">${escapeHTML(b.section_key)}</span>
|
||||
<span class="admin-bb-list-vis admin-bb-list-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
|
||||
${b.is_published ? "" : `<span class="admin-bb-list-draft">${isEN() ? "draft" : "Entwurf"}</span>`}
|
||||
</span>`;
|
||||
row.addEventListener("click", () => onSelect(b.id));
|
||||
host.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSelect(id: string): Promise<void> {
|
||||
state.selectedID = id;
|
||||
state.dirty = false;
|
||||
paintList();
|
||||
const b = state.blocks.find(x => x.id === id);
|
||||
if (!b) return;
|
||||
paintEditor(b);
|
||||
await loadVersions(id);
|
||||
}
|
||||
|
||||
function onNew(): void {
|
||||
state.selectedID = null;
|
||||
state.versions = [];
|
||||
state.dirty = false;
|
||||
paintList();
|
||||
paintEditor(null);
|
||||
paintVersions();
|
||||
}
|
||||
|
||||
function paintEditor(b: BuildingBlockJSON | null): void {
|
||||
const host = document.getElementById("admin-bb-editor");
|
||||
if (!host) return;
|
||||
const isNew = b === null;
|
||||
const data = b ?? {
|
||||
id: "",
|
||||
slug: "",
|
||||
firm: "",
|
||||
section_key: "requests",
|
||||
proceeding_family: "",
|
||||
title_de: "",
|
||||
title_en: "",
|
||||
description_de: "",
|
||||
description_en: "",
|
||||
content_md_de: "",
|
||||
content_md_en: "",
|
||||
visibility: "firm",
|
||||
is_published: false,
|
||||
} as Partial<BuildingBlockJSON>;
|
||||
|
||||
host.innerHTML = "";
|
||||
const form = document.createElement("form");
|
||||
form.className = "admin-bb-form";
|
||||
form.addEventListener("submit", (e) => { e.preventDefault(); onSave(isNew); });
|
||||
|
||||
form.appendChild(textField("slug", isEN() ? "Slug" : "Slug", data.slug ?? "", true));
|
||||
form.appendChild(textField("firm", "Firm", data.firm ?? "", false, isEN() ? "leer = firmenagnostisch" : "leer = firmenagnostisch"));
|
||||
form.appendChild(selectField("section_key", isEN() ? "Section key" : "Abschnitts-Slug", data.section_key ?? "requests", SECTION_KEYS, false));
|
||||
form.appendChild(textField("proceeding_family", isEN() ? "Proceeding family" : "Verfahrensfamilie", data.proceeding_family ?? "", false, "z. B. de.inf.lg"));
|
||||
form.appendChild(textField("title_de", "Titel (DE)", data.title_de ?? "", true));
|
||||
form.appendChild(textField("title_en", "Title (EN)", data.title_en ?? "", true));
|
||||
form.appendChild(textareaField("description_de", "Beschreibung (DE)", data.description_de ?? "", 2));
|
||||
form.appendChild(textareaField("description_en", "Description (EN)", data.description_en ?? "", 2));
|
||||
form.appendChild(textareaField("content_md_de", isEN() ? "Content (DE Markdown)" : "Inhalt (DE Markdown)", data.content_md_de ?? "", 10));
|
||||
form.appendChild(textareaField("content_md_en", isEN() ? "Content (EN Markdown)" : "Inhalt (EN Markdown)", data.content_md_en ?? "", 10));
|
||||
form.appendChild(selectField("visibility", isEN() ? "Visibility" : "Sichtbarkeit", data.visibility ?? "firm", VISIBILITIES, false));
|
||||
form.appendChild(checkboxField("is_published", isEN() ? "Published" : "Veröffentlicht", Boolean(data.is_published)));
|
||||
|
||||
if (!isNew) {
|
||||
form.appendChild(textField("note", isEN() ? "Save note (optional)" : "Speicher-Notiz (optional)", "", false));
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "admin-bb-form-actions";
|
||||
|
||||
const save = document.createElement("button");
|
||||
save.type = "submit";
|
||||
save.className = "btn-primary btn-cta-lime";
|
||||
save.textContent = isEN() ? "Save" : "Speichern";
|
||||
actions.appendChild(save);
|
||||
|
||||
if (!isNew) {
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "btn-link-danger";
|
||||
del.textContent = isEN() ? "Delete" : "Löschen";
|
||||
del.addEventListener("click", () => onDelete());
|
||||
actions.appendChild(del);
|
||||
}
|
||||
form.appendChild(actions);
|
||||
host.appendChild(form);
|
||||
}
|
||||
|
||||
function textField(name: string, label: string, value: string, required: boolean, hint?: string): HTMLElement {
|
||||
const wrap = document.createElement("label");
|
||||
wrap.className = "admin-bb-form-row";
|
||||
const lab = document.createElement("span");
|
||||
lab.textContent = label + (required ? " *" : "");
|
||||
wrap.appendChild(lab);
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.name = name;
|
||||
input.className = "entity-form-input";
|
||||
input.value = value;
|
||||
if (required) input.required = true;
|
||||
wrap.appendChild(input);
|
||||
if (hint) {
|
||||
const h = document.createElement("small");
|
||||
h.className = "admin-bb-form-hint";
|
||||
h.textContent = hint;
|
||||
wrap.appendChild(h);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function textareaField(name: string, label: string, value: string, rows: number): HTMLElement {
|
||||
const wrap = document.createElement("label");
|
||||
wrap.className = "admin-bb-form-row";
|
||||
const lab = document.createElement("span");
|
||||
lab.textContent = label;
|
||||
wrap.appendChild(lab);
|
||||
const ta = document.createElement("textarea");
|
||||
ta.name = name;
|
||||
ta.className = "entity-form-input";
|
||||
ta.rows = rows;
|
||||
ta.value = value;
|
||||
wrap.appendChild(ta);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function selectField(name: string, label: string, value: string, options: string[], required: boolean): HTMLElement {
|
||||
const wrap = document.createElement("label");
|
||||
wrap.className = "admin-bb-form-row";
|
||||
const lab = document.createElement("span");
|
||||
lab.textContent = label + (required ? " *" : "");
|
||||
wrap.appendChild(lab);
|
||||
const sel = document.createElement("select");
|
||||
sel.name = name;
|
||||
sel.className = "entity-form-input";
|
||||
for (const opt of options) {
|
||||
const o = document.createElement("option");
|
||||
o.value = opt;
|
||||
o.textContent = opt;
|
||||
if (opt === value) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
wrap.appendChild(sel);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function checkboxField(name: string, label: string, value: boolean): HTMLElement {
|
||||
const wrap = document.createElement("label");
|
||||
wrap.className = "admin-bb-form-row admin-bb-form-row--checkbox";
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.name = name;
|
||||
input.checked = value;
|
||||
wrap.appendChild(input);
|
||||
const lab = document.createElement("span");
|
||||
lab.textContent = label;
|
||||
wrap.appendChild(lab);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function onSave(isNew: boolean): Promise<void> {
|
||||
const form = document.querySelector(".admin-bb-form") as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
const data = new FormData(form);
|
||||
const payload: Record<string, unknown> = {};
|
||||
for (const key of ["slug", "section_key", "title_de", "title_en", "content_md_de", "content_md_en", "visibility"]) {
|
||||
const v = data.get(key);
|
||||
if (v !== null) payload[key] = String(v);
|
||||
}
|
||||
for (const key of ["firm", "proceeding_family", "description_de", "description_en"]) {
|
||||
const v = data.get(key);
|
||||
if (v !== null) {
|
||||
const s = String(v).trim();
|
||||
payload[key] = s === "" ? null : s;
|
||||
}
|
||||
}
|
||||
payload.is_published = (data.get("is_published") === "on");
|
||||
if (!isNew) {
|
||||
const note = data.get("note");
|
||||
if (note) payload.note = String(note);
|
||||
}
|
||||
try {
|
||||
const url = isNew
|
||||
? "/api/admin/submission-building-blocks"
|
||||
: `/api/admin/submission-building-blocks/${state.selectedID}`;
|
||||
const method = isNew ? "POST" : "PATCH";
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({} as { error?: string }));
|
||||
feedback(body.error ?? `HTTP ${res.status}`, true);
|
||||
return;
|
||||
}
|
||||
const saved = await res.json() as BuildingBlockJSON;
|
||||
feedback(isEN() ? "Saved." : "Gespeichert.", false);
|
||||
await loadList();
|
||||
state.selectedID = saved.id;
|
||||
paintList();
|
||||
paintEditor(saved);
|
||||
await loadVersions(saved.id);
|
||||
} catch (err) {
|
||||
feedback(String(err), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(): Promise<void> {
|
||||
if (!state.selectedID) return;
|
||||
const sure = confirm(isEN() ? "Delete this block?" : "Diesen Baustein löschen?");
|
||||
if (!sure) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/submission-building-blocks/${state.selectedID}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
feedback(`HTTP ${res.status}`, true);
|
||||
return;
|
||||
}
|
||||
feedback(isEN() ? "Deleted." : "Gelöscht.", false);
|
||||
state.selectedID = null;
|
||||
await loadList();
|
||||
paintEditor(null);
|
||||
state.versions = [];
|
||||
paintVersions();
|
||||
} catch (err) {
|
||||
feedback(String(err), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVersions(blockID: string): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/submission-building-blocks/${blockID}/versions`, { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
state.versions = [];
|
||||
paintVersions();
|
||||
return;
|
||||
}
|
||||
const body = await res.json() as { versions?: VersionJSON[] };
|
||||
state.versions = body.versions ?? [];
|
||||
paintVersions();
|
||||
} catch {
|
||||
state.versions = [];
|
||||
paintVersions();
|
||||
}
|
||||
}
|
||||
|
||||
function paintVersions(): void {
|
||||
const host = document.getElementById("admin-bb-versions");
|
||||
if (!host) return;
|
||||
host.innerHTML = "";
|
||||
if (state.versions.length === 0) return;
|
||||
const h = document.createElement("h3");
|
||||
h.textContent = isEN() ? "History" : "Verlauf";
|
||||
host.appendChild(h);
|
||||
for (const v of state.versions) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "admin-bb-version-row";
|
||||
const date = new Date(v.created_at).toLocaleString();
|
||||
row.innerHTML = `
|
||||
<div class="admin-bb-version-meta">${escapeHTML(date)} — ${escapeHTML(v.note ?? "")}</div>`;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "btn-small btn-secondary";
|
||||
btn.textContent = isEN() ? "Restore" : "Wiederherstellen";
|
||||
btn.addEventListener("click", () => onRestore(v.id));
|
||||
row.appendChild(btn);
|
||||
host.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function onRestore(versionID: string): Promise<void> {
|
||||
if (!state.selectedID) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/submission-building-blocks/${state.selectedID}/restore/${versionID}`,
|
||||
{ method: "POST", credentials: "include" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
feedback(`HTTP ${res.status}`, true);
|
||||
return;
|
||||
}
|
||||
const restored = await res.json() as BuildingBlockJSON;
|
||||
feedback(isEN() ? "Restored." : "Wiederhergestellt.", false);
|
||||
paintEditor(restored);
|
||||
await loadVersions(restored.id);
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
feedback(String(err), true);
|
||||
}
|
||||
}
|
||||
|
||||
function feedback(msg: string, isError: boolean): void {
|
||||
const host = document.getElementById("admin-bb-feedback");
|
||||
if (!host) return;
|
||||
host.style.display = "";
|
||||
host.className = "form-msg " + (isError ? "form-msg--error" : "form-msg--ok");
|
||||
host.textContent = msg;
|
||||
if (!isError) {
|
||||
setTimeout(() => { host.style.display = "none"; }, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Silence unused-import warning when t() isn't called directly — i18n
|
||||
// is initialised so data-i18n attrs render on first paint.
|
||||
void t;
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
} else {
|
||||
void boot();
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
// Akte-mode wiring for the Litigation Builder (m/paliad#153 B4,
|
||||
// t-paliad-347).
|
||||
//
|
||||
// PRD §2.3 + §3.1 + §3.2: the page-header Akte picker lists every
|
||||
// project (`type='case'`) the user can see. Picking one POSTs to
|
||||
// /api/builder/scenarios/from-project, which mints a project-backed
|
||||
// scenario (origin_project_id pinned) seeded with the project's
|
||||
// proceeding + scenario_flags + completed deadlines. Subsequent
|
||||
// builder edits dual-write through to paliad.deadlines + projects.
|
||||
// scenario_flags via the server-side dual-write hooks.
|
||||
//
|
||||
// The picker is its own module so the builder.ts orchestrator only
|
||||
// has to expose two hooks:
|
||||
//
|
||||
// - `onProjectChosen(projectId)` — called when the user picks a
|
||||
// project. Builder calls the from-project endpoint and loads the
|
||||
// returned scenario.
|
||||
// - `setSelectedProject(scenario)` — called after a scenario loads
|
||||
// so the picker reflects the current Akte (or "— ohne —" for
|
||||
// kontextfrei scenarios).
|
||||
//
|
||||
// Cross-surface scenario-flag-changed (mig 154 ssoT, m/paliad#149):
|
||||
// the builder listens to the existing CustomEvent so any peer surface
|
||||
// that PATCHes /api/projects/{id}/scenario-flags triggers a re-fetch
|
||||
// on the builder's active proceeding when the projectId matches the
|
||||
// scenario's origin_project_id. The dispatch direction is already
|
||||
// covered by patchScenarioFlags inside scenario-flags.ts — the
|
||||
// builder's own PATCH /api/projects/.../scenario-flags goes through
|
||||
// that helper so peer surfaces stay in sync without a separate dispatch.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface AkteProjectMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
case_number?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
our_side?: string | null;
|
||||
}
|
||||
|
||||
export type OnProjectChosen = (projectId: string) => void | Promise<void>;
|
||||
|
||||
interface State {
|
||||
projects: AkteProjectMeta[];
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
projects: [],
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
// fetchAkteProjects pulls every type=case project the caller can see.
|
||||
// Visibility is enforced by /api/projects via the project_teams /
|
||||
// can_see_project predicate. We filter client-side to projects with a
|
||||
// proceeding_type_id — those are the ones the builder can render. We
|
||||
// don't filter server-side because /api/projects' filter param doesn't
|
||||
// accept proceeding_type_id_not_null and round-tripping for that one
|
||||
// reason isn't worth a new endpoint.
|
||||
export async function fetchAkteProjects(): Promise<AkteProjectMeta[]> {
|
||||
try {
|
||||
const resp = await fetch("/api/projects?type=case", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn("builder-akte: /api/projects", resp.status);
|
||||
return [];
|
||||
}
|
||||
const rows = (await resp.json()) as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
case_number?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
our_side?: string | null;
|
||||
status?: string;
|
||||
}>;
|
||||
return rows
|
||||
.filter((r) => r.proceeding_type_id != null && (r.status ?? "active") === "active")
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
reference: r.reference ?? null,
|
||||
case_number: r.case_number ?? null,
|
||||
proceeding_type_id: r.proceeding_type_id ?? null,
|
||||
our_side: r.our_side ?? null,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error("builder-akte: fetch projects failed", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// formatProjectLabel renders the dropdown row for a project. Reference
|
||||
// + title are the primary anchors; the case_number tail disambiguates
|
||||
// when two cases share a reference family.
|
||||
function formatProjectLabel(p: AkteProjectMeta): string {
|
||||
const parts: string[] = [];
|
||||
if (p.reference) parts.push(p.reference);
|
||||
parts.push(p.title);
|
||||
if (p.case_number) parts.push("(" + p.case_number + ")");
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
// renderAktePicker fills the existing <select id="builder-akte-picker">
|
||||
// with the project list + a "— ohne —" sentinel. Idempotent.
|
||||
function renderAktePicker(selectedId: string | null): void {
|
||||
const sel = document.getElementById("builder-akte-picker") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const none = t("builder.akte.none");
|
||||
const opts: string[] = [`<option value="" data-i18n="builder.akte.none">${escHtml(none)}</option>`];
|
||||
for (const p of state.projects) {
|
||||
const selected = p.id === selectedId ? " selected" : "";
|
||||
opts.push(
|
||||
`<option value="${escAttr(p.id)}"${selected}>${escHtml(formatProjectLabel(p))}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = opts.join("");
|
||||
}
|
||||
|
||||
// mountAktePicker is the entry point. It fetches the project list once,
|
||||
// wires the dropdown change event to the supplied callback, and
|
||||
// returns a controller exposing setSelectedProject so the builder can
|
||||
// keep the picker reflective of the active scenario's Akte.
|
||||
//
|
||||
// The picker re-enables itself the moment projects load. While
|
||||
// loading, the existing `disabled` attribute (set in procedures.tsx)
|
||||
// stays so users don't pick during the fetch — but if the user lands
|
||||
// on the page after the catalog is cached this is essentially
|
||||
// instantaneous.
|
||||
export interface AktePickerHandle {
|
||||
setSelectedProject: (projectId: string | null) => void;
|
||||
isAkteMode: () => boolean;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function mountAktePicker(onChosen: OnProjectChosen): Promise<AktePickerHandle> {
|
||||
const sel = document.getElementById("builder-akte-picker") as HTMLSelectElement | null;
|
||||
if (!sel) {
|
||||
return {
|
||||
setSelectedProject: () => {},
|
||||
isAkteMode: () => false,
|
||||
reload: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// First load — fill the dropdown, enable it, wire change.
|
||||
state.projects = await fetchAkteProjects();
|
||||
state.loaded = true;
|
||||
renderAktePicker(null);
|
||||
sel.disabled = false;
|
||||
|
||||
sel.addEventListener("change", () => {
|
||||
const id = sel.value;
|
||||
if (!id) {
|
||||
// "— ohne —" reset is intentional; the builder treats this as
|
||||
// "leave the current scenario alone, just clear the picker".
|
||||
// Switching the active scenario to a non-Akte one happens via
|
||||
// the scenario picker, not by clicking the empty Akte option.
|
||||
return;
|
||||
}
|
||||
void onChosen(id);
|
||||
});
|
||||
|
||||
return {
|
||||
setSelectedProject: (projectId: string | null) => {
|
||||
const next = projectId ?? "";
|
||||
// Renderless quick-sync when the option is present; otherwise
|
||||
// re-render so the option appears (covers freshly created
|
||||
// projects since this picker last loaded).
|
||||
const optEl = sel.querySelector<HTMLOptionElement>(`option[value="${cssEscape(next)}"]`);
|
||||
if (next && !optEl) {
|
||||
renderAktePicker(next);
|
||||
} else {
|
||||
sel.value = next;
|
||||
}
|
||||
},
|
||||
isAkteMode: () => sel.value !== "",
|
||||
reload: async () => {
|
||||
state.projects = await fetchAkteProjects();
|
||||
renderAktePicker(sel.value || null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// createScenarioFromProject posts to the B4 entry point. Returns the
|
||||
// new scenario's deep payload on success (id + proceedings + events),
|
||||
// null on failure. Caller is expected to load the returned scenario
|
||||
// via the builder's existing fetchScenarioDeep / state.active path.
|
||||
export async function createScenarioFromProject(projectId: string): Promise<{ id: string } | null> {
|
||||
try {
|
||||
const resp = await fetch("/api/builder/scenarios/from-project", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ project_id: projectId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn("builder-akte: from-project", resp.status, await resp.text().catch(() => ""));
|
||||
return null;
|
||||
}
|
||||
const out = await resp.json();
|
||||
return out && typeof out.id === "string" ? { id: out.id } : null;
|
||||
} catch (e) {
|
||||
console.error("builder-akte: from-project failed", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// renderAkteBanner toggles the "Aus Akte: <code>" badge next to the
|
||||
// scenario picker. The badge is a <span class="builder-akte-banner">
|
||||
// inserted/removed by this helper; CSS gives it a lime tint to match
|
||||
// the Akte affordance throughout the app. Pass `null` (or omit
|
||||
// projectId) to hide.
|
||||
export function renderAkteBanner(projectId: string | null): void {
|
||||
const host = document.querySelector(".builder-pageheader") as HTMLElement | null;
|
||||
if (!host) return;
|
||||
let badge = document.getElementById("builder-akte-banner");
|
||||
if (!projectId) {
|
||||
if (badge) badge.remove();
|
||||
return;
|
||||
}
|
||||
const meta = state.projects.find((p) => p.id === projectId);
|
||||
const label = meta ? formatProjectLabel(meta) : projectId.slice(0, 8);
|
||||
const text =
|
||||
t("builder.akte.banner.prefix") + " " + label;
|
||||
if (!badge) {
|
||||
badge = document.createElement("span");
|
||||
badge.id = "builder-akte-banner";
|
||||
badge.className = "builder-akte-banner";
|
||||
badge.setAttribute("role", "note");
|
||||
host.appendChild(badge);
|
||||
}
|
||||
badge.textContent = text;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// cssEscape is a small fallback for browsers that don't yet expose
|
||||
// CSS.escape. UUIDs only contain [0-9a-f-] so even the naïve replacer
|
||||
// keeps us safe; the function exists to make intent obvious.
|
||||
function cssEscape(s: string): string {
|
||||
if (typeof CSS !== "undefined" && typeof (CSS as { escape?: (s: string) => string }).escape === "function") {
|
||||
return (CSS as { escape: (s: string) => string }).escape(s);
|
||||
}
|
||||
return s.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
// Add-proceeding inline picker for the Litigation Builder.
|
||||
//
|
||||
// PRD §3 + §3.1: "+ Verfahren hinzufügen" button at the bottom of the
|
||||
// triplet stack opens an inline picker. Forum chip row (UPC for v1)
|
||||
// gates the Verfahren chip row, click → callback. Designed for B1's
|
||||
// single-triplet flow and B2's multi-triplet stacking with no shape
|
||||
// change between slices.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface ProceedingTypeMeta {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
// group / jurisdiction. The proceeding-types API returns "UPC" /
|
||||
// "DE" / etc. as the canonical jurisdiction; for v1 the picker
|
||||
// only renders UPC.
|
||||
group?: string;
|
||||
jurisdiction?: string;
|
||||
}
|
||||
|
||||
type OnPick = (meta: ProceedingTypeMeta) => void | Promise<void>;
|
||||
|
||||
let activePopover: HTMLElement | null = null;
|
||||
|
||||
export function mountAddProceedingPicker(
|
||||
anchor: HTMLElement,
|
||||
types: ProceedingTypeMeta[],
|
||||
onPick: OnPick,
|
||||
): void {
|
||||
closeActive();
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "builder-picker-popover";
|
||||
pop.setAttribute("role", "dialog");
|
||||
pop.setAttribute("aria-label", t("builder.picker.aria"));
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "builder-picker-header";
|
||||
header.innerHTML = `
|
||||
<strong class="builder-picker-title">${escHtml(t("builder.picker.title"))}</strong>
|
||||
<button type="button" class="builder-picker-close" aria-label="${escAttr(t("builder.picker.close"))}">×</button>
|
||||
`;
|
||||
pop.appendChild(header);
|
||||
|
||||
// Forum row — UPC only for v1. Disabled chips render greyed.
|
||||
const forumRow = document.createElement("div");
|
||||
forumRow.className = "builder-picker-row";
|
||||
forumRow.innerHTML = `
|
||||
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.forum"))}</span>
|
||||
<div class="builder-picker-chips">
|
||||
<button type="button" class="builder-picker-chip is-active" data-forum="UPC">UPC</button>
|
||||
<button type="button" class="builder-picker-chip" data-forum="DE" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DE</button>
|
||||
<button type="button" class="builder-picker-chip" data-forum="EPA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">EPA</button>
|
||||
<button type="button" class="builder-picker-chip" data-forum="DPMA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DPMA</button>
|
||||
</div>
|
||||
`;
|
||||
pop.appendChild(forumRow);
|
||||
|
||||
const procRow = document.createElement("div");
|
||||
procRow.className = "builder-picker-row";
|
||||
procRow.innerHTML = `
|
||||
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.proc"))}</span>
|
||||
<div class="builder-picker-chips builder-picker-chips--wrap" id="builder-picker-proc-chips"></div>
|
||||
`;
|
||||
pop.appendChild(procRow);
|
||||
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "builder-picker-empty";
|
||||
empty.hidden = true;
|
||||
empty.textContent = t("builder.picker.empty");
|
||||
pop.appendChild(empty);
|
||||
|
||||
const procHost = pop.querySelector("#builder-picker-proc-chips") as HTMLElement;
|
||||
const lang = document.documentElement.lang === "en" ? "en" : "de";
|
||||
for (const meta of types) {
|
||||
const label = lang === "en" ? (meta.nameEN || meta.name) : meta.name;
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "builder-picker-chip builder-picker-chip--proc";
|
||||
chip.setAttribute("data-code", meta.code);
|
||||
chip.innerHTML = `<span class="builder-picker-chip-code">${escHtml(meta.code)}</span>
|
||||
<span class="builder-picker-chip-name">${escHtml(label)}</span>`;
|
||||
chip.addEventListener("click", () => {
|
||||
closeActive();
|
||||
void onPick(meta);
|
||||
});
|
||||
procHost.appendChild(chip);
|
||||
}
|
||||
if (types.length === 0) empty.hidden = false;
|
||||
|
||||
header.querySelector(".builder-picker-close")?.addEventListener("click", () => {
|
||||
closeActive();
|
||||
});
|
||||
|
||||
// Position the popover under the anchor button.
|
||||
positionUnder(pop, anchor);
|
||||
document.body.appendChild(pop);
|
||||
activePopover = pop;
|
||||
document.addEventListener("click", onOutsideClick, true);
|
||||
document.addEventListener("keydown", onEscape, true);
|
||||
}
|
||||
|
||||
function positionUnder(pop: HTMLElement, anchor: HTMLElement): void {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
pop.style.position = "absolute";
|
||||
const top = rect.bottom + window.scrollY + 6;
|
||||
// Default left = anchor's left; clamp so popover stays in viewport.
|
||||
const left = Math.max(8, rect.left + window.scrollX);
|
||||
pop.style.top = `${top}px`;
|
||||
pop.style.left = `${left}px`;
|
||||
pop.style.maxWidth = "min(640px, calc(100vw - 24px))";
|
||||
pop.style.zIndex = "60";
|
||||
}
|
||||
|
||||
function onOutsideClick(ev: Event): void {
|
||||
if (!activePopover) return;
|
||||
const target = ev.target as Node;
|
||||
if (activePopover.contains(target)) return;
|
||||
closeActive();
|
||||
}
|
||||
|
||||
function onEscape(ev: KeyboardEvent): void {
|
||||
if (ev.key === "Escape") closeActive();
|
||||
}
|
||||
|
||||
function closeActive(): void {
|
||||
if (activePopover) {
|
||||
activePopover.remove();
|
||||
activePopover = null;
|
||||
}
|
||||
document.removeEventListener("click", onOutsideClick, true);
|
||||
document.removeEventListener("keydown", onEscape, true);
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
// Litigation Builder — promote-to-project wizard (m/paliad#153 PRD §2.4
|
||||
// + §5.4, B5).
|
||||
//
|
||||
// 3 steps: Bestätigen (read-only summary) → Parteien ergänzen (party
|
||||
// names) → Akte-Metadaten (title, reference, case number, our_side,
|
||||
// litigation parent, team). Commit POSTs the merged payload to
|
||||
// /api/builder/scenarios/{id}/promote — a single server-side transaction
|
||||
// (no partial promotions) that creates the paliad.projects 'case' row,
|
||||
// cascades deadlines, and flips the scenario to 'promoted'. On success
|
||||
// the wizard navigates to /projects/{new-id}.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
interface ProjectOption {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
reference?: string;
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name?: string;
|
||||
office?: string;
|
||||
}
|
||||
|
||||
interface PartyRow {
|
||||
name: string;
|
||||
role: string;
|
||||
representative: string;
|
||||
}
|
||||
|
||||
export interface PromoteContext {
|
||||
scenarioId: string;
|
||||
ownerId?: string;
|
||||
proceedingLabel: string;
|
||||
filedCount: number;
|
||||
plannedCount: number;
|
||||
flagCount: number;
|
||||
extraTopLevel: number;
|
||||
defaultOurSide: "claimant" | "defendant" | null;
|
||||
defaultTitle: string;
|
||||
onSuccess: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export async function openPromoteWizard(ctx: PromoteContext): Promise<void> {
|
||||
// Parallel fetch: litigation parents + HLC users (both optional pickers).
|
||||
const [parents, users] = await Promise.all([
|
||||
fetchProjects("litigation"),
|
||||
fetchUsers(),
|
||||
]);
|
||||
|
||||
let step = 1;
|
||||
const parties: PartyRow[] = [];
|
||||
const meta = {
|
||||
title: ctx.defaultTitle || "",
|
||||
reference: "",
|
||||
caseNumber: "",
|
||||
clientNumber: "",
|
||||
ourSide: (ctx.defaultOurSide ?? "") as "" | "claimant" | "defendant",
|
||||
parentId: "",
|
||||
teamIds: new Set<string>(),
|
||||
};
|
||||
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "builder-modal-backdrop";
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "builder-modal builder-promote-modal";
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.setAttribute("aria-label", t("builder.promote.title"));
|
||||
backdrop.appendChild(modal);
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener("keydown", onEsc, true);
|
||||
backdrop.remove();
|
||||
};
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
document.addEventListener("keydown", onEsc, true);
|
||||
|
||||
function stepHeader(): string {
|
||||
const steps = [
|
||||
t("builder.promote.step1"),
|
||||
t("builder.promote.step2"),
|
||||
t("builder.promote.step3"),
|
||||
];
|
||||
const dots = steps.map((label, i) => {
|
||||
const n = i + 1;
|
||||
const cls = n === step ? " is-active" : n < step ? " is-done" : "";
|
||||
return `<li class="builder-promote-step${cls}"><span class="builder-promote-step-n">${n}</span>` +
|
||||
`<span class="builder-promote-step-label">${escHtml(label)}</span></li>`;
|
||||
}).join("");
|
||||
return `<ol class="builder-promote-steps">${dots}</ol>`;
|
||||
}
|
||||
|
||||
function renderStep1(): string {
|
||||
const rows = [
|
||||
`<li><span>${escHtml(t("builder.promote.summary.proceeding"))}</span><strong>${escHtml(ctx.proceedingLabel)}</strong></li>`,
|
||||
`<li><span>${escHtml(t("builder.promote.summary.events_filed"))}</span><strong>${ctx.filedCount}</strong></li>`,
|
||||
`<li><span>${escHtml(t("builder.promote.summary.events_planned"))}</span><strong>${ctx.plannedCount}</strong></li>`,
|
||||
`<li><span>${escHtml(t("builder.promote.summary.flags"))}</span><strong>${ctx.flagCount}</strong></li>`,
|
||||
].join("");
|
||||
const extra = ctx.extraTopLevel > 0
|
||||
? `<p class="builder-promote-note">${escHtml(
|
||||
t("builder.promote.summary.note_extra").replace("{n}", String(ctx.extraTopLevel)),
|
||||
)}</p>`
|
||||
: "";
|
||||
return (
|
||||
`<h3 class="builder-promote-section-title">${escHtml(t("builder.promote.summary.heading"))}</h3>` +
|
||||
`<ul class="builder-promote-summary">${rows}</ul>${extra}`
|
||||
);
|
||||
}
|
||||
|
||||
function renderStep2(): string {
|
||||
const list = parties.length === 0
|
||||
? `<p class="builder-promote-empty">${escHtml(t("builder.promote.parties.empty"))}</p>`
|
||||
: parties.map((p, i) => (
|
||||
`<div class="builder-promote-party" data-idx="${i}">` +
|
||||
`<input class="builder-promote-party-name" placeholder="${escAttr(t("builder.promote.parties.name"))}" value="${escAttr(p.name)}" />` +
|
||||
`<input class="builder-promote-party-role" placeholder="${escAttr(t("builder.promote.parties.role"))}" value="${escAttr(p.role)}" />` +
|
||||
`<input class="builder-promote-party-rep" placeholder="${escAttr(t("builder.promote.parties.representative"))}" value="${escAttr(p.representative)}" />` +
|
||||
`<button type="button" class="builder-promote-party-remove" aria-label="${escAttr(t("builder.promote.parties.remove"))}">×</button>` +
|
||||
`</div>`
|
||||
)).join("");
|
||||
return (
|
||||
`<p class="builder-promote-hint">${escHtml(t("builder.promote.parties.hint"))}</p>` +
|
||||
`<div class="builder-promote-parties">${list}</div>` +
|
||||
`<button type="button" class="builder-promote-party-add">${escHtml(t("builder.promote.parties.add"))}</button>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderStep3(): string {
|
||||
const parentOpts = [`<option value="">${escHtml(t("builder.promote.meta.parent.none"))}</option>`]
|
||||
.concat(parents.map((p) => {
|
||||
const sel = p.id === meta.parentId ? " selected" : "";
|
||||
const label = p.reference ? `${p.title} (${p.reference})` : p.title;
|
||||
return `<option value="${escAttr(p.id)}"${sel}>${escHtml(label)}</option>`;
|
||||
})).join("");
|
||||
const sideSel = (v: string) => (meta.ourSide === v ? " selected" : "");
|
||||
const team = users
|
||||
.filter((u) => u.id !== ctx.ownerId)
|
||||
.slice(0, 40)
|
||||
.map((u) => {
|
||||
const checked = meta.teamIds.has(u.id) ? " checked" : "";
|
||||
const label = (u.display_name || "").trim()
|
||||
? ((u.office ? `${u.display_name} · ${u.office}` : u.display_name) as string)
|
||||
: u.email;
|
||||
return (
|
||||
`<label class="builder-promote-team-item">` +
|
||||
`<input type="checkbox" class="builder-promote-team-cb" data-user-id="${escAttr(u.id)}"${checked} />` +
|
||||
`<span>${escHtml(label)}</span></label>`
|
||||
);
|
||||
}).join("");
|
||||
return (
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.title"))}</span>` +
|
||||
`<input class="builder-promote-title" placeholder="${escAttr(t("builder.promote.meta.title.placeholder"))}" value="${escAttr(meta.title)}" /></label>` +
|
||||
`<div class="builder-promote-field-row">` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.reference"))}</span>` +
|
||||
`<input class="builder-promote-reference" value="${escAttr(meta.reference)}" /></label>` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.case_number"))}</span>` +
|
||||
`<input class="builder-promote-casenumber" value="${escAttr(meta.caseNumber)}" /></label>` +
|
||||
`</div>` +
|
||||
`<div class="builder-promote-field-row">` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.client_number"))}</span>` +
|
||||
`<input class="builder-promote-clientnumber" value="${escAttr(meta.clientNumber)}" /></label>` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.our_side"))}</span>` +
|
||||
`<select class="builder-promote-ourside">` +
|
||||
`<option value=""${sideSel("")}>${escHtml(t("builder.promote.meta.our_side.none"))}</option>` +
|
||||
`<option value="claimant"${sideSel("claimant")}>${escHtml(t("builder.promote.meta.our_side.claimant"))}</option>` +
|
||||
`<option value="defendant"${sideSel("defendant")}>${escHtml(t("builder.promote.meta.our_side.defendant"))}</option>` +
|
||||
`</select></label>` +
|
||||
`</div>` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.parent"))}</span>` +
|
||||
`<select class="builder-promote-parent">${parentOpts}</select></label>` +
|
||||
`<div class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.team"))}</span>` +
|
||||
`<p class="builder-promote-team-hint">${escHtml(t("builder.promote.meta.team.hint"))}</p>` +
|
||||
`<div class="builder-promote-team">${team}</div></div>` +
|
||||
`<p class="builder-promote-error" hidden></p>`
|
||||
);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
let body = "";
|
||||
if (step === 1) body = renderStep1();
|
||||
else if (step === 2) body = renderStep2();
|
||||
else body = renderStep3();
|
||||
|
||||
const backLabel = t("builder.promote.back");
|
||||
const cancelLabel = t("builder.promote.cancel");
|
||||
const nextLabel = step < 3 ? t("builder.promote.next") : t("builder.promote.commit");
|
||||
|
||||
modal.innerHTML = `
|
||||
<header class="builder-modal-header">
|
||||
<h2 class="builder-modal-title">${escHtml(t("builder.promote.title"))}</h2>
|
||||
<button type="button" class="builder-modal-close" aria-label="${escAttr(cancelLabel)}">×</button>
|
||||
</header>
|
||||
${stepHeader()}
|
||||
<div class="builder-promote-body">${body}</div>
|
||||
<footer class="builder-promote-footer">
|
||||
<button type="button" class="builder-promote-cancel">${escHtml(cancelLabel)}</button>
|
||||
<span class="builder-promote-footer-spacer"></span>
|
||||
${step > 1 ? `<button type="button" class="builder-promote-backbtn">${escHtml(backLabel)}</button>` : ""}
|
||||
<button type="button" class="builder-promote-nextbtn builder-action-btn--primary">${escHtml(nextLabel)}</button>
|
||||
</footer>`;
|
||||
wire();
|
||||
}
|
||||
|
||||
function captureStep2(): void {
|
||||
modal.querySelectorAll<HTMLElement>(".builder-promote-party").forEach((row) => {
|
||||
const idx = Number(row.getAttribute("data-idx"));
|
||||
if (Number.isNaN(idx) || !parties[idx]) return;
|
||||
parties[idx].name = (row.querySelector(".builder-promote-party-name") as HTMLInputElement).value;
|
||||
parties[idx].role = (row.querySelector(".builder-promote-party-role") as HTMLInputElement).value;
|
||||
parties[idx].representative = (row.querySelector(".builder-promote-party-rep") as HTMLInputElement).value;
|
||||
});
|
||||
}
|
||||
|
||||
function captureStep3(): void {
|
||||
const get = (sel: string) => (modal.querySelector(sel) as HTMLInputElement | null)?.value ?? "";
|
||||
meta.title = get(".builder-promote-title");
|
||||
meta.reference = get(".builder-promote-reference");
|
||||
meta.caseNumber = get(".builder-promote-casenumber");
|
||||
meta.clientNumber = get(".builder-promote-clientnumber");
|
||||
meta.ourSide = ((modal.querySelector(".builder-promote-ourside") as HTMLSelectElement)?.value || "") as typeof meta.ourSide;
|
||||
meta.parentId = (modal.querySelector(".builder-promote-parent") as HTMLSelectElement)?.value || "";
|
||||
meta.teamIds = new Set(
|
||||
Array.from(modal.querySelectorAll<HTMLInputElement>(".builder-promote-team-cb:checked"))
|
||||
.map((cb) => cb.getAttribute("data-user-id") || "")
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function wire(): void {
|
||||
modal.querySelector(".builder-modal-close")?.addEventListener("click", close);
|
||||
modal.querySelector(".builder-promote-cancel")?.addEventListener("click", close);
|
||||
modal.querySelector(".builder-promote-backbtn")?.addEventListener("click", () => {
|
||||
if (step === 2) captureStep2();
|
||||
if (step === 3) captureStep3();
|
||||
step = Math.max(1, step - 1);
|
||||
render();
|
||||
});
|
||||
modal.querySelector(".builder-promote-nextbtn")?.addEventListener("click", () => {
|
||||
if (step === 2) captureStep2();
|
||||
if (step < 3) {
|
||||
step += 1;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
captureStep3();
|
||||
void commit();
|
||||
});
|
||||
if (step === 2) {
|
||||
modal.querySelector(".builder-promote-party-add")?.addEventListener("click", () => {
|
||||
captureStep2();
|
||||
parties.push({ name: "", role: "", representative: "" });
|
||||
render();
|
||||
});
|
||||
modal.querySelectorAll<HTMLElement>(".builder-promote-party-remove").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
captureStep2();
|
||||
const row = btn.closest(".builder-promote-party") as HTMLElement;
|
||||
const idx = Number(row?.getAttribute("data-idx"));
|
||||
if (!Number.isNaN(idx)) parties.splice(idx, 1);
|
||||
render();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function commit(): Promise<void> {
|
||||
const errEl = modal.querySelector(".builder-promote-error") as HTMLElement | null;
|
||||
const showErr = (msg: string) => {
|
||||
if (errEl) {
|
||||
errEl.textContent = msg;
|
||||
errEl.hidden = false;
|
||||
}
|
||||
};
|
||||
if (!meta.title.trim()) {
|
||||
showErr(t("builder.promote.error.title_required"));
|
||||
return;
|
||||
}
|
||||
const nextBtn = modal.querySelector(".builder-promote-nextbtn") as HTMLButtonElement | null;
|
||||
if (nextBtn) nextBtn.disabled = true;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title: meta.title.trim(),
|
||||
reference: meta.reference.trim() || undefined,
|
||||
case_number: meta.caseNumber.trim() || undefined,
|
||||
client_number: meta.clientNumber.trim() || undefined,
|
||||
our_side: meta.ourSide || undefined,
|
||||
parent_id: meta.parentId || undefined,
|
||||
parties: parties
|
||||
.filter((p) => p.name.trim())
|
||||
.map((p) => ({
|
||||
name: p.name.trim(),
|
||||
role: p.role.trim() || undefined,
|
||||
representative: p.representative.trim() || undefined,
|
||||
})),
|
||||
team_members: Array.from(meta.teamIds).map((id) => ({ user_id: id })),
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(ctx.scenarioId) + "/promote",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
if (nextBtn) nextBtn.disabled = false;
|
||||
showErr(t("builder.promote.error.generic"));
|
||||
return;
|
||||
}
|
||||
const out = (await resp.json()) as { project_id: string };
|
||||
const body = modal.querySelector(".builder-promote-body") as HTMLElement;
|
||||
if (body) body.innerHTML = `<p class="builder-promote-success">${escHtml(t("builder.promote.success"))}</p>`;
|
||||
ctx.onSuccess(out.project_id);
|
||||
} catch {
|
||||
if (nextBtn) nextBtn.disabled = false;
|
||||
showErr(t("builder.promote.error.generic"));
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
document.body.appendChild(backdrop);
|
||||
(modal.querySelector(".builder-promote-nextbtn") as HTMLElement | null)?.focus();
|
||||
}
|
||||
|
||||
async function fetchProjects(type: string): Promise<ProjectOption[]> {
|
||||
try {
|
||||
const resp = await fetch("/api/projects?type=" + encodeURIComponent(type));
|
||||
if (!resp.ok) return [];
|
||||
const data = (await resp.json()) as ProjectOption[];
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsers(): Promise<UserOption[]> {
|
||||
try {
|
||||
const resp = await fetch("/api/users");
|
||||
if (!resp.ok) return [];
|
||||
const data = (await resp.json()) as UserOption[];
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
// Universal search dropdown for the Litigation Builder (m/paliad#153 B3).
|
||||
//
|
||||
// PRD §2.2 + §3.1 + §6.3: the page-header search box ("Suche") drives
|
||||
// a typed dropdown returning grouped event / scenario / project hits.
|
||||
// Picking an event lands the user on a scratch scenario with one
|
||||
// triplet anchored on that event's proceeding type. Picking a scenario
|
||||
// loads it; picking a project (Akte) is deferred to B4 (the dropdown
|
||||
// row renders but pick falls through to a console hint until B4 wires
|
||||
// project-backed scenarios).
|
||||
//
|
||||
// The controller is owned by builder.ts; this module exports
|
||||
// `mountBuilderSearch` which wires the input + dropdown lifecycle and
|
||||
// invokes the supplied callbacks. No module-level state — re-mounting
|
||||
// is safe.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface EventSearchHit {
|
||||
id: string;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
event_kind?: string | null;
|
||||
primary_party?: string | null;
|
||||
anchor_rule_id: string;
|
||||
follow_up_count: number;
|
||||
proceeding_type: {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScenarioSearchHit {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProjectSearchHit {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
case_number?: string | null;
|
||||
matter_number?: string | null;
|
||||
client_number?: string | null;
|
||||
}
|
||||
|
||||
export interface UniversalSearchResponse {
|
||||
query: string;
|
||||
events: EventSearchHit[];
|
||||
scenarios: ScenarioSearchHit[];
|
||||
projects: ProjectSearchHit[];
|
||||
counts: { events: number; scenarios: number; projects: number };
|
||||
}
|
||||
|
||||
export interface BuilderSearchCallbacks {
|
||||
onPickEvent: (hit: EventSearchHit) => void | Promise<void>;
|
||||
onPickScenario: (hit: ScenarioSearchHit) => void | Promise<void>;
|
||||
onPickProject?: (hit: ProjectSearchHit) => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface Controller {
|
||||
input: HTMLInputElement;
|
||||
dropdown: HTMLElement;
|
||||
open: boolean;
|
||||
abort: AbortController | null;
|
||||
debounceTimer: number | null;
|
||||
lang: "de" | "en";
|
||||
}
|
||||
|
||||
let active: Controller | null = null;
|
||||
|
||||
// mountBuilderSearch wires the universal search behavior onto an
|
||||
// existing <input>. Idempotent — re-calling tears down the previous
|
||||
// dropdown and rebinds. Returns a controller exposing focus() so the
|
||||
// entry-mode toggle in builder.ts can land on the search input.
|
||||
export function mountBuilderSearch(
|
||||
input: HTMLInputElement,
|
||||
cb: BuilderSearchCallbacks,
|
||||
): { focus: () => void; close: () => void } {
|
||||
teardown();
|
||||
|
||||
const lang: "de" | "en" = document.documentElement.lang === "en" ? "en" : "de";
|
||||
|
||||
// Single dropdown container, anchored under the input. Positioned
|
||||
// absolutely so it floats above the canvas without reflowing layout.
|
||||
const dropdown = document.createElement("div");
|
||||
dropdown.className = "builder-search-dropdown";
|
||||
dropdown.setAttribute("role", "listbox");
|
||||
dropdown.hidden = true;
|
||||
document.body.appendChild(dropdown);
|
||||
|
||||
active = {
|
||||
input,
|
||||
dropdown,
|
||||
open: false,
|
||||
abort: null,
|
||||
debounceTimer: null,
|
||||
lang,
|
||||
};
|
||||
|
||||
input.addEventListener("input", onInput);
|
||||
input.addEventListener("focus", onFocus);
|
||||
input.addEventListener("keydown", onKeydown);
|
||||
document.addEventListener("click", onOutsideClick, true);
|
||||
window.addEventListener("resize", reposition);
|
||||
window.addEventListener("scroll", reposition, true);
|
||||
|
||||
// Click handler is wired once on the dropdown root via event
|
||||
// delegation; per-row data attributes identify the hit type.
|
||||
dropdown.addEventListener("click", (ev) => {
|
||||
const row = (ev.target as HTMLElement).closest<HTMLElement>(".builder-search-row");
|
||||
if (!row) return;
|
||||
const kind = row.getAttribute("data-hit-kind");
|
||||
const payload = row.getAttribute("data-hit-payload");
|
||||
if (!kind || !payload) return;
|
||||
try {
|
||||
const hit = JSON.parse(payload);
|
||||
ev.stopPropagation();
|
||||
closeDropdown();
|
||||
if (kind === "event") void cb.onPickEvent(hit);
|
||||
else if (kind === "scenario") void cb.onPickScenario(hit);
|
||||
else if (kind === "project" && cb.onPickProject) void cb.onPickProject(hit);
|
||||
} catch (err) {
|
||||
console.error("builder-search: bad payload", err);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
focus: () => {
|
||||
input.focus();
|
||||
// Open the dropdown on focus even when input is empty — show the
|
||||
// "start typing" hint per PRD §2.2 (search box auto-focuses).
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.start"));
|
||||
},
|
||||
close: closeDropdown,
|
||||
};
|
||||
}
|
||||
|
||||
function teardown(): void {
|
||||
if (!active) return;
|
||||
if (active.abort) active.abort.abort();
|
||||
if (active.debounceTimer !== null) window.clearTimeout(active.debounceTimer);
|
||||
active.dropdown.remove();
|
||||
active.input.removeEventListener("input", onInput);
|
||||
active.input.removeEventListener("focus", onFocus);
|
||||
active.input.removeEventListener("keydown", onKeydown);
|
||||
document.removeEventListener("click", onOutsideClick, true);
|
||||
window.removeEventListener("resize", reposition);
|
||||
window.removeEventListener("scroll", reposition, true);
|
||||
active = null;
|
||||
}
|
||||
|
||||
function onInput(): void {
|
||||
if (!active) return;
|
||||
const q = active.input.value.trim();
|
||||
if (active.debounceTimer !== null) window.clearTimeout(active.debounceTimer);
|
||||
if (q.length === 0) {
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.start"));
|
||||
return;
|
||||
}
|
||||
if (q.length < 2) {
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.short"));
|
||||
return;
|
||||
}
|
||||
active.debounceTimer = window.setTimeout(() => {
|
||||
void runSearch(q);
|
||||
}, 180);
|
||||
}
|
||||
|
||||
function onFocus(): void {
|
||||
if (!active) return;
|
||||
const q = active.input.value.trim();
|
||||
if (q.length === 0) {
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.start"));
|
||||
} else if (q.length >= 2) {
|
||||
void runSearch(q);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(ev: KeyboardEvent): void {
|
||||
if (!active) return;
|
||||
if (ev.key === "Escape") {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
|
||||
const rows = Array.from(active.dropdown.querySelectorAll<HTMLElement>(".builder-search-row"));
|
||||
if (rows.length === 0) return;
|
||||
ev.preventDefault();
|
||||
const current = active.dropdown.querySelector<HTMLElement>(".builder-search-row.is-focus");
|
||||
let idx = current ? rows.indexOf(current) : -1;
|
||||
idx = ev.key === "ArrowDown"
|
||||
? Math.min(rows.length - 1, idx + 1)
|
||||
: Math.max(0, idx - 1);
|
||||
rows.forEach((r) => r.classList.remove("is-focus"));
|
||||
rows[idx].classList.add("is-focus");
|
||||
rows[idx].scrollIntoView({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
if (ev.key === "Enter") {
|
||||
const focused = active.dropdown.querySelector<HTMLElement>(".builder-search-row.is-focus");
|
||||
if (focused) {
|
||||
ev.preventDefault();
|
||||
focused.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onOutsideClick(ev: Event): void {
|
||||
if (!active) return;
|
||||
const target = ev.target as Node;
|
||||
if (active.input.contains(target)) return;
|
||||
if (active.dropdown.contains(target)) return;
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
async function runSearch(q: string): Promise<void> {
|
||||
if (!active) return;
|
||||
// Cancel any in-flight request so a slow earlier query can't clobber
|
||||
// a faster newer one.
|
||||
if (active.abort) active.abort.abort();
|
||||
const ctl = new AbortController();
|
||||
active.abort = ctl;
|
||||
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.loading"));
|
||||
|
||||
try {
|
||||
const url = "/api/builder/search?q=" + encodeURIComponent(q);
|
||||
const resp = await fetch(url, { signal: ctl.signal });
|
||||
if (!resp.ok) {
|
||||
renderHint(t("builder.search.hint.error"));
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as UniversalSearchResponse;
|
||||
if (active.abort !== ctl) return;
|
||||
renderResults(data);
|
||||
} catch (err) {
|
||||
if ((err as { name?: string })?.name === "AbortError") return;
|
||||
console.error("builder-search error:", err);
|
||||
renderHint(t("builder.search.hint.error"));
|
||||
}
|
||||
}
|
||||
|
||||
function renderHint(message: string): void {
|
||||
if (!active) return;
|
||||
active.dropdown.innerHTML = `<div class="builder-search-hint">${escHtml(message)}</div>`;
|
||||
reposition();
|
||||
}
|
||||
|
||||
function renderResults(data: UniversalSearchResponse): void {
|
||||
if (!active) return;
|
||||
const lang = active.lang;
|
||||
|
||||
const total = data.events.length + data.scenarios.length + data.projects.length;
|
||||
if (total === 0) {
|
||||
renderHint(t("builder.search.hint.empty"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Result-count summary per PRD §2.2: "N Ereignisse · M Szenarios · K Akten"
|
||||
const counts = `<div class="builder-search-summary">` +
|
||||
escHtml(tCount("builder.search.summary.events", data.events.length)) +
|
||||
` · ` +
|
||||
escHtml(tCount("builder.search.summary.scenarios", data.scenarios.length)) +
|
||||
` · ` +
|
||||
escHtml(tCount("builder.search.summary.projects", data.projects.length)) +
|
||||
`</div>`;
|
||||
|
||||
const sections: string[] = [counts];
|
||||
|
||||
if (data.events.length > 0) {
|
||||
sections.push(renderGroup(
|
||||
t("builder.search.group.events"),
|
||||
data.events.map((e) => renderEventRow(e, lang)).join(""),
|
||||
));
|
||||
}
|
||||
if (data.scenarios.length > 0) {
|
||||
sections.push(renderGroup(
|
||||
t("builder.search.group.scenarios"),
|
||||
data.scenarios.map((s) => renderScenarioRow(s)).join(""),
|
||||
));
|
||||
}
|
||||
if (data.projects.length > 0) {
|
||||
sections.push(renderGroup(
|
||||
t("builder.search.group.projects"),
|
||||
data.projects.map((p) => renderProjectRow(p, lang)).join(""),
|
||||
));
|
||||
}
|
||||
|
||||
active.dropdown.innerHTML = sections.join("");
|
||||
reposition();
|
||||
}
|
||||
|
||||
function renderGroup(label: string, rowsHtml: string): string {
|
||||
return `<section class="builder-search-group">` +
|
||||
`<header class="builder-search-group-label">${escHtml(label)}</header>` +
|
||||
rowsHtml +
|
||||
`</section>`;
|
||||
}
|
||||
|
||||
function renderEventRow(hit: EventSearchHit, lang: "de" | "en"): string {
|
||||
const name = lang === "en" ? (hit.name_en || hit.name_de) : (hit.name_de || hit.name_en);
|
||||
const ptName = lang === "en"
|
||||
? (hit.proceeding_type.name_en || hit.proceeding_type.name_de)
|
||||
: (hit.proceeding_type.name_de || hit.proceeding_type.name_en);
|
||||
const party = hit.primary_party ? `<span class="builder-search-party">${escHtml(hit.primary_party)}</span>` : "";
|
||||
const kind = hit.event_kind ? `<span class="builder-search-kind">${escHtml(hit.event_kind)}</span>` : "";
|
||||
// Payload for the click handler — we embed the full hit so builder.ts
|
||||
// doesn't need a second lookup. JSON-encoded into a data attribute,
|
||||
// attr-escaped on the way in.
|
||||
const payload = escAttr(JSON.stringify(hit));
|
||||
return `<div class="builder-search-row" data-hit-kind="event" data-hit-payload="${payload}" tabindex="-1" role="option">` +
|
||||
`<div class="builder-search-row-main">` +
|
||||
`<span class="builder-search-pt-code">${escHtml(hit.proceeding_type.code)}</span>` +
|
||||
`<span class="builder-search-event-name">${escHtml(name)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="builder-search-row-meta">` +
|
||||
`<span class="builder-search-pt-name">${escHtml(ptName)}</span>` +
|
||||
kind + party +
|
||||
`</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function renderScenarioRow(hit: ScenarioSearchHit): string {
|
||||
const payload = escAttr(JSON.stringify(hit));
|
||||
return `<div class="builder-search-row" data-hit-kind="scenario" data-hit-payload="${payload}" tabindex="-1" role="option">` +
|
||||
`<div class="builder-search-row-main">` +
|
||||
`<span class="builder-search-scenario-name">${escHtml(hit.name)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="builder-search-row-meta">` +
|
||||
`<span class="builder-search-status">${escHtml(hit.status)}</span>` +
|
||||
`</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function renderProjectRow(hit: ProjectSearchHit, _lang: "de" | "en"): string {
|
||||
const meta: string[] = [];
|
||||
if (hit.case_number) meta.push(hit.case_number);
|
||||
if (hit.matter_number) meta.push(hit.matter_number);
|
||||
if (hit.client_number) meta.push(hit.client_number);
|
||||
if (hit.reference) meta.push(hit.reference);
|
||||
const metaText = meta.length > 0 ? meta.join(" · ") : "";
|
||||
const payload = escAttr(JSON.stringify(hit));
|
||||
return `<div class="builder-search-row" data-hit-kind="project" data-hit-payload="${payload}" tabindex="-1" role="option">` +
|
||||
`<div class="builder-search-row-main">` +
|
||||
`<span class="builder-search-project-type">${escHtml(hit.type)}</span>` +
|
||||
`<span class="builder-search-project-title">${escHtml(hit.title)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="builder-search-row-meta">${escHtml(metaText)}</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function openDropdown(): void {
|
||||
if (!active) return;
|
||||
active.dropdown.hidden = false;
|
||||
active.open = true;
|
||||
reposition();
|
||||
}
|
||||
|
||||
function closeDropdown(): void {
|
||||
if (!active) return;
|
||||
active.dropdown.hidden = true;
|
||||
active.open = false;
|
||||
if (active.abort) {
|
||||
active.abort.abort();
|
||||
active.abort = null;
|
||||
}
|
||||
}
|
||||
|
||||
function reposition(): void {
|
||||
if (!active || !active.open) return;
|
||||
const rect = active.input.getBoundingClientRect();
|
||||
const top = rect.bottom + window.scrollY + 4;
|
||||
const left = rect.left + window.scrollX;
|
||||
const width = Math.max(rect.width, 380);
|
||||
active.dropdown.style.position = "absolute";
|
||||
active.dropdown.style.top = `${top}px`;
|
||||
active.dropdown.style.left = `${left}px`;
|
||||
active.dropdown.style.width = `${width}px`;
|
||||
active.dropdown.style.zIndex = "60";
|
||||
}
|
||||
|
||||
// tCount applies a simple plural pick: keys ".one" / ".other" carry
|
||||
// the singular/plural variants; the caller's key is the bare stem.
|
||||
function tCount(key: string, n: number): string {
|
||||
const variant = n === 1 ? `${key}.one` : `${key}.other`;
|
||||
return t(variant).replace("{n}", String(n));
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
// Litigation Builder — share-with-team UI (m/paliad#153 PRD §2.5, B5).
|
||||
//
|
||||
// "Teilen" opens a modal with an HLC user picker. Picking a colleague +
|
||||
// "Schreibgeschützt teilen" POSTs a paliad.scenario_shares row; the owner
|
||||
// stays sole editor. Existing shares are listed with a revoke affordance.
|
||||
// The sharee sees the scenario in their "Geteilt mit mir" bucket (read-
|
||||
// only) — that side is handled by builder.ts.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface ShareUser {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name?: string;
|
||||
office?: string;
|
||||
}
|
||||
|
||||
export interface BuilderShareRow {
|
||||
id: string;
|
||||
scenario_id: string;
|
||||
shared_with_user_id: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ShareModalOpts {
|
||||
scenarioId: string;
|
||||
ownerId?: string;
|
||||
currentShares: BuilderShareRow[];
|
||||
// Called after a successful add/revoke with the fresh share list so the
|
||||
// caller can update state.active.shares + re-render side panel buckets.
|
||||
onChanged: (shares: BuilderShareRow[]) => void;
|
||||
}
|
||||
|
||||
let allUsers: ShareUser[] | null = null;
|
||||
|
||||
async function fetchUsers(): Promise<ShareUser[]> {
|
||||
if (allUsers) return allUsers;
|
||||
try {
|
||||
const resp = await fetch("/api/users");
|
||||
if (!resp.ok) return [];
|
||||
const data = (await resp.json()) as ShareUser[];
|
||||
allUsers = Array.isArray(data) ? data : [];
|
||||
return allUsers;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function userLabel(u: ShareUser): string {
|
||||
const name = (u.display_name || "").trim();
|
||||
if (name) return u.office ? `${name} · ${u.office}` : name;
|
||||
return u.email;
|
||||
}
|
||||
|
||||
export async function openShareModal(opts: ShareModalOpts): Promise<void> {
|
||||
const users = await fetchUsers();
|
||||
let shares = [...opts.currentShares];
|
||||
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "builder-modal-backdrop";
|
||||
backdrop.innerHTML = `
|
||||
<div class="builder-modal builder-share-modal" role="dialog" aria-modal="true"
|
||||
aria-label="${escAttr(t("builder.share.title"))}">
|
||||
<header class="builder-modal-header">
|
||||
<h2 class="builder-modal-title">${escHtml(t("builder.share.title"))}</h2>
|
||||
<button type="button" class="builder-modal-close" aria-label="${escAttr(t("builder.share.close"))}">×</button>
|
||||
</header>
|
||||
<p class="builder-modal-subtitle">${escHtml(t("builder.share.subtitle"))}</p>
|
||||
<div class="builder-share-pickerbox">
|
||||
<input type="search" class="builder-share-search" autocomplete="off" spellcheck="false"
|
||||
placeholder="${escAttr(t("builder.share.search.placeholder"))}" />
|
||||
<ul class="builder-share-results" aria-label="${escAttr(t("builder.share.title"))}"></ul>
|
||||
</div>
|
||||
<div class="builder-share-current">
|
||||
<h3 class="builder-share-current-title">${escHtml(t("builder.share.current.title"))}</h3>
|
||||
<ul class="builder-share-current-list"></ul>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener("keydown", onEsc, true);
|
||||
backdrop.remove();
|
||||
};
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
backdrop.querySelector(".builder-modal-close")?.addEventListener("click", close);
|
||||
document.addEventListener("keydown", onEsc, true);
|
||||
|
||||
const searchEl = backdrop.querySelector(".builder-share-search") as HTMLInputElement;
|
||||
const resultsEl = backdrop.querySelector(".builder-share-results") as HTMLElement;
|
||||
const currentEl = backdrop.querySelector(".builder-share-current-list") as HTMLElement;
|
||||
|
||||
function renderCurrent(): void {
|
||||
if (shares.length === 0) {
|
||||
currentEl.innerHTML = `<li class="builder-share-current-empty">${escHtml(t("builder.share.current.empty"))}</li>`;
|
||||
return;
|
||||
}
|
||||
currentEl.innerHTML = shares.map((sh) => {
|
||||
const u = users.find((x) => x.id === sh.shared_with_user_id);
|
||||
const label = u ? userLabel(u) : sh.shared_with_user_id;
|
||||
return (
|
||||
`<li class="builder-share-current-item" data-share-id="${escAttr(sh.id)}">` +
|
||||
`<span class="builder-share-current-name">${escHtml(label)}</span>` +
|
||||
`<button type="button" class="builder-share-revoke">${escHtml(t("builder.share.revoke"))}</button>` +
|
||||
`</li>`
|
||||
);
|
||||
}).join("");
|
||||
currentEl.querySelectorAll<HTMLElement>(".builder-share-current-item").forEach((li) => {
|
||||
const id = li.getAttribute("data-share-id");
|
||||
if (!id) return;
|
||||
li.querySelector(".builder-share-revoke")?.addEventListener("click", () => {
|
||||
void revoke(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderResults(): void {
|
||||
const q = searchEl.value.trim().toLowerCase();
|
||||
const sharedIds = new Set(shares.map((s) => s.shared_with_user_id));
|
||||
const matches = users
|
||||
.filter((u) => u.id !== opts.ownerId && !sharedIds.has(u.id))
|
||||
.filter((u) => {
|
||||
if (!q) return true;
|
||||
return (
|
||||
(u.display_name || "").toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
(u.office || "").toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
.slice(0, 12);
|
||||
if (matches.length === 0) {
|
||||
resultsEl.innerHTML = `<li class="builder-share-result-empty">${escHtml(t("builder.share.no_results"))}</li>`;
|
||||
return;
|
||||
}
|
||||
resultsEl.innerHTML = matches.map((u) => (
|
||||
`<li class="builder-share-result" data-user-id="${escAttr(u.id)}">` +
|
||||
`<span class="builder-share-result-name">${escHtml(userLabel(u))}</span>` +
|
||||
`<button type="button" class="builder-share-add">${escHtml(t("builder.share.button"))}</button>` +
|
||||
`</li>`
|
||||
)).join("");
|
||||
resultsEl.querySelectorAll<HTMLElement>(".builder-share-result").forEach((li) => {
|
||||
const uid = li.getAttribute("data-user-id");
|
||||
if (!uid) return;
|
||||
li.querySelector(".builder-share-add")?.addEventListener("click", () => {
|
||||
void add(uid);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function add(userId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) + "/shares",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ shared_with_user_id: userId }),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
flashError();
|
||||
return;
|
||||
}
|
||||
const row = (await resp.json()) as BuilderShareRow;
|
||||
shares = [...shares.filter((s) => s.id !== row.id), row];
|
||||
searchEl.value = "";
|
||||
renderResults();
|
||||
renderCurrent();
|
||||
opts.onChanged(shares);
|
||||
} catch {
|
||||
flashError();
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(shareId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) +
|
||||
"/shares/" + encodeURIComponent(shareId),
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
flashError();
|
||||
return;
|
||||
}
|
||||
shares = shares.filter((s) => s.id !== shareId);
|
||||
renderResults();
|
||||
renderCurrent();
|
||||
opts.onChanged(shares);
|
||||
} catch {
|
||||
flashError();
|
||||
}
|
||||
}
|
||||
|
||||
function flashError(): void {
|
||||
const box = backdrop.querySelector(".builder-share-pickerbox") as HTMLElement;
|
||||
let err = box.querySelector(".builder-share-error") as HTMLElement | null;
|
||||
if (!err) {
|
||||
err = document.createElement("p");
|
||||
err.className = "builder-share-error";
|
||||
box.appendChild(err);
|
||||
}
|
||||
err.textContent = t("builder.share.error");
|
||||
}
|
||||
|
||||
searchEl.addEventListener("input", renderResults);
|
||||
renderResults();
|
||||
renderCurrent();
|
||||
document.body.appendChild(backdrop);
|
||||
searchEl.focus();
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
// ProceedingTriplet renderer for the Litigation Builder.
|
||||
//
|
||||
// PRD §3.3 + §3.4 + §6.1: one triplet = jurisdiction badge + name +
|
||||
// perspective + Detailgrad + columnar `proaktiv | court | reaktiv`
|
||||
// body.
|
||||
//
|
||||
// B2 wires the live controls — perspective radio, scenario-flag strip,
|
||||
// remove button, collapse — and the per-event-card overlays (3-state
|
||||
// machine, action buttons, optional-horizon chip). The 3-column body
|
||||
// itself is still produced by verfahrensablauf-core.renderColumnsBody;
|
||||
// per-card overlays are layered on top after innerHTML write via the
|
||||
// data-rule-id hooks added in the same slice.
|
||||
|
||||
import { t, tDyn, getLang } from "./i18n";
|
||||
import type { DeadlineResponse, Side } from "./views/verfahrensablauf-core";
|
||||
import type { BuilderProceeding, BuilderEvent } from "./builder";
|
||||
import type { ProceedingTypeMeta } from "./builder-picker";
|
||||
|
||||
export interface ScenarioFlagCatalogEntry {
|
||||
flag_key: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
description?: string;
|
||||
hidden_unless_set: boolean;
|
||||
}
|
||||
|
||||
export interface TripletViewInput {
|
||||
proceeding: BuilderProceeding;
|
||||
meta: ProceedingTypeMeta;
|
||||
data: DeadlineResponse | null;
|
||||
side: Side;
|
||||
// Flag catalog filtered to the keys the active proceeding actually
|
||||
// references via its rules' condition_expr. B2 passes the global
|
||||
// catalog and lets the user toggle any — flags that don't gate any
|
||||
// rule are simply no-ops on this triplet.
|
||||
flagCatalog: ScenarioFlagCatalogEntry[];
|
||||
// Map keyed by sequencing_rule_id (lowercased UUID) → BuilderEvent
|
||||
// for the per-card state machine. Cards whose rule is absent default
|
||||
// to "planned".
|
||||
eventsByRule: Map<string, BuilderEvent>;
|
||||
// Per-card optional-horizon registry. Each rule with optional
|
||||
// children carries a `+N Optionen` chip; the chip's count comes from
|
||||
// here (defaults to scenario_events.horizon_optional, falls back to
|
||||
// proceeding-level when not stored per-card).
|
||||
columnsHtml: string;
|
||||
isChild: boolean;
|
||||
}
|
||||
|
||||
// Triplet header + controls + columns body. Pure-string render; the
|
||||
// caller (builder.ts) wires click handlers on top.
|
||||
export function renderTriplet(input: TripletViewInput): string {
|
||||
const lang = getLang();
|
||||
const procLabel = lang === "en"
|
||||
? (input.meta.nameEN || input.meta.name)
|
||||
: (input.meta.name || input.meta.nameEN);
|
||||
const flagsBadge = activeFlagsBadge(input.proceeding.scenario_flags);
|
||||
|
||||
const body = input.data
|
||||
? input.columnsHtml
|
||||
: `<div class="builder-triplet-loading">${escHtml(t("builder.triplet.loading"))}</div>`;
|
||||
|
||||
const controls = renderControls(input);
|
||||
const flagStrip = renderFlagStrip(input);
|
||||
|
||||
return `
|
||||
<header class="builder-triplet-header">
|
||||
<span class="builder-triplet-jurisdiction">${escHtml(jurisdictionFor(input.meta))}</span>
|
||||
<span class="builder-triplet-code">${escHtml(input.meta.code)}</span>
|
||||
<span class="builder-triplet-name">${escHtml(procLabel)}</span>
|
||||
${flagsBadge}
|
||||
</header>
|
||||
${controls}
|
||||
${flagStrip}
|
||||
<div class="builder-triplet-body">
|
||||
${body}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderControls(input: TripletViewInput): string {
|
||||
const perspective = input.side ?? "";
|
||||
const detailgrad = input.proceeding.detailgrad || "selected";
|
||||
|
||||
const radio = (value: string, key: string, current: string): string => {
|
||||
const active = value === current ? " is-active" : "";
|
||||
return `<button type="button" class="builder-triplet-perspective-btn${active}"
|
||||
data-action="perspective" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
|
||||
};
|
||||
const detailBtn = (value: string, key: string, current: string): string => {
|
||||
const active = value === current ? " is-active" : "";
|
||||
return `<button type="button" class="builder-triplet-detailgrad-btn${active}"
|
||||
data-action="detailgrad" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
|
||||
};
|
||||
|
||||
return `<div class="builder-triplet-controls">
|
||||
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.perspective.label"))}</span>
|
||||
<div class="builder-triplet-perspective">
|
||||
${radio("", "builder.triplet.perspective.none", perspective)}
|
||||
${radio("claimant", "builder.triplet.perspective.claimant", perspective)}
|
||||
${radio("defendant", "builder.triplet.perspective.defendant", perspective)}
|
||||
</div>
|
||||
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.detailgrad.label"))}</span>
|
||||
<div class="builder-triplet-detailgrad">
|
||||
${detailBtn("selected", "builder.triplet.detailgrad.selected", detailgrad)}
|
||||
${detailBtn("all_options", "builder.triplet.detailgrad.all_options", detailgrad)}
|
||||
</div>
|
||||
<button type="button" class="builder-triplet-remove" data-action="remove">
|
||||
${escHtml(t("builder.triplet.remove"))}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderFlagStrip(input: TripletViewInput): string {
|
||||
// B2 ships the full global catalog. Flags that don't gate any of the
|
||||
// active proceeding's rules are still toggle-able but have no effect
|
||||
// on the calc result (the engine simply doesn't read them).
|
||||
const lang = getLang();
|
||||
const flags = input.proceeding.scenario_flags || {};
|
||||
if (input.flagCatalog.length === 0) {
|
||||
return `<div class="builder-triplet-flagstrip">
|
||||
<span class="builder-triplet-flag-empty">${escHtml(t("builder.triplet.no_flags"))}</span>
|
||||
</div>`;
|
||||
}
|
||||
const toggles = input.flagCatalog.map((entry) => {
|
||||
const label = lang === "en" ? entry.label_en : entry.label_de;
|
||||
const isOn = flags[entry.flag_key] === true;
|
||||
return `<label class="builder-triplet-flag-toggle">
|
||||
<input type="checkbox"
|
||||
data-action="flag"
|
||||
data-flag-key="${escAttr(entry.flag_key)}"
|
||||
${isOn ? "checked" : ""} />
|
||||
<span>${escHtml(label)}</span>
|
||||
</label>`;
|
||||
}).join("");
|
||||
return `<div class="builder-triplet-flagstrip">${toggles}</div>`;
|
||||
}
|
||||
|
||||
function jurisdictionFor(meta: ProceedingTypeMeta): string {
|
||||
if (meta.jurisdiction) return meta.jurisdiction;
|
||||
if (meta.group) return meta.group;
|
||||
const dot = meta.code.indexOf(".");
|
||||
if (dot > 0) return meta.code.slice(0, dot).toUpperCase();
|
||||
return meta.code.toUpperCase();
|
||||
}
|
||||
|
||||
function activeFlagsBadge(flags: Record<string, unknown>): string {
|
||||
const active = Object.entries(flags).filter(([, v]) => v === true).map(([k]) => k);
|
||||
if (active.length === 0) return "";
|
||||
const label = t("builder.triplet.flags.label");
|
||||
const chips = active.map((f) =>
|
||||
`<span class="builder-triplet-flag-chip">${escHtml(f)}</span>`,
|
||||
).join("");
|
||||
return `<span class="builder-triplet-flags">${escHtml(label)} ${chips}</span>`;
|
||||
}
|
||||
|
||||
// overlayEventStates walks the rendered .fr-col-item nodes and:
|
||||
// - sets data-builder-state from eventsByRule lookup;
|
||||
// - appends a per-card action row (file / skip / reset);
|
||||
// - shows a +N Optionen chip when the rule has optional children
|
||||
// (the chip placeholder; B2 ships the per-card horizon control —
|
||||
// the actual horizon-count→render expansion lands when the calc
|
||||
// engine surfaces "available optionals" for a parent rule, which
|
||||
// pasteur's Options.IncludeOptional flag already exposes server-
|
||||
// side; full wiring is a follow-up). Cards without optional
|
||||
// children get no chip.
|
||||
export function overlayEventStates(
|
||||
root: HTMLElement,
|
||||
eventsByRule: Map<string, BuilderEvent>,
|
||||
on: {
|
||||
onAction: (ruleId: string, action: "file" | "skip" | "reset", payload?: { date?: string; reason?: string }) => void;
|
||||
onHorizon: (ruleId: string, delta: 1 | -1) => void;
|
||||
},
|
||||
): void {
|
||||
const items = root.querySelectorAll<HTMLElement>(".fr-col-item[data-rule-id]");
|
||||
items.forEach((item) => {
|
||||
const ruleId = item.getAttribute("data-rule-id");
|
||||
if (!ruleId) return;
|
||||
const ev = eventsByRule.get(ruleId.toLowerCase());
|
||||
const state = ev?.state || "planned";
|
||||
item.setAttribute("data-builder-state", state);
|
||||
|
||||
// Append actions (idempotent: clear any prior overlay first).
|
||||
item.querySelectorAll(".builder-event-actions, .builder-event-horizon-chip").forEach((n) => n.remove());
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "builder-event-actions";
|
||||
actions.innerHTML = actionButtonsHtml(state);
|
||||
item.appendChild(actions);
|
||||
|
||||
actions.addEventListener("click", (ev) => {
|
||||
const btn = (ev.target as HTMLElement).closest<HTMLElement>(".builder-event-action");
|
||||
if (!btn) return;
|
||||
const action = btn.getAttribute("data-action") as "file" | "skip" | "reset" | null;
|
||||
if (!action) return;
|
||||
ev.stopPropagation();
|
||||
if (action === "file") {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const v = window.prompt(t("builder.event.actual_date.prompt"), today);
|
||||
if (v === null) return;
|
||||
on.onAction(ruleId, "file", { date: v.trim() || today });
|
||||
} else if (action === "skip") {
|
||||
const reason = window.prompt(t("builder.event.skip_reason.prompt"), "");
|
||||
if (reason === null) return;
|
||||
on.onAction(ruleId, "skip", { reason: reason.trim() });
|
||||
} else {
|
||||
on.onAction(ruleId, "reset");
|
||||
}
|
||||
});
|
||||
|
||||
// Per-card optional horizon chip. The PRD §3.4 places the chip on
|
||||
// every card with optional children; until the calc surface exposes
|
||||
// an "optionals available count" on each parent rule, the chip is
|
||||
// shown only when the card has a stored non-zero horizon (so the
|
||||
// user can see and reduce a previously-set horizon). This is the
|
||||
// graceful B2 baseline; the full surface lands once the engine
|
||||
// emits an optionalsAvailable counter (PRD §3.4 follow-up).
|
||||
const horizonCount = ev?.horizon_optional ?? 0;
|
||||
if (horizonCount > 0) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "builder-event-horizon-chip";
|
||||
chip.setAttribute("data-action", "horizon-toggle");
|
||||
chip.textContent = t("builder.event.horizon.label").replace("{n}", String(horizonCount));
|
||||
chip.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
on.onHorizon(ruleId, -1);
|
||||
});
|
||||
item.appendChild(chip);
|
||||
} else {
|
||||
// Inline "+ Optionen" affordance — adds a horizon entry when
|
||||
// first clicked. Tagged as data-builder-feature so the cleanup
|
||||
// sweep can rip it out if the calc surface lands a counter.
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "builder-event-horizon-chip";
|
||||
chip.setAttribute("data-action", "horizon-add");
|
||||
chip.setAttribute("data-builder-feature", "horizon-add");
|
||||
chip.textContent = "+ " + t("builder.event.horizon.label").replace("+{n} ", "");
|
||||
chip.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
on.onHorizon(ruleId, 1);
|
||||
});
|
||||
item.appendChild(chip);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function actionButtonsHtml(state: BuilderEvent["state"]): string {
|
||||
// Re-render the action row per state. Cards in the planned state
|
||||
// show "File / Skip"; filed/skipped cards show "Reset to planned".
|
||||
if (state === "planned") {
|
||||
return `
|
||||
<button type="button" class="builder-event-action" data-action="file">${escHtml(t("builder.event.action.file"))}</button>
|
||||
<button type="button" class="builder-event-action" data-action="skip">${escHtml(t("builder.event.action.skip"))}</button>
|
||||
`;
|
||||
}
|
||||
return `<button type="button" class="builder-event-action" data-action="reset">${escHtml(t("builder.event.action.reset"))}</button>`;
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -191,37 +191,25 @@ export function mountDateRangePicker(opts: MountOpts): PickerHandle {
|
||||
function renderPanel(): void {
|
||||
panel.replaceChildren();
|
||||
|
||||
// Three vertical columns: Past (closest→farthest top→bottom),
|
||||
// NOW (Heute + Alles), Future (closest→farthest). The grid
|
||||
// visualises time as space around NOW — each column's top is
|
||||
// closest to the current moment, bottom is furthest away.
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "date-range-grid";
|
||||
// Three groups in a single row: past fan / ALLES centre / next fan.
|
||||
const row = document.createElement("div");
|
||||
row.className = "date-range-row";
|
||||
|
||||
// Past column: PAST_HORIZONS registry is outermost→innermost
|
||||
// (past_all → past_1d); reverse for closeness-to-NOW ordering
|
||||
// (past_1d at top, past_all at bottom).
|
||||
const pastCol = renderColumn(
|
||||
const pastGroup = renderFan(
|
||||
PAST_HORIZONS.filter((h) => presets.includes(h)),
|
||||
"past",
|
||||
t("date_range.fan.past.label"),
|
||||
[...PAST_HORIZONS].reverse().filter((h) => presets.includes(h)),
|
||||
);
|
||||
const nowCol = renderNowColumn();
|
||||
// Future column: NEXT_HORIZONS registry is already in closeness
|
||||
// order (next_1d → next_all). next_1d moves to the NOW column as
|
||||
// "Heute" (semantically just-today, single-day window), so the
|
||||
// future column skips it.
|
||||
const futureCol = renderColumn(
|
||||
"future",
|
||||
t("date_range.fan.future.label"),
|
||||
NEXT_HORIZONS.filter((h) => h !== "next_1d" && presets.includes(h)),
|
||||
const centerGroup = renderCenter();
|
||||
const nextGroup = renderFan(
|
||||
NEXT_HORIZONS.filter((h) => presets.includes(h)),
|
||||
"next",
|
||||
);
|
||||
|
||||
if (pastCol) grid.appendChild(pastCol);
|
||||
if (nowCol) grid.appendChild(nowCol);
|
||||
if (futureCol) grid.appendChild(futureCol);
|
||||
if (pastGroup) row.appendChild(pastGroup);
|
||||
if (centerGroup) row.appendChild(centerGroup);
|
||||
if (nextGroup) row.appendChild(nextGroup);
|
||||
|
||||
panel.appendChild(grid);
|
||||
panel.appendChild(row);
|
||||
|
||||
// Custom-range section ("Anpassen"). Toggle button + collapsible
|
||||
// date-pair editor below.
|
||||
@@ -230,57 +218,49 @@ export function mountDateRangePicker(opts: MountOpts): PickerHandle {
|
||||
}
|
||||
}
|
||||
|
||||
function renderColumn(
|
||||
side: "past" | "future",
|
||||
heading: string,
|
||||
horizons: readonly TimeHorizon[],
|
||||
): HTMLElement | null {
|
||||
function renderFan(horizons: readonly TimeHorizon[], side: "past" | "next"): HTMLElement | null {
|
||||
if (horizons.length === 0) return null;
|
||||
const col = document.createElement("div");
|
||||
col.className = `date-range-col date-range-col--${side}`;
|
||||
col.setAttribute("role", "group");
|
||||
col.setAttribute("aria-label", heading);
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "date-range-col-heading";
|
||||
head.textContent = heading;
|
||||
col.appendChild(head);
|
||||
|
||||
const group = document.createElement("div");
|
||||
group.className = `date-range-fan date-range-fan--${side}`;
|
||||
group.setAttribute("role", "group");
|
||||
group.setAttribute("aria-label", side === "past"
|
||||
? t("date_range.fan.past.label")
|
||||
: t("date_range.fan.future.label"));
|
||||
for (const h of horizons) {
|
||||
col.appendChild(makeChip(h));
|
||||
group.appendChild(makeChip(h));
|
||||
}
|
||||
return col;
|
||||
return group;
|
||||
}
|
||||
|
||||
function renderNowColumn(): HTMLElement | null {
|
||||
const showHeute = presets.includes("next_1d");
|
||||
const showAlles = presets.includes("any");
|
||||
if (!showHeute && !showAlles) return null;
|
||||
function renderCenter(): HTMLElement | null {
|
||||
if (!presets.includes("any")) return null;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "date-range-center";
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "date-range-center-btn";
|
||||
if (value.horizon === "any" || value.horizon === "all") {
|
||||
btn.classList.add("date-range-center-btn--active");
|
||||
}
|
||||
btn.setAttribute("aria-pressed", String(value.horizon === "any" || value.horizon === "all"));
|
||||
btn.dataset.testid = `${opts.surface}.date-range-chip.any`;
|
||||
|
||||
const col = document.createElement("div");
|
||||
col.className = "date-range-col date-range-col--now";
|
||||
col.setAttribute("role", "group");
|
||||
col.setAttribute("aria-label", t("date_range.center.label"));
|
||||
|
||||
const glyph = document.createElement("div");
|
||||
glyph.className = "date-range-col-heading date-range-col-heading--glyph";
|
||||
const glyph = document.createElement("span");
|
||||
glyph.className = "date-range-center-glyph";
|
||||
glyph.setAttribute("aria-hidden", "true");
|
||||
glyph.textContent = "⌖"; // ⌖ POSITION INDICATOR
|
||||
col.appendChild(glyph);
|
||||
const label = document.createElement("span");
|
||||
label.className = "date-range-center-label";
|
||||
label.textContent = t("date_range.center.label");
|
||||
btn.appendChild(glyph);
|
||||
btn.appendChild(label);
|
||||
|
||||
if (showHeute) col.appendChild(makeChip("next_1d"));
|
||||
if (showAlles) {
|
||||
const allesChip = makeChip("any");
|
||||
// Legacy "all" horizon also lights up Alles for back-compat
|
||||
// with saved Custom Views that store the bidirectional-unbounded
|
||||
// value (Q26 — parser preserves it, picker surfaces it here).
|
||||
if (value.horizon === "all") {
|
||||
allesChip.classList.add("agenda-chip-active");
|
||||
allesChip.setAttribute("aria-pressed", "true");
|
||||
}
|
||||
col.appendChild(allesChip);
|
||||
}
|
||||
return col;
|
||||
btn.addEventListener("click", () => {
|
||||
commit({ horizon: "any" }, /*closeAfter*/ true);
|
||||
});
|
||||
|
||||
wrap.appendChild(btn);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function makeChip(h: TimeHorizon): HTMLButtonElement {
|
||||
|
||||
@@ -465,8 +465,7 @@ function refreshRuleAutoDisplay(): void {
|
||||
panel.style.display = "";
|
||||
const r = currentAutoRule();
|
||||
if (r) {
|
||||
// Canonical "Name · Citation" with muted citation (t-paliad-258 addendum).
|
||||
text.innerHTML = formatRuleLabelHTML(r, esc);
|
||||
text.textContent = formatRuleLabel(r);
|
||||
text.classList.remove("rule-auto-text--empty");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { formatRuleLabel, formatRuleLabelHTML } from "./rule-label";
|
||||
import { formatRuleLabel } from "./rule-label";
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let currentUserAdmin = false;
|
||||
@@ -192,8 +192,7 @@ function refreshRuleAutoDisplay(): void {
|
||||
panel.style.display = "";
|
||||
const rule = currentAutoRule();
|
||||
if (rule) {
|
||||
// Canonical "Name · Citation" with muted citation (t-paliad-258 addendum).
|
||||
text.innerHTML = formatRuleLabelHTML(rule, esc);
|
||||
text.textContent = formatRuleLabel(rule);
|
||||
text.classList.remove("rule-auto-text--empty");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,16 +73,13 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts):
|
||||
|
||||
type TimeHorizonValue = NonNullable<BarState["time"]>["horizon"];
|
||||
|
||||
// Default chip set when the surface doesn't override. Mirrors m's
|
||||
// 3-column picker spec (t-paliad-278): symmetric 7d/30d/90d/all fan
|
||||
// per side, plus Heute (next_1d) + Alles (any) in the centre column,
|
||||
// plus Anpassen. Surfaces with a tighter scope (project history is
|
||||
// past-only) keep overriding via `timePresets`.
|
||||
// Default chip set when the surface doesn't override. Matches the
|
||||
// forward-leaning bias of the legacy filter-bar default (the universal
|
||||
// substrate is more often used for "what's coming up" than "what just
|
||||
// happened") but now covers the full symmetric fan plus past_30d for
|
||||
// quick recent-history lookups.
|
||||
const DEFAULT_TIME_PRESETS: TimeHorizonValue[] = [
|
||||
"past_7d", "past_30d", "past_90d", "past_all",
|
||||
"next_1d", "any",
|
||||
"next_7d", "next_30d", "next_90d", "next_all",
|
||||
"custom",
|
||||
"past_30d", "past_7d", "any", "next_7d", "next_30d", "next_90d", "custom",
|
||||
];
|
||||
|
||||
function renderTimeAxis(ctx: AxisCtx, presetOverride?: TimeHorizonValue[]): HTMLElement {
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
// Unit tests for the FilterBar's computeEffective() overlay. These pin
|
||||
// the contract that any chip the user clicks ends up as a predicate the
|
||||
// server can see — the t-paliad-283 regression had four sources picking
|
||||
// up zero narrowing for /views/any because the bar's chip click didn't
|
||||
// produce a non-empty `filter.predicates` for that source.
|
||||
//
|
||||
// Run with `bun test`.
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { computeEffective } from "./index";
|
||||
import type { FilterSpec, RenderSpec } from "../views/types";
|
||||
import type { BarState } from "./types";
|
||||
|
||||
// Mirrors paliad.user_views row {slug: "any"} — the saved Custom View
|
||||
// that triggered the t-paliad-283 regression report.
|
||||
const ANY_VIEW_FILTER: FilterSpec = {
|
||||
version: 1,
|
||||
sources: ["deadline", "appointment", "project_event", "approval_request"],
|
||||
scope: { projects: { mode: "all_visible" } },
|
||||
time: { field: "auto", horizon: "past_30d" },
|
||||
};
|
||||
|
||||
const ANY_VIEW_RENDER: RenderSpec = {
|
||||
shape: "list",
|
||||
list: { sort: "date_asc", density: "comfortable" },
|
||||
};
|
||||
|
||||
describe("filter-bar/computeEffective — /views/any (all 4 sources)", () => {
|
||||
test("empty state leaves base spec intact (no overlays)", () => {
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, {});
|
||||
expect(eff.filter.sources).toEqual([
|
||||
"deadline", "appointment", "project_event", "approval_request",
|
||||
]);
|
||||
expect(eff.filter.time).toEqual({ field: "auto", horizon: "past_30d" });
|
||||
// predicates may be {} (the bar zero-fills it) but never carries a
|
||||
// stray narrowing on any source — that would silently filter
|
||||
// results the user never asked to filter.
|
||||
for (const src of ANY_VIEW_FILTER.sources) {
|
||||
expect(eff.filter.predicates?.[src]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("deadline_status chip narrows deadline predicate", () => {
|
||||
const state: BarState = { deadline_status: ["pending"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.deadline?.status).toEqual(["pending"]);
|
||||
});
|
||||
|
||||
test("appointment_type chip narrows appointment predicate", () => {
|
||||
const state: BarState = { appointment_type: ["hearing"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.appointment?.appointment_types).toEqual(["hearing"]);
|
||||
});
|
||||
|
||||
test("approval_viewer_role chip narrows approval predicate", () => {
|
||||
const state: BarState = { approval_viewer_role: "any_visible" };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.approval_request?.viewer_role).toBe("any_visible");
|
||||
});
|
||||
|
||||
test("approval_status chip narrows approval predicate", () => {
|
||||
const state: BarState = { approval_status: ["pending", "approved"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.approval_request?.status).toEqual(["pending", "approved"]);
|
||||
});
|
||||
|
||||
test("approval_entity_type chip narrows approval predicate", () => {
|
||||
const state: BarState = { approval_entity_type: ["deadline"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.approval_request?.entity_types).toEqual(["deadline"]);
|
||||
});
|
||||
|
||||
test("project_event_kind chip narrows project_event predicate", () => {
|
||||
const state: BarState = { project_event_kind: ["deadline_created"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.project_event?.event_types).toEqual(["deadline_created"]);
|
||||
});
|
||||
|
||||
test("time chip overrides base horizon", () => {
|
||||
const state: BarState = { time: { horizon: "past_7d" } };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.time.horizon).toBe("past_7d");
|
||||
expect(eff.filter.time.field).toBe("auto"); // preserved from base
|
||||
});
|
||||
|
||||
test("personal_only chip flips scope flag", () => {
|
||||
const state: BarState = { personal_only: true };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.scope.personal_only).toBe(true);
|
||||
});
|
||||
|
||||
test("multiple chips combine into the same effective spec", () => {
|
||||
const state: BarState = {
|
||||
time: { horizon: "past_7d" },
|
||||
deadline_status: ["pending"],
|
||||
appointment_type: ["hearing"],
|
||||
approval_status: ["pending"],
|
||||
project_event_kind: ["deadline_created"],
|
||||
};
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.time.horizon).toBe("past_7d");
|
||||
expect(eff.filter.predicates?.deadline?.status).toEqual(["pending"]);
|
||||
expect(eff.filter.predicates?.appointment?.appointment_types).toEqual(["hearing"]);
|
||||
expect(eff.filter.predicates?.approval_request?.status).toEqual(["pending"]);
|
||||
expect(eff.filter.predicates?.project_event?.event_types).toEqual(["deadline_created"]);
|
||||
});
|
||||
|
||||
test("overlay does not mutate the caller's base filter", () => {
|
||||
const base: FilterSpec = JSON.parse(JSON.stringify(ANY_VIEW_FILTER));
|
||||
const state: BarState = { deadline_status: ["pending"], time: { horizon: "past_7d" } };
|
||||
computeEffective(base, ANY_VIEW_RENDER, state);
|
||||
// The bar deep-clones; the base must come back unchanged so a
|
||||
// second click doesn't compound the previous click's overlay.
|
||||
expect(base).toEqual(ANY_VIEW_FILTER);
|
||||
});
|
||||
|
||||
test("inbox-only axes do not affect a /views/any spec (no inbox axis exposed)", () => {
|
||||
// /views/any's axes don't include unread_only or inbox_focus, so
|
||||
// those keys never appear in state. Verify that even if they did,
|
||||
// the bar's overlay doesn't silently mutate sources or predicates
|
||||
// in a way that would break a 4-source Custom View.
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, {});
|
||||
expect(eff.filter.sources).toHaveLength(4);
|
||||
expect(eff.filter.unread_only ?? false).toBe(false);
|
||||
});
|
||||
});
|
||||
3939
frontend/src/client/fristenrechner.ts
Normal file
3939
frontend/src/client/fristenrechner.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -109,7 +109,7 @@ export function routeNameFor(pathname: string): string {
|
||||
if (pathname === "/links") return "links";
|
||||
if (pathname === "/downloads") return "downloads";
|
||||
if (pathname === "/checklists") return "checklists";
|
||||
if (pathname.startsWith("/tools/procedures")) return "tools.procedures";
|
||||
if (pathname.startsWith("/tools/fristenrechner")) return "tools.fristenrechner";
|
||||
if (pathname.startsWith("/tools/kostenrechner")) return "tools.kostenrechner";
|
||||
if (pathname.startsWith("/tools/gebuehrentabellen")) return "tools.gebuehrentabellen";
|
||||
if (pathname === "/events") return "events";
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// /tools/procedures bundle entry — Litigation Builder (m/paliad#153 B1).
|
||||
//
|
||||
// Replaces cronus's U0-U4 catalog bootstrap. The page chrome is
|
||||
// emitted by procedures.tsx; this file boots the i18n + sidebar
|
||||
// runtime and hands off to builder.ts.
|
||||
|
||||
import { initI18n } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { mountBuilder } from "./builder";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
void mountBuilder();
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
// Per-project scenario_flags client — the single source of truth
|
||||
// (m/paliad#149 Phase 2 P0, mig 154). Wraps GET/PATCH
|
||||
// /api/projects/{id}/scenario-flags so any project-bound surface can
|
||||
// read + write the same flag map.
|
||||
//
|
||||
// Shape on the wire:
|
||||
//
|
||||
// GET → { flags: { "with_ccr": true, "rule:<uuid>": false }, catalog: [...] }
|
||||
// PATCH body: { "with_ccr": true, "with_amend": null }
|
||||
// - bool → write the value verbatim
|
||||
// - null → delete the key (priority-driven default returns)
|
||||
// - undefined → caller never sends this key; the value is left alone
|
||||
//
|
||||
// Cross-surface coherence: every successful PATCH dispatches a
|
||||
// `scenario-flag-changed` CustomEvent on document so other surfaces
|
||||
// (Verfahrensablauf strip, Mode B result-view conditional group) can
|
||||
// re-render without a fresh fetch. Detail carries the merged map so
|
||||
// listeners can use it directly.
|
||||
|
||||
export interface ScenarioFlagCatalogEntry {
|
||||
flag_key: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
description?: string;
|
||||
hidden_unless_set: boolean;
|
||||
}
|
||||
|
||||
export interface ScenarioFlagsView {
|
||||
flags: Record<string, boolean>;
|
||||
catalog: ScenarioFlagCatalogEntry[];
|
||||
}
|
||||
|
||||
// PatchDelta represents a partial update. Keys present with `null`
|
||||
// delete the entry; keys present with a bool overwrite; keys not
|
||||
// present are left untouched.
|
||||
export type ScenarioFlagsDelta = Record<string, boolean | null>;
|
||||
|
||||
export interface ScenarioFlagChangedDetail {
|
||||
projectId: string;
|
||||
flags: Record<string, boolean>;
|
||||
// The keys that were touched by the PATCH that fired this event.
|
||||
// Useful for surfaces that re-render only when *their* flag moved.
|
||||
changedKeys: string[];
|
||||
}
|
||||
|
||||
export const SCENARIO_FLAG_CHANGED_EVENT = "scenario-flag-changed";
|
||||
|
||||
// fetchScenarioFlags loads the current state and catalog for a project.
|
||||
// Returns null if the project is invisible to the caller (404 path) or
|
||||
// the server rejected the request — callers should fall back to local
|
||||
// defaults in that case rather than surfacing a hard error to the UI.
|
||||
export async function fetchScenarioFlags(projectId: string): Promise<ScenarioFlagsView | null> {
|
||||
if (!projectId) return null;
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/scenario-flags`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 401 || resp.status === 403 || resp.status === 404) {
|
||||
return null;
|
||||
}
|
||||
console.warn(`scenario-flags GET ${resp.status}`);
|
||||
return null;
|
||||
}
|
||||
return (await resp.json()) as ScenarioFlagsView;
|
||||
} catch (e) {
|
||||
console.error("scenario-flags GET failed", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// patchScenarioFlags writes a delta. Returns the merged map on success;
|
||||
// returns null on failure (caller decides whether to roll back UI).
|
||||
// Dispatches `scenario-flag-changed` on success so peer surfaces can
|
||||
// re-sync.
|
||||
export async function patchScenarioFlags(
|
||||
projectId: string,
|
||||
delta: ScenarioFlagsDelta,
|
||||
): Promise<ScenarioFlagsView | null> {
|
||||
if (!projectId) return null;
|
||||
if (Object.keys(delta).length === 0) return null;
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/scenario-flags`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(delta),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn(`scenario-flags PATCH ${resp.status}`);
|
||||
return null;
|
||||
}
|
||||
const view = (await resp.json()) as ScenarioFlagsView;
|
||||
dispatchScenarioFlagChanged(projectId, view.flags, Object.keys(delta));
|
||||
return view;
|
||||
} catch (e) {
|
||||
console.error("scenario-flags PATCH failed", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchScenarioFlagChanged(
|
||||
projectId: string,
|
||||
flags: Record<string, boolean>,
|
||||
changedKeys: string[],
|
||||
): void {
|
||||
const detail: ScenarioFlagChangedDetail = { projectId, flags, changedKeys };
|
||||
document.dispatchEvent(new CustomEvent(SCENARIO_FLAG_CHANGED_EVENT, { detail }));
|
||||
}
|
||||
|
||||
// onScenarioFlagsChanged subscribes a listener and returns an
|
||||
// unsubscribe function. Convenient for surfaces wired by lifecycle
|
||||
// hooks (init / teardown).
|
||||
export function onScenarioFlagsChanged(
|
||||
listener: (detail: ScenarioFlagChangedDetail) => void,
|
||||
): () => void {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<ScenarioFlagChangedDetail>).detail;
|
||||
if (detail) listener(detail);
|
||||
};
|
||||
document.addEventListener(SCENARIO_FLAG_CHANGED_EVENT, handler);
|
||||
return () => document.removeEventListener(SCENARIO_FLAG_CHANGED_EVENT, handler);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,314 +0,0 @@
|
||||
import { initI18n } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { escapeHtml } from "../lib/docforge-editor/dom";
|
||||
import { fetchVariableCatalogue, type VariableEntry } from "../lib/docforge-editor/catalogue";
|
||||
|
||||
// t-paliad-349 docforge slice 6 — client for the template authoring page.
|
||||
//
|
||||
// Flow: list templates → upload a .docx (or open one) → the carrier renders
|
||||
// as run spans (<span class="docforge-run" data-run="N">) → the admin
|
||||
// selects text within one run, then clicks a variable in the palette → the
|
||||
// server injects {{slot}} at the selection and returns the updated view.
|
||||
//
|
||||
// The select-then-pick gesture keys on the run index (data-run) + the
|
||||
// selected text, matching the server's text-based InjectSlot so umlauts
|
||||
// can't desync the selection from the slice. Selections that span more than
|
||||
// one run are rejected with a hint (v1 scope: single-run text slots).
|
||||
|
||||
interface TemplateMeta {
|
||||
id: string;
|
||||
slug?: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
kind: string;
|
||||
source_format: string;
|
||||
firm?: string;
|
||||
is_active: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface TemplateSlot {
|
||||
key: string;
|
||||
anchor: string;
|
||||
label?: string;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
interface AuthoringView {
|
||||
template: TemplateMeta;
|
||||
preview_html: string;
|
||||
slots: TemplateSlot[];
|
||||
}
|
||||
|
||||
interface Selection1Run {
|
||||
runIndex: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
catalogue: VariableEntry[];
|
||||
openID: string | null;
|
||||
activeSlotKey: string | null;
|
||||
selection: Selection1Run | null;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
catalogue: [],
|
||||
openID: null,
|
||||
activeSlotKey: null,
|
||||
selection: null,
|
||||
};
|
||||
|
||||
function isEN(): boolean {
|
||||
return (document.documentElement.lang || "de").toLowerCase().startsWith("en");
|
||||
}
|
||||
|
||||
function labelOf(e: VariableEntry): string {
|
||||
return isEN() ? e.label_en : e.label_de;
|
||||
}
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
try {
|
||||
state.catalogue = await fetchVariableCatalogue();
|
||||
} catch (err) {
|
||||
console.warn("templates-authoring: catalogue fetch failed", err);
|
||||
}
|
||||
|
||||
wireUploadForm();
|
||||
await loadList();
|
||||
}
|
||||
|
||||
async function loadList(): Promise<void> {
|
||||
const host = document.getElementById("docforge-template-list");
|
||||
if (!host) return;
|
||||
let metas: TemplateMeta[] = [];
|
||||
try {
|
||||
const res = await fetch("/api/admin/templates", { headers: { Accept: "application/json" } });
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as { templates: TemplateMeta[] };
|
||||
metas = body.templates ?? [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("templates-authoring: list fetch failed", err);
|
||||
}
|
||||
if (metas.length === 0) {
|
||||
host.innerHTML = `<li class="docforge-template-empty">${escapeHtml(isEN() ? "No templates yet." : "Noch keine Vorlagen.")}</li>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = metas
|
||||
.map((m) => {
|
||||
const name = isEN() ? m.name_en : m.name_de;
|
||||
const firm = m.firm ? ` · ${escapeHtml(m.firm)}` : "";
|
||||
return `<li class="docforge-template-row" data-template-id="${escapeHtml(m.id)}">
|
||||
<span class="docforge-template-name">${escapeHtml(name)}</span>
|
||||
<span class="docforge-template-meta">v${m.version}${firm}</span>
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
host.querySelectorAll<HTMLLIElement>(".docforge-template-row").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const id = li.dataset.templateId;
|
||||
if (id) void openTemplate(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wireUploadForm(): void {
|
||||
const form = document.getElementById("docforge-upload-form") as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
const status = document.getElementById("docforge-upload-status");
|
||||
const data = new FormData(form);
|
||||
setText(status, isEN() ? "Uploading…" : "Lädt hoch…");
|
||||
try {
|
||||
const res = await fetch("/api/admin/templates", { method: "POST", body: data });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
setText(status, (isEN() ? "Error: " : "Fehler: ") + (body.error ?? res.status));
|
||||
return;
|
||||
}
|
||||
const view = (await res.json()) as AuthoringView;
|
||||
setText(status, "");
|
||||
form.reset();
|
||||
await loadList();
|
||||
openView(view);
|
||||
} catch (err) {
|
||||
setText(status, (isEN() ? "Error: " : "Fehler: ") + String(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function openTemplate(id: string): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/templates/${encodeURIComponent(id)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
openView((await res.json()) as AuthoringView);
|
||||
} catch (err) {
|
||||
console.warn("templates-authoring: open failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
function openView(view: AuthoringView): void {
|
||||
state.openID = view.template.id;
|
||||
state.activeSlotKey = null;
|
||||
state.selection = null;
|
||||
|
||||
const workspace = document.getElementById("docforge-workspace");
|
||||
if (workspace) workspace.hidden = false;
|
||||
|
||||
const title = document.getElementById("docforge-workspace-title");
|
||||
if (title) {
|
||||
const name = isEN() ? view.template.name_en : view.template.name_de;
|
||||
title.textContent = `${name} · v${view.template.version}`;
|
||||
}
|
||||
|
||||
renderPreview(view.preview_html);
|
||||
renderSlots(view.slots);
|
||||
renderPalette();
|
||||
setWorkspaceStatus("");
|
||||
}
|
||||
|
||||
function renderPreview(html: string): void {
|
||||
const host = document.getElementById("docforge-preview");
|
||||
if (!host) return;
|
||||
host.innerHTML = html;
|
||||
host.addEventListener("mouseup", onPreviewSelect);
|
||||
}
|
||||
|
||||
// onPreviewSelect captures a selection that lies entirely within one run
|
||||
// span; otherwise it clears the pending selection and hints.
|
||||
function onPreviewSelect(): void {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
|
||||
state.selection = null;
|
||||
return;
|
||||
}
|
||||
const text = sel.toString();
|
||||
if (text === "") {
|
||||
state.selection = null;
|
||||
return;
|
||||
}
|
||||
const anchorRun = closestRun(sel.anchorNode);
|
||||
const focusRun = closestRun(sel.focusNode);
|
||||
if (!anchorRun || anchorRun !== focusRun) {
|
||||
state.selection = null;
|
||||
setWorkspaceStatus(isEN()
|
||||
? "Select within a single text span."
|
||||
: "Bitte innerhalb einer Textstelle markieren.");
|
||||
return;
|
||||
}
|
||||
const runIndex = Number(anchorRun.dataset.run);
|
||||
if (Number.isNaN(runIndex)) {
|
||||
state.selection = null;
|
||||
return;
|
||||
}
|
||||
state.selection = { runIndex, text };
|
||||
setWorkspaceStatus(state.activeSlotKey
|
||||
? (isEN() ? `Click to bind “${text}” → ${state.activeSlotKey}` : `Variable wählen, um „${text}“ zu setzen`)
|
||||
: (isEN() ? `Selected “${text}” — now pick a variable.` : `„${text}" markiert — jetzt Variable wählen.`));
|
||||
}
|
||||
|
||||
function closestRun(node: Node | null): HTMLElement | null {
|
||||
let el: Node | null = node;
|
||||
while (el && el !== document.body) {
|
||||
if (el instanceof HTMLElement && el.classList.contains("docforge-run")) return el;
|
||||
el = el.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// renderPalette groups catalogue entries by their namespace group and wires
|
||||
// each as a click-to-place control.
|
||||
function renderPalette(): void {
|
||||
const host = document.getElementById("docforge-palette");
|
||||
if (!host) return;
|
||||
if (state.catalogue.length === 0) {
|
||||
host.innerHTML = `<p class="docforge-palette-empty">${escapeHtml(isEN() ? "No variables." : "Keine Variablen.")}</p>`;
|
||||
return;
|
||||
}
|
||||
const groups = new Map<string, VariableEntry[]>();
|
||||
for (const e of state.catalogue) {
|
||||
const arr = groups.get(e.group) ?? [];
|
||||
arr.push(e);
|
||||
groups.set(e.group, arr);
|
||||
}
|
||||
let html = `<h3>${escapeHtml(isEN() ? "Variables" : "Variablen")}</h3>`;
|
||||
for (const [group, entries] of groups) {
|
||||
html += `<div class="docforge-palette-group"><h4>${escapeHtml(group)}</h4>`;
|
||||
for (const e of entries) {
|
||||
html += `<button type="button" class="docforge-palette-var" data-slot-key="${escapeHtml(e.key)}" title="{{${escapeHtml(e.key)}}}">${escapeHtml(labelOf(e))}</button>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
host.innerHTML = html;
|
||||
host.querySelectorAll<HTMLButtonElement>(".docforge-palette-var").forEach((btn) => {
|
||||
btn.addEventListener("click", () => onPaletteClick(btn.dataset.slotKey ?? "", btn));
|
||||
});
|
||||
}
|
||||
|
||||
function onPaletteClick(slotKey: string, btn: HTMLButtonElement): void {
|
||||
state.activeSlotKey = slotKey;
|
||||
const host = document.getElementById("docforge-palette");
|
||||
host?.querySelectorAll(".docforge-palette-var--active").forEach((el) => el.classList.remove("docforge-palette-var--active"));
|
||||
btn.classList.add("docforge-palette-var--active");
|
||||
|
||||
if (state.selection) {
|
||||
void placeSlot(state.selection.runIndex, state.selection.text, slotKey);
|
||||
} else {
|
||||
setWorkspaceStatus(isEN()
|
||||
? `${slotKey} selected — now highlight the text to replace.`
|
||||
: `${slotKey} gewählt — jetzt den zu ersetzenden Text markieren.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function placeSlot(runIndex: number, selectedText: string, slotKey: string): Promise<void> {
|
||||
if (!state.openID) return;
|
||||
setWorkspaceStatus(isEN() ? "Placing…" : "Setze…");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/templates/${encodeURIComponent(state.openID)}/slots`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ run_index: runIndex, selected_text: selectedText, slot_key: slotKey }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
setWorkspaceStatus((isEN() ? "Error: " : "Fehler: ") + (body.error ?? res.status));
|
||||
return;
|
||||
}
|
||||
openView((await res.json()) as AuthoringView);
|
||||
} catch (err) {
|
||||
setWorkspaceStatus((isEN() ? "Error: " : "Fehler: ") + String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function renderSlots(slots: TemplateSlot[]): void {
|
||||
const host = document.getElementById("docforge-slot-list");
|
||||
if (!host) return;
|
||||
if (slots.length === 0) {
|
||||
host.innerHTML = `<li class="docforge-slot-empty">${escapeHtml(isEN() ? "No slots yet." : "Noch keine Platzhalter.")}</li>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = slots
|
||||
.map((s) => `<li class="docforge-slot-row" data-slot="${escapeHtml(s.key)}"><code>{{${escapeHtml(s.key)}}}</code></li>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function setWorkspaceStatus(msg: string): void {
|
||||
setText(document.getElementById("docforge-workspace-status"), msg);
|
||||
}
|
||||
|
||||
function setText(el: Element | null, msg: string): void {
|
||||
if (el) el.textContent = msg;
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => void boot());
|
||||
} else {
|
||||
void boot();
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { type CalculatedDeadline } from "./views/verfahrensablauf-core";
|
||||
import { filterByDetailMode, isRuleSelected } from "./verfahrensablauf-detail-mode";
|
||||
|
||||
// Helper — minimum-viable CalculatedDeadline for unit testing the filter
|
||||
// (the renderer's other fields don't matter to the filter).
|
||||
function mkRule(
|
||||
ruleId: string,
|
||||
priority: "mandatory" | "recommended" | "optional",
|
||||
extras: Partial<CalculatedDeadline> = {},
|
||||
): CalculatedDeadline {
|
||||
return {
|
||||
ruleId,
|
||||
code: ruleId,
|
||||
name: ruleId,
|
||||
nameEN: ruleId,
|
||||
party: "",
|
||||
priority,
|
||||
ruleRef: "",
|
||||
dueDate: "2026-06-01",
|
||||
originalDate: "2026-06-01",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
...extras,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isRuleSelected", () => {
|
||||
it("mandatory rules are always selected, even with explicit deselect", () => {
|
||||
const dl = mkRule("a", "mandatory");
|
||||
expect(isRuleSelected(dl, null)).toBe(true);
|
||||
expect(isRuleSelected(dl, { "rule:a": false })).toBe(true);
|
||||
});
|
||||
|
||||
it("recommended rules default to selected; explicit false deselects", () => {
|
||||
const dl = mkRule("a", "recommended");
|
||||
expect(isRuleSelected(dl, null)).toBe(true);
|
||||
expect(isRuleSelected(dl, {})).toBe(true);
|
||||
expect(isRuleSelected(dl, { "rule:a": false })).toBe(false);
|
||||
expect(isRuleSelected(dl, { "rule:a": true })).toBe(true);
|
||||
});
|
||||
|
||||
it("optional rules default to unselected; explicit true selects", () => {
|
||||
const dl = mkRule("a", "optional");
|
||||
expect(isRuleSelected(dl, null)).toBe(false);
|
||||
expect(isRuleSelected(dl, {})).toBe(false);
|
||||
expect(isRuleSelected(dl, { "rule:a": true })).toBe(true);
|
||||
expect(isRuleSelected(dl, { "rule:a": false })).toBe(false);
|
||||
});
|
||||
|
||||
it("conditional rules are treated as unselected in 'Gewählt' (engine left them unprojected)", () => {
|
||||
const dl = mkRule("a", "mandatory", { isConditional: true });
|
||||
expect(isRuleSelected(dl, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterByDetailMode", () => {
|
||||
const deadlines = [
|
||||
mkRule("anchor", "mandatory", { isRootEvent: true }),
|
||||
mkRule("m1", "mandatory"),
|
||||
mkRule("r1", "recommended"),
|
||||
mkRule("o1", "optional"),
|
||||
mkRule("o2", "optional"),
|
||||
];
|
||||
|
||||
it("mandatory_only returns mandatory + root only", () => {
|
||||
const out = filterByDetailMode(deadlines, "mandatory_only", null);
|
||||
const ids = out.map((d) => d.ruleId);
|
||||
expect(ids).toEqual(["anchor", "m1"]);
|
||||
});
|
||||
|
||||
it("selected (default flags) returns mandatory + recommended + root", () => {
|
||||
const out = filterByDetailMode(deadlines, "selected", null);
|
||||
const ids = out.map((d) => d.ruleId);
|
||||
expect(ids).toEqual(["anchor", "m1", "r1"]);
|
||||
});
|
||||
|
||||
it("selected with explicit per-rule overrides flips both directions", () => {
|
||||
const flags = { "rule:r1": false, "rule:o1": true };
|
||||
const out = filterByDetailMode(deadlines, "selected", flags);
|
||||
const ids = out.map((d) => d.ruleId);
|
||||
expect(ids).toEqual(["anchor", "m1", "o1"]);
|
||||
});
|
||||
|
||||
it("all_options returns the full list and tags unselected rules", () => {
|
||||
const out = filterByDetailMode(deadlines, "all_options", null);
|
||||
expect(out.length).toBe(5);
|
||||
const unselected = out.filter(
|
||||
(d) => (d as CalculatedDeadline & { __detailUnselected?: boolean }).__detailUnselected,
|
||||
);
|
||||
// Root + mandatory + recommended are selected; the two optionals
|
||||
// are unselected → 2 tagged rows.
|
||||
expect(unselected.map((d) => d.ruleId).sort()).toEqual(["o1", "o2"]);
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
// Detail-level filter for /tools/verfahrensablauf (m/paliad#149 Phase 2 P3).
|
||||
//
|
||||
// m's framing (2026-05-27 14:40, design §2.4a + §3.3a):
|
||||
//
|
||||
// "It is more that I want a grade of detail in our swimlane display.
|
||||
// I want to show them but also be able to 'focus' by not displaying
|
||||
// optional things. We need an option 'show only selected' or
|
||||
// 'mandatory' ... filter events from the timeline based on whether
|
||||
// they are selected in this scenario."
|
||||
//
|
||||
// Three modes:
|
||||
// - mandatory_only — render only priority='mandatory' rules
|
||||
// - selected (default) — mandatory + every rule whose effective
|
||||
// selection (priority-default OR scenario-flag
|
||||
// override) is true. Honest summary of "the
|
||||
// lawyer's scenario".
|
||||
// - all_options — render everything, with unselected optionals
|
||||
// rendered dotted-border + muted so the user sees
|
||||
// what they're NOT considering.
|
||||
//
|
||||
// Selection model (per design §2.4a):
|
||||
// - priority='mandatory' → always selected (cannot be deselected)
|
||||
// - priority='recommended' → default-selected; rule:<uuid>=false in
|
||||
// scenario_flags deselects
|
||||
// - priority='optional' → default-unselected; rule:<uuid>=true in
|
||||
// scenario_flags selects
|
||||
// - conditional rules → respect their condition_expr first; if
|
||||
// the predicate doesn't hold, they're
|
||||
// effectively unselected regardless of
|
||||
// their priority default
|
||||
|
||||
import { type CalculatedDeadline } from "./views/verfahrensablauf-core";
|
||||
|
||||
export type DetailMode = "mandatory_only" | "selected" | "all_options";
|
||||
|
||||
const STORAGE_KEY = "verfahrensablauf:view_mode";
|
||||
const DEFAULT_MODE: DetailMode = "selected";
|
||||
|
||||
export function getDetailMode(): DetailMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === "mandatory_only" || raw === "selected" || raw === "all_options") {
|
||||
return raw;
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable (private mode, security policy) — fall
|
||||
// through to default. Render still works; just no persistence.
|
||||
}
|
||||
return DEFAULT_MODE;
|
||||
}
|
||||
|
||||
export function setDetailMode(mode: DetailMode): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// isRuleSelected: combine priority default with the scenario-flag
|
||||
// override map. Returns the effective selection state.
|
||||
//
|
||||
// priority='mandatory' → always true
|
||||
// priority='recommended' → default true, flipped by rule:<uuid>=false
|
||||
// priority='optional' → default false, flipped by rule:<uuid>=true
|
||||
// other (informational) → treated as optional
|
||||
export function isRuleSelected(
|
||||
dl: CalculatedDeadline,
|
||||
scenarioFlags: Record<string, boolean> | null,
|
||||
): boolean {
|
||||
// A conditional rule that the engine left unprojected (no concrete
|
||||
// date because its predicate doesn't hold) is effectively unselected
|
||||
// in "selected" view mode — even for priority='mandatory' rules,
|
||||
// because mandatory means "must be filed IF the predicate fires",
|
||||
// not "always render". Surfacing a non-applicable conditional row in
|
||||
// "Gewählt" would be a lie. The "all_options" view re-surfaces it via
|
||||
// the unfiltered render path so the lawyer can see what scenarios
|
||||
// would unlock it.
|
||||
if (dl.isConditional) return false;
|
||||
|
||||
if (dl.priority === "mandatory") return true;
|
||||
|
||||
const key = dl.ruleId ? `rule:${dl.ruleId}` : null;
|
||||
const override = key && scenarioFlags ? scenarioFlags[key] : undefined;
|
||||
if (typeof override === "boolean") return override;
|
||||
|
||||
return dl.priority === "recommended";
|
||||
}
|
||||
|
||||
// filterByDetailMode applies the three-way filter to a deadlines list.
|
||||
// Returns a NEW array with the appropriate subset; the caller passes
|
||||
// the filtered list to the existing renderColumnsBody / renderTimelineBody.
|
||||
//
|
||||
// all_options: returns the input as-is, with an `__detailUnselected`
|
||||
// flag set on optionals/conditionals that aren't part of the active
|
||||
// scenario — the renderer reads this flag to add the dotted-border
|
||||
// muted styling.
|
||||
export function filterByDetailMode(
|
||||
deadlines: CalculatedDeadline[],
|
||||
mode: DetailMode,
|
||||
scenarioFlags: Record<string, boolean> | null,
|
||||
): CalculatedDeadline[] {
|
||||
if (mode === "all_options") {
|
||||
// No filtering, but tag the unselected rows so the renderer can
|
||||
// dim them. The original CalculatedDeadline doesn't carry this
|
||||
// axis — we stamp it via a cast so the renderer can pick it up
|
||||
// without growing the public type. Read-only at the renderer side.
|
||||
return deadlines.map((dl) => {
|
||||
const unselected = !isRuleSelected(dl, scenarioFlags) && !dl.isRootEvent;
|
||||
return unselected
|
||||
? ({ ...dl, __detailUnselected: true } as CalculatedDeadline & { __detailUnselected: true })
|
||||
: dl;
|
||||
});
|
||||
}
|
||||
if (mode === "mandatory_only") {
|
||||
return deadlines.filter(
|
||||
(dl) => dl.priority === "mandatory" || dl.isRootEvent,
|
||||
);
|
||||
}
|
||||
// "selected": mandatory always, plus rules whose effective selection
|
||||
// is true. Root events always render (they're the proceeding anchor).
|
||||
return deadlines.filter(
|
||||
(dl) => dl.isRootEvent || isRuleSelected(dl, scenarioFlags),
|
||||
);
|
||||
}
|
||||
550
frontend/src/client/verfahrensablauf.ts
Normal file
550
frontend/src/client/verfahrensablauf.ts
Normal file
@@ -0,0 +1,550 @@
|
||||
// /tools/verfahrensablauf client (t-paliad-179 Slice 1)
|
||||
//
|
||||
// Abstract-browse surface: pick a proceeding, pick a trigger date,
|
||||
// see the typical timeline. No Akte, no save-to-project, no anchor
|
||||
// override editing, no Pathway B cascade. Variant chips + lane view
|
||||
// (Slice 3) and compare (Slice 4) layer on top of this in later
|
||||
// slices. Court picker + view toggle + calc fetch + renderers all
|
||||
// come from ./views/verfahrensablauf-core, which fristenrechner.ts
|
||||
// shares.
|
||||
|
||||
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
type DeadlineResponse,
|
||||
type Side,
|
||||
calculateDeadlines,
|
||||
escHtml,
|
||||
formatDate,
|
||||
populateCourtPicker,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
wireDateEditClicks,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
// Perspective state (t-paliad-250 / m/paliad#81). URL-driven so the
|
||||
// view is shareable and survives reload:
|
||||
// ?side=claimant|defendant → swaps which column owns the user's
|
||||
// side (proactive vs reactive label).
|
||||
// Default null = claimant-on-the-left.
|
||||
// ?appellant=claimant|defendant → collapses party=both rows into the
|
||||
// appellant's column (no mirror).
|
||||
// Only meaningful for role-swap
|
||||
// proceedings (Appeal etc.). Default
|
||||
// null = legacy mirror behaviour.
|
||||
let currentSide: Side = null;
|
||||
let currentAppellant: Side = null;
|
||||
|
||||
// Proceedings where one party initiates and "both" rows are role-swap
|
||||
// (i.e. either party files depending on who acted at the lower
|
||||
// instance). For these proceedings the appellant selector is meaningful
|
||||
// — when set, "both" rows collapse to a single row in the appellant's
|
||||
// column. For first-instance proceedings (Inf, Rev, …) the selector is
|
||||
// hidden because there's no appellant axis.
|
||||
//
|
||||
// Today: every upc.apl.* family member plus dpma.appeal.* and
|
||||
// de.inf.olg / de.inf.bgh / de.null.bgh (DE Berufung / Revision).
|
||||
// Conservative — false negatives just hide a control; false positives
|
||||
// would show an irrelevant control.
|
||||
const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
||||
"upc.apl.merits",
|
||||
"upc.apl.cost",
|
||||
"upc.apl.order",
|
||||
"de.inf.olg",
|
||||
"de.inf.bgh",
|
||||
"de.null.bgh",
|
||||
"dpma.appeal.bpatg",
|
||||
"dpma.appeal.bgh",
|
||||
"epa.opp.boa",
|
||||
]);
|
||||
|
||||
function hasAppellantAxis(proceedingType: string): boolean {
|
||||
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
|
||||
function readSideFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("side");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function readAppellantFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("appellant");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function writeSideToURL(s: Side) {
|
||||
const url = new URL(window.location.href);
|
||||
if (s === null) url.searchParams.delete("side");
|
||||
else url.searchParams.set("side", s);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
function writeAppellantToURL(a: Side) {
|
||||
const url = new URL(window.location.href);
|
||||
if (a === null) url.searchParams.delete("appellant");
|
||||
else url.searchParams.set("appellant", a);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// Per-rule anchor overrides set by the click-to-edit affordance on
|
||||
// timeline / column date cells. Posted as `anchorOverrides` to the
|
||||
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
|
||||
// user's chosen date. Cleared whenever the trigger changes (proceeding,
|
||||
// trigger date, flag toggle) so a fresh calc starts unanchored — same
|
||||
// semantic as /tools/fristenrechner.
|
||||
const anchorOverrides = new Map<string, string>();
|
||||
function clearAnchorOverrides() { anchorOverrides.clear(); }
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
// Notes toggle — when off (default), per-rule descriptive notes render
|
||||
// as a compact ⓘ icon next to the meta line (hover for full text). When
|
||||
// on, the full notes block expands under each card. Choice persists in
|
||||
// localStorage so a reload or recalc keeps the user's preference.
|
||||
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
|
||||
function readNotesPref(): boolean {
|
||||
try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
|
||||
}
|
||||
function writeNotesPref(on: boolean): void {
|
||||
try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
|
||||
}
|
||||
let showNotes = readNotesPref();
|
||||
|
||||
// Jurisdiction display prefix for the proceeding-summary chip + the
|
||||
// trigger-event placeholder. Same forum slugs the .proceeding-group
|
||||
// `data-forum` attribute carries in verfahrensablauf.tsx /
|
||||
// fristenrechner.tsx (upc / de / epa / dpma). Disambiguates the
|
||||
// 4 redundancies in the corpus (UPC Verletzungsverfahren vs DE
|
||||
// Verletzungsklage etc.) once the picker collapses.
|
||||
const FORUM_LABEL: Record<string, string> = {
|
||||
upc: "UPC",
|
||||
de: "DE",
|
||||
epa: "EPA",
|
||||
dpma: "DPMA",
|
||||
};
|
||||
|
||||
function jurisdictionFor(btn: HTMLButtonElement): string {
|
||||
const group = btn.closest<HTMLElement>(".proceeding-group");
|
||||
const forum = group?.dataset.forum || "";
|
||||
return FORUM_LABEL[forum] || "";
|
||||
}
|
||||
|
||||
function proceedingDisplayName(btn: HTMLButtonElement): string {
|
||||
const name = btn.querySelector("strong")?.textContent || "";
|
||||
const jur = jurisdictionFor(btn);
|
||||
return jur ? `${jur} ${name}` : name;
|
||||
}
|
||||
|
||||
function activeProceedingButton(): HTMLButtonElement | null {
|
||||
return document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
|
||||
}
|
||||
|
||||
// Auto-calc plumbing — sequence + debounce mirror /tools/fristenrechner
|
||||
// so rapid input changes never let a stale response overwrite a fresh
|
||||
// one.
|
||||
let calcSeq = 0;
|
||||
let calcTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function scheduleCalc(delayMs = 200) {
|
||||
if (calcTimer !== null) clearTimeout(calcTimer);
|
||||
calcTimer = setTimeout(() => {
|
||||
calcTimer = null;
|
||||
void doCalc();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function showStep(n: number) {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const el = document.getElementById(`step-${i}`);
|
||||
if (el) el.style.display = i <= n ? "block" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
// Read the proceeding-specific flag checkboxes and assemble the
|
||||
// payload the calculator expects. Mirrors fristenrechner.ts so the
|
||||
// gating semantics stay identical: with_amend on upc.inf.cfi is
|
||||
// nested under with_ccr (R.30 is only available with a CCR);
|
||||
// upc.rev.cfi exposes with_amend + with_cci as two independent
|
||||
// gates. R.19 Einspruch is NOT flag-gated (mig 098, m's 2026-05-18
|
||||
// call): it's just an always-available optional submission, so it
|
||||
// has no checkbox.
|
||||
function readFlags(): string[] {
|
||||
const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
||||
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
|
||||
const revAmend = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
|
||||
const revCci = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
|
||||
const flags: string[] = [];
|
||||
if (selectedType === "upc.inf.cfi") {
|
||||
if (ccr?.checked) flags.push("with_ccr");
|
||||
if (ccr?.checked && infAmend?.checked) flags.push("with_amend");
|
||||
}
|
||||
if (selectedType === "upc.rev.cfi") {
|
||||
if (revAmend?.checked) flags.push("with_amend");
|
||||
if (revCci?.checked) flags.push("with_cci");
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
async function doCalc() {
|
||||
const seq = ++calcSeq;
|
||||
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
|
||||
const triggerDate = dateInput?.value || "";
|
||||
if (!triggerDate || !selectedType) return;
|
||||
|
||||
const courtPickerRow = document.getElementById("court-picker-row");
|
||||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
const courtId = courtPickerRow && courtPickerRow.style.display !== "none" && courtPicker?.value
|
||||
? courtPicker.value
|
||||
: "";
|
||||
|
||||
const overrides: Record<string, string> = {};
|
||||
for (const [code, date] of anchorOverrides) overrides[code] = date;
|
||||
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
flags: readFlags(),
|
||||
anchorOverrides: overrides,
|
||||
courtId,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
if (!data) return;
|
||||
lastResponse = data;
|
||||
renderResults(data);
|
||||
showStep(3);
|
||||
}
|
||||
|
||||
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
|
||||
// label from the calc response. Precedence:
|
||||
//
|
||||
// 1. Server-supplied triggerEventLabel from proceeding_types
|
||||
// (mig 121, m/paliad#81). UPC Appeal sets this to
|
||||
// "Anfechtbare Entscheidung" / "Appealable Decision" — its rules
|
||||
// all carry a non-zero duration off the trigger date so none is
|
||||
// the root, and the proceedingName fallback ("Berufungsverfahren")
|
||||
// misnamed the input as the proceeding itself.
|
||||
// 2. Root rule (isRootEvent=true) — the first event in the
|
||||
// proceeding, e.g. Klageerhebung for upc.inf.cfi,
|
||||
// Nichtigkeitsklage for upc.rev.cfi.
|
||||
// 3. Active proceeding name — last-resort fallback. Language-aware
|
||||
// (m/paliad#58: prior code rendered DE on EN for sub-track
|
||||
// proceedings like upc.ccr.cfi which had no rules → no root).
|
||||
function triggerEventLabelFor(data: DeadlineResponse): string {
|
||||
const lang = getLang();
|
||||
const curated = lang === "en"
|
||||
? (data.triggerEventLabelEN || data.triggerEventLabel)
|
||||
: (data.triggerEventLabel || data.triggerEventLabelEN);
|
||||
if (curated) return curated;
|
||||
const root = data.deadlines.find((d) => d.isRootEvent);
|
||||
if (root) {
|
||||
return lang === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
|
||||
}
|
||||
if (lang === "en") {
|
||||
return data.proceedingNameEN || data.proceedingName || "";
|
||||
}
|
||||
return data.proceedingName || data.proceedingNameEN || "";
|
||||
}
|
||||
|
||||
function syncTriggerEventLabel() {
|
||||
const triggerEventEl = document.getElementById("trigger-event");
|
||||
if (!triggerEventEl) return;
|
||||
if (lastResponse) {
|
||||
triggerEventEl.textContent = triggerEventLabelFor(lastResponse);
|
||||
} else {
|
||||
triggerEventEl.textContent = "—";
|
||||
}
|
||||
}
|
||||
|
||||
function renderResults(data: DeadlineResponse) {
|
||||
const container = document.getElementById("timeline-container");
|
||||
if (!container) return;
|
||||
const printBtn = document.getElementById("fristen-print-btn");
|
||||
const toggle = document.getElementById("fristen-view-toggle");
|
||||
|
||||
// Header shows the picked proceeding with its jurisdiction prefix
|
||||
// so the user can tell UPC Verletzungsverfahren apart from DE
|
||||
// Verletzungsklage once the picker collapses.
|
||||
const activeBtn = activeProceedingButton();
|
||||
const procName = activeBtn ? proceedingDisplayName(activeBtn)
|
||||
: tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
|
||||
const headerHtml = `<div class="timeline-header">
|
||||
<strong>${procName}</strong>
|
||||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||||
</div>`;
|
||||
|
||||
// Sub-track contextual note (m/paliad#58). Surfaces above the
|
||||
// timeline body when the server routed the user-picked proceeding
|
||||
// through a parent (e.g. upc.ccr.cfi → upc.inf.cfi with with_ccr).
|
||||
// Plain-text banner — server-side copy is plain text per the
|
||||
// SubTrackRouting contract.
|
||||
const noteText = getLang() === "en"
|
||||
? (data.contextualNoteEN || data.contextualNote || "")
|
||||
: (data.contextualNote || data.contextualNoteEN || "");
|
||||
const noteHtml = noteText
|
||||
? `<div class="timeline-context-note" role="note">${escHtml(noteText)}</div>`
|
||||
: "";
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, {
|
||||
editable: true,
|
||||
showNotes,
|
||||
side: currentSide,
|
||||
appellant: hasAppellantAxis(selectedType) ? currentAppellant : null,
|
||||
})
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + noteHtml + bodyHtml;
|
||||
if (printBtn) printBtn.style.display = "block";
|
||||
if (toggle) toggle.style.display = "";
|
||||
|
||||
syncTriggerEventLabel();
|
||||
}
|
||||
|
||||
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
|
||||
const groups = document.querySelectorAll<HTMLElement>(".proceeding-group");
|
||||
const summary = document.getElementById("proceeding-summary") as HTMLElement | null;
|
||||
const summaryName = document.getElementById("proceeding-summary-name");
|
||||
groups.forEach((g) => { g.style.display = collapsed ? "none" : ""; });
|
||||
if (summary) summary.style.display = collapsed ? "" : "none";
|
||||
if (summaryName && displayName) summaryName.textContent = displayName;
|
||||
}
|
||||
|
||||
// syncFlagRows shows/hides the proceeding-specific checkbox rows
|
||||
// based on selectedType. Same disposition as fristenrechner.ts —
|
||||
// the with_amend nested-under-ccr semantic is enforced via
|
||||
// syncInfAmendEnabled().
|
||||
function syncFlagRows() {
|
||||
const show = (id: string, when: boolean) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = when ? "" : "none";
|
||||
};
|
||||
show("ccr-flag-row", selectedType === "upc.inf.cfi");
|
||||
show("inf-amend-flag-row", selectedType === "upc.inf.cfi");
|
||||
show("rev-amend-flag-row", selectedType === "upc.rev.cfi");
|
||||
show("rev-cci-flag-row", selectedType === "upc.rev.cfi");
|
||||
syncInfAmendEnabled();
|
||||
}
|
||||
|
||||
// R.30 amendment-application is only available with a CCR — disable
|
||||
// (and clear) the nested inf-amend checkbox while ccr is off so the
|
||||
// calc payload stays coherent. Mirrors fristenrechner.ts.
|
||||
function syncInfAmendEnabled() {
|
||||
const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
||||
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
|
||||
if (!ccr || !infAmend) return;
|
||||
infAmend.disabled = !ccr.checked;
|
||||
if (!ccr.checked) infAmend.checked = false;
|
||||
}
|
||||
|
||||
function selectProceeding(btn: HTMLButtonElement) {
|
||||
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
const nextType = btn.dataset.code || "";
|
||||
// Different proceeding tree → previously-set overrides reference
|
||||
// rule codes that don't exist in the new tree. Clear before the
|
||||
// next calc so the fresh proceeding starts unanchored.
|
||||
if (selectedType !== nextType) clearAnchorOverrides();
|
||||
selectedType = nextType;
|
||||
|
||||
// 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();
|
||||
syncAppellantRowVisibility();
|
||||
|
||||
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
|
||||
|
||||
showStep(2);
|
||||
scheduleCalc(0);
|
||||
}
|
||||
|
||||
// syncAppellantRowVisibility hides the appellant selector for
|
||||
// proceedings that have no appellant axis (first-instance Inf, Rev,
|
||||
// …). Clears the in-memory state and the URL param when hidden so a
|
||||
// shared link with ?appellant= doesn't leak into an unrelated
|
||||
// proceeding's render.
|
||||
function syncAppellantRowVisibility() {
|
||||
const row = document.getElementById("appellant-row");
|
||||
if (!row) return;
|
||||
const visible = hasAppellantAxis(selectedType);
|
||||
row.style.display = visible ? "" : "none";
|
||||
if (!visible && currentAppellant !== null) {
|
||||
currentAppellant = null;
|
||||
writeAppellantToURL(null);
|
||||
syncRadioGroup("appellant", "");
|
||||
}
|
||||
}
|
||||
|
||||
function syncRadioGroup(name: string, value: string) {
|
||||
document.querySelectorAll<HTMLInputElement>(`input[type=radio][name=${name}]`).forEach((input) => {
|
||||
input.checked = input.value === value;
|
||||
});
|
||||
}
|
||||
|
||||
function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
|
||||
// Mirrors the events.ts pattern (body.events-view-*). The print
|
||||
// stylesheet keys `body.verfahrensablauf-view-timeline` to
|
||||
// `@page paliad-landscape`, so flipping this class is what lets a
|
||||
// user print the horizontal timeline in landscape without affecting
|
||||
// the columns view (which stays portrait).
|
||||
document.body.classList.toggle("verfahrensablauf-view-timeline", view === "timeline");
|
||||
document.body.classList.toggle("verfahrensablauf-view-columns", view === "columns");
|
||||
}
|
||||
|
||||
function initViewToggle() {
|
||||
const toggle = document.getElementById("fristen-view-toggle");
|
||||
if (!toggle) return;
|
||||
|
||||
const initial = new URLSearchParams(window.location.search).get("view");
|
||||
if (initial === "timeline") procedureView = "timeline";
|
||||
applyVerfahrensablaufViewBodyClass(procedureView);
|
||||
|
||||
toggle.querySelectorAll<HTMLInputElement>("input[name=fristen-view]").forEach((input) => {
|
||||
input.checked = input.value === procedureView;
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
procedureView = input.value === "columns" ? "columns" : "timeline";
|
||||
applyVerfahrensablaufViewBodyClass(procedureView);
|
||||
const url = new URL(window.location.href);
|
||||
if (procedureView === "columns") {
|
||||
url.searchParams.delete("view");
|
||||
} else {
|
||||
url.searchParams.set("view", procedureView);
|
||||
}
|
||||
history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
|
||||
toggle.style.display = "none";
|
||||
}
|
||||
|
||||
// initPerspectiveControls hydrates side+appellant from the URL,
|
||||
// reflects state into the radio inputs, and wires onchange handlers
|
||||
// that update state + URL + re-render. Re-render path skips the
|
||||
// /api/tools/fristenrechner round-trip — perspective is a pure
|
||||
// projection of the last response, no backend involved.
|
||||
function initPerspectiveControls() {
|
||||
currentSide = readSideFromURL();
|
||||
currentAppellant = readAppellantFromURL();
|
||||
syncRadioGroup("side", currentSide ?? "");
|
||||
syncRadioGroup("appellant", currentAppellant ?? "");
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentSide = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeSideToURL(currentSide);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appellant]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentAppellant = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeAppellantToURL(currentAppellant);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => selectProceeding(btn));
|
||||
});
|
||||
|
||||
document.getElementById("proceeding-summary-reselect")?.addEventListener("click", () => {
|
||||
setProceedingPickerCollapsed(false);
|
||||
});
|
||||
|
||||
document.getElementById("calculate-btn")?.addEventListener("click", () => scheduleCalc(0));
|
||||
|
||||
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
|
||||
if (dateInput) {
|
||||
dateInput.addEventListener("change", () => scheduleCalc());
|
||||
dateInput.addEventListener("input", () => scheduleCalc());
|
||||
dateInput.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
|
||||
|
||||
// Flag-checkbox listeners — each flip triggers a fresh calc so the
|
||||
// timeline re-projects with the new gating. ccr-flag additionally
|
||||
// enables/disables the nested inf-amend row.
|
||||
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
||||
if (ccrFlag) ccrFlag.addEventListener("change", () => {
|
||||
syncInfAmendEnabled();
|
||||
scheduleCalc(0);
|
||||
});
|
||||
(["inf-amend-flag", "rev-amend-flag", "rev-cci-flag"]).forEach((id) => {
|
||||
const cb = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (cb) cb.addEventListener("change", () => scheduleCalc(0));
|
||||
});
|
||||
|
||||
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
|
||||
|
||||
// Click-to-edit on timeline / column date cells — same delegated
|
||||
// pattern as /tools/fristenrechner. Survives renderResults()'s
|
||||
// innerHTML rewrites because the listener lives on the container.
|
||||
const timelineContainer = document.getElementById("timeline-container");
|
||||
if (timelineContainer) {
|
||||
wireDateEditClicks(timelineContainer, (ruleCode, newValue) => {
|
||||
if (newValue === "") {
|
||||
anchorOverrides.delete(ruleCode);
|
||||
} else {
|
||||
anchorOverrides.set(ruleCode, newValue);
|
||||
}
|
||||
scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Notes toggle — restores last preference on load + re-renders when
|
||||
// the user flips it. Lives in the same toggle bar as the view picker.
|
||||
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
|
||||
if (notesShowCb) {
|
||||
notesShowCb.checked = showNotes;
|
||||
notesShowCb.addEventListener("change", () => {
|
||||
showNotes = notesShowCb.checked;
|
||||
writeNotesPref(showNotes);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
}
|
||||
|
||||
initViewToggle();
|
||||
initPerspectiveControls();
|
||||
|
||||
onLangChange(() => {
|
||||
// Active-button name updates with language change (the data-i18n
|
||||
// pass swaps the inner <strong>'s text). Re-collapse the summary
|
||||
// chip and re-derive the trigger event label from the lang-current
|
||||
// calc response.
|
||||
const activeBtn = activeProceedingButton();
|
||||
if (activeBtn) {
|
||||
const summary = document.getElementById("proceeding-summary-name");
|
||||
if (summary) summary.textContent = proceedingDisplayName(activeBtn);
|
||||
}
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
syncTriggerEventLabel();
|
||||
});
|
||||
|
||||
// Pre-select the first proceeding tile so users see a timeline
|
||||
// immediately on landing — matches /tools/fristenrechner behaviour.
|
||||
const firstBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
|
||||
if (firstBtn) selectProceeding(firstBtn);
|
||||
});
|
||||
@@ -66,13 +66,7 @@ export interface FilterSpec {
|
||||
sources: DataSource[];
|
||||
scope: ScopeSpec;
|
||||
time: TimeSpec;
|
||||
// Per-source narrowing. Flat shape — one entry per data source. The
|
||||
// Go side (internal/services/filter_spec.go: FilterSpec.Predicates)
|
||||
// mirrors this exactly; the previous Partial<Record<DataSource,
|
||||
// Predicates>> spelling was a latent contract bug (t-paliad-283)
|
||||
// where every chip click sent a single-nested shape the server
|
||||
// unmarshalled to no-op.
|
||||
predicates?: Predicates;
|
||||
predicates?: Partial<Record<DataSource, Predicates>>;
|
||||
// Inbox unread-only overlay (t-paliad-249). When true, the view
|
||||
// service drops project_event rows older than the caller's
|
||||
// users.inbox_seen_at cursor. Pending approval_requests always
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
type DeadlineResponse,
|
||||
bucketDeadlinesIntoColumns,
|
||||
calculateDeadlines,
|
||||
deadlineCardHtml,
|
||||
formatDurationLabel,
|
||||
renderColumnsBody,
|
||||
stripLeadingDurationFromNotes,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
// Regression tests for the editable→click-to-edit wiring on timeline date
|
||||
@@ -72,153 +67,6 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-293 (m/paliad#125): the "Wieder einblenden" affordance
|
||||
// moved from an inline chip in the card header into the caret popover
|
||||
// to fix horizontal-scroll on narrow viewports (the long German label
|
||||
// pushed the card past its column width). The renderer now signals
|
||||
// hidden state two ways: (1) a 👁⃠ state-icon in the title row and
|
||||
// (2) data-is-hidden="1" on the caret button so event-card-choices.ts
|
||||
// can surface the prominent "Wieder einblenden" popover entry when
|
||||
// the user opens the menu. The legacy `.event-card-choices-unhide`
|
||||
// inline chip class must NOT appear in the output.
|
||||
describe("deadlineCardHtml — isHidden surfaces state-icon + caret hint (t-paliad-293)", () => {
|
||||
test("isHidden=true emits the hidden state-icon", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
|
||||
{ showParty: true },
|
||||
);
|
||||
expect(html).toContain("timeline-state-icon--hidden");
|
||||
});
|
||||
|
||||
test("isHidden=true with choicesOffered.skip annotates the caret with data-is-hidden=\"1\"", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
|
||||
{ showParty: true },
|
||||
);
|
||||
expect(html).toContain('data-is-hidden="1"');
|
||||
expect(html).toContain("event-card-choices-caret");
|
||||
});
|
||||
|
||||
test("isHidden=false (default) suppresses the state-icon and reports data-is-hidden=\"0\"", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({ choicesOffered: { skip: [true, false] } }),
|
||||
{ showParty: true },
|
||||
);
|
||||
expect(html).not.toContain("timeline-state-icon--hidden");
|
||||
expect(html).toContain('data-is-hidden="0"');
|
||||
});
|
||||
|
||||
test("isHidden=true with empty choicesOffered still emits caret with synthesized skip offer (defensive)", () => {
|
||||
// Edge case: admin edits the rule's choices_offered after a user
|
||||
// has already saved a `skip=true` choice. Without the fallback
|
||||
// the card would re-surface as hidden with no popover entrypoint
|
||||
// — the user would have no way to un-hide it. The renderer
|
||||
// synthesizes a `{skip:[true,false]}` offer so the prominent
|
||||
// "Wieder einblenden" button still renders in the popover.
|
||||
const html = deadlineCardHtml(dl({ isHidden: true }), { showParty: true });
|
||||
expect(html).toContain("event-card-choices-caret");
|
||||
expect(html).toContain('data-is-hidden="1"');
|
||||
expect(html).toContain("data-choices-offered=\"{"skip":[true,false]}\"");
|
||||
});
|
||||
|
||||
test("isHidden=false with empty choicesOffered suppresses caret (regression guard)", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true });
|
||||
expect(html).not.toContain("event-card-choices-caret");
|
||||
});
|
||||
|
||||
test("legacy inline `.event-card-choices-unhide` class is no longer emitted", () => {
|
||||
// Pinned to catch a regression that would re-introduce the
|
||||
// horizontal-scroll surface that motivated the move. The popover
|
||||
// now uses `.event-card-choices-unhide-btn` (with the -btn suffix)
|
||||
// inside the body-attached popover dom node — never in the card
|
||||
// header HTML the renderer returns.
|
||||
const html = deadlineCardHtml(
|
||||
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
|
||||
{ showParty: true },
|
||||
);
|
||||
expect(html).not.toContain('class="event-card-choices-unhide"');
|
||||
expect(html).not.toMatch(/event-card-choices-unhide(?!-btn)/);
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-293: the `optional` priority used to render an inline text
|
||||
// badge in the card title. The overhaul replaces it with a ⊙ state
|
||||
// icon so the title row stays compact on narrow viewports. Tooltip is
|
||||
// driven by the `state.optional.tooltip` i18n key.
|
||||
describe("deadlineCardHtml — optional priority renders the state icon (t-paliad-293)", () => {
|
||||
test("priority='optional' emits the timeline-state-icon--optional marker", () => {
|
||||
const html = deadlineCardHtml(dl({ priority: "optional" }), { showParty: true });
|
||||
expect(html).toContain("timeline-state-icon--optional");
|
||||
expect(html).not.toContain("optional-badge");
|
||||
});
|
||||
|
||||
test("priority='mandatory' (default) omits the optional marker", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true });
|
||||
expect(html).not.toContain("timeline-state-icon--optional");
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-289 — isConditional rules render an "abhängig von <parent>"
|
||||
// chip in place of the date column, and the chip keeps the click-to-edit
|
||||
// affordance so the user can pin a real date once the upstream anchor
|
||||
// resolves (oral hearing scheduled, opposing party's motion received, …).
|
||||
// Mirrors Symptom A (R.109(1) backward-anchor without oral-hearing date)
|
||||
// and Symptom B (R.262(2) without recorded Vertraulichkeitsantrag) from
|
||||
// the issue.
|
||||
describe("deadlineCardHtml — isConditional rendering (t-paliad-289)", () => {
|
||||
test("isConditional + parentRuleName emits 'abhängig von <parent>' chip with click-to-edit", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({
|
||||
code: "upc.inf.cfi.translation_request",
|
||||
isConditional: true,
|
||||
parentRuleCode: "upc.inf.cfi.oral",
|
||||
parentRuleName: "Mündliche Verhandlung",
|
||||
}),
|
||||
{ showParty: true, editable: true },
|
||||
);
|
||||
expect(html).toContain("timeline-conditional");
|
||||
expect(html).toContain("abhängig von Mündliche Verhandlung");
|
||||
expect(html).toContain('data-rule-code="upc.inf.cfi.translation_request"');
|
||||
expect(html).toContain('role="button"');
|
||||
expect(html).not.toContain("timeline-court-set");
|
||||
});
|
||||
|
||||
test("isConditional with no parentRuleName falls back to generic upstream-event label", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({ isConditional: true }),
|
||||
{ showParty: true, editable: true },
|
||||
);
|
||||
expect(html).toContain("timeline-conditional");
|
||||
expect(html).toContain("abhängig von vorgelagertem Ereignis");
|
||||
});
|
||||
|
||||
test("isConditional wins over isCourtSet — overlapping cases render conditional chip", () => {
|
||||
// Court-set ancestor without override sets BOTH isCourtSet=true AND
|
||||
// isConditional=true on the wire. The renderer must pick the
|
||||
// conditional chip; otherwise the row keeps the legacy "wird vom
|
||||
// Gericht bestimmt" label and the user can't see WHICH upstream
|
||||
// event blocks them.
|
||||
const html = deadlineCardHtml(
|
||||
dl({
|
||||
isConditional: true,
|
||||
isCourtSet: true,
|
||||
isCourtSetIndirect: true,
|
||||
parentRuleName: "Entscheidung",
|
||||
}),
|
||||
{ showParty: true, editable: true },
|
||||
);
|
||||
expect(html).toContain("abhängig von Entscheidung");
|
||||
expect(html).not.toContain("timeline-court-set");
|
||||
});
|
||||
|
||||
test("isConditional=false keeps the normal date span (regression guard)", () => {
|
||||
const html = deadlineCardHtml(dl({ isConditional: false }), { showParty: true });
|
||||
expect(html).toContain("timeline-date");
|
||||
expect(html).not.toContain("timeline-conditional");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Pure column-routing behaviour. Originally pinned by m/paliad#81
|
||||
// (side + appellant axes), re-framed by m/paliad#88: the column
|
||||
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
|
||||
@@ -330,29 +178,6 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("side=defendant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => {
|
||||
// When the user has committed to a perspective via `?side=`, the
|
||||
// mirror is visual noise: the same card renders twice on one row,
|
||||
// once in 'Unsere Seite' and once in 'Gegnerseite'. The card's
|
||||
// '↔ beide Seiten' indicator already conveys the both-parties
|
||||
// semantic, so collapsing into ours is sufficient.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Antrag auf Simultanübersetzung", "2026-04-27")],
|
||||
{ side: "defendant" },
|
||||
);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("side=claimant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Antrag auf Simultanübersetzung", "2026-04-27")],
|
||||
{ side: "claimant" },
|
||||
);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
|
||||
const sameDate = "2026-07-23";
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
@@ -366,48 +191,6 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
|
||||
expect(rows[0].court.map((d) => d.name)).toEqual(["C"]);
|
||||
});
|
||||
|
||||
test("appellantContext overrides the page-level appellant for descendants (t-paliad-265)", () => {
|
||||
// A per-decision pick stamps AppellantContext on descendants of
|
||||
// that decision. The bucketer prefers it over the page-level
|
||||
// appellant: if a "both" row carries appellantContext='defendant',
|
||||
// it collapses to defendant's column regardless of the global
|
||||
// appellant opt.
|
||||
const dl: CalculatedDeadline = {
|
||||
...both("Notice of Appeal", "2026-07-23"),
|
||||
appellantContext: "defendant",
|
||||
};
|
||||
const rows = bucketDeadlinesIntoColumns([dl], { appellant: "claimant" });
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
});
|
||||
|
||||
test("appellantContext='claimant' + side='defendant' lands the row in opponent (claimant ≠ us)", () => {
|
||||
// The user is on the defendant side; per-card pick says the
|
||||
// claimant appealed. The "both" row collapses to the claimant's
|
||||
// column, which after the side-swap is opponent (right).
|
||||
const dl: CalculatedDeadline = {
|
||||
...both("Notice of Appeal", "2026-07-23"),
|
||||
appellantContext: "claimant",
|
||||
};
|
||||
const rows = bucketDeadlinesIntoColumns([dl], { side: "defendant", appellant: "defendant" });
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
});
|
||||
|
||||
test("appellantContext='both' or 'none' falls back to page-level mirror (t-paliad-265)", () => {
|
||||
// 'both' and 'none' aren't side-collapse values — they're
|
||||
// statements about who appealed but don't pick a column. The
|
||||
// bucketer treats them as no override, so the page-level
|
||||
// appellant (or default mirror) applies.
|
||||
const both1: CalculatedDeadline = {
|
||||
...both("Notice of Appeal", "2026-07-23"),
|
||||
appellantContext: "both",
|
||||
};
|
||||
const rowsBoth = bucketDeadlinesIntoColumns([both1]);
|
||||
expect(rowsBoth[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rowsBoth[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
});
|
||||
|
||||
test("unscheduled rows (no dueDate) trail dated rows, preserving declaration order", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("court", "Oral Hearing", ""),
|
||||
@@ -420,435 +203,4 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
|
||||
["Decision"],
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// m's correction in m/paliad#127 (t-paliad-295) reverted half of #88's
|
||||
// header refresh: the user-perspective labels "Unsere Seite"/"Gegnerseite"
|
||||
// only make sense once the user has picked a side. While the side is
|
||||
// still "Nicht festgelegt" (side === null — the default after #120) the
|
||||
// header falls back to the semantic-neutral "Proaktiv"/"Reaktiv" labels.
|
||||
// Picking a side re-enables the #88 labels. The bucketing primitive
|
||||
// itself is unchanged — only the column-header text differs.
|
||||
describe("renderColumnsBody — side-aware column header labels (m/paliad#127)", () => {
|
||||
const dlFix = (party: string, name: string, due: string): CalculatedDeadline => ({
|
||||
code: name,
|
||||
name,
|
||||
nameEN: name,
|
||||
party,
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: due,
|
||||
originalDate: due,
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
});
|
||||
const data: DeadlineResponse = {
|
||||
proceedingType: "upc.inf.cfi",
|
||||
proceedingName: "UPC Verletzungsverfahren",
|
||||
triggerDate: "2026-01-01",
|
||||
deadlines: [
|
||||
dlFix("claimant", "Klageschrift", "2026-01-01"),
|
||||
dlFix("defendant", "Klageerwiderung", "2026-04-01"),
|
||||
],
|
||||
};
|
||||
|
||||
test("side=null renders Proaktiv/Gericht/Reaktiv headers", () => {
|
||||
const html = renderColumnsBody(data, { side: null });
|
||||
expect(html).toContain(">Proaktiv<");
|
||||
expect(html).toContain(">Gericht<");
|
||||
expect(html).toContain(">Reaktiv<");
|
||||
expect(html).not.toContain(">Unsere Seite<");
|
||||
expect(html).not.toContain(">Gegnerseite<");
|
||||
});
|
||||
|
||||
test("side=null when opts omitted (default) still renders Proaktiv/Reaktiv", () => {
|
||||
const html = renderColumnsBody(data);
|
||||
expect(html).toContain(">Proaktiv<");
|
||||
expect(html).toContain(">Reaktiv<");
|
||||
});
|
||||
|
||||
test("side=claimant renders Unsere Seite/Gericht/Gegnerseite headers", () => {
|
||||
const html = renderColumnsBody(data, { side: "claimant" });
|
||||
expect(html).toContain(">Unsere Seite<");
|
||||
expect(html).toContain(">Gericht<");
|
||||
expect(html).toContain(">Gegnerseite<");
|
||||
expect(html).not.toContain(">Proaktiv<");
|
||||
expect(html).not.toContain(">Reaktiv<");
|
||||
});
|
||||
|
||||
test("side=defendant renders Unsere Seite/Gegnerseite headers (column swap is bucketing, not labels)", () => {
|
||||
// The user-perspective labels are picked once a side is set; the
|
||||
// bucketer still routes defendant filings into the `ours` column when
|
||||
// side=defendant, so the left column's header truthfully reads
|
||||
// "Unsere Seite" regardless of which underlying party occupies it.
|
||||
const html = renderColumnsBody(data, { side: "defendant" });
|
||||
expect(html).toContain(">Unsere Seite<");
|
||||
expect(html).toContain(">Gegnerseite<");
|
||||
expect(html).not.toContain(">Proaktiv<");
|
||||
expect(html).not.toContain(">Reaktiv<");
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-307 / m/paliad#136 Bug 1 — appeal-aware column routing.
|
||||
// All appeal rules carry party='both' (either side could be the
|
||||
// appellant). With appealAware=true + dl.appealRole set, the bucketer
|
||||
// routes by (filer matches user) instead of collapsing every 'both'
|
||||
// row into the user's column. Without a side picked, the bucketer
|
||||
// keeps the legacy mirror so every appeal rule is visible.
|
||||
describe("bucketDeadlinesIntoColumns — appeal-aware routing (t-paliad-307)", () => {
|
||||
const appeal = (
|
||||
name: string,
|
||||
role: "appellant" | "appellee",
|
||||
due: string,
|
||||
): CalculatedDeadline => ({
|
||||
code: name,
|
||||
name,
|
||||
nameEN: name,
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: due,
|
||||
originalDate: due,
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
appealRole: role,
|
||||
});
|
||||
|
||||
const notice = appeal("Berufungseinlegung", "appellant", "2026-07-26");
|
||||
const grounds = appeal("Berufungsbegründung", "appellant", "2026-09-26");
|
||||
const response = appeal("Berufungserwiderung", "appellee", "2026-12-26");
|
||||
|
||||
test("appealAware + side=claimant: appellant rules → ours, appellee rules → opponent", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([notice, grounds, response], {
|
||||
side: "claimant",
|
||||
appealAware: true,
|
||||
});
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(byKey.get(notice.dueDate)?.opponent).toHaveLength(0);
|
||||
expect(byKey.get(response.dueDate)?.ours).toHaveLength(0);
|
||||
expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
|
||||
});
|
||||
|
||||
test("appealAware + side=defendant: appellant rules → opponent, appellee rules → ours", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([notice, response], {
|
||||
side: "defendant",
|
||||
appealAware: true,
|
||||
});
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(byKey.get(notice.dueDate)?.ours).toHaveLength(0);
|
||||
expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
|
||||
expect(byKey.get(response.dueDate)?.opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("appealAware + side=null: mirror to both columns (every rule visible)", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([notice, response], {
|
||||
side: null,
|
||||
appealAware: true,
|
||||
});
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
|
||||
expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
|
||||
});
|
||||
|
||||
test("appealAware off: appealRole is ignored and legacy bucketing applies", () => {
|
||||
// Regression guard: a stale frontend that drops `appealAware: true`
|
||||
// must not silently route via appealRole — the side selector
|
||||
// would visibly change behaviour without a UI control to opt in.
|
||||
const rows = bucketDeadlinesIntoColumns([notice, response], { side: "defendant" });
|
||||
// Legacy "side without appellant" collapse → both rows into ours.
|
||||
const allOurs = rows.flatMap((r) => r.ours.map((d) => d.name));
|
||||
expect(allOurs).toEqual(["Berufungseinlegung", "Berufungserwiderung"]);
|
||||
rows.forEach((r) => expect(r.opponent).toHaveLength(0));
|
||||
});
|
||||
|
||||
test("appealAware respects court party — court rows always route to court column", () => {
|
||||
const decision: CalculatedDeadline = {
|
||||
...notice,
|
||||
name: "Entscheidung",
|
||||
party: "court",
|
||||
appealRole: "", // court events deliberately stay empty
|
||||
dueDate: "",
|
||||
};
|
||||
const rows = bucketDeadlinesIntoColumns([decision], { side: "claimant", appealAware: true });
|
||||
expect(rows[0].court.map((d) => d.name)).toEqual(["Entscheidung"]);
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("appealAware + rule without appealRole falls back to legacy bucketing", () => {
|
||||
// A future appeal rule we forgot to map: appealRole='' falls
|
||||
// through the appealAware branch and lands in the legacy
|
||||
// side-collapse path → ours.
|
||||
const unmapped: CalculatedDeadline = { ...notice, appealRole: "" };
|
||||
const rows = bucketDeadlinesIntoColumns([unmapped], { side: "claimant", appealAware: true });
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-307 / m/paliad#136 Bug 3 — duration label appends the
|
||||
// parent rule name (or the proceeding's trigger event label for
|
||||
// root rules) so the chip reads "4 Monate nach Endentscheidung"
|
||||
// instead of the dangling "4 Monate nach".
|
||||
describe("formatDurationLabel — appends parent name (t-paliad-307)", () => {
|
||||
const dl = (overrides: Partial<CalculatedDeadline> = {}): CalculatedDeadline => ({
|
||||
code: "x",
|
||||
name: "x",
|
||||
nameEN: "x",
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: "",
|
||||
originalDate: "",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
durationValue: 4,
|
||||
durationUnit: "months",
|
||||
timing: "after",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("with parent label: appends to head", () => {
|
||||
expect(formatDurationLabel(dl(), "Endentscheidung (R.118)"))
|
||||
.toBe("4 Monate nach Endentscheidung (R.118)");
|
||||
});
|
||||
|
||||
test("without parent label: bare head — caller decides whether to render", () => {
|
||||
expect(formatDurationLabel(dl())).toBe("4 Monate nach");
|
||||
});
|
||||
|
||||
test("without timing: parent is not appended (degenerate phrasing)", () => {
|
||||
// No timing == we can't form "4 Monate <timing> <parent>" cleanly,
|
||||
// so the bare "4 Monate" head stays. Pinned to catch a future
|
||||
// edit that would emit "4 Monate Endentscheidung" without a
|
||||
// preposition.
|
||||
expect(formatDurationLabel(dl({ timing: "" }), "Endentscheidung")).toBe("4 Monate");
|
||||
});
|
||||
|
||||
test("singular value: switches to .one unit key", () => {
|
||||
expect(formatDurationLabel(dl({ durationValue: 1 }), "X")).toBe("1 Monat nach X");
|
||||
});
|
||||
|
||||
test("zero / missing duration: empty string", () => {
|
||||
expect(formatDurationLabel(dl({ durationValue: 0 }), "X")).toBe("");
|
||||
expect(formatDurationLabel(dl({ durationValue: 0, durationUnit: "" }), "X")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deadlineCardHtml — duration tooltip reads parent name (t-paliad-307)", () => {
|
||||
test("root rule with non-zero duration uses opts.triggerEventLabel as parent fallback", () => {
|
||||
// upc.apl.merits.notice has no parent_id but a 2-month duration
|
||||
// off the trigger event (the appealed decision). The duration
|
||||
// tooltip must read the appeal-target label, not just "2 Monate
|
||||
// nach".
|
||||
const dl: CalculatedDeadline = {
|
||||
code: "upc.apl.merits.notice",
|
||||
name: "Berufungseinlegung",
|
||||
nameEN: "Notice of Appeal",
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: "2026-07-26",
|
||||
originalDate: "2026-07-26",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
durationValue: 2,
|
||||
durationUnit: "months",
|
||||
timing: "after",
|
||||
};
|
||||
const html = deadlineCardHtml(dl, {
|
||||
showParty: false,
|
||||
editable: true,
|
||||
triggerEventLabel: "Endentscheidung (R.118)",
|
||||
});
|
||||
expect(html).toContain("title=\"2 Monate nach Endentscheidung (R.118)\"");
|
||||
});
|
||||
|
||||
test("non-root rule prefers parent rule name over triggerEventLabel", () => {
|
||||
// merits.response chains off merits.grounds; the duration label
|
||||
// should read "3 Monate nach Berufungsbegründung", not the
|
||||
// appeal-target fallback.
|
||||
const dl: CalculatedDeadline = {
|
||||
code: "upc.apl.merits.response",
|
||||
name: "Berufungserwiderung",
|
||||
nameEN: "Response to Appeal",
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: "2026-12-26",
|
||||
originalDate: "2026-12-26",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
durationValue: 3,
|
||||
durationUnit: "months",
|
||||
timing: "after",
|
||||
parentRuleCode: "upc.apl.merits.grounds",
|
||||
parentRuleName: "Berufungsbegründung",
|
||||
parentRuleNameEN: "Statement of Grounds",
|
||||
};
|
||||
const html = deadlineCardHtml(dl, {
|
||||
showParty: false,
|
||||
editable: true,
|
||||
triggerEventLabel: "Endentscheidung (R.118)",
|
||||
});
|
||||
expect(html).toContain("title=\"3 Monate nach Berufungsbegründung\"");
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-307 / m/paliad#136 Bug 4 — leading "Frist N <unit> …"
|
||||
// substring is stripped before deadline_notes renders so the new
|
||||
// duration affordance and the legacy free-text don't duplicate.
|
||||
describe("stripLeadingDurationFromNotes — render-side dedup (t-paliad-307)", () => {
|
||||
test("DE: strips 'Frist 1 Monat VOR …. ' and keeps the rest", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag auf Simultanübersetzung.",
|
||||
"de",
|
||||
);
|
||||
expect(out).toBe("Antrag auf Simultanübersetzung.");
|
||||
});
|
||||
|
||||
test("DE: strips 'Frist 15 Tage ab …' when the whole notes is the duration prose", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"Frist 15 Tage ab Zustellung der Kostenentscheidung",
|
||||
"de",
|
||||
);
|
||||
expect(out).toBe("");
|
||||
});
|
||||
|
||||
test("DE: strips 'Frist beträgt 2 Monate ab …. ' (Wiedereinsetzung variant)", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens 1 Jahr.",
|
||||
"de",
|
||||
);
|
||||
expect(out).toBe("Spätestens 1 Jahr.");
|
||||
});
|
||||
|
||||
test("DE: composite 'Frist N … ODER M …' is preserved (option b follow-up)", () => {
|
||||
const composite =
|
||||
"Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung der einstweiligen Maßnahme.";
|
||||
expect(stripLeadingDurationFromNotes(composite, "de")).toBe(composite);
|
||||
});
|
||||
|
||||
test("DE: 'Frist vom Gericht' (no number) is preserved", () => {
|
||||
const out = stripLeadingDurationFromNotes("Frist vom Gericht bestimmt", "de");
|
||||
expect(out).toBe("Frist vom Gericht bestimmt");
|
||||
});
|
||||
|
||||
test("EN: strips '1 month BEFORE …. ' and keeps the rest", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"1 month BEFORE the oral hearing (R.109.1). Request for simultaneous interpretation.",
|
||||
"en",
|
||||
);
|
||||
expect(out).toBe("Request for simultaneous interpretation.");
|
||||
});
|
||||
|
||||
test("EN: strips '15-day period from …'", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"15-day period from service of the cost decision",
|
||||
"en",
|
||||
);
|
||||
expect(out).toBe("");
|
||||
});
|
||||
|
||||
test("EN: strips 'Period is N <unit> from …'", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest 12 months.",
|
||||
"en",
|
||||
);
|
||||
expect(out).toBe("Latest 12 months.");
|
||||
});
|
||||
|
||||
test("EN: empty / non-matching notes pass through unchanged", () => {
|
||||
expect(stripLeadingDurationFromNotes("", "en")).toBe("");
|
||||
expect(stripLeadingDurationFromNotes("Time limit set by the court", "en"))
|
||||
.toBe("Time limit set by the court");
|
||||
});
|
||||
});
|
||||
|
||||
// Pin the engine-options plumbing surface (t-paliad-348 / yoUPC#178).
|
||||
// calculateDeadlines must forward `includeOptional` and
|
||||
// `triggerEventAnchors` straight into the POST body so the Go handler
|
||||
// (handleFristenrechnerAPI) can pass them into lp.CalcOptions. If a
|
||||
// future refactor drops the fields, the Builder triplet silently
|
||||
// reverts to "engine emits optional rules" and the unified
|
||||
// /tools/procedures page loses its naked-proceeding default.
|
||||
describe("calculateDeadlines — forwards engine options into request body", () => {
|
||||
type CapturedRequest = { url: string; body: Record<string, unknown> };
|
||||
let captured: CapturedRequest | null;
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
captured = null;
|
||||
originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
|
||||
captured = { url: String(input), body };
|
||||
return new Response(JSON.stringify({
|
||||
proceedingType: "x", proceedingName: "x", triggerDate: "2026-01-01", deadlines: [],
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}) as typeof globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("default call omits includeOptional and triggerEventAnchors", async () => {
|
||||
await calculateDeadlines({ proceedingType: "upc.inf.cfi", triggerDate: "2026-05-26" });
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.body.includeOptional).toBeUndefined();
|
||||
expect(captured!.body.triggerEventAnchors).toBeUndefined();
|
||||
});
|
||||
|
||||
test("includeOptional=true sends includeOptional: true", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
includeOptional: true,
|
||||
});
|
||||
expect(captured!.body.includeOptional).toBe(true);
|
||||
});
|
||||
|
||||
test("includeOptional=false is omitted (matches engine default)", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
includeOptional: false,
|
||||
});
|
||||
expect(captured!.body.includeOptional).toBeUndefined();
|
||||
});
|
||||
|
||||
test("triggerEventAnchors forwarded as object", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
triggerEventAnchors: {
|
||||
"upc.inf.cfi.oral": "2026-09-01",
|
||||
"upc.inf.cfi.decision": "2026-12-15",
|
||||
},
|
||||
});
|
||||
expect(captured!.body.triggerEventAnchors).toEqual({
|
||||
"upc.inf.cfi.oral": "2026-09-01",
|
||||
"upc.inf.cfi.decision": "2026-12-15",
|
||||
});
|
||||
});
|
||||
|
||||
test("empty triggerEventAnchors is omitted", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
triggerEventAnchors: {},
|
||||
});
|
||||
expect(captured!.body.triggerEventAnchors).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,11 +28,6 @@ export interface AdjustmentReason {
|
||||
}
|
||||
|
||||
export interface CalculatedDeadline {
|
||||
// ruleId is the sequencing_rule.id UUID, used by the P3 per-rule
|
||||
// selection deviations (`rule:<uuid>` keys in projects.scenario_flags).
|
||||
// Empty on synthetic UI markers like the appeal trigger row that the
|
||||
// engine prepends — those carry no real rule_id.
|
||||
ruleId?: string;
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
@@ -66,145 +61,6 @@ export interface CalculatedDeadline {
|
||||
// Frontend save-modal logic doesn't read this; the rule editor
|
||||
// (Slice 11) is the consumer. Unknown shape on this side — pass-through.
|
||||
conditionExpr?: unknown;
|
||||
// choicesOffered (t-paliad-265): declares which per-card choice-kinds
|
||||
// this rule offers on the Verfahrensablauf timeline. Object shape:
|
||||
// { appellant?: string[], include_ccr?: [true,false], skip?: [true,false] }.
|
||||
// null/undefined = no caret affordance.
|
||||
choicesOffered?: Record<string, unknown>;
|
||||
// appellantContext (t-paliad-265): the per-decision appellant pick
|
||||
// that applies to descendants of the closest ancestor decision card
|
||||
// with a per-card appellant set. Empty = no per-card override (the
|
||||
// page-level appellant axis still applies in that case). The bucketer
|
||||
// reads this in preference to the page-level appellant.
|
||||
appellantContext?: string;
|
||||
// isHidden (t-paliad-290 / m/paliad#122): server-side flag set when
|
||||
// a previously-hidden card is re-surfaced via the "Ausgeblendete
|
||||
// anzeigen" toggle. The renderer fades the card and exposes an
|
||||
// inline "Wieder einblenden" chip that deletes the skip choice.
|
||||
isHidden?: boolean;
|
||||
// isConditional (t-paliad-289): the rule's anchor is uncertain, so
|
||||
// no concrete date is projected. Set by the calculator when the rule
|
||||
// depends on a court-set ancestor without override, when a backward-
|
||||
// anchored rule's forward anchor isn't set, or for optional rules
|
||||
// whose true triggering event sits outside the rule data (e.g.
|
||||
// R.262(2) Erwiderung auf Vertraulichkeitsantrag — anchored on SoC
|
||||
// in the data, but the real trigger is the opposing party's
|
||||
// confidentiality motion). The renderer drops the date column entry
|
||||
// and shows an "abhängig von <parentRuleName>" chip instead.
|
||||
isConditional?: boolean;
|
||||
// parentRuleCode / parentRuleName / parentRuleNameEN surface the
|
||||
// parent rule's identity so the renderer can label the
|
||||
// "abhängig von <parent>" chip on conditional rows. Populated for
|
||||
// every rule with a parent (not just conditional ones), so the
|
||||
// dependency-footer logic can reuse it. Empty for root rules.
|
||||
parentRuleCode?: string;
|
||||
parentRuleName?: string;
|
||||
parentRuleNameEN?: string;
|
||||
// durationValue / durationUnit / timing surface the rule's arithmetic
|
||||
// so the timeline card can show "2 Mo. nach" on hover (and inline when
|
||||
// the "Dauern anzeigen" toggle is on). Zero-duration rules (root
|
||||
// event, court-set) carry durationValue=0 and the renderer suppresses
|
||||
// the affordance — those don't have an explainable interval.
|
||||
// (m/paliad#133, t-paliad-302)
|
||||
durationValue?: number;
|
||||
durationUnit?: string;
|
||||
timing?: string;
|
||||
// appealRole carries the rule's appeal-filer identity when the
|
||||
// server computed the timeline under an appeal_target filter:
|
||||
// "appellant" (Berufungskläger files this rule), "appellee"
|
||||
// (Berufungsbeklagter files this rule), or empty for court events
|
||||
// and non-appeal timelines. The column bucketer reads this in
|
||||
// preference to primary_party='both' so a user-perspective `?side=`
|
||||
// pick can split appeal filings into the user's column vs the
|
||||
// opponent's, instead of routing every "both" rule into the
|
||||
// user's column. (t-paliad-307 / m/paliad#136 Bug 1)
|
||||
appealRole?: "appellant" | "appellee" | "";
|
||||
// isTriggerEvent marks the synthetic row the engine prepends to the
|
||||
// timeline when computing an appeal: a court-set decision dated to
|
||||
// the trigger date with the per-appeal-target label
|
||||
// (Endentscheidung / Kostenentscheidung / Anordnung / …). The row
|
||||
// carries no real rule_id — it's a UI marker so the timeline reads
|
||||
// decision → appeal filings → next decision. (t-paliad-307 /
|
||||
// m/paliad#136 Bug 2)
|
||||
isTriggerEvent?: boolean;
|
||||
}
|
||||
|
||||
// stripLeadingDurationFromNotes drops the leading
|
||||
// "Frist N <unit> <preposition> <subject>." (DE) /
|
||||
// "N <unit> <preposition> <subject>." (EN) prefix from a rule's
|
||||
// deadline_notes so it doesn't duplicate the new duration affordance
|
||||
// added in m/paliad#133 (t-paliad-307 Bug 4).
|
||||
//
|
||||
// The duration affordance now renders the same prose as a badge on
|
||||
// the card ("4 Monate nach Endentscheidung (R.118)"); a free-text
|
||||
// notes string that opens with the same prose reads as a verbatim
|
||||
// duplicate. Only the leading-prefix shape is stripped — anything
|
||||
// after the first sentence is preserved (the editorial commentary
|
||||
// the lawyers actually want to read).
|
||||
//
|
||||
// Conservative: composite-duration prefaces with "ODER" /
|
||||
// "whichever is the longer" don't match and stay untouched — those
|
||||
// are the follow-up editorial cleanup (option b in the issue brief).
|
||||
//
|
||||
// Examples:
|
||||
// "Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag …"
|
||||
// → "Antrag …"
|
||||
// "Frist 15 Tage ab Zustellung der Kostenentscheidung"
|
||||
// → ""
|
||||
// "Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens …"
|
||||
// → "Spätestens …"
|
||||
// "1-month period from service of the main decision"
|
||||
// → ""
|
||||
// "1 month BEFORE the oral hearing (R.109.1). Request for …"
|
||||
// → "Request for …"
|
||||
// "Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest …"
|
||||
// → "Latest …"
|
||||
// "Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung …"
|
||||
// → unchanged (composite — option b follow-up)
|
||||
export function stripLeadingDurationFromNotes(notes: string, lang: "de" | "en"): string {
|
||||
if (!notes) return notes;
|
||||
// Terminator `(?:\.\s+|$)` matches the FIRST sentence boundary
|
||||
// (period followed by whitespace) OR end of input. Embedded dots
|
||||
// inside parenthesised citations (R.109.1, § 123(2), Rule 136(1))
|
||||
// are skipped because the char right after them isn't whitespace.
|
||||
// `[^]*?` is the JS-portable form of `.*?` with the dotAll flag —
|
||||
// any character including newlines, non-greedy.
|
||||
const re = lang === "en"
|
||||
? /^(?:Period\s+is\s+)?\d+(?:[-\s]\S+)?\s+(?:\S+\s+)?(?:before|from|after|since)\b[^]*?(?:\.\s+|$)/i
|
||||
: /^Frist\s+(?:beträgt\s+)?\d+\s+\S+\s+(?:VOR|vor|nach|ab|seit)\b[^]*?(?:\.\s+|$)/;
|
||||
return notes.replace(re, "");
|
||||
}
|
||||
|
||||
// formatDurationLabel renders the per-rule duration label for the
|
||||
// Verfahrensablauf card affordance: "2 Monate nach Endentscheidung",
|
||||
// "1 Monat vor Mündlicher Verhandlung", …
|
||||
// (m/paliad#133, t-paliad-302; parent-name append: t-paliad-307 /
|
||||
// m/paliad#136 Bug 3).
|
||||
//
|
||||
// Returns empty string for rules without a usable duration so the
|
||||
// caller can skip the tooltip / inline span entirely. Pluralisation
|
||||
// key naming mirrors the Fristenrechner event-mode renderer
|
||||
// (deadlines.event.unit.<unit>.{one,many}) — the unit and timing
|
||||
// translations already exist for /tools/fristenrechner's
|
||||
// "Was kommt nach…" mode and are reused here as the single source
|
||||
// of truth.
|
||||
//
|
||||
// `parentLabel` is the rule's anchor name (parent rule's name when
|
||||
// the rule has a parent_id; otherwise the proceeding's
|
||||
// triggerEventLabel from the wire). Empty falls back to bare
|
||||
// "<n> <unit> <timing>" — bare phrasing is the pre-fix shape and
|
||||
// remains the default for fixtures / tests that omit a parent.
|
||||
export function formatDurationLabel(dl: CalculatedDeadline, parentLabel: string = ""): string {
|
||||
const value = dl.durationValue ?? 0;
|
||||
const unit = dl.durationUnit || "";
|
||||
if (value <= 0 || !unit) return "";
|
||||
const unitKey = `deadlines.event.unit.${unit}` + (value === 1 ? ".one" : ".many");
|
||||
const unitStr = tDyn(unitKey);
|
||||
const timing = dl.timing || "";
|
||||
const timingStr = timing ? tDyn(`deadlines.event.timing.${timing}`) : "";
|
||||
const head = timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`;
|
||||
if (!timingStr || !parentLabel) return head;
|
||||
return `${head} ${parentLabel}`;
|
||||
}
|
||||
|
||||
// priorityRendering returns the per-priority UX hints the save-modal
|
||||
@@ -264,19 +120,6 @@ export interface DeadlineResponse {
|
||||
// (m/paliad#81)
|
||||
triggerEventLabel?: string;
|
||||
triggerEventLabelEN?: string;
|
||||
// hiddenCount (t-paliad-290 / m/paliad#122): number of rules that
|
||||
// would have been hidden in this projection (i.e. their
|
||||
// submission_code is in skipRules and they passed the condition_expr
|
||||
// gate). Surfaces the "Ausgeblendete (N)" badge on the toggle even
|
||||
// when the toggle is OFF — so users know there's something to
|
||||
// re-surface.
|
||||
hiddenCount?: number;
|
||||
// rulesAwaitingAnchor (t-paliad-348 / yoUPC#178): number of rules the
|
||||
// engine suppressed because their `trigger_event_id` anchor wasn't
|
||||
// supplied via CalcParams.triggerEventAnchors. Mirrors the Go
|
||||
// Timeline.RulesAwaitingAnchor counter — a single integer surface for
|
||||
// "N rules waiting on an anchor" UI affordances.
|
||||
rulesAwaitingAnchor?: number;
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
@@ -296,41 +139,6 @@ export interface CalcParams {
|
||||
flags?: string[];
|
||||
anchorOverrides?: Record<string, string>;
|
||||
courtId?: string;
|
||||
// t-paliad-265: per-event-card choices. Either pass `projectId` for
|
||||
// server-side lookup against paliad.project_event_choices, OR pass
|
||||
// an inline list (for the unbound /tools/verfahrensablauf surface).
|
||||
// When both are supplied the inline list wins server-side.
|
||||
projectId?: string;
|
||||
perCardChoices?: Array<{
|
||||
submission_code: string;
|
||||
choice_kind: string;
|
||||
choice_value: string;
|
||||
}>;
|
||||
// includeHidden (t-paliad-290): when true the calculator returns
|
||||
// previously-skipped rules as faded cards instead of dropping them.
|
||||
// Sent only when the page-level "Ausgeblendete anzeigen" toggle is
|
||||
// ON.
|
||||
includeHidden?: boolean;
|
||||
// Slice B1 / m/paliad#124 §18.1: narrows the unified UPC Berufung
|
||||
// (upc.apl) timeline to the rule subset whose applies_to_target
|
||||
// contains the requested slug. Empty = no filter. Valid values:
|
||||
// endentscheidung | kostenentscheidung | anordnung |
|
||||
// schadensbemessung | bucheinsicht.
|
||||
appealTarget?: string;
|
||||
// t-paliad-348 / yoUPC#178 — surface the engine's two new CalcOptions
|
||||
// axes to the HTTP boundary:
|
||||
//
|
||||
// includeOptional: when true, the engine returns priority='optional'
|
||||
// rules in the timeline. Default false matches the engine default
|
||||
// (mandatory backbone only). The /tools/procedures detailgrad
|
||||
// toggle ("all_options" mode) drives this to true so the dimmed
|
||||
// optional cards can be rendered for the lawyer to opt into.
|
||||
// triggerEventAnchors: per-event-code anchor dates the engine
|
||||
// consults for rules carrying trigger_event_id. Empty/omitted =
|
||||
// no anchors → such rules render as IsConditional (the engine
|
||||
// refuses to fabricate a date off the proceeding's trigger date).
|
||||
includeOptional?: boolean;
|
||||
triggerEventAnchors?: Record<string, string>;
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
@@ -346,20 +154,10 @@ export function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// Pure-string HTML escape — keeps the module testable in bun test
|
||||
// (plain Node, no jsdom). Used to be backed by document.createElement,
|
||||
// which forced fixtures to leave any field that flowed through it
|
||||
// empty just to exercise unrelated branches; the regex form is safe
|
||||
// for arbitrary text including the per-rule name strings that the
|
||||
// conditional-row chip ("abhängig von <parent>") now exposes.
|
||||
// (t-paliad-289)
|
||||
export function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string): string {
|
||||
@@ -451,128 +249,27 @@ export interface CardOpts {
|
||||
// Page shells expose a toggle ("Hinweise anzeigen") that flips this and
|
||||
// re-renders. Default false — notes are noisy on long timelines.
|
||||
showNotes?: boolean;
|
||||
// showDurations controls per-rule duration rendering on event cards
|
||||
// (m/paliad#133, t-paliad-302):
|
||||
// true → inline `<span class="timeline-duration">2 Mo. nach</span>`
|
||||
// next to the date.
|
||||
// false → hover-only tooltip on the date span (browser-native
|
||||
// `title` attribute). Cards without a usable
|
||||
// `durationValue > 0` get neither — court-set and trigger-
|
||||
// event cards have no explainable interval.
|
||||
// /tools/verfahrensablauf exposes a toggle ("Dauern anzeigen") that
|
||||
// flips this and re-renders; persisted via the localStorage key
|
||||
// `paliad.verfahrensablauf.durations-show`. Default false.
|
||||
showDurations?: boolean;
|
||||
// triggerEventLabel: per-language label of the proceeding's anchor
|
||||
// event ("Endentscheidung (R.118)" for an Endentscheidung appeal;
|
||||
// "Klageerhebung" for upc.inf.cfi; …). Used by formatDurationLabel
|
||||
// as the parent-name fallback when a rule is a root rule (no
|
||||
// parent_id) but carries a non-zero duration — e.g. the
|
||||
// Berufungseinlegung 2 months after Endentscheidung. Pages pass the
|
||||
// already-language-resolved string. (t-paliad-307 / m/paliad#136
|
||||
// Bug 3)
|
||||
triggerEventLabel?: string;
|
||||
}
|
||||
|
||||
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
|
||||
const wantsEditable = !!opts.editable;
|
||||
const editable = wantsEditable && !dl.isRootEvent && dl.code !== "";
|
||||
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
|
||||
// Parent name for the duration label (t-paliad-307 / m/paliad#136
|
||||
// Bug 3): use the rule's parent if set, else fall back to the
|
||||
// proceeding's trigger event label (e.g. "Endentscheidung (R.118)"
|
||||
// for an Endentscheidung appeal; "Klageerhebung" for upc.inf.cfi).
|
||||
// Empty for rules whose anchor isn't surface-able — the duration
|
||||
// label degrades to the bare "<n> <unit> <timing>" form in that case.
|
||||
const parentLabelForDuration = (getLang() === "en"
|
||||
? (dl.parentRuleNameEN || dl.parentRuleName)
|
||||
: (dl.parentRuleName || dl.parentRuleNameEN)) || opts.triggerEventLabel || "";
|
||||
// Duration affordance (m/paliad#133, t-paliad-302). Computed once so
|
||||
// both the date-span tooltip and the inline meta-row span pull from
|
||||
// the same string. Empty for rules without a usable duration.
|
||||
const durationLabel = formatDurationLabel(dl, parentLabelForDuration);
|
||||
// Hover affordance on the date span: prefer the duration tooltip when
|
||||
// we have one, else fall back to the edit-hint when the cell is
|
||||
// click-to-edit. The edit affordance still works either way — the
|
||||
// title is purely advisory.
|
||||
const dateTitle = durationLabel
|
||||
? durationLabel
|
||||
: (editable ? t("deadlines.date.edit.hint") : "");
|
||||
const editAttrs = editable
|
||||
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0"${dateTitle ? ` title="${escAttr(dateTitle)}"` : ""}`
|
||||
: (dateTitle ? ` title="${escAttr(dateTitle)}"` : "");
|
||||
// Conditional rows (t-paliad-289) replace the date column with an
|
||||
// "abhängig von <parent>" chip. The chip remains click-to-edit so
|
||||
// the user can pin a real date once known (e.g. once the oral
|
||||
// hearing date is set, or the opposing party's Vertraulichkeits-
|
||||
// antrag arrives) — the same data-rule-code wiring fires the
|
||||
// existing inline date editor. IsConditional wins over IsCourtSet:
|
||||
// they overlap (court-set ancestor without override produces both),
|
||||
// and "abhängig von <parent>" is the clearer user-facing signal.
|
||||
const parentLabel = (getLang() === "en"
|
||||
? (dl.parentRuleNameEN || dl.parentRuleName)
|
||||
: dl.parentRuleName) || "";
|
||||
let dateStr: string;
|
||||
if (dl.isConditional) {
|
||||
const chipText = parentLabel
|
||||
? tDyn("deadlines.conditional.depends_on").replace("{parent}", escHtml(parentLabel))
|
||||
: t("deadlines.conditional.unset");
|
||||
dateStr = `<span class="timeline-conditional frist-date-edit"${editAttrs}>${chipText}</span>`;
|
||||
} else if (dl.isCourtSet) {
|
||||
const courtLabelKey = dl.isCourtSetIndirect
|
||||
? "deadlines.court.indirect"
|
||||
: "deadlines.court.set";
|
||||
dateStr = `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`;
|
||||
} else {
|
||||
dateStr = `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
||||
}
|
||||
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
|
||||
: "";
|
||||
const courtLabelKey = dl.isCourtSetIndirect
|
||||
? "deadlines.court.indirect"
|
||||
: "deadlines.court.set";
|
||||
const dateStr = dl.isCourtSet
|
||||
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
|
||||
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
||||
|
||||
// t-paliad-293 — iconified state markers. The card surface speaks
|
||||
// "cut the tree of possibilities": each card carries 0–N small icons
|
||||
// in the title row that summarise its decision state at a glance.
|
||||
// The text "optional" badge that used to sit inline next to the name
|
||||
// is now a ⊙ icon (state.optional). Hidden cards get a 👁⃠ eye-slash
|
||||
// marker. Conditional cards already have the date-column chip; the
|
||||
// marker is redundant in the title row. CCR-included / appellant
|
||||
// picks remain on the chip row (event-card-choices-chip) — see below.
|
||||
// Tooltips are i18n-driven so they read in the user's language.
|
||||
const stateIcons: string[] = [];
|
||||
if (dl.priority === "optional") {
|
||||
stateIcons.push(
|
||||
`<span class="timeline-state-icon timeline-state-icon--optional" role="img" aria-label="${escAttr(t("state.optional.tooltip"))}" title="${escAttr(t("state.optional.tooltip"))}">⊙</span>`,
|
||||
);
|
||||
}
|
||||
if (dl.isHidden) {
|
||||
stateIcons.push(
|
||||
`<span class="timeline-state-icon timeline-state-icon--hidden" role="img" aria-label="${escAttr(t("state.hidden.tooltip"))}" title="${escAttr(t("state.hidden.tooltip"))}">👁⃠</span>`,
|
||||
);
|
||||
}
|
||||
const stateIconsHtml = stateIcons.join("");
|
||||
|
||||
// t-paliad-265 — caret affordance + chip indicator when this rule
|
||||
// offers per-card choices and the user has made a pick. The popover
|
||||
// open/commit lifecycle lives in client/views/event-card-choices.ts;
|
||||
// the data-* attributes here are the wire contract between the two.
|
||||
//
|
||||
// t-paliad-293 — hidden cards always expose the caret so the user
|
||||
// can un-hide via the popover's "Wieder einblenden" entry. Normally
|
||||
// a hidden card was hidden via a skip choice, so `choicesOffered.skip`
|
||||
// is present. Defensive fallback: if a rule's `choices_offered` was
|
||||
// edited away after the skip entry was saved, the user would lose
|
||||
// the un-hide path entirely. Synthesize a `{skip:[true,false]}`
|
||||
// offer for the popover in that edge case so the prominent
|
||||
// "Wieder einblenden" button still renders.
|
||||
const offeredForCaret = (dl.choicesOffered && Object.keys(dl.choicesOffered).length > 0)
|
||||
? dl.choicesOffered
|
||||
: (dl.isHidden ? { skip: [true, false] } : null);
|
||||
const showCaret = dl.code !== "" && offeredForCaret !== null;
|
||||
const choicesHtml = showCaret
|
||||
? `<button type="button" class="event-card-choices-caret"
|
||||
data-submission-code="${escAttr(dl.code)}"
|
||||
data-choices-offered="${escAttr(JSON.stringify(offeredForCaret))}"
|
||||
data-is-hidden="${dl.isHidden ? "1" : "0"}"
|
||||
aria-label="${escAttr(t("choices.caret.title"))}"
|
||||
title="${escAttr(t("choices.caret.title"))}">▾</button>`
|
||||
// Slice 9 (t-paliad-195): the legacy boolean pair is gone — read
|
||||
// priority directly. Optional badge fires only on 'optional'
|
||||
// priority (RoP.151-style opt-in deadlines).
|
||||
const mandatoryBadge = dl.priority === "optional"
|
||||
? '<span class="optional-badge">optional</span>'
|
||||
: "";
|
||||
|
||||
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
|
||||
@@ -596,14 +293,7 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
ruleRef = `<span class="timeline-rule">${escHtml(dl.ruleRef)}</span>`;
|
||||
}
|
||||
|
||||
const rawNoteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
// Strip the leading-duration prefix so the new duration affordance
|
||||
// doesn't duplicate what the lawyer wrote verbatim into deadline_notes
|
||||
// for those legacy rule rows that still carry it.
|
||||
// (t-paliad-307 / m/paliad#136 Bug 4)
|
||||
const noteText = rawNoteText
|
||||
? stripLeadingDurationFromNotes(rawNoteText, getLang() === "en" ? "en" : "de")
|
||||
: rawNoteText;
|
||||
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
const showNotes = opts.showNotes === true;
|
||||
const notesBlock = noteText && showNotes
|
||||
? `<div class="timeline-notes">${noteText}</div>`
|
||||
@@ -612,70 +302,20 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
? `<span class="timeline-note-hint" tabindex="0" role="note" aria-label="${escAttr(noteText)}" title="${escAttr(noteText)}">ⓘ</span>`
|
||||
: "";
|
||||
|
||||
// Inline duration affordance (m/paliad#133, t-paliad-302). Only
|
||||
// emitted when the "Dauern anzeigen" toggle is on AND the rule has a
|
||||
// usable duration; the default-off hover-tooltip path is wired
|
||||
// separately on the date span itself.
|
||||
const showDurations = opts.showDurations === true;
|
||||
const durationInline = showDurations && durationLabel
|
||||
? `<span class="timeline-duration">${escHtml(durationLabel)}</span>`
|
||||
: "";
|
||||
|
||||
const meta = (opts.showParty || ruleRef || noteHint || durationInline)
|
||||
const meta = (opts.showParty || ruleRef || noteHint)
|
||||
? `<div class="timeline-meta">
|
||||
${opts.showParty ? partyBadge(dl.party) : ""}
|
||||
${durationInline}
|
||||
${ruleRef}
|
||||
${noteHint}
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
// Chip indicator surfaces the active per-card pick (t-paliad-265).
|
||||
// The popover module rehydrates this on commit so it stays in sync.
|
||||
const chipHtml = dl.code !== ""
|
||||
? `<span class="event-card-choices-chip"
|
||||
data-submission-code="${escAttr(dl.code)}"
|
||||
data-empty="true"></span>`
|
||||
: "";
|
||||
|
||||
// m/paliad#149 Phase 2 P3 — Aufnehmen / Entfernen chip on optional /
|
||||
// recommended rules (when the detail-mode filter is in "all_options"
|
||||
// or "selected"). The detail-mode filter tags unselected rules with
|
||||
// __detailUnselected; the renderer picks that up to render the chip
|
||||
// in its "Aufnehmen" state. Mandatory rules never get the chip — the
|
||||
// user can't deselect them.
|
||||
const detailUnselected = (dl as CalculatedDeadline & { __detailUnselected?: boolean }).__detailUnselected === true;
|
||||
let selectionChip = "";
|
||||
if (dl.ruleId && dl.priority !== "mandatory" && !dl.isRootEvent) {
|
||||
if (detailUnselected) {
|
||||
selectionChip = `<button type="button" class="timeline-selection-chip timeline-selection-chip--add"
|
||||
data-rule-id="${escAttr(dl.ruleId)}"
|
||||
data-priority="${escAttr(dl.priority)}"
|
||||
data-action="aufnehmen"
|
||||
title="${escAttr(t("deadlines.detail.optional_unselected_hint"))}">
|
||||
${escHtml(t("deadlines.detail.aufnehmen"))}
|
||||
</button>`;
|
||||
} else if (dl.priority === "recommended" || dl.priority === "optional") {
|
||||
// The rule IS in the active scenario but can be removed. Renders
|
||||
// as a discreet [Entfernen] chip on optional / recommended cards.
|
||||
selectionChip = `<button type="button" class="timeline-selection-chip timeline-selection-chip--remove"
|
||||
data-rule-id="${escAttr(dl.ruleId)}"
|
||||
data-priority="${escAttr(dl.priority)}"
|
||||
data-action="entfernen">
|
||||
${escHtml(t("deadlines.detail.entfernen"))}
|
||||
</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `<div class="timeline-item-header${detailUnselected ? " timeline-item-header--unselected" : ""}">
|
||||
return `<div class="timeline-item-header">
|
||||
<span class="timeline-name">
|
||||
${dlName}
|
||||
${stateIconsHtml}
|
||||
${chipHtml}
|
||||
${mandatoryBadge}
|
||||
</span>
|
||||
${dateStr}
|
||||
${selectionChip}
|
||||
${choicesHtml}
|
||||
</div>
|
||||
${meta}
|
||||
${adjustedNote}
|
||||
@@ -763,54 +403,17 @@ export function wireDateEditClicks(
|
||||
});
|
||||
}
|
||||
|
||||
// pickTriggerEventLabel returns the per-language trigger event label
|
||||
// from a DeadlineResponse, used as the parent-fallback for root-rule
|
||||
// duration labels. Mirrors the precedence the page-level
|
||||
// triggerEventLabelFor uses (curated server label > proceedingName
|
||||
// fallback). Distinct from the page helper in that it stays language-
|
||||
// scoped to the current getLang() — root-rule duration labels render
|
||||
// in the user's current language. (t-paliad-307 / m/paliad#136 Bug 3)
|
||||
export function pickTriggerEventLabel(data: DeadlineResponse): string {
|
||||
const lang = getLang();
|
||||
const curated = lang === "en"
|
||||
? (data.triggerEventLabelEN || data.triggerEventLabel || "")
|
||||
: (data.triggerEventLabel || data.triggerEventLabelEN || "");
|
||||
if (curated) return curated;
|
||||
return lang === "en"
|
||||
? (data.proceedingNameEN || data.proceedingName || "")
|
||||
: (data.proceedingName || data.proceedingNameEN || "");
|
||||
}
|
||||
|
||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||
// Resolve the trigger event label once so the duration affordance on
|
||||
// root rules (no parent) can read it as the anchor fallback. Caller-
|
||||
// provided value wins (lets the page override for sub-track flows).
|
||||
const cardOpts: CardOpts = {
|
||||
...opts,
|
||||
triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
|
||||
};
|
||||
let html = '<div class="timeline">';
|
||||
for (const dl of data.deadlines) {
|
||||
const itemClasses = [
|
||||
"timeline-item",
|
||||
dl.isRootEvent ? "timeline-root" : "",
|
||||
// t-paliad-290: re-surfaced hidden cards render faded via the
|
||||
// shared timeline-item--hidden modifier (same modifier the columns
|
||||
// view uses; see fr-col-item--hidden below).
|
||||
dl.isHidden ? "timeline-item--hidden" : "",
|
||||
// t-paliad-289: dotted-border + faded styling for conditional rows
|
||||
// so the "abhängig von <parent>" state is visually distinct from
|
||||
// both anchored deadlines and direct court-set rows.
|
||||
dl.isConditional ? "timeline-item--conditional" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
html += `
|
||||
<div class="${itemClasses}">
|
||||
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
|
||||
<div class="timeline-dot-col">
|
||||
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
|
||||
<div class="timeline-line"></div>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
${deadlineCardHtml(dl, cardOpts)}
|
||||
${deadlineCardHtml(dl, opts)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -857,9 +460,6 @@ type ColumnPosition = "ours" | "opponent";
|
||||
export interface ColumnsBodyOpts {
|
||||
editable?: boolean;
|
||||
showNotes?: boolean;
|
||||
// Forwarded to deadlineCardHtml — see CardOpts.showDurations.
|
||||
// (m/paliad#133, t-paliad-302)
|
||||
showDurations?: boolean;
|
||||
// side: which side the user is on. Drives column placement;
|
||||
// does NOT filter rows. Default null = claimant-on-the-left
|
||||
// (i.e. "ours = claimant", legacy default).
|
||||
@@ -869,15 +469,6 @@ export interface ColumnsBodyOpts {
|
||||
// (no mirror). Default null = mirror "both" into both cells
|
||||
// (legacy behaviour). Independent of `side`.
|
||||
appellant?: Side;
|
||||
// appealAware: forwarded to bucketDeadlinesIntoColumns when the
|
||||
// page is rendering an appeal_target-filtered timeline. Routes
|
||||
// each rule to its filer-perspective column via dl.appealRole
|
||||
// instead of the legacy primary_party='both' collapse.
|
||||
// (t-paliad-307 / m/paliad#136 Bug 1)
|
||||
appealAware?: boolean;
|
||||
// triggerEventLabel: forwarded to deadlineCardHtml — see CardOpts.
|
||||
// (t-paliad-307 / m/paliad#136 Bug 3)
|
||||
triggerEventLabel?: string;
|
||||
}
|
||||
|
||||
// ColumnsRow is the per-due-date bucket the renderer consumes. Public
|
||||
@@ -893,15 +484,6 @@ export interface ColumnsRow {
|
||||
export interface BucketingOpts {
|
||||
side?: Side;
|
||||
appellant?: Side;
|
||||
// appealAware: when true, rules carrying a `dl.appealRole` of
|
||||
// "appellant" / "appellee" route via the appeal role + user side
|
||||
// axis instead of the legacy primary_party='both' collapse. With
|
||||
// `side=null` the bucketer keeps the mirror semantic (both columns
|
||||
// render every appeal rule); with `side` set, "appellant" rules
|
||||
// land in the user's column when the user IS the appellant, in
|
||||
// the opponent's column otherwise — mirror for "appellee" rules.
|
||||
// (t-paliad-307 / m/paliad#136 Bug 1)
|
||||
appealAware?: boolean;
|
||||
}
|
||||
|
||||
// bucketDeadlinesIntoColumns is the pure routing primitive that
|
||||
@@ -936,8 +518,6 @@ export function bucketDeadlinesIntoColumns(
|
||||
return r;
|
||||
};
|
||||
|
||||
const appealAware = opts.appealAware === true;
|
||||
|
||||
deadlines.forEach((dl, idx) => {
|
||||
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
|
||||
const row = ensureRow(key);
|
||||
@@ -952,49 +532,11 @@ export function bucketDeadlinesIntoColumns(
|
||||
row.court.push(dl);
|
||||
break;
|
||||
case "both":
|
||||
// t-paliad-265: a per-card appellant set on a decision
|
||||
// ancestor propagates as appellantContext on this rule. When
|
||||
// present, it overrides the page-level appellant for the
|
||||
// collapse decision on THIS row. Falls through to page-level
|
||||
// when empty.
|
||||
if (dl.appellantContext === "claimant" || dl.appellantContext === "defendant") {
|
||||
const perCardCol = dl.appellantContext === "claimant" ? claimantColumn : defendantColumn;
|
||||
row[perCardCol].push(dl);
|
||||
} else if (
|
||||
appealAware &&
|
||||
(dl.appealRole === "appellant" || dl.appealRole === "appellee")
|
||||
) {
|
||||
// Appeal-aware routing (t-paliad-307 / m/paliad#136 Bug 1).
|
||||
// With no side picked, mirror to both columns so every rule
|
||||
// is visible regardless of which side the user is on. With
|
||||
// a side picked, route by (filer matches user) → ours
|
||||
// column, else opponent column. side=claimant maps the
|
||||
// user to "appellant" (Berufungskläger); side=defendant
|
||||
// maps the user to "appellee" (Berufungsbeklagter).
|
||||
if (userSide === null) {
|
||||
row.ours.push(dl);
|
||||
row.opponent.push(dl);
|
||||
} else {
|
||||
const userIsAppellant = userSide === "claimant";
|
||||
const filerIsAppellant = dl.appealRole === "appellant";
|
||||
row[filerIsAppellant === userIsAppellant ? "ours" : "opponent"].push(dl);
|
||||
}
|
||||
} else if (appellantColumn !== null) {
|
||||
if (appellantColumn !== null) {
|
||||
// Role-swap collapse: appellant initiated → both → one row
|
||||
// in appellant's column. Mirror suppressed.
|
||||
row[appellantColumn].push(dl);
|
||||
} else if (userSide !== null) {
|
||||
// Side picked but no appellant axis (first-instance Inf, Rev,
|
||||
// …): the user has committed to a perspective, so the mirror
|
||||
// is visual noise — the same card appears twice on the same
|
||||
// row, once in "Unsere Seite" and once in "Gegnerseite".
|
||||
// Collapse into ours; the "↔ beide Seiten" indicator on the
|
||||
// card already conveys that the rule applies to both parties.
|
||||
// (m/paliad#135 / t-paliad-304)
|
||||
row.ours.push(dl);
|
||||
} else {
|
||||
// No perspective picked → keep the legacy mirror so neither
|
||||
// axis is privileged. Pinned by the "default (no opts)" test.
|
||||
row.ours.push(dl);
|
||||
row.opponent.push(dl);
|
||||
}
|
||||
@@ -1017,31 +559,15 @@ export function bucketDeadlinesIntoColumns(
|
||||
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
|
||||
const userSide: Side = opts.side ?? null;
|
||||
const rows = bucketDeadlinesIntoColumns(data.deadlines, {
|
||||
side: userSide,
|
||||
appellant: opts.appellant,
|
||||
appealAware: opts.appealAware,
|
||||
});
|
||||
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
|
||||
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
|
||||
|
||||
const cardOpts: CardOpts = {
|
||||
showParty: false,
|
||||
editable: opts.editable,
|
||||
showNotes: opts.showNotes,
|
||||
showDurations: opts.showDurations,
|
||||
triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
|
||||
};
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
|
||||
|
||||
// Collapsed "both" rows lose their mirror tag — there's no longer
|
||||
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
|
||||
// be misleading. Both collapse paths suppress it:
|
||||
// - appellantPinned: role-swap collapse into appellant's column
|
||||
// - userSide !== null without appellantPinned: perspective-locked
|
||||
// collapse into ours (m/paliad#135 / t-paliad-304).
|
||||
// Legacy mirror path (no side, no appellant) keeps the tag — both
|
||||
// sibling rows still render so the tag has a visual referent.
|
||||
const sideCollapse = userSide !== null;
|
||||
const showMirrorTag = !appellantPinned && !sideCollapse;
|
||||
// be misleading. Keep it for the legacy mirror path.
|
||||
const showMirrorTag = !appellantPinned;
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
@@ -1052,25 +578,7 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
const mirrorTag = showMirrorTag && dl.party === "both"
|
||||
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
|
||||
: "";
|
||||
const itemClasses = [
|
||||
"fr-col-item",
|
||||
dl.isRootEvent ? "fr-col-root" : "",
|
||||
// t-paliad-290: re-surfaced hidden cards render faded via the
|
||||
// shared fr-col-item--hidden modifier.
|
||||
dl.isHidden ? "fr-col-item--hidden" : "",
|
||||
// t-paliad-289: same conditional treatment as the linear
|
||||
// timeline-item — dotted border + faded styling.
|
||||
dl.isConditional ? "fr-col-item--conditional" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
// data-rule-id on the card root lets the Litigation Builder
|
||||
// overlay per-card state (planned/filed/skipped) + action
|
||||
// affordances onto cards rendered through this shared body
|
||||
// without re-implementing the columns renderer. Empty on
|
||||
// synthetic rows (appeal trigger marker etc.); the Builder
|
||||
// skips state lookup when missing.
|
||||
const ruleIdAttr = dl.ruleId ? ` data-rule-id="${escAttr(dl.ruleId)}"` : "";
|
||||
const submissionCodeAttr = dl.code ? ` data-submission-code="${escAttr(dl.code)}"` : "";
|
||||
return `<div class="${itemClasses}"${ruleIdAttr}${submissionCodeAttr}>
|
||||
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
|
||||
${deadlineCardHtml(dl, cardOpts)}
|
||||
${mirrorTag}
|
||||
</div>`;
|
||||
@@ -1082,29 +590,14 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
// Column-header labels have two modes (m/paliad#127):
|
||||
// - side picked → "Unsere Seite" / "Gegnerseite" (the columns
|
||||
// truthfully describe whose filings sit there,
|
||||
// because the bucketer routed the user's side into
|
||||
// `ours`).
|
||||
// - side === null → "Proaktiv" / "Reaktiv" (semantic-neutral). The
|
||||
// user-perspective labels would lie here: we don't
|
||||
// know yet which party is "us", so calling the left
|
||||
// column "Unsere Seite" presumes a pick the user
|
||||
// hasn't made. The neutral Proaktiv/Reaktiv pair
|
||||
// keeps the spatial axis ("who initiates vs who
|
||||
// responds") legible while the hint chip on the
|
||||
// page nudges the user to pick a side.
|
||||
//
|
||||
// Note: the COLUMN PROJECTION does not change — the bucketing primitive
|
||||
// still routes claimant→left, defendant→right when side=null (legacy
|
||||
// claimant-on-the-left fallback). Only the HEADER label changes.
|
||||
const leftLabel = userSide === null ? t("deadlines.col.proactive") : t("deadlines.col.ours");
|
||||
const rightLabel = userSide === null ? t("deadlines.col.reactive") : t("deadlines.col.opponent");
|
||||
// Static labels — "Unsere Seite" is always the left column, regardless
|
||||
// of which physical party (claimant vs defendant) occupies it. The
|
||||
// bucketing primitive already routes the user's side into the `ours`
|
||||
// bucket, so the header truth-fully describes the column contents.
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(leftLabel, "fr-col-ours");
|
||||
html += headerCell(t("deadlines.col.ours"), "fr-col-ours");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(rightLabel, "fr-col-opponent");
|
||||
html += headerCell(t("deadlines.col.opponent"), "fr-col-opponent");
|
||||
|
||||
for (const row of rows) {
|
||||
html += renderCell(row.ours);
|
||||
@@ -1132,16 +625,6 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
|
||||
? params.anchorOverrides
|
||||
: undefined,
|
||||
courtId: params.courtId || undefined,
|
||||
projectId: params.projectId || undefined,
|
||||
perCardChoices: params.perCardChoices && params.perCardChoices.length > 0
|
||||
? params.perCardChoices
|
||||
: undefined,
|
||||
includeHidden: params.includeHidden ? true : undefined,
|
||||
appealTarget: params.appealTarget || undefined,
|
||||
includeOptional: params.includeOptional ? true : undefined,
|
||||
triggerEventAnchors: params.triggerEventAnchors && Object.keys(params.triggerEventAnchors).length > 0
|
||||
? params.triggerEventAnchors
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -5,7 +5,7 @@ export function Footer(): string {
|
||||
<footer className="footer">
|
||||
<div className="container">
|
||||
<p>
|
||||
<span data-i18n="footer.text">{"© 2026 Paliad — by"}</span>{" "}
|
||||
<span data-i18n="footer.text">{"© 2026 Paliad — ein Werkzeug von"}</span>{" "}
|
||||
<a href="https://flexsiebels.de" target="_blank" rel="noopener">flexsiebels.de</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function Header({ showLogout }: HeaderProps): string {
|
||||
{showLogout && (
|
||||
<Fragment>
|
||||
<a href="/tools/kostenrechner" className="nav-link" data-i18n="nav.kostenrechner">Kostenrechner</a>
|
||||
<a href="/tools/procedures" className="nav-link" data-i18n="nav.procedures">Verfahren & Fristen</a>
|
||||
<a href="/tools/fristenrechner" className="nav-link" data-i18n="nav.fristenrechner">Fristenrechner</a>
|
||||
<a href="/logout" className="nav-logout" data-i18n="nav.logout">Abmelden</a>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
@@ -177,7 +177,8 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
brief: calculators first, then reference (Checklisten /
|
||||
Gerichte / Glossar), then content (Links / Downloads). */}
|
||||
{group("nav.group.werkzeuge", "Werkzeuge",
|
||||
navItem("/tools/procedures", ICON_BOOK_OPEN, "nav.procedures", "Verfahren & Fristen", currentPath) +
|
||||
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
|
||||
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
|
||||
navItem("/submissions", ICON_FILE_TEXT, "nav.submissions", "Schriftsätze", currentPath) +
|
||||
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
||||
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
|
||||
@@ -203,7 +204,8 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
|
||||
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
|
||||
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
|
||||
{navItem("/admin/procedural-events", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
||||
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
||||
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
|
||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
|
||||
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
||||
|
||||
657
frontend/src/fristenrechner.tsx
Normal file
657
frontend/src/fristenrechner.tsx
Normal file
@@ -0,0 +1,657 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
interface ProceedingDef {
|
||||
code: string;
|
||||
i18nKey: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function proceedingBtn(p: ProceedingDef): string {
|
||||
return (
|
||||
<button type="button" className="proceeding-btn" data-code={p.code}>
|
||||
<strong data-i18n={p.i18nKey}>{p.name}</strong>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Quick-pick chip definition. Each chip targets ONE deadline_concepts
|
||||
// slug — clicking sets the search query to the concept's name in the
|
||||
// active language so trigram search lands on the right concept card.
|
||||
// Single source of truth for both fork-shortcut and B2-search-bar
|
||||
// chip rows. Dedup invariant: no two chips share a slug. Label flips
|
||||
// per language via the chip wiring in client/fristenrechner.ts.
|
||||
interface QuickChip {
|
||||
slug: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
}
|
||||
|
||||
const QUICK_CHIPS: QuickChip[] = [
|
||||
{ slug: "statement-of-defence", name_de: "Klageerwiderung", name_en: "Statement of Defence" },
|
||||
{ slug: "notice-of-appeal", name_de: "Berufungsschrift", name_en: "Notice of Appeal" },
|
||||
{ slug: "opposition", name_de: "Einspruchsfrist", name_en: "Opposition" },
|
||||
{ slug: "reply-to-defence", name_de: "Replik", name_en: "Reply to Defence" },
|
||||
{ slug: "nichtzulassungsbeschwerde", name_de: "Nichtzulassungsbeschwerde", name_en: "Non-admission Appeal (NZB)" },
|
||||
{ slug: "application-for-determination-of-damages",name_de: "Antrag auf Schadensbemessung", name_en: "Application for Determination of Damages" },
|
||||
{ slug: "wiedereinsetzung", name_de: "Wiedereinsetzung", name_en: "Re-establishment of Rights" },
|
||||
];
|
||||
|
||||
function quickChip(c: QuickChip): string {
|
||||
return (
|
||||
<button type="button" className="fristen-search-chip"
|
||||
data-chip-slug={c.slug}
|
||||
data-chip-name-de={c.name_de}
|
||||
data-chip-name-en={c.name_en}
|
||||
data-q={c.name_de}>
|
||||
{c.name_de}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const UPC_TYPES: ProceedingDef[] = [
|
||||
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
|
||||
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
|
||||
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
|
||||
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Ma\u00dfnahmen" },
|
||||
{ code: "upc.apl.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" },
|
||||
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
|
||||
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
|
||||
{ code: "upc.apl.cost", i18nKey: "deadlines.upc.apl.cost", name: "Berufung Kosten" },
|
||||
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
|
||||
];
|
||||
|
||||
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
|
||||
// 2026-05-18 ask. Labels are parallel: <court> (<procedural role>),
|
||||
// so a user scanning the picker sees the instance-and-role at a glance
|
||||
// without one tile reading "Berufung OLG" and another "Nichtigkeits-
|
||||
// verfahren". Sub-group headers convey the type grouping. Combined-
|
||||
// timeline behaviour (LG→OLG→BGH as one calc) is filed as m/paliad#41.
|
||||
const DE_INF_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
|
||||
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
|
||||
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
|
||||
];
|
||||
|
||||
const DE_NULL_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
|
||||
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
|
||||
];
|
||||
|
||||
const EPA_TYPES: ProceedingDef[] = [
|
||||
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
|
||||
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
|
||||
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
|
||||
];
|
||||
|
||||
const DPMA_TYPES: ProceedingDef[] = [
|
||||
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
|
||||
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
|
||||
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
|
||||
];
|
||||
|
||||
export function renderFristenrechner(): string {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="deadlines.title">Fristenrechner — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/tools/fristenrechner" />
|
||||
<BottomNav currentPath="/tools/fristenrechner" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="deadlines.heading">Fristenrechner</h1>
|
||||
<p className="tool-subtitle" data-i18n="deadlines.subtitle">
|
||||
Berechnung von Verfahrensfristen für UPC-, deutsche und EPA-Verfahren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* m's 2026-05-08 18:08 Determinator redesign — Step 1: pick the
|
||||
Akte (project) that scopes the rest of the flow. Filtered
|
||||
list of visible projects + "Neue Akte anlegen" link +
|
||||
four ad-hoc explore-mode chips for users who just want to
|
||||
look up a rule without saving anywhere. */}
|
||||
<div className="fristen-step1" id="fristen-step1" role="group" aria-label="Akte picker">
|
||||
<h2 className="fristen-step-heading" data-i18n="deadlines.step1.heading">
|
||||
Schritt 1 — Welche Akte?
|
||||
</h2>
|
||||
<div className="fristen-step1-search-row">
|
||||
<svg className="fristen-search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input type="search" id="fristen-akte-search"
|
||||
className="fristen-akte-search" autocomplete="off"
|
||||
data-i18n-placeholder="deadlines.step1.search.placeholder"
|
||||
placeholder="Akte suchen…" />
|
||||
</div>
|
||||
<ul className="fristen-akte-list" id="fristen-akte-list" role="listbox" aria-label="Akten"></ul>
|
||||
|
||||
<div className="fristen-step1-divider">
|
||||
<span data-i18n="deadlines.step1.divider.new">oder eine neue Akte</span>
|
||||
</div>
|
||||
{/* return-bounce: projects-new.ts honours ?return= and
|
||||
redirects back to /tools/fristenrechner?project=<new_uuid>
|
||||
so the new Akte preselects itself in Step 1. */}
|
||||
<a href="/projects/new?return=/tools/fristenrechner" className="fristen-step1-new" id="fristen-step1-new"
|
||||
data-i18n="deadlines.step1.new.cta">
|
||||
+ Neue Akte anlegen
|
||||
</a>
|
||||
|
||||
<div className="fristen-step1-divider">
|
||||
<span data-i18n="deadlines.step1.divider.adhoc">oder ad-hoc, ohne Akte</span>
|
||||
</div>
|
||||
<div className="fristen-adhoc-chips" role="group" aria-label="Ad-hoc proceeding">
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="upc"
|
||||
data-i18n="deadlines.step1.adhoc.upc">
|
||||
UPC proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="de"
|
||||
data-i18n="deadlines.step1.adhoc.de">
|
||||
DE proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="epa"
|
||||
data-i18n="deadlines.step1.adhoc.epa">
|
||||
EPA proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="dpma"
|
||||
data-i18n="deadlines.step1.adhoc.dpma">
|
||||
DPMA proceeding
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1 collapsed summary, shown after a pick. Mirrors the
|
||||
proceeding-summary collapse pattern from 097e21c. */}
|
||||
<div className="fristen-step1-summary" id="fristen-step1-summary" style="display:none" role="group">
|
||||
<span className="fristen-step1-summary-label" data-i18n="deadlines.step1.selected">Akte:</span>
|
||||
<strong className="fristen-step1-summary-name" id="fristen-step1-summary-name">—</strong>
|
||||
<span className="fristen-step1-summary-meta" id="fristen-step1-summary-meta"></span>
|
||||
<button type="button" className="fristen-step1-summary-reselect" id="fristen-step1-summary-reselect"
|
||||
data-i18n="deadlines.step1.reselect">
|
||||
Andere Akte
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step 2 — Do / Happened bifurcation. Hidden until Step 1 is
|
||||
satisfied. Click on a card routes to the existing Pathway A
|
||||
(Verfahrensablauf wizard) or Pathway B (cascade) shells —
|
||||
we keep the routing primitive in showPathway()/showBMode(). */}
|
||||
<div className="fristen-step2" id="fristen-step2" hidden>
|
||||
<h2 className="fristen-step-heading" data-i18n="deadlines.step2.heading">
|
||||
Schritt 2 — Was möchten Sie tun?
|
||||
</h2>
|
||||
<div className="fristen-step2-cards">
|
||||
<button type="button" className="fristen-step2-card" data-action="file" id="fristen-step2-file">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">✏️</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.file.title">
|
||||
Etwas einreichen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.file.desc">
|
||||
Outgoing — eine Frist tritt aus eigener Handlung ein.
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-step2-card" data-action="happened" id="fristen-step2-happened">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">📥</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.happened.title">
|
||||
Etwas ist passiert
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.happened.desc">
|
||||
Incoming — ein Ereignis hat eine Frist ausgelöst.
|
||||
</span>
|
||||
</button>
|
||||
{/* t-paliad-179 Slice 1: the third "Verfahrensablauf
|
||||
einsehen" card retired — abstract-browse intent now
|
||||
owns its own route at /tools/verfahrensablauf. */}
|
||||
</div>
|
||||
<div className="fristen-step2-shortcut">
|
||||
<div className="fristen-pathway-fork-shortcut-label" data-i18n="deadlines.pathway.shortcut.label">
|
||||
oder direkt zu einer Frist springen:
|
||||
</div>
|
||||
<div className="fristen-search-chips" id="fristen-fork-chips" role="group" aria-label="Schnellzugriff">
|
||||
{QUICK_CHIPS.map((c) => quickChip(c))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pathway B container — search bar relocates here from the page top.
|
||||
Mode toggle (B1 tree / B2 filter) sits above the panels.
|
||||
Hidden until ?path=b. */}
|
||||
<div className="fristen-pathway-shell" id="fristen-pathway-b" data-path="b" hidden>
|
||||
<button type="button" className="fristen-pathway-back" id="fristen-pathway-b-back">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.pathway.back">zurück zur Auswahl</span>
|
||||
</button>
|
||||
<h2 className="fristen-pathway-heading">
|
||||
<span aria-hidden="true">📅</span>{" "}
|
||||
<span data-i18n="deadlines.pathway.b.title">Frist eintragen aufgrund Ereignis</span>
|
||||
</h2>
|
||||
|
||||
{/* B1 panel — row-stack cascade.
|
||||
`#fristen-row-stack` hosts the perspective / inbox /
|
||||
cascade rows (t-paliad-180 Slice 1; t-paliad-197 Slice 2
|
||||
added project-driven prefills + auto-walk). The
|
||||
stack-header above carries the inline-search trigger
|
||||
(t-paliad-198 Slice 3 — clicking expands
|
||||
`#fristen-row-search-panel` over the row stack instead
|
||||
of routing to the legacy B2 surface) and the reset link.
|
||||
`#fristen-b1-results` is unchanged — it renders concept
|
||||
cards for both cascade-narrowing AND inline-search
|
||||
results, so users see the same card layout regardless
|
||||
of how they reached a deadline rule. */}
|
||||
<div className="fristen-b1-panel" id="fristen-b1-panel" data-mode="tree" hidden>
|
||||
<div className="fristen-row-stack-header" id="fristen-row-stack-header">
|
||||
<button type="button" className="fristen-row-search-link" id="fristen-row-search-link"
|
||||
data-i18n-title="deadlines.row.search.link.title"
|
||||
aria-expanded="false"
|
||||
aria-controls="fristen-row-search-panel"
|
||||
title="Direkt nach einer Frist suchen">
|
||||
<span aria-hidden="true">🔍</span>{" "}
|
||||
<span data-i18n="deadlines.row.search.link">Direkt suchen</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-row-reset-link" id="fristen-row-reset"
|
||||
data-i18n-title="deadlines.row.reset.title"
|
||||
title="Pfad zurücksetzen — alle Cascade-Antworten verwerfen">
|
||||
<span aria-hidden="true">↺</span>{" "}
|
||||
<span data-i18n="deadlines.row.reset">Pfad zurücksetzen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline search overlay (t-paliad-198 Slice 3). Hidden by
|
||||
default; the search icon-button in the stack header
|
||||
toggles it open / closed. While open, the row stack is
|
||||
hidden and the search input drives `#fristen-b1-results`
|
||||
directly — same surface the cascade leaf populates so
|
||||
the user sees one consistent concept-card list. */}
|
||||
<div className="fristen-row-search-panel" id="fristen-row-search-panel" hidden role="search">
|
||||
<button type="button" className="fristen-row-search-panel-back" id="fristen-row-search-panel-back"
|
||||
data-i18n-title="deadlines.row.search.panel.back.title"
|
||||
title="Zurück zum Entscheidungsbaum">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.row.search.panel.back">Zurück zum Entscheidungsbaum</span>
|
||||
</button>
|
||||
<div className="fristen-row-search-panel-input-wrap">
|
||||
<svg className="fristen-row-search-panel-icon" width="18" height="18" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
id="fristen-row-search-panel-input"
|
||||
className="fristen-row-search-panel-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-i18n-placeholder="deadlines.row.search.panel.placeholder"
|
||||
placeholder="Frist suchen…"
|
||||
aria-label="Frist suchen"
|
||||
/>
|
||||
<button type="button" className="fristen-row-search-panel-clear" id="fristen-row-search-panel-clear"
|
||||
data-i18n-title="deadlines.row.search.panel.clear" title="Eingabe leeren" hidden>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fristen-row-stack" id="fristen-row-stack" aria-live="polite"></div>
|
||||
<div className="fristen-b1-results" id="fristen-b1-results" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
{/* B2 panel — search bar + chips + concept-card results.
|
||||
The search input + chips + results host live here so
|
||||
fristenrechner.ts can drive both Phase D (today) and the
|
||||
B1↔B2 state-share in Phase D (forum filter). */}
|
||||
<div className="fristen-b2-panel" id="fristen-b2-panel" data-mode="filter">
|
||||
<div className="fristen-search">
|
||||
<label htmlFor="fristen-search-input" className="visually-hidden" data-i18n="deadlines.search.label">Frist suchen</label>
|
||||
<div className="fristen-search-row">
|
||||
<svg className="fristen-search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
id="fristen-search-input"
|
||||
className="fristen-search-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-i18n-placeholder="deadlines.search.placeholder"
|
||||
placeholder="Klageerwiderung, RoP 23, § 82, Wiedereinsetzung…"
|
||||
/>
|
||||
<button type="button" id="fristen-search-clear" className="fristen-search-clear" aria-label="Suche leeren" data-i18n-aria-label="deadlines.search.clear" hidden>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="fristen-search-chips" id="fristen-search-chips" role="group" aria-label="Schnellzugriff">
|
||||
<span className="fristen-search-chips-label" data-i18n="deadlines.search.chips.label">Schnellzugriff:</span>
|
||||
{QUICK_CHIPS.map((c) => quickChip(c))}
|
||||
</div>
|
||||
{/* Forum filter row — populated by Phase D. */}
|
||||
<div className="fristen-forum-filter" id="fristen-forum-filter" hidden>
|
||||
<span className="fristen-forum-filter-label" data-i18n="deadlines.filter.forum.label">Gericht / System:</span>
|
||||
<div className="fristen-forum-chips" id="fristen-forum-chips"></div>
|
||||
</div>
|
||||
<div id="fristen-search-results" className="fristen-search-results" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3a — outgoing-intent chooser. Reached when the user
|
||||
picks "Etwas einreichen" on Step 2. Three options per
|
||||
m's 2026-05-08 18:09 spec: File (drives the Pathway A
|
||||
wizard), Draft (future drafting surface; v1
|
||||
placeholder), Enter (routes to the existing manual-
|
||||
create form). */}
|
||||
<div className="fristen-pathway-shell" id="fristen-step3a" data-path="outgoing" hidden>
|
||||
<button type="button" className="fristen-pathway-back" id="fristen-step3a-back">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.step3a.back">zurück zur Auswahl</span>
|
||||
</button>
|
||||
<h2 className="fristen-pathway-heading">
|
||||
<span aria-hidden="true">✏️</span>{" "}
|
||||
<span data-i18n="deadlines.step3a.heading">Was möchten Sie einreichen?</span>
|
||||
</h2>
|
||||
<div className="fristen-step2-cards">
|
||||
<button type="button" className="fristen-step2-card" id="fristen-step3a-file" data-action="file">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">📝</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.file.title">
|
||||
Schriftsatz einreichen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.file.desc">
|
||||
Verfahrensablauf laden — Frist berechnen und zur Akte hinzufügen.
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-step2-card fristen-step2-card--soon" id="fristen-step3a-draft" data-action="draft" disabled
|
||||
data-i18n-title="deadlines.step3a.soon">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">🖉</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.draft.title">
|
||||
Schriftsatz entwerfen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.draft.desc">
|
||||
Vorbereitung — später mit Drafting-Surface verknüpft.
|
||||
</span>
|
||||
<span className="fristen-step2-card-soon" data-i18n="deadlines.step3a.soon">kommt bald</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-step2-card" id="fristen-step3a-enter" data-action="enter">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">💾</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.enter.title">
|
||||
Frist manuell erfassen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.enter.desc">
|
||||
Direkt eintragen — bereits bekanntes Datum / bekannter Typ.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pathway A container — wraps the existing wizard.
|
||||
Hidden until ?path=a. */}
|
||||
<div className="fristen-pathway-shell" id="fristen-pathway-a" data-path="a" hidden>
|
||||
<button type="button" className="fristen-pathway-back" id="fristen-pathway-a-back">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.pathway.back">zurück zur Auswahl</span>
|
||||
</button>
|
||||
<h2 className="fristen-pathway-heading">
|
||||
<span aria-hidden="true">📖</span>{" "}
|
||||
<span data-i18n="deadlines.pathway.a.title">Verfahrensablauf informieren</span>
|
||||
</h2>
|
||||
|
||||
{/* v3: legacy mode tabs retired (m's spec lock §10 Q1, 2026-05-05).
|
||||
Pathway A is Verfahrensablauf-only; trigger-event drill-in
|
||||
surfaces via concept-card pills with ?path=a&trigger=N URL,
|
||||
which resurfaces mode-event-panel programmatically below. */}
|
||||
<div className="fristen-wizard mode-panel" id="mode-procedure-panel" data-mode="procedure" role="tabpanel">
|
||||
<div className="wizard-step" id="step-1">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">1</span>
|
||||
<span data-i18n="deadlines.step1">Verfahrensart wählen</span>
|
||||
</h3>
|
||||
|
||||
<div className="proceeding-group" data-forum="upc">
|
||||
<h4 data-i18n="deadlines.upc">UPC</h4>
|
||||
<div className="proceeding-btns">
|
||||
{UPC_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="de">
|
||||
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="epa">
|
||||
<h4 data-i18n="deadlines.epa">EPA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{EPA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="dpma">
|
||||
<h4 data-i18n="deadlines.dpma">DPMA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DPMA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* m's 2026-05-08 18:26: collapse the proceeding picker once
|
||||
a choice is made; this summary line replaces the four
|
||||
group blocks with a one-line "Selected: X [Reselect]"
|
||||
affordance. JS toggles `.proceeding-summary` visibility
|
||||
in lockstep with `.proceeding-group` blocks. */}
|
||||
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
|
||||
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
|
||||
<strong className="proceeding-summary-name" id="proceeding-summary-name">—</strong>
|
||||
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
|
||||
data-i18n="deadlines.proceeding.reselect">
|
||||
Anderes Verfahren wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-2" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">2</span>
|
||||
<span data-i18n="deadlines.step2">Ausgangsdatum eingeben</span>
|
||||
</h3>
|
||||
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
{/* Read-only caption labelling the value <span>. Not a
|
||||
<label htmlFor> — m/paliad#60: <label for=…> must
|
||||
point at a labelable form control, never a span. */}
|
||||
<span className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</span>
|
||||
<span id="trigger-event" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
|
||||
<input type="date" id="trigger-date" className="date-input" value={today} />
|
||||
</div>
|
||||
<div className="date-field-row" id="court-picker-row" style="display:none">
|
||||
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
|
||||
<select id="court-picker" className="date-input"></select>
|
||||
</div>
|
||||
<div className="date-field-row" id="priority-date-row" style="display:none">
|
||||
<label htmlFor="priority-date" className="date-label" data-i18n="deadlines.priority.date">Prioritätstag (optional):</label>
|
||||
<input type="date" id="priority-date" className="date-input" />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-3" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">3</span>
|
||||
<span data-i18n="deadlines.step3">Ergebnis</span>
|
||||
</h3>
|
||||
|
||||
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
|
||||
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="columns" checked />
|
||||
<span data-i18n="deadlines.view.columns">Spalten</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
</div>
|
||||
|
||||
<div className="fristen-result-actions">
|
||||
<button type="button" id="fristen-save-cta" className="btn-primary btn-cta-lime" style="display:none" data-i18n="deadlines.save.cta">
|
||||
Als Frist(en) speichern
|
||||
</button>
|
||||
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
|
||||
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 6 2 18 2 18 9"></polyline>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
|
||||
<rect x="6" y="14" width="12" height="8"></rect>
|
||||
</svg>
|
||||
<span data-i18n="deadlines.print">Drucken</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" id="reset-btn" className="reset-btn" style="display:none" data-i18n="deadlines.reset">
|
||||
← Neu berechnen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="fristen-wizard mode-panel" id="mode-event-panel" data-mode="event" role="tabpanel" hidden>
|
||||
<div className="wizard-step" id="event-step-1">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">1</span>
|
||||
<span data-i18n="deadlines.event.step1">Trigger-Ereignis wählen</span>
|
||||
</h3>
|
||||
<p className="wizard-step-hint" data-i18n="deadlines.event.step1.hint">
|
||||
Welches Ereignis ist eingetreten? (z.B. Klageerhebung, Entscheidung des EPA, Zustellung einer Verfügung)
|
||||
</p>
|
||||
<div className="event-picker-row">
|
||||
<label htmlFor="event-search" className="visually-hidden" data-i18n="deadlines.event.search.label">Trigger-Ereignis suchen</label>
|
||||
<input
|
||||
type="search"
|
||||
id="event-search"
|
||||
className="event-search-input"
|
||||
autocomplete="off"
|
||||
data-i18n-placeholder="deadlines.event.search.placeholder"
|
||||
placeholder="Tippe, um zu suchen…"
|
||||
/>
|
||||
<ul id="event-list" className="event-list" role="listbox" aria-label="Trigger-Ereignisse"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="event-step-2" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">2</span>
|
||||
<span data-i18n="deadlines.event.step2">Datum des Ereignisses</span>
|
||||
</h3>
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
<label className="date-label" data-i18n="deadlines.event.selected">Gewähltes Ereignis:</label>
|
||||
<span id="event-selected-name" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="event-date" className="date-label" data-i18n="deadlines.event.date">Eintrittsdatum:</label>
|
||||
<input type="date" id="event-date" className="date-input" value={today} />
|
||||
</div>
|
||||
<button type="button" id="event-calculate-btn" className="calculate-btn" data-i18n="deadlines.event.calculate">
|
||||
Folgefristen berechnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="event-step-3" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">3</span>
|
||||
<span data-i18n="deadlines.event.step3">Folgefristen</span>
|
||||
</h3>
|
||||
<div id="event-results-container"></div>
|
||||
<div className="fristen-result-actions">
|
||||
<button type="button" id="event-print-btn" className="print-btn" style="display:none">
|
||||
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 6 2 18 2 18 9"></polyline>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
|
||||
<rect x="6" y="14" width="12" height="8"></rect>
|
||||
</svg>
|
||||
<span data-i18n="deadlines.print">Drucken</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" id="event-reset-btn" className="reset-btn" style="display:none" data-i18n="deadlines.reset">
|
||||
← Neu berechnen
|
||||
</button>
|
||||
</div>
|
||||
</div>{/* /pathway-a */}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/fristenrechner.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -125,12 +125,6 @@ export type I18nKey =
|
||||
| "admin.broadcasts.loading"
|
||||
| "admin.broadcasts.subtitle"
|
||||
| "admin.broadcasts.title"
|
||||
| "admin.building_blocks.action.new"
|
||||
| "admin.building_blocks.editor.empty"
|
||||
| "admin.building_blocks.heading"
|
||||
| "admin.building_blocks.loading"
|
||||
| "admin.building_blocks.subtitle"
|
||||
| "admin.building_blocks.title"
|
||||
| "admin.card.approval_policies.desc"
|
||||
| "admin.card.approval_policies.title"
|
||||
| "admin.card.audit.desc"
|
||||
@@ -296,16 +290,6 @@ export type I18nKey =
|
||||
| "admin.partner_units.new.heading"
|
||||
| "admin.partner_units.subtitle"
|
||||
| "admin.partner_units.title"
|
||||
| "admin.procedural_events.col.code"
|
||||
| "admin.procedural_events.col.proceeding"
|
||||
| "admin.procedural_events.edit.breadcrumb"
|
||||
| "admin.procedural_events.edit.field.code"
|
||||
| "admin.procedural_events.edit.field.event_kind"
|
||||
| "admin.procedural_events.edit.field.parent"
|
||||
| "admin.procedural_events.edit.title"
|
||||
| "admin.procedural_events.list.heading"
|
||||
| "admin.procedural_events.list.new"
|
||||
| "admin.procedural_events.list.title"
|
||||
| "admin.rules.col.legal_citation"
|
||||
| "admin.rules.col.lifecycle"
|
||||
| "admin.rules.col.modified"
|
||||
@@ -408,6 +392,22 @@ export type I18nKey =
|
||||
| "admin.rules.edit.title"
|
||||
| "admin.rules.empty"
|
||||
| "admin.rules.error.load"
|
||||
| "admin.rules.export.breadcrumb"
|
||||
| "admin.rules.export.copied"
|
||||
| "admin.rules.export.copy"
|
||||
| "admin.rules.export.copy_failed"
|
||||
| "admin.rules.export.count"
|
||||
| "admin.rules.export.download"
|
||||
| "admin.rules.export.error"
|
||||
| "admin.rules.export.field.since"
|
||||
| "admin.rules.export.heading"
|
||||
| "admin.rules.export.latest"
|
||||
| "admin.rules.export.no_pending"
|
||||
| "admin.rules.export.ok"
|
||||
| "admin.rules.export.run"
|
||||
| "admin.rules.export.running"
|
||||
| "admin.rules.export.subtitle"
|
||||
| "admin.rules.export.title"
|
||||
| "admin.rules.filter.lifecycle"
|
||||
| "admin.rules.filter.lifecycle.any"
|
||||
| "admin.rules.filter.proceeding"
|
||||
@@ -419,6 +419,7 @@ export type I18nKey =
|
||||
| "admin.rules.lifecycle.archived"
|
||||
| "admin.rules.lifecycle.draft"
|
||||
| "admin.rules.lifecycle.published"
|
||||
| "admin.rules.list.export"
|
||||
| "admin.rules.list.heading"
|
||||
| "admin.rules.list.new"
|
||||
| "admin.rules.list.subtitle"
|
||||
@@ -728,138 +729,6 @@ export type I18nKey =
|
||||
| "bottomnav.add.title"
|
||||
| "bottomnav.badge.deadlines"
|
||||
| "bottomnav.menu"
|
||||
| "builder.action.promote"
|
||||
| "builder.action.rename"
|
||||
| "builder.action.rename.prompt"
|
||||
| "builder.action.share"
|
||||
| "builder.akte.banner.prefix"
|
||||
| "builder.akte.none"
|
||||
| "builder.bucket.active"
|
||||
| "builder.bucket.archived"
|
||||
| "builder.bucket.empty"
|
||||
| "builder.bucket.promoted"
|
||||
| "builder.bucket.shared"
|
||||
| "builder.canvas.add_proceeding"
|
||||
| "builder.empty.cta"
|
||||
| "builder.empty.headline"
|
||||
| "builder.empty.hint"
|
||||
| "builder.empty.recent"
|
||||
| "builder.event.action.file"
|
||||
| "builder.event.action.reset"
|
||||
| "builder.event.action.skip"
|
||||
| "builder.event.actual_date.prompt"
|
||||
| "builder.event.horizon.hide"
|
||||
| "builder.event.horizon.label"
|
||||
| "builder.event.skip_reason.prompt"
|
||||
| "builder.event.state.filed"
|
||||
| "builder.event.state.planned"
|
||||
| "builder.event.state.skipped"
|
||||
| "builder.header.akte"
|
||||
| "builder.header.scenario"
|
||||
| "builder.header.search"
|
||||
| "builder.header.stichtag"
|
||||
| "builder.mobile.blocked"
|
||||
| "builder.mode.akte"
|
||||
| "builder.mode.cold"
|
||||
| "builder.mode.event"
|
||||
| "builder.panel.empty"
|
||||
| "builder.panel.new"
|
||||
| "builder.panel.title"
|
||||
| "builder.picker.aria"
|
||||
| "builder.picker.axis.forum"
|
||||
| "builder.picker.axis.proc"
|
||||
| "builder.picker.close"
|
||||
| "builder.picker.empty"
|
||||
| "builder.picker.future_jurisdiction"
|
||||
| "builder.picker.placeholder"
|
||||
| "builder.picker.title"
|
||||
| "builder.promote.back"
|
||||
| "builder.promote.cancel"
|
||||
| "builder.promote.commit"
|
||||
| "builder.promote.error.generic"
|
||||
| "builder.promote.error.title_required"
|
||||
| "builder.promote.meta.case_number"
|
||||
| "builder.promote.meta.client_number"
|
||||
| "builder.promote.meta.our_side"
|
||||
| "builder.promote.meta.our_side.claimant"
|
||||
| "builder.promote.meta.our_side.defendant"
|
||||
| "builder.promote.meta.our_side.none"
|
||||
| "builder.promote.meta.parent"
|
||||
| "builder.promote.meta.parent.none"
|
||||
| "builder.promote.meta.reference"
|
||||
| "builder.promote.meta.team"
|
||||
| "builder.promote.meta.team.hint"
|
||||
| "builder.promote.meta.title"
|
||||
| "builder.promote.meta.title.placeholder"
|
||||
| "builder.promote.next"
|
||||
| "builder.promote.parties.add"
|
||||
| "builder.promote.parties.empty"
|
||||
| "builder.promote.parties.hint"
|
||||
| "builder.promote.parties.name"
|
||||
| "builder.promote.parties.remove"
|
||||
| "builder.promote.parties.representative"
|
||||
| "builder.promote.parties.role"
|
||||
| "builder.promote.step1"
|
||||
| "builder.promote.step2"
|
||||
| "builder.promote.step3"
|
||||
| "builder.promote.success"
|
||||
| "builder.promote.summary.events_filed"
|
||||
| "builder.promote.summary.events_planned"
|
||||
| "builder.promote.summary.flags"
|
||||
| "builder.promote.summary.heading"
|
||||
| "builder.promote.summary.note_extra"
|
||||
| "builder.promote.summary.proceeding"
|
||||
| "builder.promote.title"
|
||||
| "builder.readonly.blocked"
|
||||
| "builder.readonly.watermark"
|
||||
| "builder.save.error"
|
||||
| "builder.save.idle"
|
||||
| "builder.save.saved"
|
||||
| "builder.save.saving"
|
||||
| "builder.search.anchor.divider"
|
||||
| "builder.search.group.events"
|
||||
| "builder.search.group.projects"
|
||||
| "builder.search.group.scenarios"
|
||||
| "builder.search.hint.akte_b4"
|
||||
| "builder.search.hint.empty"
|
||||
| "builder.search.hint.error"
|
||||
| "builder.search.hint.loading"
|
||||
| "builder.search.hint.short"
|
||||
| "builder.search.hint.start"
|
||||
| "builder.search.placeholder"
|
||||
| "builder.search.summary.events.one"
|
||||
| "builder.search.summary.events.other"
|
||||
| "builder.search.summary.projects.one"
|
||||
| "builder.search.summary.projects.other"
|
||||
| "builder.search.summary.scenarios.one"
|
||||
| "builder.search.summary.scenarios.other"
|
||||
| "builder.share.button"
|
||||
| "builder.share.close"
|
||||
| "builder.share.current.empty"
|
||||
| "builder.share.current.title"
|
||||
| "builder.share.error"
|
||||
| "builder.share.no_results"
|
||||
| "builder.share.revoke"
|
||||
| "builder.share.search.placeholder"
|
||||
| "builder.share.subtitle"
|
||||
| "builder.share.title"
|
||||
| "builder.subtitle"
|
||||
| "builder.triplet.collapse"
|
||||
| "builder.triplet.detailgrad.all_options"
|
||||
| "builder.triplet.detailgrad.label"
|
||||
| "builder.triplet.detailgrad.selected"
|
||||
| "builder.triplet.expand"
|
||||
| "builder.triplet.flags.label"
|
||||
| "builder.triplet.loading"
|
||||
| "builder.triplet.no_flags"
|
||||
| "builder.triplet.perspective.claimant"
|
||||
| "builder.triplet.perspective.defendant"
|
||||
| "builder.triplet.perspective.label"
|
||||
| "builder.triplet.perspective.none"
|
||||
| "builder.triplet.remove"
|
||||
| "builder.triplet.side.claimant"
|
||||
| "builder.triplet.side.defendant"
|
||||
| "builder.triplet.unknown_proceeding"
|
||||
| "cal.day.back_to_month"
|
||||
| "cal.day.fri"
|
||||
| "cal.day.mon"
|
||||
@@ -1130,26 +999,6 @@ export type I18nKey =
|
||||
| "checklisten.tab.mine"
|
||||
| "checklisten.tab.templates"
|
||||
| "checklisten.title"
|
||||
| "choices.appellant.both"
|
||||
| "choices.appellant.chip"
|
||||
| "choices.appellant.claimant"
|
||||
| "choices.appellant.defendant"
|
||||
| "choices.appellant.none"
|
||||
| "choices.appellant.title"
|
||||
| "choices.caret.title"
|
||||
| "choices.commit.error"
|
||||
| "choices.include_ccr.chip"
|
||||
| "choices.include_ccr.false"
|
||||
| "choices.include_ccr.title"
|
||||
| "choices.include_ccr.true"
|
||||
| "choices.reset"
|
||||
| "choices.show_hidden.count"
|
||||
| "choices.show_hidden.label"
|
||||
| "choices.skip.false"
|
||||
| "choices.skip.title"
|
||||
| "choices.skip.true"
|
||||
| "choices.skipped.chip"
|
||||
| "choices.unhide.chip"
|
||||
| "common.cancel"
|
||||
| "common.close"
|
||||
| "common.forbidden"
|
||||
@@ -1323,12 +1172,10 @@ export type I18nKey =
|
||||
| "deadlines.adjusted.weekend"
|
||||
| "deadlines.adjusted.weekend.saturday"
|
||||
| "deadlines.adjusted.weekend.sunday"
|
||||
| "deadlines.appeal_target.anordnung"
|
||||
| "deadlines.appeal_target.bucheinsicht"
|
||||
| "deadlines.appeal_target.endentscheidung"
|
||||
| "deadlines.appeal_target.kostenentscheidung"
|
||||
| "deadlines.appeal_target.label"
|
||||
| "deadlines.appeal_target.schadensbemessung"
|
||||
| "deadlines.appellant.claimant"
|
||||
| "deadlines.appellant.defendant"
|
||||
| "deadlines.appellant.label"
|
||||
| "deadlines.appellant.none"
|
||||
| "deadlines.calculate"
|
||||
| "deadlines.card.calc.add_to_project"
|
||||
| "deadlines.card.calc.add_to_project.disabled"
|
||||
@@ -1357,15 +1204,11 @@ export type I18nKey =
|
||||
| "deadlines.col.event_type"
|
||||
| "deadlines.col.opponent"
|
||||
| "deadlines.col.ours"
|
||||
| "deadlines.col.proactive"
|
||||
| "deadlines.col.reactive"
|
||||
| "deadlines.col.rule"
|
||||
| "deadlines.col.status"
|
||||
| "deadlines.col.title"
|
||||
| "deadlines.complete.action"
|
||||
| "deadlines.complete.confirm"
|
||||
| "deadlines.conditional.depends_on"
|
||||
| "deadlines.conditional.unset"
|
||||
| "deadlines.court.indirect"
|
||||
| "deadlines.court.label"
|
||||
| "deadlines.court.set"
|
||||
@@ -1378,8 +1221,6 @@ export type I18nKey =
|
||||
| "deadlines.de.inf.olg"
|
||||
| "deadlines.de.null.bgh"
|
||||
| "deadlines.de.null.bpatg"
|
||||
| "deadlines.detail.all_options"
|
||||
| "deadlines.detail.aufnehmen"
|
||||
| "deadlines.detail.back"
|
||||
| "deadlines.detail.cancel"
|
||||
| "deadlines.detail.complete"
|
||||
@@ -1393,24 +1234,18 @@ export type I18nKey =
|
||||
| "deadlines.detail.delete.confirm.title"
|
||||
| "deadlines.detail.due"
|
||||
| "deadlines.detail.edit"
|
||||
| "deadlines.detail.entfernen"
|
||||
| "deadlines.detail.label"
|
||||
| "deadlines.detail.loading"
|
||||
| "deadlines.detail.mandatory_only"
|
||||
| "deadlines.detail.notes"
|
||||
| "deadlines.detail.notfound"
|
||||
| "deadlines.detail.optional_unselected_hint"
|
||||
| "deadlines.detail.reopen"
|
||||
| "deadlines.detail.rule"
|
||||
| "deadlines.detail.save"
|
||||
| "deadlines.detail.selected"
|
||||
| "deadlines.detail.source"
|
||||
| "deadlines.detail.title"
|
||||
| "deadlines.dpma"
|
||||
| "deadlines.dpma.appeal.bgh"
|
||||
| "deadlines.dpma.appeal.bpatg"
|
||||
| "deadlines.dpma.opp.dpma"
|
||||
| "deadlines.durations.show"
|
||||
| "deadlines.empty.filtered"
|
||||
| "deadlines.empty.hint"
|
||||
| "deadlines.empty.title"
|
||||
@@ -1516,72 +1351,6 @@ export type I18nKey =
|
||||
| "deadlines.neu.title"
|
||||
| "deadlines.notes.show"
|
||||
| "deadlines.optional.badge"
|
||||
| "deadlines.overhaul.condition.badge"
|
||||
| "deadlines.overhaul.crossparty.badge"
|
||||
| "deadlines.overhaul.crossparty.tooltip"
|
||||
| "deadlines.overhaul.edit_date.label"
|
||||
| "deadlines.overhaul.edit_date.title"
|
||||
| "deadlines.overhaul.empty"
|
||||
| "deadlines.overhaul.followups.label"
|
||||
| "deadlines.overhaul.footer.count"
|
||||
| "deadlines.overhaul.footer.cta"
|
||||
| "deadlines.overhaul.group.conditional"
|
||||
| "deadlines.overhaul.group.mandatory"
|
||||
| "deadlines.overhaul.group.optional"
|
||||
| "deadlines.overhaul.group.recommended"
|
||||
| "deadlines.overhaul.kind.decision"
|
||||
| "deadlines.overhaul.kind.filing"
|
||||
| "deadlines.overhaul.kind.hearing"
|
||||
| "deadlines.overhaul.kind.missed"
|
||||
| "deadlines.overhaul.kind.order"
|
||||
| "deadlines.overhaul.load_error"
|
||||
| "deadlines.overhaul.loading"
|
||||
| "deadlines.overhaul.modea.axis.forum"
|
||||
| "deadlines.overhaul.modea.axis.inbox"
|
||||
| "deadlines.overhaul.modea.axis.kind"
|
||||
| "deadlines.overhaul.modea.axis.party"
|
||||
| "deadlines.overhaul.modea.axis.proc"
|
||||
| "deadlines.overhaul.modea.chip.all"
|
||||
| "deadlines.overhaul.modea.filters.heading"
|
||||
| "deadlines.overhaul.modea.filters.label"
|
||||
| "deadlines.overhaul.modea.inbox.postal"
|
||||
| "deadlines.overhaul.modea.inbox.summary"
|
||||
| "deadlines.overhaul.modea.loading"
|
||||
| "deadlines.overhaul.modea.no_proceedings"
|
||||
| "deadlines.overhaul.modea.no_results"
|
||||
| "deadlines.overhaul.modea.results.count"
|
||||
| "deadlines.overhaul.modea.results.heading"
|
||||
| "deadlines.overhaul.modea.results.label"
|
||||
| "deadlines.overhaul.modea.row.followups"
|
||||
| "deadlines.overhaul.modea.search.label"
|
||||
| "deadlines.overhaul.modea.search.placeholder"
|
||||
| "deadlines.overhaul.modea.search_error"
|
||||
| "deadlines.overhaul.modes.label"
|
||||
| "deadlines.overhaul.modes.search"
|
||||
| "deadlines.overhaul.modes.wizard"
|
||||
| "deadlines.overhaul.notes.summary"
|
||||
| "deadlines.overhaul.nudge.no_project"
|
||||
| "deadlines.overhaul.select_rule"
|
||||
| "deadlines.overhaul.spawn.badge"
|
||||
| "deadlines.overhaul.spawn.tooltip"
|
||||
| "deadlines.overhaul.trigger.date"
|
||||
| "deadlines.overhaul.trigger.label"
|
||||
| "deadlines.overhaul.wizard.anno.from_project"
|
||||
| "deadlines.overhaul.wizard.anno.implicit"
|
||||
| "deadlines.overhaul.wizard.badge.filter"
|
||||
| "deadlines.overhaul.wizard.badge.qualifier"
|
||||
| "deadlines.overhaul.wizard.coming_soon"
|
||||
| "deadlines.overhaul.wizard.edit"
|
||||
| "deadlines.overhaul.wizard.heading"
|
||||
| "deadlines.overhaul.wizard.hint"
|
||||
| "deadlines.overhaul.wizard.r1.label"
|
||||
| "deadlines.overhaul.wizard.r2.label"
|
||||
| "deadlines.overhaul.wizard.r3.empty"
|
||||
| "deadlines.overhaul.wizard.r3.label"
|
||||
| "deadlines.overhaul.wizard.r4.empty"
|
||||
| "deadlines.overhaul.wizard.r4.label"
|
||||
| "deadlines.overhaul.wizard.r5.label"
|
||||
| "deadlines.overhaul.wizard.r5.probing"
|
||||
| "deadlines.party.both"
|
||||
| "deadlines.party.both.label"
|
||||
| "deadlines.party.claimant"
|
||||
@@ -1665,13 +1434,10 @@ export type I18nKey =
|
||||
| "deadlines.search.placeholder"
|
||||
| "deadlines.search.results.count"
|
||||
| "deadlines.search.results.count_one"
|
||||
| "deadlines.side.both"
|
||||
| "deadlines.side.claimant"
|
||||
| "deadlines.side.defendant"
|
||||
| "deadlines.side.from_project"
|
||||
| "deadlines.side.hint"
|
||||
| "deadlines.side.label"
|
||||
| "deadlines.side.override"
|
||||
| "deadlines.side.undefined"
|
||||
| "deadlines.source.caldav"
|
||||
| "deadlines.source.fristenrechner"
|
||||
| "deadlines.source.imported"
|
||||
@@ -1702,7 +1468,6 @@ export type I18nKey =
|
||||
| "deadlines.step2.happened.desc"
|
||||
| "deadlines.step2.happened.title"
|
||||
| "deadlines.step2.heading"
|
||||
| "deadlines.step2.perspective"
|
||||
| "deadlines.step3"
|
||||
| "deadlines.step3a.back"
|
||||
| "deadlines.step3a.draft.desc"
|
||||
@@ -1729,7 +1494,6 @@ export type I18nKey =
|
||||
| "deadlines.upc.apl.cost"
|
||||
| "deadlines.upc.apl.merits"
|
||||
| "deadlines.upc.apl.order"
|
||||
| "deadlines.upc.apl.unified"
|
||||
| "deadlines.upc.ccr.cfi"
|
||||
| "deadlines.upc.disc.cfi"
|
||||
| "deadlines.upc.dmgs.cfi"
|
||||
@@ -1840,7 +1604,6 @@ export type I18nKey =
|
||||
| "event.title.appointment_deleted"
|
||||
| "event.title.appointment_project_changed"
|
||||
| "event.title.appointment_updated"
|
||||
| "event.title.approval_decided"
|
||||
| "event.title.checklist_created"
|
||||
| "event.title.checklist_deleted"
|
||||
| "event.title.checklist_linked"
|
||||
@@ -1859,7 +1622,6 @@ export type I18nKey =
|
||||
| "event.title.deadline_reopened"
|
||||
| "event.title.deadline_updated"
|
||||
| "event.title.deadlines_imported"
|
||||
| "event.title.member_role_changed"
|
||||
| "event.title.note_created"
|
||||
| "event.title.our_side_changed"
|
||||
| "event.title.project_archived"
|
||||
@@ -2193,6 +1955,7 @@ export type I18nKey =
|
||||
| "nav.admin.paliadin"
|
||||
| "nav.admin.partner_units"
|
||||
| "nav.admin.rules"
|
||||
| "nav.admin.rules_export"
|
||||
| "nav.admin.team"
|
||||
| "nav.agenda"
|
||||
| "nav.akten"
|
||||
@@ -2203,6 +1966,7 @@ export type I18nKey =
|
||||
| "nav.downloads"
|
||||
| "nav.einstellungen"
|
||||
| "nav.fristen"
|
||||
| "nav.fristenrechner"
|
||||
| "nav.gebuehrentabellen"
|
||||
| "nav.gerichte"
|
||||
| "nav.glossar"
|
||||
@@ -2219,13 +1983,13 @@ export type I18nKey =
|
||||
| "nav.logout"
|
||||
| "nav.neuigkeiten"
|
||||
| "nav.paliadin"
|
||||
| "nav.procedures"
|
||||
| "nav.projekte"
|
||||
| "nav.soon.tooltip"
|
||||
| "nav.submissions"
|
||||
| "nav.team"
|
||||
| "nav.termine"
|
||||
| "nav.user_views.new"
|
||||
| "nav.verfahrensablauf"
|
||||
| "notes.cancel"
|
||||
| "notes.delete"
|
||||
| "notes.delete.confirm"
|
||||
@@ -2335,19 +2099,6 @@ export type I18nKey =
|
||||
| "partner_unit.members_label"
|
||||
| "partner_unit.none"
|
||||
| "partner_unit.subtitle"
|
||||
| "procedures.filter.axis.forum"
|
||||
| "procedures.filter.axis.kind"
|
||||
| "procedures.filter.axis.party"
|
||||
| "procedures.filter.axis.proc"
|
||||
| "procedures.filter.search.placeholder"
|
||||
| "procedures.heading"
|
||||
| "procedures.panel.akte.placeholder"
|
||||
| "procedures.subtitle"
|
||||
| "procedures.tab.akte"
|
||||
| "procedures.tab.proceeding"
|
||||
| "procedures.tab.search"
|
||||
| "procedures.tab.wizard"
|
||||
| "procedures.title"
|
||||
| "project.instance_level.appeal"
|
||||
| "project.instance_level.cassation"
|
||||
| "project.instance_level.first"
|
||||
@@ -2833,28 +2584,15 @@ export type I18nKey =
|
||||
| "search.no_results"
|
||||
| "search.placeholder"
|
||||
| "sidebar.resize.title"
|
||||
| "state.hidden.tooltip"
|
||||
| "state.optional.tooltip"
|
||||
| "submissions.draft.action.delete"
|
||||
| "submissions.draft.action.export"
|
||||
| "submissions.draft.action.new"
|
||||
| "submissions.draft.back"
|
||||
| "submissions.draft.base.hint"
|
||||
| "submissions.draft.base.label"
|
||||
| "submissions.draft.import.button"
|
||||
| "submissions.draft.language"
|
||||
| "submissions.draft.language.de"
|
||||
| "submissions.draft.language.en"
|
||||
| "submissions.draft.language.fallback_notice"
|
||||
| "submissions.draft.loading"
|
||||
| "submissions.draft.name.placeholder"
|
||||
| "submissions.draft.notfound"
|
||||
| "submissions.draft.parties.hint"
|
||||
| "submissions.draft.parties.title"
|
||||
| "submissions.draft.preview.hint"
|
||||
| "submissions.draft.preview.title"
|
||||
| "submissions.draft.sections.hint"
|
||||
| "submissions.draft.sections.title"
|
||||
| "submissions.draft.switcher.label"
|
||||
| "submissions.draft.title"
|
||||
| "submissions.index.action.new"
|
||||
@@ -2941,24 +2679,15 @@ export type I18nKey =
|
||||
| "team.selection.toggle_card"
|
||||
| "team.subtitle"
|
||||
| "team.title"
|
||||
| "templates.authoring.heading"
|
||||
| "templates.authoring.intro"
|
||||
| "templates.authoring.list.title"
|
||||
| "templates.authoring.slots.title"
|
||||
| "templates.authoring.title"
|
||||
| "templates.authoring.upload.file"
|
||||
| "templates.authoring.upload.firm"
|
||||
| "templates.authoring.upload.name_de"
|
||||
| "templates.authoring.upload.name_en"
|
||||
| "templates.authoring.upload.submit"
|
||||
| "templates.authoring.upload.title"
|
||||
| "templates.authoring.workspace.hint"
|
||||
| "theme.toggle.auto"
|
||||
| "theme.toggle.cycle.auto"
|
||||
| "theme.toggle.cycle.dark"
|
||||
| "theme.toggle.cycle.light"
|
||||
| "theme.toggle.dark"
|
||||
| "theme.toggle.light"
|
||||
| "tools.verfahrensablauf.heading"
|
||||
| "tools.verfahrensablauf.subtitle"
|
||||
| "tools.verfahrensablauf.title"
|
||||
| "unit_role.attorney"
|
||||
| "unit_role.lead"
|
||||
| "unit_role.pa"
|
||||
|
||||
@@ -74,7 +74,7 @@ export function renderIndex(): string {
|
||||
<p data-i18n="index.cost.desc">Schätzung der Verfahrenskosten für DE-Gerichte, UPC und EPA-Verfahren. Gerichts- und Anwaltskosten auf einen Blick.</p>
|
||||
</a>
|
||||
|
||||
<a href="/tools/procedures" className="card card-link">
|
||||
<a href="/tools/fristenrechner" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_CLOCK }} />
|
||||
<h2 data-i18n="index.deadline.title">Fristenrechner</h2>
|
||||
<p data-i18n="index.deadline.desc">Berechnung von Verfahrensfristen für UPC-, deutsche und EPA-Verfahren mit Feiertags-Anpassung.</p>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// docforge-editor — the variable catalogue client.
|
||||
//
|
||||
// The catalogue (key + bilingual label + namespace group) is served by the
|
||||
// Go backend at GET /api/docforge/variables, built from the resolvers'
|
||||
// Keys() as the single source of truth. A consumer fetches it once and uses
|
||||
// labelMap() to label its sidebar form + authoring palette, instead of
|
||||
// hard-coding a parallel label table that can drift from the resolvers.
|
||||
|
||||
export interface VariableEntry {
|
||||
key: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface VariablesResponse {
|
||||
variables: VariableEntry[];
|
||||
}
|
||||
|
||||
// fetchVariableCatalogue loads the catalogue from the backend. Throws on a
|
||||
// non-2xx response so the caller can decide how to degrade.
|
||||
export async function fetchVariableCatalogue(): Promise<VariableEntry[]> {
|
||||
const res = await fetch("/api/docforge/variables", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`docforge variables: HTTP ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as VariablesResponse;
|
||||
return body.variables ?? [];
|
||||
}
|
||||
|
||||
// labelMap turns a catalogue into a key → {de, en} lookup for a label
|
||||
// function. Keys absent from the map fall back to the raw key at the call
|
||||
// site, so a failed fetch degrades to dotted-key labels rather than a
|
||||
// broken form.
|
||||
export function labelMap(catalogue: VariableEntry[]): Record<string, { de: string; en: string }> {
|
||||
const out: Record<string, { de: string; en: string }> = {};
|
||||
for (const e of catalogue) {
|
||||
out[e.key] = { de: e.label_de, en: e.label_en };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { escapeHtml, cssEscape } from "./dom";
|
||||
|
||||
test("escapeHtml escapes the five HTML-significant characters", () => {
|
||||
expect(escapeHtml(`<a href="x" title='y'>& z</a>`)).toBe(
|
||||
"<a href="x" title='y'>& z</a>",
|
||||
);
|
||||
});
|
||||
|
||||
test("escapeHtml is a no-op on plain text", () => {
|
||||
expect(escapeHtml("Aktenzeichen 4c O 12/23")).toBe("Aktenzeichen 4c O 12/23");
|
||||
});
|
||||
|
||||
test("escapeHtml escapes & first to avoid double-encoding", () => {
|
||||
expect(escapeHtml("<")).toBe("&lt;");
|
||||
});
|
||||
|
||||
test("cssEscape backslash-escapes the dots in a placeholder key", () => {
|
||||
// Both CSS.escape and the regex fallback escape '.' the same way, so the
|
||||
// result is stable across environments (bun has no CSS global → fallback).
|
||||
expect(cssEscape("project.case_number")).toBe("project\\.case_number");
|
||||
});
|
||||
|
||||
test("cssEscape leaves identifier-safe characters untouched", () => {
|
||||
expect(cssEscape("today")).toBe("today");
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
// docforge-editor — shared, framework-agnostic editor utilities.
|
||||
//
|
||||
// Slice 5 of the docforge train (t-paliad-349 / m/paliad#157) begins
|
||||
// extracting the generic editor plumbing out of the submission-specific
|
||||
// client bundle so a second consumer (and the slice-6 authoring page) can
|
||||
// reuse it. This module holds the pure DOM-string helpers — no DOM
|
||||
// mutation, no editor state — so they unit-test cleanly under bun.
|
||||
|
||||
// escapeHtml escapes the five HTML-significant characters for safe
|
||||
// insertion into element text or an attribute value. Matches the
|
||||
// server-side emitTextWithDraftVars/htmlEscape contract so preview markup
|
||||
// round-trips identically.
|
||||
export function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// cssEscape escapes a string for use inside a CSS attribute selector
|
||||
// (e.g. `[data-var="${cssEscape(key)}"]`). Prefers the native CSS.escape
|
||||
// and falls back to escaping CSS-special characters for older runtimes.
|
||||
// Placeholder keys ([A-Za-z][A-Za-z0-9_.]*) never carry whitespace or
|
||||
// quotes, so the fallback is straightforward.
|
||||
export function cssEscape(s: string): string {
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return CSS.escape(s);
|
||||
}
|
||||
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /tools/procedures — Litigation Builder (m/paliad#153 PRD §3).
|
||||
//
|
||||
// Replaces cronus's 4-tab catalog (U0-U4) with a persistence-backed
|
||||
// builder shell. Server-rendered chrome is minimal — the page-header
|
||||
// scenario picker, side panel, and canvas are all hydrated by
|
||||
// `builder.ts` at boot. The builder loads scenarios from
|
||||
// /api/builder/scenarios (B0 surface, t-paliad-340) and renders the
|
||||
// per-proceeding triplets with the existing verfahrensablauf-core calc.
|
||||
//
|
||||
// B1 — Builder shell + cold-open mode + single triplet end-to-end.
|
||||
// B2 — Multi-triplet stack + spawn nesting + per-event state machine.
|
||||
// B3+ — event-triggered + Akte modes, sharing, promotion (head-gated).
|
||||
|
||||
export function renderProcedures(): string {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="procedures.title">Verfahren & Fristen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-procedures page-builder">
|
||||
<Sidebar currentPath="/tools/procedures" />
|
||||
<BottomNav currentPath="/tools/procedures" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page builder-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="procedures.heading">Verfahren & Fristen</h1>
|
||||
<p className="tool-subtitle" data-i18n="builder.subtitle">
|
||||
Litigation Builder — Szenarien bauen, Verfahren stapeln, Fristen behalten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Page header (PRD §3.1): scenario picker · save state · name · share · promote
|
||||
· Akte picker · Stichtag input. B1 wires the scenario picker
|
||||
+ name action + Stichtag + save indicator. Akte / share /
|
||||
promote land at B4 / B5; the affordances render disabled in
|
||||
B1 so the layout is stable across slices. */}
|
||||
<section className="builder-pageheader" aria-label="Builder-Steuerung">
|
||||
<div className="builder-pageheader-row">
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.scenario">Szenario:</span>
|
||||
<select id="builder-scenario-picker" className="builder-scenario-picker" aria-label="Szenario wählen"></select>
|
||||
</label>
|
||||
<span id="builder-save-status" className="builder-save-status" aria-live="polite" data-state="idle">
|
||||
<span data-i18n="builder.save.idle"> </span>
|
||||
</span>
|
||||
<span className="builder-pageheader-spacer"></span>
|
||||
<button type="button" id="builder-rename-btn"
|
||||
className="builder-action-btn builder-action-btn--secondary"
|
||||
disabled
|
||||
data-i18n="builder.action.rename">Benennen</button>
|
||||
<button type="button" id="builder-share-btn"
|
||||
className="builder-action-btn builder-action-btn--secondary"
|
||||
disabled
|
||||
data-i18n="builder.action.share">Teilen</button>
|
||||
<button type="button" id="builder-promote-btn"
|
||||
className="builder-action-btn builder-action-btn--primary"
|
||||
disabled
|
||||
data-i18n="builder.action.promote">Als Projekt anlegen</button>
|
||||
</div>
|
||||
<div className="builder-pageheader-row">
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.akte">Akte:</span>
|
||||
<select id="builder-akte-picker" className="builder-akte-picker" disabled aria-label="Akte wählen">
|
||||
<option value="" data-i18n="builder.akte.none">— ohne —</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.stichtag">Stichtag:</span>
|
||||
<input type="date" id="builder-stichtag-input" className="builder-stichtag-input"
|
||||
defaultValue={today} aria-label="Stichtag" />
|
||||
</label>
|
||||
<label className="builder-pageheader-field builder-pageheader-field--grow">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.search">Suche:</span>
|
||||
<input type="search" id="builder-search-input" className="builder-search-input"
|
||||
data-i18n-placeholder="builder.search.placeholder"
|
||||
placeholder="Ereignis, Szenario, Akte …"
|
||||
autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Entry-mode radio (PRD §0.2, §2). B1 ships cold-open active;
|
||||
event-triggered + akte ship at B3 / B4 and are disabled
|
||||
here so the layout stays stable across slices. */}
|
||||
<nav className="builder-modebar" role="tablist" aria-label="Einstieg">
|
||||
<button type="button"
|
||||
className="builder-mode is-active"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
data-mode="cold"
|
||||
id="builder-mode-cold">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.cold">Übersicht</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="builder-mode"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-mode="event"
|
||||
id="builder-mode-event">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.event">Ereignis</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="builder-mode"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-mode="akte"
|
||||
id="builder-mode-akte">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.akte">Aus Akte</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Two-column body: side panel (left, scenarios list) + canvas (right). */}
|
||||
<div className="builder-body">
|
||||
<aside className="builder-sidepanel" aria-label="Meine Szenarien">
|
||||
<header className="builder-sidepanel-header">
|
||||
<h2 className="builder-sidepanel-title" data-i18n="builder.panel.title">Meine Szenarien</h2>
|
||||
<button type="button" id="builder-new-scenario-btn"
|
||||
className="builder-sidepanel-newbtn"
|
||||
data-i18n="builder.panel.new">+ Neues Szenario</button>
|
||||
</header>
|
||||
<div className="builder-sidepanel-bucket" data-bucket="active">
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.active">Aktiv</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-active" aria-label="Aktive Szenarien"></ul>
|
||||
</div>
|
||||
{/* B5 — Geteilt mit mir / Als Projekt angelegt / Archiviert.
|
||||
Each bucket hides itself when empty (builder.ts toggles
|
||||
the hidden attribute). */}
|
||||
<div className="builder-sidepanel-bucket" data-bucket="shared" id="builder-bucket-shared" hidden>
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.shared">Geteilt mit mir</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-shared" aria-label="Mit mir geteilte Szenarien"></ul>
|
||||
</div>
|
||||
<div className="builder-sidepanel-bucket" data-bucket="promoted" id="builder-bucket-promoted" hidden>
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.promoted">Als Projekt angelegt</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-promoted" aria-label="Promotete Szenarien"></ul>
|
||||
</div>
|
||||
<div className="builder-sidepanel-bucket" data-bucket="archived" id="builder-bucket-archived" hidden>
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.archived">Archiviert</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-archived" aria-label="Archivierte Szenarien"></ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="builder-canvas-wrap" aria-label="Builder-Canvas">
|
||||
{/* B5 — read-only watermark for shared / promoted scenarios.
|
||||
builder.ts fills + unhides it when the active scenario
|
||||
is not editable by the current user. */}
|
||||
<div id="builder-readonly-watermark" className="builder-readonly-watermark" hidden></div>
|
||||
<div id="builder-canvas" className="builder-canvas">
|
||||
{/* Cold-open placeholder — replaced by triplet stack once a
|
||||
scenario is loaded. */}
|
||||
<div className="builder-empty" id="builder-empty">
|
||||
<p className="builder-empty-headline" data-i18n="builder.empty.headline">
|
||||
Noch kein Szenario geöffnet.
|
||||
</p>
|
||||
<p className="builder-empty-hint" data-i18n="builder.empty.hint">
|
||||
Starte ein neues Szenario, wähle aus deiner Liste oder übernimm eine Akte (B4).
|
||||
</p>
|
||||
<button type="button" id="builder-cta-new" className="builder-cta-new"
|
||||
data-i18n="builder.empty.cta">
|
||||
Neues Szenario starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/procedures.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -109,143 +109,11 @@ export function renderSubmissionDraft(): string {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-313 (m/paliad#141) Composer Slice A —
|
||||
base picker. Hydrated by client/submission-draft.ts
|
||||
once /api/submission-bases returns. Disabled
|
||||
for pre-Composer drafts (base_id NULL); switching
|
||||
autosaves the draft. */}
|
||||
<div
|
||||
className="submission-draft-base-row"
|
||||
id="submission-draft-base-row"
|
||||
style="display:none">
|
||||
<label htmlFor="submission-draft-base" data-i18n="submissions.draft.base.label">
|
||||
Vorlagenbasis
|
||||
</label>
|
||||
<select id="submission-draft-base" />
|
||||
<p
|
||||
className="submission-draft-base-hint"
|
||||
id="submission-draft-base-hint"
|
||||
data-i18n="submissions.draft.base.hint">
|
||||
Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-276 — output language toggle (DE/EN).
|
||||
Hydrated by client/submission-draft.ts; switching
|
||||
autosaves the draft and re-renders the preview. */}
|
||||
<div
|
||||
className="submission-draft-language-row"
|
||||
id="submission-draft-language-row"
|
||||
role="radiogroup"
|
||||
aria-labelledby="submission-draft-language-label">
|
||||
<span
|
||||
id="submission-draft-language-label"
|
||||
className="submission-draft-language-label"
|
||||
data-i18n="submissions.draft.language">
|
||||
Sprache
|
||||
</span>
|
||||
<label className="submission-draft-language-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="submission-draft-language"
|
||||
value="de"
|
||||
id="submission-draft-language-de"
|
||||
/>
|
||||
<span data-i18n="submissions.draft.language.de">DE</span>
|
||||
</label>
|
||||
<label className="submission-draft-language-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="submission-draft-language"
|
||||
value="en"
|
||||
id="submission-draft-language-en"
|
||||
/>
|
||||
<span data-i18n="submissions.draft.language.en">EN</span>
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
className="submission-draft-language-fallback"
|
||||
id="submission-draft-language-fallback"
|
||||
style="display:none"
|
||||
data-i18n="submissions.draft.language.fallback_notice">
|
||||
Fallback: universelles Skelett (keine sprachspezifische Vorlage).
|
||||
</p>
|
||||
|
||||
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
|
||||
|
||||
{/* t-paliad-277: "Aus Projekt importieren" + last-
|
||||
imported-at timestamp. Only visible when the
|
||||
draft has a project_id attached. */}
|
||||
<div
|
||||
id="submission-draft-import-row"
|
||||
className="submission-draft-import-row"
|
||||
style="display:none">
|
||||
<button
|
||||
type="button"
|
||||
id="submission-draft-import-btn"
|
||||
className="btn-small btn-secondary"
|
||||
data-i18n="submissions.draft.import.button">
|
||||
Aus Projekt importieren
|
||||
</button>
|
||||
<span
|
||||
id="submission-draft-import-stamp"
|
||||
className="submission-draft-import-stamp"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-277 / t-paliad-287: multi-select party
|
||||
picker plus per-side Add-Party affordance.
|
||||
Populated from view.available_parties; checkbox
|
||||
per party, grouped by role. Hidden when no
|
||||
project is attached; visible even on empty
|
||||
rosters so the lawyer can use Add Party to
|
||||
populate. */}
|
||||
<div
|
||||
id="submission-draft-parties"
|
||||
className="submission-draft-parties"
|
||||
style="display:none">
|
||||
<h3
|
||||
className="submission-draft-var-group-title"
|
||||
data-i18n="submissions.draft.parties.title">
|
||||
Parteien
|
||||
</h3>
|
||||
<p
|
||||
className="submission-draft-parties-hint"
|
||||
data-i18n="submissions.draft.parties.hint">
|
||||
Wählen Sie aus, welche Parteien im Schriftsatz genannt werden sollen.
|
||||
</p>
|
||||
<div
|
||||
id="submission-draft-parties-list"
|
||||
className="submission-draft-parties-list"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="submission-draft-variables" id="submission-draft-variables" />
|
||||
</aside>
|
||||
|
||||
{/* t-paliad-313 (m/paliad#141) Composer Slice A —
|
||||
read-only section list. Painted from
|
||||
view.sections. Empty/hidden for pre-Composer
|
||||
drafts where no rows have been seeded. Slice B
|
||||
turns these into in-place editable prose blocks. */}
|
||||
<section
|
||||
className="submission-draft-sections-wrap"
|
||||
id="submission-draft-sections-wrap"
|
||||
style="display:none">
|
||||
<header className="submission-draft-sections-header">
|
||||
<h2 data-i18n="submissions.draft.sections.title">Abschnitte</h2>
|
||||
<span
|
||||
className="submission-draft-sections-hint"
|
||||
data-i18n="submissions.draft.sections.hint">
|
||||
Inhalt pro Abschnitt — Autosave nach 500 ms. Letztes Layout in Word.
|
||||
</span>
|
||||
</header>
|
||||
<ol
|
||||
className="submission-draft-sections-list"
|
||||
id="submission-draft-sections-list"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Preview pane — read-only HTML render of the merged
|
||||
document body. Re-renders on autosave round-trip. */}
|
||||
<section className="submission-draft-preview-wrap">
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-349 docforge slice 6 — template authoring page at
|
||||
// /admin/templates.
|
||||
//
|
||||
// Admin uploads a base .docx, sees it rendered as run-addressable text,
|
||||
// selects a span + a variable from the palette to drop a {{slot}}, and the
|
||||
// result saves as a reusable docforge template. Pure shell:
|
||||
// client/templates-authoring.ts hydrates the list, upload form, preview,
|
||||
// palette, and slot list after load. The palette labels come from the Go
|
||||
// variable catalogue (GET /api/docforge/variables, the SSOT from slice 5).
|
||||
//
|
||||
// Design ref: docs/plans/prd-docforge-2026-05-29.md §2.1.
|
||||
|
||||
export function renderTemplatesAuthoring(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<PWAHead />
|
||||
<title data-i18n="templates.authoring.title">Vorlagen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-templates-authoring">
|
||||
<Sidebar currentPath="/admin" />
|
||||
<BottomNav currentPath="/admin" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page docforge-templates-page">
|
||||
<div className="container">
|
||||
<header className="docforge-templates-header">
|
||||
<h1 data-i18n="templates.authoring.heading">Vorlagen</h1>
|
||||
<p
|
||||
className="docforge-templates-intro"
|
||||
data-i18n="templates.authoring.intro">
|
||||
Lade eine Word-Vorlage hoch, markiere Stellen und setze Variablen ein.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Upload a new base .docx */}
|
||||
<section className="docforge-upload" id="docforge-upload">
|
||||
<h2 data-i18n="templates.authoring.upload.title">Neue Vorlage hochladen</h2>
|
||||
<form id="docforge-upload-form" className="entity-form">
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.file">Word-Datei (.docx)</span>
|
||||
<input type="file" name="file" accept=".docx,.dotx,.docm,.dotm" required />
|
||||
</label>
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.name_de">Name (DE)</span>
|
||||
<input type="text" name="name_de" className="entity-form-input" required />
|
||||
</label>
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.name_en">Name (EN)</span>
|
||||
<input type="text" name="name_en" className="entity-form-input" required />
|
||||
</label>
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.firm">Kanzlei (optional)</span>
|
||||
<input type="text" name="firm" className="entity-form-input" />
|
||||
</label>
|
||||
<button type="submit" className="btn-primary" data-i18n="templates.authoring.upload.submit">
|
||||
Hochladen
|
||||
</button>
|
||||
<span className="docforge-upload-status" id="docforge-upload-status" />
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Existing templates */}
|
||||
<section className="docforge-template-list-wrap">
|
||||
<h2 data-i18n="templates.authoring.list.title">Vorhandene Vorlagen</h2>
|
||||
<ul className="entity-table docforge-template-list" id="docforge-template-list" />
|
||||
</section>
|
||||
|
||||
{/* Authoring workspace — hidden until a template is opened. */}
|
||||
<section className="docforge-workspace" id="docforge-workspace" hidden>
|
||||
<header className="docforge-workspace-header">
|
||||
<h2 id="docforge-workspace-title" />
|
||||
<span className="docforge-workspace-hint" data-i18n="templates.authoring.workspace.hint">
|
||||
Text markieren, dann eine Variable wählen, um einen Platzhalter zu setzen.
|
||||
</span>
|
||||
<span className="docforge-workspace-status" id="docforge-workspace-status" />
|
||||
</header>
|
||||
<div className="docforge-workspace-grid">
|
||||
{/* Variable palette (left) — populated from the catalogue. */}
|
||||
<aside className="docforge-palette" id="docforge-palette" />
|
||||
{/* Run-addressable preview (center) — selection target. */}
|
||||
<div className="docforge-preview" id="docforge-preview" />
|
||||
{/* Placed slots (right). */}
|
||||
<aside className="docforge-slots">
|
||||
<h3 data-i18n="templates.authoring.slots.title">Platzhalter</h3>
|
||||
<ul className="docforge-slot-list" id="docforge-slot-list" />
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
|
||||
<script src="/assets/templates-authoring.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
309
frontend/src/verfahrensablauf.tsx
Normal file
309
frontend/src/verfahrensablauf.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Slice 1 (t-paliad-179) — the dedicated abstract-browse surface for
|
||||
// procedural shape. Same backend (POST /api/tools/fristenrechner) +
|
||||
// same renderer module (./client/views/verfahrensablauf-core) as
|
||||
// /tools/fristenrechner; this page strips the Step 1 Akte picker /
|
||||
// Step 2 cards / Pathway A wizard / Pathway B cascade / save modal,
|
||||
// leaving just: proceeding-type tile picker + trigger date + court
|
||||
// picker + result panel. Variant chips, lane view and compare arrive in
|
||||
// Slices 2-4.
|
||||
|
||||
interface ProceedingDef {
|
||||
code: string;
|
||||
i18nKey: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function proceedingBtn(p: ProceedingDef): string {
|
||||
return (
|
||||
<button type="button" className="proceeding-btn" data-code={p.code}>
|
||||
<strong data-i18n={p.i18nKey}>{p.name}</strong>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const UPC_TYPES: ProceedingDef[] = [
|
||||
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
|
||||
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
|
||||
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
|
||||
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
|
||||
{ code: "upc.apl.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" },
|
||||
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
|
||||
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
|
||||
{ code: "upc.apl.cost", i18nKey: "deadlines.upc.apl.cost", name: "Berufung Kosten" },
|
||||
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
|
||||
];
|
||||
|
||||
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
|
||||
// 2026-05-18 ask. Labels are parallel: <court> (<procedural role>),
|
||||
// so a user scanning the picker sees the instance-and-role at a glance
|
||||
// without one tile reading "Berufung OLG" and another "Nichtigkeits-
|
||||
// verfahren". Sub-group headers convey the type grouping. Combined-
|
||||
// timeline behaviour (LG→OLG→BGH as one calc) is filed as m/paliad#41.
|
||||
const DE_INF_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
|
||||
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
|
||||
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
|
||||
];
|
||||
|
||||
const DE_NULL_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
|
||||
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
|
||||
];
|
||||
|
||||
const EPA_TYPES: ProceedingDef[] = [
|
||||
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
|
||||
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
|
||||
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
|
||||
];
|
||||
|
||||
const DPMA_TYPES: ProceedingDef[] = [
|
||||
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
|
||||
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
|
||||
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
|
||||
];
|
||||
|
||||
export function renderVerfahrensablauf(): string {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="tools.verfahrensablauf.title">Verfahrensablauf — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-verfahrensablauf">
|
||||
<Sidebar currentPath="/tools/verfahrensablauf" />
|
||||
<BottomNav currentPath="/tools/verfahrensablauf" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="tools.verfahrensablauf.heading">Verfahrensablauf</h1>
|
||||
<p className="tool-subtitle" data-i18n="tools.verfahrensablauf.subtitle">
|
||||
Typischen Verfahrensablauf einsehen — Verfahrensart wählen, Datum optional setzen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Verfahrensart picker (single-tile mode — same DOM ids as
|
||||
/tools/fristenrechner so the shared renderer module and
|
||||
court-picker primitives bind without parameterisation). */}
|
||||
<div className="fristen-wizard" id="verfahrensablauf-wizard" data-mode="procedure">
|
||||
<div className="wizard-step" id="step-1">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">1</span>
|
||||
<span data-i18n="deadlines.step1">Verfahrensart wählen</span>
|
||||
</h3>
|
||||
|
||||
<div className="proceeding-group" data-forum="upc">
|
||||
<h4 data-i18n="deadlines.upc">UPC</h4>
|
||||
<div className="proceeding-btns">
|
||||
{UPC_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="de">
|
||||
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="epa">
|
||||
<h4 data-i18n="deadlines.epa">EPA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{EPA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="dpma">
|
||||
<h4 data-i18n="deadlines.dpma">DPMA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DPMA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
|
||||
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
|
||||
<strong className="proceeding-summary-name" id="proceeding-summary-name">—</strong>
|
||||
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
|
||||
data-i18n="deadlines.proceeding.reselect">
|
||||
Anderes Verfahren wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-2" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">2</span>
|
||||
<span data-i18n="deadlines.step2">Ausgangsdatum eingeben</span>
|
||||
</h3>
|
||||
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
{/* Read-only caption labelling the value <span>. Not a
|
||||
<label htmlFor> — m/paliad#60: <label for=…> must
|
||||
point at a labelable form control, never a span. */}
|
||||
<span className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</span>
|
||||
<span id="trigger-event" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
|
||||
<input type="date" id="trigger-date" className="date-input" value={today} />
|
||||
</div>
|
||||
<div className="date-field-row" id="court-picker-row" style="display:none">
|
||||
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
|
||||
<select id="court-picker" className="date-input"></select>
|
||||
</div>
|
||||
{/* Proceeding-specific flag rows — mirror /tools/fristenrechner
|
||||
so an abstract-browse user can model the same variants
|
||||
(CCR, Patentänderung, Verletzungswiderklage,
|
||||
Vorab-Einrede). Show/hide driven by selectedType in
|
||||
the client. */}
|
||||
<div className="date-field-row" id="ccr-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="ccr-flag" />
|
||||
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="inf-amend-flag" />
|
||||
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patentä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>
|
||||
</div>
|
||||
|
||||
{/* Perspective strip (t-paliad-250 / m/paliad#81). Side
|
||||
swaps the column LABELS so the user's own side is
|
||||
proactive (= "your filings"). Appellant collapses
|
||||
party=both rows to a single column when set — only
|
||||
relevant for role-swap proceedings (Appeal etc.);
|
||||
the row hides itself when the picked proceeding has
|
||||
no appellant axis (see hasAppellantAxis() in the
|
||||
client). Both selectors are URL-driven (?side= +
|
||||
?appellant=) so the perspective survives reload
|
||||
and is shareable. */}
|
||||
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
|
||||
<div className="verfahrensablauf-perspective-row" id="side-row">
|
||||
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="claimant" />
|
||||
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="defendant" />
|
||||
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="" checked />
|
||||
<span data-i18n="deadlines.side.both">Beide</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="claimant" />
|
||||
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="defendant" />
|
||||
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="" checked />
|
||||
<span data-i18n="deadlines.appellant.none">—</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-3" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">3</span>
|
||||
<span data-i18n="deadlines.step3">Ergebnis</span>
|
||||
</h3>
|
||||
|
||||
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
|
||||
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="columns" checked />
|
||||
<span data-i18n="deadlines.view.columns">Spalten</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
</div>
|
||||
|
||||
<div className="fristen-result-actions">
|
||||
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
|
||||
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 6 2 18 2 18 9"></polyline>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
|
||||
<rect x="6" y="14" width="12" height="8"></rect>
|
||||
</svg>
|
||||
<span data-i18n="deadlines.print">Drucken</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/verfahrensablauf.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -116,57 +116,6 @@ func TestMigrations_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrations_NoDuplicateSlot is a free-standing pre-flight check that
|
||||
// scanEmbeddedMigrations refuses to walk a tree where two *.up.sql files
|
||||
// claim the same NNN slot. This is the brunel-slot-collision class of
|
||||
// outage (m/paliad#114, 2026-05-25 ~13:20): a worker writes a migration
|
||||
// at slot N while another shipped slot N from a separate branch, both
|
||||
// merge, both end up in the embed.FS, and the runner refuses to start.
|
||||
//
|
||||
// Catching this at CI time (no DB needed) lets the second PR fail before
|
||||
// it merges, instead of breaking prod at the next deploy. Pure unit test;
|
||||
// runs even on developer laptops that don't set TEST_DATABASE_URL.
|
||||
func TestMigrations_NoDuplicateSlot(t *testing.T) {
|
||||
if _, err := scanEmbeddedMigrations(); err != nil {
|
||||
t.Fatalf("scanEmbeddedMigrations: %v "+
|
||||
"(two migrations share the same NNN slot — coordinate with head "+
|
||||
"and rename one of them before merging)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrations_EndToEndAsAppRole applies every embedded migration in
|
||||
// numeric order against a scratch DB connected as a NON-SUPERUSER role.
|
||||
// This is the prod-shape smoke that the per-mig BEGIN/ROLLBACK dry-run
|
||||
// (TestMigrations_DryRun) cannot deliver: the dry-run runs each
|
||||
// statement in isolation and rolls back, so it cannot reproduce the
|
||||
// mig-129-class outage (m/paliad#114, 2026-05-25 ~14:56 — pq: must be
|
||||
// owner of table project_event_choices, SQLSTATE 42501) where a
|
||||
// migration assumes ownership the deploy role doesn't have.
|
||||
//
|
||||
// Requires TEST_APP_DATABASE_URL — a Postgres URL whose role is NOT a
|
||||
// superuser and does NOT own the `paliad` schema (m's Q11.2 pick:
|
||||
// generic two-role model, see docs/design-cicd-pre-deploy-gate-2026-05-25.md
|
||||
// §6.2(a)). The CI workflow creates the role + schema split before
|
||||
// invoking the test; a developer who wants to reproduce the gate locally
|
||||
// runs the same SQL preamble (see Makefile target `verify-migrations`).
|
||||
//
|
||||
// Skipped without TEST_APP_DATABASE_URL — keeps `go test ./...` green
|
||||
// on machines that haven't set up the role split.
|
||||
func TestMigrations_EndToEndAsAppRole(t *testing.T) {
|
||||
url := os.Getenv("TEST_APP_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_APP_DATABASE_URL not set — skipping role-split end-to-end migration smoke")
|
||||
}
|
||||
if err := ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("ApplyMigrations as app role failed: %v "+
|
||||
"(a migration assumes more privilege than the deploy role has — "+
|
||||
"common cases: ALTER TABLE on a schema-owner table, CREATE EXTENSION "+
|
||||
"without grants, SET ROLE without permission. Fix the migration to "+
|
||||
"work as the deploy role, or arrange for the schema to be owned by "+
|
||||
"the deploy role)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// readAppliedVersions returns the set of versions present in
|
||||
// paliad.applied_migrations on the scratch DB. Missing table → empty set
|
||||
// (fresh-DB path; the table only exists after the runner has been called).
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
// Slice B.1 (t-paliad-273) — migration 136 backfill invariants.
|
||||
//
|
||||
// The dry-run gate (migrate_test.go: TestMigrations_DryRun) catches
|
||||
// migrations that crash on apply, but it rolls back inside its own
|
||||
// transaction — the post-state assertions in mig 136's PL/pgSQL block
|
||||
// run, but a future refactor of those assertions might forget a check
|
||||
// or introduce a silent count drift. This test layers a Go-side
|
||||
// invariant check on top so the contract is restated in test code,
|
||||
// outside the PL/pgSQL block, against the resulting tables.
|
||||
//
|
||||
// Skipped without TEST_DATABASE_URL, same pattern as
|
||||
// internal/services/submission_codes_shape_test.go.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// TestMigration136_BackfillInvariants applies every embedded migration
|
||||
// (which lands mig 136 along the way) and then asserts the four
|
||||
// invariants the B.1 design + B.0 findings nailed down:
|
||||
//
|
||||
// 1. procedural_events row count = (distinct submission_codes in
|
||||
// deadline_rules) + (deadline_rules with NULL submission_code).
|
||||
// Codes-bearing branch is 1:1 per the B.0 audit (no multi-row
|
||||
// codes since the _archived_litigation.* removal); the NULL
|
||||
// branch gets one synthetic procedural_event per rule.
|
||||
// 2. sequencing_rules row count = deadline_rules row count (1:1).
|
||||
// 3. legal_sources row count = distinct legal_source in
|
||||
// deadline_rules (NULL excluded).
|
||||
// 4. every sequencing_rules row's procedural_event_id resolves to a
|
||||
// procedural_events row (NOT NULL FK already enforces this at the
|
||||
// DB level — this test catches a future relaxation of the FK).
|
||||
// 5. no two synthetic codes collide (covered by the UNIQUE on
|
||||
// procedural_events.code; restated here for documentation).
|
||||
//
|
||||
// The test is robust against corpus size — it derives all expected
|
||||
// counts from the live deadline_rules state, so a scratch DB with 0
|
||||
// rules trivially passes, and a prod-shaped scratch DB exercises the
|
||||
// real invariants.
|
||||
func TestMigration136_BackfillInvariants(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping mig 136 invariant test")
|
||||
}
|
||||
if err := ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
|
||||
conn, err := sql.Open("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
var (
|
||||
drTotal, drCodesDistinct, drCodesNull, drLegalDistinct int
|
||||
peTotal, srTotal, lsTotal int
|
||||
orphanPE, dupSynthetic int
|
||||
)
|
||||
|
||||
mustQ := func(label, q string, dst *int) {
|
||||
t.Helper()
|
||||
if err := conn.QueryRowContext(ctx, q).Scan(dst); err != nil {
|
||||
t.Fatalf("%s: %v", label, err)
|
||||
}
|
||||
}
|
||||
|
||||
mustQ("dr_total", `SELECT COUNT(*) FROM paliad.deadline_rules`, &drTotal)
|
||||
mustQ("dr_codes_distinct",
|
||||
`SELECT COUNT(DISTINCT submission_code) FROM paliad.deadline_rules WHERE submission_code IS NOT NULL`,
|
||||
&drCodesDistinct)
|
||||
mustQ("dr_codes_null",
|
||||
`SELECT COUNT(*) FROM paliad.deadline_rules WHERE submission_code IS NULL`,
|
||||
&drCodesNull)
|
||||
mustQ("dr_legal_distinct",
|
||||
`SELECT COUNT(DISTINCT legal_source) FROM paliad.deadline_rules WHERE legal_source IS NOT NULL`,
|
||||
&drLegalDistinct)
|
||||
mustQ("pe_total", `SELECT COUNT(*) FROM paliad.procedural_events`, &peTotal)
|
||||
mustQ("sr_total", `SELECT COUNT(*) FROM paliad.sequencing_rules`, &srTotal)
|
||||
mustQ("ls_total", `SELECT COUNT(*) FROM paliad.legal_sources`, &lsTotal)
|
||||
|
||||
// Invariant 1: procedural_events = distinct_codes + null_codes
|
||||
wantPE := drCodesDistinct + drCodesNull
|
||||
if peTotal != wantPE {
|
||||
t.Errorf("procedural_events count mismatch: got %d, want %d (distinct codes=%d + null-code rules=%d)",
|
||||
peTotal, wantPE, drCodesDistinct, drCodesNull)
|
||||
}
|
||||
|
||||
// Invariant 2: sequencing_rules 1:1 with deadline_rules
|
||||
if srTotal != drTotal {
|
||||
t.Errorf("sequencing_rules count mismatch: got %d, want %d (1:1 with deadline_rules)",
|
||||
srTotal, drTotal)
|
||||
}
|
||||
|
||||
// Invariant 3: legal_sources = distinct legal_source
|
||||
if lsTotal != drLegalDistinct {
|
||||
t.Errorf("legal_sources count mismatch: got %d, want %d (distinct legal_source)",
|
||||
lsTotal, drLegalDistinct)
|
||||
}
|
||||
|
||||
// Invariant 4: every sequencing_rules.procedural_event_id resolves
|
||||
mustQ("orphan_pe", `
|
||||
SELECT COUNT(*)
|
||||
FROM paliad.sequencing_rules sr
|
||||
LEFT JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE pe.id IS NULL`, &orphanPE)
|
||||
if orphanPE != 0 {
|
||||
t.Errorf("FK integrity violated: %d sequencing_rules row(s) have no resolving procedural_event_id", orphanPE)
|
||||
}
|
||||
|
||||
// Invariant 5: no duplicate synthetic codes
|
||||
mustQ("dup_synthetic", `
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT code FROM paliad.procedural_events
|
||||
WHERE code LIKE 'null.%'
|
||||
GROUP BY code
|
||||
HAVING COUNT(*) > 1
|
||||
) d`, &dupSynthetic)
|
||||
if dupSynthetic != 0 {
|
||||
t.Errorf("synthetic code uniqueness violated: %d duplicate(s) under 'null.%%' prefix", dupSynthetic)
|
||||
}
|
||||
|
||||
t.Logf("mig 136 invariants OK: deadline_rules=%d, procedural_events=%d (=%d+%d), "+
|
||||
"sequencing_rules=%d, legal_sources=%d (distinct legal_source=%d)",
|
||||
drTotal, peTotal, drCodesDistinct, drCodesNull, srTotal, lsTotal, drLegalDistinct)
|
||||
}
|
||||
@@ -26,24 +26,24 @@ DO $$ BEGIN ALTER TABLE paliad.department_members RENAME COLUMN dezernat_id TO d
|
||||
-- Constraints (primary key + foreign keys + check). Renaming a pkey
|
||||
-- constraint also renames the underlying index of the same name.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_pkey TO departments_pkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_lead_user_id_fkey TO departments_lead_user_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_office_check TO departments_office_check; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_pkey TO department_members_pkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_dezernat_id_fkey TO department_members_department_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_user_id_fkey TO department_members_user_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_pkey TO departments_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_lead_user_id_fkey TO departments_lead_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_office_check TO departments_office_check; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_pkey TO department_members_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_dezernat_id_fkey TO department_members_department_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_user_id_fkey TO department_members_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Standalone indexes (non-pkey).
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernate_office_idx RENAME TO departments_office_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernate_lead_idx RENAME TO departments_lead_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernat_mitglieder_user_idx RENAME TO department_members_user_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernate_office_idx RENAME TO departments_office_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernate_lead_idx RENAME TO departments_lead_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernat_mitglieder_user_idx RENAME TO department_members_user_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- RLS policies
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER POLICY dezernate_select ON paliad.departments RENAME TO departments_select; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernate_write ON paliad.departments RENAME TO departments_write; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernat_mitglieder_select ON paliad.department_members RENAME TO department_members_select; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernat_mitglieder_write ON paliad.department_members RENAME TO department_members_write; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernate_select ON paliad.departments RENAME TO departments_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernate_write ON paliad.departments RENAME TO departments_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernat_mitglieder_select ON paliad.department_members RENAME TO department_members_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernat_mitglieder_write ON paliad.department_members RENAME TO department_members_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
@@ -63,27 +63,27 @@ ALTER TABLE paliad.partner_unit_members RENAME COLUMN department_id TO partner_u
|
||||
-- 5. Rename constraints. Postgres auto-renames the underlying index for
|
||||
-- pkey/uniq constraints; standalone indexes are renamed in step 6.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_pkey TO partner_units_pkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_lead_user_id_fkey TO partner_units_lead_user_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_office_check TO partner_units_office_check; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_pkey TO partner_unit_members_pkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_department_id_fkey TO partner_unit_members_partner_unit_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_user_id_fkey TO partner_unit_members_user_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_pkey TO partner_units_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_lead_user_id_fkey TO partner_units_lead_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_office_check TO partner_units_office_check; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_pkey TO partner_unit_members_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_department_id_fkey TO partner_unit_members_partner_unit_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_user_id_fkey TO partner_unit_members_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 6. Rename non-pkey indexes.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER INDEX paliad.departments_office_idx RENAME TO partner_units_office_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.departments_lead_idx RENAME TO partner_units_lead_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.department_members_user_idx RENAME TO partner_unit_members_user_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.departments_office_idx RENAME TO partner_units_office_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.departments_lead_idx RENAME TO partner_units_lead_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.department_members_user_idx RENAME TO partner_unit_members_user_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 7. Rename RLS policies.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER POLICY departments_select ON paliad.partner_units RENAME TO partner_units_select; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY departments_write ON paliad.partner_units RENAME TO partner_units_write; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY department_members_select ON paliad.partner_unit_members RENAME TO partner_unit_members_select; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY department_members_write ON paliad.partner_unit_members RENAME TO partner_unit_members_write; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY departments_select ON paliad.partner_units RENAME TO partner_units_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY departments_write ON paliad.partner_units RENAME TO partner_units_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY department_members_select ON paliad.partner_unit_members RENAME TO partner_unit_members_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY department_members_write ON paliad.partner_unit_members RENAME TO partner_unit_members_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 8. Audit table for partner-unit events. Mutations on partner_units +
|
||||
|
||||
@@ -55,6 +55,8 @@ INSERT INTO paliad.deadline_rules (
|
||||
name_en,
|
||||
primary_party,
|
||||
event_type,
|
||||
is_mandatory,
|
||||
is_optional,
|
||||
is_court_set,
|
||||
is_spawn,
|
||||
duration_value,
|
||||
@@ -68,6 +70,7 @@ INSERT INTO paliad.deadline_rules (
|
||||
deadline_notes_en,
|
||||
legal_source,
|
||||
condition_expr,
|
||||
condition_flag,
|
||||
sequence_order,
|
||||
is_active,
|
||||
priority,
|
||||
@@ -87,6 +90,8 @@ SELECT
|
||||
'Petition for re-establishment of rights (UPC R.320)',
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
2,
|
||||
@@ -100,6 +105,7 @@ SELECT
|
||||
'Period is 2 months from removal of the obstacle (UPC R.320 RoP). Latest 12 months after expiry of the missed deadline.',
|
||||
'UPC.RoP.320',
|
||||
NULL::jsonb,
|
||||
NULL::text[],
|
||||
1207,
|
||||
true,
|
||||
'mandatory',
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Revert t-paliad-271 Wave 2 Tier-3 Slice A — drop duration_unit /
|
||||
-- alt_duration_unit CHECK constraints. Pre-mig-128 the columns accepted
|
||||
-- arbitrary text, so dropping the CHECKs restores that shape exactly.
|
||||
-- No data revert necessary — the constraint addition was purely
|
||||
-- additive and validated against live data before adding.
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_duration_unit_check;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_alt_duration_unit_check;
|
||||
@@ -1,36 +0,0 @@
|
||||
-- t-paliad-271 Wave 2 Tier-3 Slice A — duration_unit CHECK constraint with
|
||||
-- 'working_days' added to the allowed set.
|
||||
--
|
||||
-- Per docs/research-deadlines-completeness-2026-05-25.md Tier 3 Primitive 1
|
||||
-- (T3.1) — the calculator gains a business-day arithmetic path for UPC RoP
|
||||
-- R.198 / R.213 (and downstream for any rule that needs the 31d-OR-20wd
|
||||
-- combine-max pattern). The schema currently accepts free-text on
|
||||
-- duration_unit (no CHECK), which is why 'working_days' rows already exist
|
||||
-- in the DB but were silently dropped by the calculator. Adding the CHECK
|
||||
-- pins the contract and prevents typos.
|
||||
--
|
||||
-- alt_duration_unit gets the same constraint (NULL-tolerant) so the alt
|
||||
-- path stays in lockstep with the primary path.
|
||||
--
|
||||
-- Idempotent: DROP CONSTRAINT IF EXISTS before ADD. Existing data was
|
||||
-- audited via `SELECT DISTINCT duration_unit FROM paliad.deadline_rules`
|
||||
-- on 2026-05-25 (returned only days/weeks/months) plus the two live
|
||||
-- alt-unit rows already at 'working_days' — both shapes pass.
|
||||
--
|
||||
-- audit_reason set_config is NOT needed for DDL (mig 079 trigger fires on
|
||||
-- INSERT/UPDATE/DELETE on the rows, not on ALTER TABLE).
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_duration_unit_check;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_duration_unit_check
|
||||
CHECK (duration_unit IN ('days', 'weeks', 'months', 'working_days'));
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_alt_duration_unit_check;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_alt_duration_unit_check
|
||||
CHECK (alt_duration_unit IS NULL
|
||||
OR alt_duration_unit IN ('days', 'weeks', 'months', 'working_days'));
|
||||
@@ -1,11 +0,0 @@
|
||||
-- t-paliad-265 — drop per-event-card choices schema.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 129 down: drop project_event_choices + deadline_rules.choices_offered',
|
||||
true);
|
||||
|
||||
DROP TABLE IF EXISTS paliad.project_event_choices;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP COLUMN IF EXISTS choices_offered;
|
||||
@@ -1,116 +0,0 @@
|
||||
-- t-paliad-265 / m/paliad#96 — per-event-card optional choices on the
|
||||
-- Verfahrensablauf timeline.
|
||||
--
|
||||
-- Design: docs/design-event-card-choices-2026-05-25.md
|
||||
-- Decisions: see §11 of the design doc.
|
||||
--
|
||||
-- Two schema changes:
|
||||
--
|
||||
-- 1. paliad.project_event_choices — new persistence table holding the
|
||||
-- user's per-card picks scoped to a project. One row per
|
||||
-- (project, submission_code, choice_kind). Re-picking is an UPDATE
|
||||
-- (UNIQUE constraint enforces idempotence).
|
||||
--
|
||||
-- 2. paliad.deadline_rules.choices_offered jsonb — opt-in declaration
|
||||
-- of which choice-kinds each rule offers. The projection engine
|
||||
-- reads this to decide whether to render the caret affordance on
|
||||
-- a card. Seeded for every event_type='decision' rule (appellant),
|
||||
-- every priority='optional' rule (skip), and the two Klageerwiderung
|
||||
-- rows (include_ccr).
|
||||
--
|
||||
-- NOTE on join key: the design doc named the join column "rule_code".
|
||||
-- Live verification (2026-05-25 SELECT against paliad.deadline_rules)
|
||||
-- showed `rule_code` is NULL on every decision row — it's the legal-
|
||||
-- source citation column, not a stable identifier. The
|
||||
-- AnchorOverrides plumbing in internal/services/fristenrechner.go
|
||||
-- already keys on `submission_code` (UIDeadline.Code populates from
|
||||
-- submission_code, lines 351-352), so we mirror that decision here:
|
||||
-- the join column is `submission_code`. Same intent, correct field.
|
||||
--
|
||||
-- Idempotent: CREATE TABLE IF NOT EXISTS + ADD COLUMN IF NOT EXISTS +
|
||||
-- UPDATEs guarded by WHERE choices_offered IS NULL so re-applying
|
||||
-- against an already-seeded DB no-ops.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 129: add paliad.project_event_choices + deadline_rules.choices_offered for per-event-card optional choices (t-paliad-265 / m/paliad#96)',
|
||||
true);
|
||||
|
||||
-- 1. The choice-storage table ----------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.project_event_choices (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
submission_code text NOT NULL,
|
||||
choice_kind text NOT NULL CHECK (choice_kind IN ('appellant', 'include_ccr', 'skip')),
|
||||
choice_value text NOT NULL,
|
||||
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (project_id, submission_code, choice_kind)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS project_event_choices_project_idx
|
||||
ON paliad.project_event_choices (project_id);
|
||||
|
||||
ALTER TABLE paliad.project_event_choices ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS project_event_choices_select ON paliad.project_event_choices;
|
||||
CREATE POLICY project_event_choices_select ON paliad.project_event_choices
|
||||
FOR SELECT USING (paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS project_event_choices_mutate ON paliad.project_event_choices;
|
||||
CREATE POLICY project_event_choices_mutate ON paliad.project_event_choices
|
||||
FOR ALL
|
||||
USING (paliad.can_see_project(project_id))
|
||||
WITH CHECK (paliad.can_see_project(project_id));
|
||||
|
||||
COMMENT ON TABLE paliad.project_event_choices IS
|
||||
'Per-event-card user picks scoped to a project. choice_kind ∈ {appellant, include_ccr, skip}. '
|
||||
'choice_value namespace per kind: appellant=claimant|defendant|both|none; include_ccr=true|false; '
|
||||
'skip=true|false. Join key submission_code matches paliad.deadline_rules.submission_code (the same key '
|
||||
'AnchorOverrides uses). UNIQUE(project,submission_code,kind) keeps re-picks idempotent. '
|
||||
'Audit-logged via paliad.system_audit_log (event_type=project_event_choice.set).';
|
||||
|
||||
-- 2. The choices_offered opt-in column ------------------------------------
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN IF NOT EXISTS choices_offered jsonb;
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.choices_offered IS
|
||||
'Declares which per-card choice-kinds this rule offers on the Verfahrensablauf timeline. '
|
||||
'NULL = no caret affordance (default). Example shapes: '
|
||||
'{"appellant": ["claimant","defendant","both","none"]} on decision rules, '
|
||||
'{"skip": [true, false]} on optional rules, '
|
||||
'{"include_ccr": [true, false]} on Klageerwiderung rules. '
|
||||
'Engine and frontend read it; storing per-kind value lists keeps the contract self-describing.';
|
||||
|
||||
-- 3. Seed -----------------------------------------------------------------
|
||||
|
||||
-- 3a. Every published decision rule offers the appellant choice.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET choices_offered = '{"appellant": ["claimant", "defendant", "both", "none"]}'::jsonb
|
||||
WHERE event_type = 'decision'
|
||||
AND lifecycle_state = 'published'
|
||||
AND choices_offered IS NULL;
|
||||
|
||||
-- 3b. Every published optional rule offers the skip choice.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET choices_offered = '{"skip": [true, false]}'::jsonb
|
||||
WHERE priority = 'optional'
|
||||
AND lifecycle_state = 'published'
|
||||
AND choices_offered IS NULL;
|
||||
|
||||
-- 3c. Klageerwiderung rules offer the include_ccr choice. Two rows
|
||||
-- today (upc.inf.cfi.sod + de.inf.lg.erwidg) — verified live
|
||||
-- (2026-05-25 SELECT FROM paliad.deadline_rules WHERE name ILIKE
|
||||
-- 'Klageerwiderung'); the UPC INF Klageerwiderung is `sod` (Statement
|
||||
-- of Defence, R.24 RoP), not `def`. Slice B (Q4 bundle) is the
|
||||
-- user-visible feature.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET choices_offered = '{"include_ccr": [true, false]}'::jsonb
|
||||
WHERE submission_code IN ('upc.inf.cfi.sod', 'de.inf.lg.erwidg')
|
||||
AND lifecycle_state = 'published'
|
||||
AND choices_offered IS NULL;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
DROP COLUMN IF EXISTS language;
|
||||
@@ -1,17 +0,0 @@
|
||||
-- t-paliad-276 / m/paliad#108: per-draft output language for the
|
||||
-- Submissions generator.
|
||||
--
|
||||
-- The submission editor lets the lawyer pick DE or EN per draft so the
|
||||
-- generator selects the matching template variant + resolves language-
|
||||
-- aware variables ({{procedural_event.name_de}} vs _en). Default is
|
||||
-- 'de' to match the primary-language convention in CLAUDE.md and to
|
||||
-- keep existing rows behaving exactly as before (every legacy draft
|
||||
-- was implicitly DE; the resolved bag for those drafts is unchanged
|
||||
-- under language='de').
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ADD COLUMN IF NOT EXISTS language text NOT NULL DEFAULT 'de'
|
||||
CONSTRAINT submission_drafts_language_check CHECK (language IN ('de', 'en'));
|
||||
|
||||
COMMENT ON COLUMN paliad.submission_drafts.language IS
|
||||
't-paliad-276: output language for the generated .docx. ''de'' or ''en''. Drives template variant selection ({code}.{lang}.docx fallback chain) and language-aware variable resolution.';
|
||||
@@ -1,4 +0,0 @@
|
||||
-- t-paliad-277 rollback.
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
DROP COLUMN IF EXISTS selected_parties,
|
||||
DROP COLUMN IF EXISTS last_imported_at;
|
||||
@@ -1,32 +0,0 @@
|
||||
-- t-paliad-277 / m/paliad#109: per-draft party selection + import provenance.
|
||||
--
|
||||
-- Adds two columns to paliad.submission_drafts:
|
||||
--
|
||||
-- selected_parties uuid[] — IDs of paliad.parties rows the lawyer
|
||||
-- has chosen to mention in this specific submission. An empty
|
||||
-- array (the default) means "include every party on the project"
|
||||
-- so all existing drafts keep their current rendering. Non-empty
|
||||
-- restricts the variable bag to the chosen subset, grouped by
|
||||
-- role in SubmissionVarsService.
|
||||
--
|
||||
-- last_imported_at timestamptz — when the lawyer last clicked
|
||||
-- "Aus Projekt importieren" on the draft editor (or NULL if they
|
||||
-- never did). The frontend surfaces this timestamp next to the
|
||||
-- button so a stale draft is obvious at a glance.
|
||||
--
|
||||
-- Both columns are purely additive and nullable / default-bearing —
|
||||
-- the migration is safe to apply with active drafts in the table.
|
||||
-- No FK on selected_parties: paliad.parties is project-scoped and we
|
||||
-- prune stale references on read inside SubmissionVarsService rather
|
||||
-- than chasing FK cascades across two tables (the variable bag silently
|
||||
-- drops any uuid that no longer matches a row in paliad.parties).
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ADD COLUMN IF NOT EXISTS selected_parties uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
||||
ADD COLUMN IF NOT EXISTS last_imported_at timestamptz;
|
||||
|
||||
COMMENT ON COLUMN paliad.submission_drafts.selected_parties IS
|
||||
't-paliad-277: party IDs (paliad.parties) the lawyer has chosen to mention in this submission. Empty array = include every party on the project (backward-compat default). Non-empty = restrict to subset, grouped by role.';
|
||||
|
||||
COMMENT ON COLUMN paliad.submission_drafts.last_imported_at IS
|
||||
't-paliad-277: timestamp of the last "Aus Projekt importieren" click — surfaced next to the button so the lawyer can see staleness at a glance. NULL = never imported.';
|
||||
@@ -1,76 +0,0 @@
|
||||
-- Rollback of mig 132 (t-paliad-284 Wave 1 + m/paliad#116).
|
||||
--
|
||||
-- Reverses §0 (R.104/R.105 citation backfill) + §1..§11 (11 Tier 1
|
||||
-- INSERTs) + §12 (T1.12 re-anchor of upc.pi.cfi.response).
|
||||
--
|
||||
-- Does NOT reverse §13b (Q6 archived-litigation cleanup) — those rows
|
||||
-- were already in lifecycle_state='archived' before deletion and are not
|
||||
-- surfaced by any product code path. Restoring them would require the
|
||||
-- pre-mig-132 backup. Leaving them gone is the correct rollback choice;
|
||||
-- emergency restore goes via mig 123 backup snapshot.
|
||||
--
|
||||
-- DOES restore §13a (re-add the deadline_rule_audit.rule_id FK) so the
|
||||
-- audit-table schema returns to its pre-mig-132 shape on rollback. Any
|
||||
-- orphan audit rows accumulated under mig 132 (rule_id pointing at
|
||||
-- now-deleted rules) would block the FK re-add; the rollback DELETE
|
||||
-- below removes them first.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 132 down: rollback Wave 1 Tier 1 rule additions + R.105 citation backfill + T1.12 re-anchor (t-paliad-284 / m/paliad#116)',
|
||||
true);
|
||||
|
||||
-- §12 down — un-re-anchor upc.pi.cfi.response back to its broken root state.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = NULL,
|
||||
is_court_set = false,
|
||||
rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.pi.cfi.response'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = true
|
||||
AND rule_code = 'RoP.211.2';
|
||||
|
||||
-- §1..§11 down — delete the 11 Tier 1 INSERTs by submission_code.
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE submission_code IN (
|
||||
'upc.inf.cfi.cmo_review',
|
||||
'upc.inf.cfi.confidentiality_response',
|
||||
'upc.apl.order.response_orders', -- delete child first (FK to grounds_orders)
|
||||
'upc.apl.order.grounds_orders',
|
||||
'upc.inf.cfi.cons_orders',
|
||||
'upc.inf.cfi.rectification',
|
||||
'upc.pi.cfi.deficiency',
|
||||
'upc.pi.cfi.merits_start',
|
||||
'upc.inf.cfi.translation_request',
|
||||
'upc.inf.cfi.interpreter_cost',
|
||||
'upc.inf.cfi.translations_lodge'
|
||||
)
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- §0 down — clear the R.104/R.105 citation on upc.inf.cfi.interim.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
rule_codes = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.inf.cfi.interim'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.104'
|
||||
AND legal_source = 'UPC.RoP.104';
|
||||
|
||||
-- §13a down — re-add the deadline_rule_audit.rule_id FK with the
|
||||
-- original ON DELETE CASCADE shape. Purge any orphan audit rows first
|
||||
-- (audit entries pointing at rule_ids that no longer exist in
|
||||
-- deadline_rules) so the FK re-add doesn't fail validation.
|
||||
DELETE FROM paliad.deadline_rule_audit a
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules dr WHERE dr.id = a.rule_id
|
||||
);
|
||||
|
||||
ALTER TABLE paliad.deadline_rule_audit
|
||||
ADD CONSTRAINT deadline_rule_audit_rule_id_fkey
|
||||
FOREIGN KEY (rule_id) REFERENCES paliad.deadline_rules(id) ON DELETE CASCADE;
|
||||
@@ -1,659 +0,0 @@
|
||||
-- t-paliad-284 Wave 1 + m/paliad#116 — Tier 1 deadline-rule additions
|
||||
-- (12 high-frequency procedural events) + UPC RoP R.104/R.105 Interim
|
||||
-- Conference citation backfill + Q6 archived-litigation cleanup.
|
||||
--
|
||||
-- Source: docs/research-deadlines-completeness-2026-05-25.md
|
||||
-- • §10 Tier 1 table (T1.1 .. T1.12)
|
||||
-- • §3.1 missing-rules catalogue (per-rule statutory citations)
|
||||
-- • §9.7 / Q6 (drop the _archived_litigation.* rows — m's design ack
|
||||
-- locked in 2026-05-25)
|
||||
--
|
||||
-- m's report (2026-05-25 17:12) also explicitly named "Zwischenverfahren /
|
||||
-- Interim Conference 105" as missing a rule citation. The audit does not
|
||||
-- list R.105 as a Tier 1 item (the row upc.inf.cfi.interim already exists
|
||||
-- as a court-set anchor), so the fix is to BACKFILL rule_code/legal_source
|
||||
-- on that row rather than to insert a new rule. Done here as a separate
|
||||
-- §0 section, with both RoP.104 (Aims of the interim conference) and
|
||||
-- RoP.105 (Holding of the interim conference) cited via rule_codes[].
|
||||
--
|
||||
-- Wave 2 Slice A primitives (mig 128: working_days unit + combine_op +
|
||||
-- timing='before' backward snap in deadline_calculator.go) are used by:
|
||||
-- • T1.8 upc.pi.cfi.merits_start — 31d OR 20wd, combine_op=max
|
||||
-- • T1.9 upc.inf.cfi.translation_request — 1 month BEFORE oral hearing
|
||||
-- • T1.10 upc.inf.cfi.interpreter_cost — 2 weeks BEFORE oral hearing
|
||||
-- Wave 2 Slice A landed mig 128 (`deadline_rules_unit_check`) — these
|
||||
-- rules are no longer blocked.
|
||||
--
|
||||
-- Slot 132 reserved: 127 brunel Wave 0, 128 knuth W2-A, 129 demeter,
|
||||
-- 130 atlas, 131 artemis → 132 this migration.
|
||||
--
|
||||
-- Idempotency:
|
||||
-- • INSERTs guarded with `WHERE NOT EXISTS (... submission_code = ...)`
|
||||
-- so re-applying matches zero rows on the second run.
|
||||
-- • UPDATEs guarded with `WHERE` clauses that match the pre-fix row
|
||||
-- state only (mig 095 convention).
|
||||
-- • DELETE guarded by lifecycle_state='archived' AND prefix — repeats
|
||||
-- match zero rows after first run.
|
||||
--
|
||||
-- audit_reason set_config is required at the top (mig 079 trigger on
|
||||
-- paliad.deadline_rules raises EXCEPTION 'audit reason required' for
|
||||
-- any INSERT / UPDATE / DELETE without it).
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 132: t-paliad-284 Wave 1 + m/paliad#116 — Tier 1 deadline-rule additions (12 rules) from curie''s audit §10 + UPC RoP R.104/105 Interim Conference citation backfill (m''s 2026-05-25 17:12 report) + Q6 archived-litigation cleanup (audit §9.7)',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- §0 R.104/R.105 — Backfill citation on the existing Interim Conference row.
|
||||
-- m's report flagged that `upc.inf.cfi.interim` (Zwischenverfahren) renders
|
||||
-- with no rule reference at /admin/rules. The row exists as a court-set
|
||||
-- anchor (duration=0, parent_id=NULL, primary_party='court'). The
|
||||
-- governing UPC Rules of Procedure are:
|
||||
-- • R.104 — Aims of the interim conference (the substantive rule)
|
||||
-- • R.105 — Holding of the interim conference (procedural)
|
||||
-- Both cited via the rule_codes[] array; rule_code/legal_source carry
|
||||
-- the primary citation (R.104 — Aims).
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.104',
|
||||
legal_source = 'UPC.RoP.104',
|
||||
rule_codes = ARRAY['RoP.104', 'RoP.105'],
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.inf.cfi.interim'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code IS NULL
|
||||
AND legal_source IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- §1 T1.1 upc.inf.cfi.cmo_review — Review of case-management order.
|
||||
-- 15 days from CMO service. UPC RoP R.333.2: "Any party adversely
|
||||
-- affected by a case management order may within 15 days of service
|
||||
-- of the order apply to the panel for a review." Routine in busy LDs
|
||||
-- (Munich CMO traffic ~weekly). Anchor: the Interim Conference row,
|
||||
-- which is where CMOs are typically issued.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 8, -- upc.inf.cfi
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.interim'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.cmo_review',
|
||||
'Überprüfung Verfahrensanordnung',
|
||||
'Review of Case-Management Order',
|
||||
'both', 15, 'days', 'after', 'RoP.333.2', 'UPC.RoP.333.2',
|
||||
'optional', false, 'published', true, 42,
|
||||
'Frist 15 Tage ab Zustellung der Verfahrensanordnung (R.333.2). Jede beschwerte Partei kann beim Spruchkörper Überprüfung beantragen.',
|
||||
'15-day period from service of the case-management order (R.333.2). Any adversely-affected party may apply to the panel for a review.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.cmo_review'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §2 T1.2 upc.inf.cfi.confidentiality_response — Response to opposing
|
||||
-- party's confidentiality application. 14 days from receipt of the
|
||||
-- opposing party's R.262.2 application: "Within 14 days of service
|
||||
-- … the other party may lodge an Application to the contrary."
|
||||
-- Trigger event 25 (paliad.trigger_events) maps 1:1 to this rule.
|
||||
-- Daily occurrence in HLC infringement work. Anchor: Statement of
|
||||
-- Claim row as proceeding root — actual trigger date supplied via
|
||||
-- 'Datum setzen' when the opp party files, since the confidentiality
|
||||
-- app is not itself modelled as a deadline_rules row.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, trigger_event_id, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.soc'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.confidentiality_response',
|
||||
'Erwiderung auf Vertraulichkeitsantrag',
|
||||
'Response to Confidentiality Application',
|
||||
'both', 14, 'days', 'after', 'RoP.262.2', 'UPC.RoP.262.2',
|
||||
'optional', false, 'published', true, 8,
|
||||
25,
|
||||
'Frist 14 Tage ab Zustellung des Vertraulichkeitsantrags der Gegenseite (R.262.2). Datum bei Eingang des Antrags manuell setzen.',
|
||||
'14-day period from service of the opposing party''s confidentiality application (R.262.2). Set trigger date manually on receipt of the application.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.confidentiality_response'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §3 T1.3 upc.apl.order.grounds_orders — Statement of Grounds on the
|
||||
-- orders-track appeal. 15 days from service of the appealed
|
||||
-- order/decision. UPC RoP R.224.2(b): "A Statement of grounds of
|
||||
-- appeal shall be lodged … within 15 days of service of the
|
||||
-- decision/order in cases referred to in Rule 220.1(c), Rule 220.2
|
||||
-- and Rule 221.3." Existing upc.apl.order tree has the with_leave
|
||||
-- notice but no separate grounds row — adding it.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 20, -- upc.apl.order
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.order.order'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.apl.order.grounds_orders',
|
||||
'Berufungsbegründung (Orders Track)',
|
||||
'Statement of Grounds (Orders Track)',
|
||||
'both', 15, 'days', 'after', 'RoP.224.2.b', 'UPC.RoP.224.2.b',
|
||||
'mandatory', false, 'published', true, 2,
|
||||
'Frist 15 Tage ab Zustellung der angegriffenen Anordnung/Entscheidung (R.224.2(b)).',
|
||||
'15-day period from service of the appealed order/decision (R.224.2(b)).'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.order.grounds_orders'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §4 T1.4 upc.apl.order.response_orders — Statement of Response on the
|
||||
-- orders-track appeal. 15 days from service of the grounds. UPC RoP
|
||||
-- R.235.2: "Within 15 days of service of grounds of appeal pursuant
|
||||
-- to Rule 224.2(b), any other party … may lodge a Statement of
|
||||
-- response." Parent: the grounds_orders row inserted in §3, looked
|
||||
-- up by submission_code so this INSERT works either against a fresh
|
||||
-- DB or a partially-applied state.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 20,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.order.grounds_orders'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.apl.order.response_orders',
|
||||
'Berufungserwiderung (Orders Track)',
|
||||
'Statement of Response (Orders Track)',
|
||||
'both', 15, 'days', 'after', 'RoP.235.2', 'UPC.RoP.235.2',
|
||||
'optional', false, 'published', true, 3,
|
||||
'Frist 15 Tage ab Zustellung der Berufungsbegründung (R.235.2).',
|
||||
'15-day period from service of the Statement of grounds of appeal (R.235.2).'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.order.response_orders'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §5 T1.5 upc.inf.cfi.cons_orders — Application for orders consequential
|
||||
-- on validity. 2 months from service of the validity decision. UPC
|
||||
-- RoP R.118.4: "The Court may, upon a reasoned request by one of
|
||||
-- the parties, … give a decision granting consequential orders.
|
||||
-- The application … shall be made within two months of service of
|
||||
-- the decision …". Common after central-division revocation in
|
||||
-- bifurcated UPC matters.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.decision'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.cons_orders',
|
||||
'Antrag auf Folgeentscheidungen',
|
||||
'Application for Consequential Orders',
|
||||
'both', 2, 'months', 'after', 'RoP.118.4', 'UPC.RoP.118.4',
|
||||
'optional', false, 'published', true, 60,
|
||||
'Frist 2 Monate ab Zustellung der Validitätsentscheidung (R.118.4). Antrag auf Folgeentscheidungen (z.B. nach Zentralkammer-Nichtigerklärung).',
|
||||
'2-month period from service of the validity decision (R.118.4). Application for orders consequential on validity (e.g. after central-division revocation).'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.cons_orders'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §6 T1.6 upc.inf.cfi.rectification — Application for rectification of a
|
||||
-- decision/order. 1 month from delivery of the decision. UPC RoP
|
||||
-- R.353: "Clerical mistakes, errors arising from any accidental
|
||||
-- slip or omission and obvious errors in a decision or order of
|
||||
-- the Court may be corrected by the Court of its own motion or on
|
||||
-- the application of a party. The application shall be made within
|
||||
-- one month of the decision or order being notified."
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.decision'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.rectification',
|
||||
'Antrag auf Berichtigung',
|
||||
'Application for Rectification',
|
||||
'both', 1, 'months', 'after', 'RoP.353', 'UPC.RoP.353',
|
||||
'optional', false, 'published', true, 70,
|
||||
'Frist 1 Monat ab Zustellung der Entscheidung/Anordnung (R.353). Berichtigung von Schreib-, Rechen- oder ähnlichen Versehen.',
|
||||
'1-month period from notification of the decision/order (R.353). Rectification of clerical mistakes, accidental slips or obvious errors.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.rectification'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §7 T1.7 upc.pi.cfi.deficiency — Cure of PI-application deficiency.
|
||||
-- 14 days from notification of the deficiency. UPC RoP R.207.6(a):
|
||||
-- "The Registry shall as soon as practicable examine the
|
||||
-- Application … and notify any deficiencies to the applicant. The
|
||||
-- applicant shall be invited to correct the deficiencies … within
|
||||
-- 14 days." Failure to cure leads to deemed-withdrawal.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 10, -- upc.pi.cfi
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.app'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.pi.cfi.deficiency',
|
||||
'Mängelbeseitigung Antrag',
|
||||
'Cure of Application Deficiency',
|
||||
'claimant', 14, 'days', 'after', 'RoP.207.6.a', 'UPC.RoP.207.6.a',
|
||||
'mandatory', false, 'published', true, 2,
|
||||
'Frist 14 Tage ab Mängelmitteilung durch die Geschäftsstelle (R.207.6(a)). Bei Nichtbehebung gilt der Antrag als zurückgenommen.',
|
||||
'14-day period from notification of deficiency by the Registry (R.207.6(a)). Failure to cure leads to deemed withdrawal of the application.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.deficiency'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §8 T1.8 upc.pi.cfi.merits_start — Start proceedings on the merits.
|
||||
-- 31 calendar days OR 20 working days, whichever is the longer,
|
||||
-- from grant of the PI. UPC RoP R.213.1 → R.198.1: "the applicant
|
||||
-- shall start proceedings leading to a decision on the merits of
|
||||
-- the case … within a period not exceeding 31 calendar days or
|
||||
-- 20 working days, whichever is the longer." Combine-max wiring
|
||||
-- via Wave 2 Slice A primitives (mig 128: working_days unit +
|
||||
-- combine_op). Failure to commence on time → PI lapses (R.213.2).
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
combine_op, timing, rule_code, legal_source, priority,
|
||||
is_court_set, lifecycle_state, is_active, sequence_order,
|
||||
deadline_notes, deadline_notes_en)
|
||||
SELECT 10,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.order'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.pi.cfi.merits_start',
|
||||
'Klage in der Hauptsache erheben',
|
||||
'Start Proceedings on the Merits',
|
||||
'claimant', 31, 'days',
|
||||
20, 'working_days', 'RoP.198.1',
|
||||
'max', 'after', 'RoP.213', 'UPC.RoP.213',
|
||||
'mandatory', false, 'published', true, 3,
|
||||
'Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung der einstweiligen Maßnahme (R.213 i.V.m. R.198.1). Bei Versäumnis erlischt die einstweilige Maßnahme.',
|
||||
'31 calendar days OR 20 working days, whichever is the longer, from grant of the provisional measure (R.213 referring to R.198.1). Failure to commence within the period causes the provisional measure to lapse.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.merits_start'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §9 T1.9 upc.inf.cfi.translation_request — Request for simultaneous
|
||||
-- translation at the oral hearing. 1 month BEFORE the oral hearing.
|
||||
-- UPC RoP R.109.1: "A party requiring simultaneous interpretation
|
||||
-- of the oral hearing into a language other than the language of
|
||||
-- proceedings shall, no later than one month before the date of
|
||||
-- the oral hearing, lodge a request with the Court." timing='before'
|
||||
-- uses the backward-snap path in deadline_calculator.go (mig 128).
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.oral'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.translation_request',
|
||||
'Antrag auf Simultanübersetzung',
|
||||
'Request for Simultaneous Translation',
|
||||
'both', 1, 'months', 'before', 'RoP.109.1', 'UPC.RoP.109.1',
|
||||
'optional', false, 'published', true, 45,
|
||||
'Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag auf Simultanübersetzung in eine andere Sprache als die Verfahrenssprache.',
|
||||
'1 month BEFORE the oral hearing (R.109.1). Request for simultaneous interpretation into a language other than the language of proceedings.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.translation_request'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §10 T1.10 upc.inf.cfi.interpreter_cost — Notification of interpreter
|
||||
-- cost-bearing. 2 weeks BEFORE the oral hearing. UPC RoP R.109.4:
|
||||
-- "Where … the party which made the request for interpretation is
|
||||
-- not the party who has chosen the language of the proceedings,
|
||||
-- the costs of the interpretation … shall be borne by the
|
||||
-- requesting party, unless the Court orders otherwise. The party
|
||||
-- shall be notified at least two weeks before the oral hearing."
|
||||
-- timing='before' as in §9.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.oral'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.interpreter_cost',
|
||||
'Mitteilung Dolmetscherkosten',
|
||||
'Notification of Interpreter Costs',
|
||||
'court', 2, 'weeks', 'before', 'RoP.109.4', 'UPC.RoP.109.4',
|
||||
'mandatory', false, 'published', true, 46,
|
||||
'Frist 2 Wochen VOR der mündlichen Verhandlung (R.109.4). Mitteilung, dass die antragstellende Partei die Dolmetscherkosten zu tragen hat.',
|
||||
'2 weeks BEFORE the oral hearing (R.109.4). Notification to the requesting party that it shall bear the interpreter costs.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.interpreter_cost'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §11 T1.11 upc.inf.cfi.translations_lodge — Lodging of translations on
|
||||
-- judge-rapporteur order. 2 weeks AFTER the JR's order. UPC RoP
|
||||
-- R.109.5: "If the judge-rapporteur orders, the parties shall lodge
|
||||
-- a translation of any pleading or other document into the language
|
||||
-- of the proceedings within two weeks." trigger_event_id=113 maps
|
||||
-- to the JR translation order. Anchor: Interim Conference row, where
|
||||
-- such JR orders are typically issued.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, trigger_event_id, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.interim'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.translations_lodge',
|
||||
'Übersetzungen einreichen',
|
||||
'Lodging of Translations',
|
||||
'both', 2, 'weeks', 'after', 'RoP.109.5', 'UPC.RoP.109.5',
|
||||
'mandatory', false, 'published', true, 47,
|
||||
113,
|
||||
'Frist 2 Wochen ab Anordnung des Berichterstatters, Übersetzungen einzureichen (R.109.5).',
|
||||
'2-week period from the judge-rapporteur''s order to lodge translations (R.109.5).'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.translations_lodge'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §12 T1.12 upc.pi.cfi.response — RE-ANCHOR of the existing PI Response
|
||||
-- row. Currently broken: parent_id=NULL with is_court_set=false and
|
||||
-- duration=0 makes the calculator treat this as a root anchor. UPC
|
||||
-- RoP R.211.2 — judge sets the inter-partes hearing date and the
|
||||
-- deadline for the response. Fix: set is_court_set=true and chain
|
||||
-- parent_id on upc.pi.cfi.app (the proceeding root). Duration
|
||||
-- remains 0 (court-set placeholder); the lawyer fills in the actual
|
||||
-- date via 'Datum setzen'.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = (SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.app'
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
LIMIT 1),
|
||||
is_court_set = true,
|
||||
rule_code = 'RoP.211.2',
|
||||
legal_source = 'UPC.RoP.211.2',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.pi.cfi.response'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND parent_id IS NULL
|
||||
AND is_court_set = false;
|
||||
|
||||
-- =============================================================================
|
||||
-- §13a Pre-requisite for §13b — drop the deadline_rule_audit.rule_id FK.
|
||||
-- The audit trigger (mig 079) tries to INSERT an audit row on AFTER
|
||||
-- DELETE pointing at OLD.id, but the existing FK constraint
|
||||
-- `deadline_rule_audit_rule_id_fkey` (FOREIGN KEY rule_id REFERENCES
|
||||
-- paliad.deadline_rules(id) ON DELETE CASCADE) makes that INSERT fail
|
||||
-- because by the time the trigger fires the parent row is gone. As a
|
||||
-- result no DELETE on paliad.deadline_rules has ever succeeded in
|
||||
-- production (`SELECT count(*) FROM paliad.deadline_rule_audit
|
||||
-- WHERE action='delete'` returns 0). The trigger's DELETE branch was
|
||||
-- dead code.
|
||||
--
|
||||
-- Standard audit-table design: the audit log is append-only history
|
||||
-- and should NOT FK-constrain on the live entity table — before_json
|
||||
-- captures the full row state at the time of the change, which is
|
||||
-- all the audit trail needs. Dropping the FK fixes the latent bug
|
||||
-- and unblocks legitimate cleanup work (here: §13b, plus any future
|
||||
-- hard-delete migrations against deadline_rules).
|
||||
--
|
||||
-- Idempotent: DROP CONSTRAINT IF EXISTS no-ops on re-run.
|
||||
-- =============================================================================
|
||||
ALTER TABLE paliad.deadline_rule_audit
|
||||
DROP CONSTRAINT IF EXISTS deadline_rule_audit_rule_id_fkey;
|
||||
|
||||
-- =============================================================================
|
||||
-- §13b Q6 cleanup — drop the _archived_litigation.* deadline rules.
|
||||
-- 40 rows at audit §9.7 flagged as obsolete Pipeline-A residue
|
||||
-- (proceeding_type id=32 '_archived_litigation' — kept for FK
|
||||
-- parity but the rules are no longer surfaced anywhere in the
|
||||
-- product). m's Q6 design ack 2026-05-25 locked in their removal.
|
||||
-- Idempotent: prefix + lifecycle_state='archived' match zero rows
|
||||
-- after first run. The proceeding_type row itself is left in place
|
||||
-- (referenced by historical deadline_rule_audit before_json blobs).
|
||||
-- =============================================================================
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE submission_code LIKE '_archived_litigation.%' ESCAPE '\'
|
||||
AND lifecycle_state = 'archived';
|
||||
|
||||
-- =============================================================================
|
||||
-- Hard assertions. Each new/changed row must end up in its post-fix
|
||||
-- shape. Re-running the migration is a no-op for the data but the
|
||||
-- assertions still pass because they check the post-fix state.
|
||||
-- =============================================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
v_count integer;
|
||||
BEGIN
|
||||
-- §0 R.105 interim conference backfilled
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.interim'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.104'
|
||||
AND legal_source = 'UPC.RoP.104'
|
||||
AND 'RoP.105' = ANY(rule_codes);
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 §0: upc.inf.cfi.interim citation backfill not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §1 T1.1 cmo_review present
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.cmo_review'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.333.2' AND duration_value = 15
|
||||
AND duration_unit = 'days' AND timing = 'after';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.1: upc.inf.cfi.cmo_review missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §2 T1.2 confidentiality_response
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.confidentiality_response'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.262.2' AND duration_value = 14
|
||||
AND duration_unit = 'days' AND trigger_event_id = 25;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.2: upc.inf.cfi.confidentiality_response missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §3 T1.3 grounds_orders
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.order.grounds_orders'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.224.2.b' AND duration_value = 15
|
||||
AND duration_unit = 'days';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.3: upc.apl.order.grounds_orders missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §4 T1.4 response_orders chained on §3
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.deadline_rules p ON p.id = dr.parent_id
|
||||
WHERE dr.submission_code = 'upc.apl.order.response_orders'
|
||||
AND dr.is_active = true AND dr.lifecycle_state = 'published'
|
||||
AND dr.rule_code = 'RoP.235.2' AND dr.duration_value = 15
|
||||
AND p.submission_code = 'upc.apl.order.grounds_orders';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.4: upc.apl.order.response_orders missing or wrong parent chain (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §5 T1.5 cons_orders
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.cons_orders'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.118.4' AND duration_value = 2
|
||||
AND duration_unit = 'months';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.5: upc.inf.cfi.cons_orders missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §6 T1.6 rectification
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.rectification'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.353' AND duration_value = 1
|
||||
AND duration_unit = 'months';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.6: upc.inf.cfi.rectification missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §7 T1.7 pi.deficiency
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.deficiency'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.207.6.a' AND duration_value = 14
|
||||
AND duration_unit = 'days';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.7: upc.pi.cfi.deficiency missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §8 T1.8 pi.merits_start — combine-max wiring
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.merits_start'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.213' AND duration_value = 31
|
||||
AND duration_unit = 'days'
|
||||
AND alt_duration_value = 20 AND alt_duration_unit = 'working_days'
|
||||
AND alt_rule_code = 'RoP.198.1' AND combine_op = 'max';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.8: upc.pi.cfi.merits_start missing or wrong combine-max shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §9 T1.9 translation_request — timing='before'
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.translation_request'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.109.1' AND duration_value = 1
|
||||
AND duration_unit = 'months' AND timing = 'before';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.9: upc.inf.cfi.translation_request missing or wrong timing (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §10 T1.10 interpreter_cost — timing='before'
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.interpreter_cost'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.109.4' AND duration_value = 2
|
||||
AND duration_unit = 'weeks' AND timing = 'before';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.10: upc.inf.cfi.interpreter_cost missing or wrong timing (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §11 T1.11 translations_lodge
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.translations_lodge'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.109.5' AND duration_value = 2
|
||||
AND duration_unit = 'weeks' AND trigger_event_id = 113;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.11: upc.inf.cfi.translations_lodge missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §12 T1.12 pi.response re-anchor
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.deadline_rules p ON p.id = dr.parent_id
|
||||
WHERE dr.submission_code = 'upc.pi.cfi.response'
|
||||
AND dr.is_active = true AND dr.lifecycle_state = 'published'
|
||||
AND dr.is_court_set = true
|
||||
AND p.submission_code = 'upc.pi.cfi.app';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.12: upc.pi.cfi.response not re-anchored on app (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §13 Q6 cleanup — no archived _archived_litigation rules left
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code LIKE '_archived_litigation.%' ESCAPE '\'
|
||||
AND lifecycle_state = 'archived';
|
||||
IF v_count <> 0 THEN
|
||||
RAISE EXCEPTION 'mig 132 §13: % archived _archived_litigation.* rules still present after cleanup', v_count;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1,33 +0,0 @@
|
||||
-- Reverses mig 133. Removes the 5 new rules:
|
||||
-- * upc.dmgs.cfi.interim
|
||||
-- * upc.dmgs.cfi.oral
|
||||
-- * upc.dmgs.cfi.decision
|
||||
-- * upc.dmgs.cfi.appeal_spawn
|
||||
-- * upc.pi.cfi.appeal_spawn
|
||||
--
|
||||
-- The audit_reason is required by the mig 079 trigger for DELETE;
|
||||
-- set_config at top supplies it.
|
||||
--
|
||||
-- Idempotent — if a rule is already missing the DELETE matches zero
|
||||
-- rows and the audit log records nothing extra.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 133 (down): revert UPC Damages tree-end rows and UPC PI appeal-spawn (t-paliad-285 / m/paliad#117 + t-paliad-286 / m/paliad#118)',
|
||||
true);
|
||||
|
||||
-- Delete the spawn rows first so the parent_id reference goes away
|
||||
-- before the parent decision row is removed.
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE submission_code IN (
|
||||
'upc.dmgs.cfi.appeal_spawn',
|
||||
'upc.pi.cfi.appeal_spawn')
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE submission_code IN (
|
||||
'upc.dmgs.cfi.interim',
|
||||
'upc.dmgs.cfi.oral',
|
||||
'upc.dmgs.cfi.decision')
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published';
|
||||
@@ -1,405 +0,0 @@
|
||||
-- t-paliad-285 (m/paliad#117) + t-paliad-286 (m/paliad#118) —
|
||||
-- post-submission court followup for UPC Damages and appeal route
|
||||
-- for UPC Provisional Measures.
|
||||
--
|
||||
-- m's 2026-05-25 report: the upc.dmgs.cfi proceeding stops at the
|
||||
-- last party submission (rejoin) — no interim conference, no oral
|
||||
-- hearing, no decision row, no appeal-spawn. The upc.pi.cfi
|
||||
-- proceeding has its decision row (`pi.order`) but no spawn into
|
||||
-- the appeal tree. Both gaps prevent the Verfahrensablauf timeline
|
||||
-- from rendering the court phase plus any downstream appeal sub-
|
||||
-- tree that atlas's #96 spawn-rendering mechanism is otherwise
|
||||
-- ready to surface.
|
||||
--
|
||||
-- Two sections in one migration (slot 133 — knuth on 132, paliadin
|
||||
-- coordinated):
|
||||
--
|
||||
-- A. UPC Damages tree-end rows (#117)
|
||||
-- A1 upc.dmgs.cfi.interim UPC RoP R.105 court-set hearing
|
||||
-- A2 upc.dmgs.cfi.oral UPC RoP R.118 / R.250 court-set hearing
|
||||
-- A3 upc.dmgs.cfi.decision UPC RoP R.118 / R.144 court-set decision
|
||||
-- A4 upc.dmgs.cfi.appeal_spawn UPC RoP R.220.1(a) / R.224.1(a) 2mo, spawn → upc.apl.merits (id=11)
|
||||
--
|
||||
-- B. UPC Provisional Measures appeal route (#118)
|
||||
-- B1 upc.pi.cfi.appeal_spawn UPC RoP R.220.1(a) / R.224.1(a) 2mo, spawn → upc.apl.merits (id=11)
|
||||
--
|
||||
-- Source citations:
|
||||
-- * docs/research-deadlines-completeness-2026-05-25.md
|
||||
-- — §2.1 (upc.dmgs.cfi has only 4 rules: R.131.2 / R.137.2 / R.139)
|
||||
-- — §D Damages table (R.144 tree-end row missing — listed
|
||||
-- in Tier 4 as "cosmetic", upgraded to Tier-0 by m's
|
||||
-- report once the wider follow-up gap was understood)
|
||||
-- * docs/audit-upc-rop-deadlines-2026-05-08.md §D row R.144,
|
||||
-- §F R.220.1(a) / R.224.1(a) (verified verbatim in youpc DB
|
||||
-- under law_type=UPCRoP).
|
||||
-- * UPC Rules of Procedure (consolidated):
|
||||
-- R.105 — Interim conference (court fixes after written
|
||||
-- procedure closes; same structural shape as the inf
|
||||
-- interim conference, already modelled as `upc.inf.cfi.interim`).
|
||||
-- R.118 — Decision after oral hearing; general rule for
|
||||
-- deciding panels.
|
||||
-- R.250 — Determination of damages decision; damages-
|
||||
-- specific decision rule (chains R.144 indication →
|
||||
-- damages award).
|
||||
-- R.144 — Final decision on damages quantum (tree-end
|
||||
-- anchor for §A3).
|
||||
-- R.220.1(a) — Appeal lies from any final decision /
|
||||
-- decision disposing of the case at first instance.
|
||||
-- A PI order under R.211 disposes of the urgent question
|
||||
-- and is therefore appealable on the main 2-month track
|
||||
-- (not the 15-day order track of R.220.1(c), which covers
|
||||
-- case-management and procedural orders requiring leave).
|
||||
-- Curie's §F table confirms the main-track wiring for
|
||||
-- decisions on merits / disposing orders.
|
||||
-- R.224.1(a) — Statement of Appeal within 2 months of
|
||||
-- service of the final decision; the deadline-notes text
|
||||
-- mirrors mig 095's inf.appeal_spawn / rev.appeal_spawn.
|
||||
-- R.224.2(a) — Statement of grounds within 4 months
|
||||
-- (separate deadline in the spawned upc.apl.merits
|
||||
-- proceeding; already present as upc.apl.merits.grounds).
|
||||
--
|
||||
-- Shape decisions (mirroring mig 012 / mig 095 conventions):
|
||||
-- * Court-set rows (interim / oral / decision) carry
|
||||
-- primary_party='court', event_type='hearing'|'decision',
|
||||
-- duration_value=0, is_court_set=true, parent_id=NULL,
|
||||
-- concept_id reuses the shared concepts already wired for
|
||||
-- upc.inf.cfi (interim-conference / oral-hearing / decision).
|
||||
-- * Spawn rows carry primary_party='both', is_spawn=true,
|
||||
-- spawn_proceeding_type_id=11 (upc.apl.merits), spawn_label
|
||||
-- identical to the merits spawn already in production. The
|
||||
-- spawn row's parent_id is the spawning decision/order row
|
||||
-- (so the audit log carries the trigger link).
|
||||
-- * No condition_expr — m's F2.3 decision recorded in mig 095
|
||||
-- §3: "the appeal deadline should always be triggered by a
|
||||
-- decision … appeal is always a possibility." Visibility
|
||||
-- filtering on the frontend hides appeals on projects where
|
||||
-- no appeal is contemplated.
|
||||
-- * sequence_order numbering follows the inf convention
|
||||
-- (40=interim, 50=oral, 60=decision, 80=appeal_spawn) so the
|
||||
-- Verfahrensablauf timeline orders consistently across
|
||||
-- proceedings. For PI the existing pi.order sits at
|
||||
-- sequence_order=3; the appeal_spawn lands at 10 (clear of
|
||||
-- the writ phase, room for future court-phase rows).
|
||||
--
|
||||
-- Idempotency: every INSERT is gated by `WHERE NOT EXISTS (… same
|
||||
-- submission_code, proceeding_type_id, lifecycle_state)`. Re-apply
|
||||
-- against an already-migrated DB inserts zero rows and the audit
|
||||
-- log carries no duplicate entries.
|
||||
--
|
||||
-- audit_reason set_config required at the top — the mig 079 trigger
|
||||
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
|
||||
-- on INSERT/UPDATE/DELETE without it.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 133: t-paliad-285 / m/paliad#117 + t-paliad-286 / m/paliad#118 — UPC Damages tree-end rows (interim conference R.105, oral hearing R.118/R.250, decision R.118/R.144, appeal-spawn R.220.1(a)) and UPC Provisional Measures appeal-spawn R.220.1(a); see docs/research-deadlines-completeness-2026-05-25.md §D and docs/audit-upc-rop-deadlines-2026-05-08.md §D/§F',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- A. UPC Damages — court-phase tree end (m/paliad#117)
|
||||
-- =============================================================================
|
||||
|
||||
-- A1. upc.dmgs.cfi.interim — Interim conference (UPC RoP R.105).
|
||||
-- Court-set hearing fixed by the judge-rapporteur once the
|
||||
-- written procedure closes. Identical shape to
|
||||
-- upc.inf.cfi.interim; reuses the shared interim-conference
|
||||
-- concept node.
|
||||
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,
|
||||
concept_id)
|
||||
SELECT
|
||||
17,
|
||||
NULL,
|
||||
'upc.dmgs.cfi.interim',
|
||||
'Zwischenverfahren',
|
||||
'Interim Conference',
|
||||
NULL,
|
||||
'court',
|
||||
'hearing',
|
||||
0,
|
||||
'months',
|
||||
'after',
|
||||
NULL,
|
||||
'Termin vom Gericht bestimmt',
|
||||
'Date set by the court',
|
||||
40,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
NULL,
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
true,
|
||||
'published',
|
||||
'e5071152-d408-4455-b644-9e79d86fd538'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.interim'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- A2. upc.dmgs.cfi.oral — Oral hearing (UPC RoP R.118 / R.250).
|
||||
-- Court-set hearing after the interim conference / close of
|
||||
-- written procedure. Same shape as upc.inf.cfi.oral; reuses
|
||||
-- the shared oral-hearing concept node.
|
||||
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,
|
||||
concept_id)
|
||||
SELECT
|
||||
17,
|
||||
NULL,
|
||||
'upc.dmgs.cfi.oral',
|
||||
'Mündliche Verhandlung',
|
||||
'Oral Hearing',
|
||||
NULL,
|
||||
'court',
|
||||
'hearing',
|
||||
0,
|
||||
'months',
|
||||
'after',
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
50,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
NULL,
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
true,
|
||||
'published',
|
||||
'd6e5b793-dcf1-4d83-81ff-34f42dbb3693'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.oral'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- A3. upc.dmgs.cfi.decision — Damages decision (UPC RoP R.118 /
|
||||
-- R.144 / R.250). Court-set decision delivered after oral
|
||||
-- hearing; closes the §3.1 audit gap (R.144 tree-end). Same
|
||||
-- shape as upc.inf.cfi.decision; reuses the shared decision
|
||||
-- concept node.
|
||||
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,
|
||||
concept_id)
|
||||
SELECT
|
||||
17,
|
||||
NULL,
|
||||
'upc.dmgs.cfi.decision',
|
||||
'Entscheidung',
|
||||
'Decision',
|
||||
NULL,
|
||||
'court',
|
||||
'decision',
|
||||
0,
|
||||
'months',
|
||||
'after',
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
60,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
NULL,
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
true,
|
||||
'published',
|
||||
'472fc32d-cc4f-4aa4-8ace-e422031812de'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.decision'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- A4. upc.dmgs.cfi.appeal_spawn — Appeal against damages decision
|
||||
-- (UPC RoP R.220.1(a), 2-month main track; grounds R.224.2(a)
|
||||
-- run as a separate deadline in the spawned upc.apl.merits
|
||||
-- proceeding). Parent points at the freshly-inserted
|
||||
-- upc.dmgs.cfi.decision; the SELECT subquery resolves it
|
||||
-- after A3 lands. Same shape as the mig 095 inf.appeal_spawn.
|
||||
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
|
||||
17,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.decision'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true),
|
||||
'upc.dmgs.cfi.appeal_spawn',
|
||||
'Berufung gegen Schadensentscheidung',
|
||||
'Appeal against damages decision',
|
||||
'Berufung gegen die Entscheidung über die Schadensbemessung (R.118 / R.144). Statutarische Frist von 2 Monaten ab Zustellung der Entscheidung (R.224.1(a)); die Berufungsbegründung folgt mit 4 Monaten ab Zustellung (R.224.2(a), eigenständige Frist im Berufungsverfahren).',
|
||||
'both',
|
||||
'filing',
|
||||
2,
|
||||
'months',
|
||||
'after',
|
||||
'RoP.220.1.a',
|
||||
'Innerhalb von 2 Monaten ab Zustellung der Schadensentscheidung Berufungsschrift einreichen (R.224.1(a)). Die Berufungsbegründung (R.224.2(a), 4 Monate) läuft als separate Frist im Berufungsverfahren.',
|
||||
'Within 2 months of service of the damages decision lodge the Statement of appeal (R.224.1(a)). The Statement of grounds (R.224.2(a), 4 months) runs as an independent deadline in the appeal proceeding.',
|
||||
80,
|
||||
true,
|
||||
11,
|
||||
'Berufungsverfahren öffnen',
|
||||
true,
|
||||
'UPC.RoP.220.1',
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
false,
|
||||
'published'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.appeal_spawn'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- =============================================================================
|
||||
-- B. UPC Provisional Measures — appeal route (m/paliad#118)
|
||||
-- =============================================================================
|
||||
|
||||
-- B1. upc.pi.cfi.appeal_spawn — Appeal against PI order (UPC RoP
|
||||
-- R.220.1(a), 2-month main track). PI orders under R.211
|
||||
-- dispose of the urgent question and are appealable on the
|
||||
-- main 2-month track (R.220.1(a)/R.224.1(a)); the 15-day
|
||||
-- order track of R.220.1(c) is for case-management /
|
||||
-- procedural orders requiring leave and does not apply to
|
||||
-- PI dispositions. Parent points at the existing
|
||||
-- upc.pi.cfi.order (sequence_order=3) so the spawn fires
|
||||
-- once the order is anchored on a project's timeline.
|
||||
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
|
||||
10,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.order'
|
||||
AND proceeding_type_id = 10
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true),
|
||||
'upc.pi.cfi.appeal_spawn',
|
||||
'Berufung gegen Anordnung',
|
||||
'Appeal against PI order',
|
||||
'Berufung gegen die einstweilige Anordnung nach R.211. Eine PI-Anordnung erledigt die einstweilige Streitfrage und wird wie eine Endentscheidung im Hauptverfahren behandelt: statutarische Frist von 2 Monaten ab Zustellung (R.224.1(a)); die Berufungsbegründung folgt mit 4 Monaten ab Zustellung (R.224.2(a), eigenständige Frist im Berufungsverfahren). Die 15-Tage-Spur nach R.220.1(c) / R.220.2 gilt für Verfahrensanordnungen mit Zulassung und ist hier nicht einschlägig.',
|
||||
'both',
|
||||
'filing',
|
||||
2,
|
||||
'months',
|
||||
'after',
|
||||
'RoP.220.1.a',
|
||||
'Innerhalb von 2 Monaten ab Zustellung der PI-Anordnung Berufungsschrift einreichen (R.224.1(a)). Die Berufungsbegründung (R.224.2(a), 4 Monate) läuft als separate Frist im Berufungsverfahren.',
|
||||
'Within 2 months of service of the PI order lodge the Statement of appeal (R.224.1(a)). The Statement of grounds (R.224.2(a), 4 months) runs as an independent deadline in the appeal proceeding.',
|
||||
10,
|
||||
true,
|
||||
11,
|
||||
'Berufungsverfahren öffnen',
|
||||
true,
|
||||
'UPC.RoP.220.1',
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
false,
|
||||
'published'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.appeal_spawn'
|
||||
AND proceeding_type_id = 10
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- =============================================================================
|
||||
-- C. Post-insert verification — raise if any expected row is missing
|
||||
-- (matches the mig 095 / 127 convention; protects against a future
|
||||
-- re-shape of the table that silently drops one of the WHERE NOT
|
||||
-- EXISTS predicates).
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_missing text;
|
||||
BEGIN
|
||||
SELECT string_agg(expected, ', ' ORDER BY expected)
|
||||
INTO v_missing
|
||||
FROM (VALUES
|
||||
('upc.dmgs.cfi.interim'),
|
||||
('upc.dmgs.cfi.oral'),
|
||||
('upc.dmgs.cfi.decision'),
|
||||
('upc.dmgs.cfi.appeal_spawn'),
|
||||
('upc.pi.cfi.appeal_spawn')
|
||||
) AS t(expected)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules dr
|
||||
WHERE dr.submission_code = t.expected
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.is_active = true);
|
||||
|
||||
IF v_missing IS NOT NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 133: expected published rules missing after insert: %', v_missing;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules dr
|
||||
WHERE dr.submission_code = 'upc.dmgs.cfi.appeal_spawn'
|
||||
AND dr.proceeding_type_id = 17
|
||||
AND dr.spawn_proceeding_type_id = 11
|
||||
AND dr.is_spawn = true
|
||||
AND dr.parent_id IS NOT NULL
|
||||
AND dr.lifecycle_state = 'published'
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 133: upc.dmgs.cfi.appeal_spawn shape check failed (expected is_spawn=true, spawn_proceeding_type_id=11, parent_id set)';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules dr
|
||||
WHERE dr.submission_code = 'upc.pi.cfi.appeal_spawn'
|
||||
AND dr.proceeding_type_id = 10
|
||||
AND dr.spawn_proceeding_type_id = 11
|
||||
AND dr.is_spawn = true
|
||||
AND dr.parent_id IS NOT NULL
|
||||
AND dr.lifecycle_state = 'published'
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 133: upc.pi.cfi.appeal_spawn shape check failed (expected is_spawn=true, spawn_proceeding_type_id=11, parent_id set)';
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1,72 +0,0 @@
|
||||
-- 134_berufung_unification — DOWN
|
||||
--
|
||||
-- Reverses the Berufung unification: un-archives the 3 old appeal
|
||||
-- proceeding_types, points the 16 rules back at their original
|
||||
-- proceeding by their applies_to_target stamp, drops the new
|
||||
-- upc.apl row, drops the two columns + their CHECK constraints.
|
||||
--
|
||||
-- The 3 old proceeding_types are recovered by code (we archived them,
|
||||
-- never deleted them — that's what makes this down-migration safe).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. Audit reason (required by mig 079 trigger — step 2 UPDATEs
|
||||
-- paliad.deadline_rules to reverse the reassignment).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 134 DOWN: revert Slice B1 — restore 3 separate UPC appeal proceeding_types, drop applies_to_target column',
|
||||
true);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Un-archive the 3 old appeal proceeding_types.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = true
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Point rules back at their original proceeding_type by stamp.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.merits'
|
||||
)
|
||||
WHERE dr.applies_to_target = ARRAY['endentscheidung']::text[];
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.cost'
|
||||
)
|
||||
WHERE dr.applies_to_target = ARRAY['kostenentscheidung']::text[];
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.order'
|
||||
)
|
||||
WHERE dr.applies_to_target = ARRAY['anordnung']::text[];
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Drop the unified upc.apl.unified row (now orphaned).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DELETE FROM paliad.proceeding_types WHERE code = 'upc.apl.unified';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. Drop the new columns + their CHECK constraints.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_applies_to_target_chk;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP COLUMN IF EXISTS applies_to_target;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP CONSTRAINT IF EXISTS proceeding_types_appeal_target_chk;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS appeal_target;
|
||||
@@ -1,272 +0,0 @@
|
||||
-- 134_berufung_unification — Slice B1, m/paliad#124, t-paliad-298+
|
||||
--
|
||||
-- Collapses the 3 active UPC appeal proceeding_types (upc.apl.merits,
|
||||
-- upc.apl.cost, upc.apl.order — 16 rules across 3 codes) into ONE
|
||||
-- unified upc.apl proceeding type + an `appeal_target` discriminator on
|
||||
-- both proceeding_types (top-level marker) and deadline_rules
|
||||
-- (per-row applies-to set, text[] for multi-target rules).
|
||||
--
|
||||
-- ADDITIVE ONLY. The migration:
|
||||
-- 1. Adds the two columns + check constraints.
|
||||
-- 2. Inserts the new upc.apl proceeding type.
|
||||
-- 3. Audit-first: NOTICES every row about to be touched.
|
||||
-- 4. Reassigns rule rows from the 3 old types to upc.apl, stamping
|
||||
-- applies_to_target by source proceeding code.
|
||||
-- 5. Archives (is_active=false) the 3 old proceeding_types — NEVER
|
||||
-- deletes them, so any historical project_event_choices / FK
|
||||
-- references stay intact.
|
||||
--
|
||||
-- Schadensbemessung + Bucheinsicht get NO rule rows in this migration
|
||||
-- (m's 2026-05-26 decision: distinct rule sets, not shared with
|
||||
-- merits). Their appeal_target enum values are defined and addressable
|
||||
-- by CalcOptions.AppealTarget; the engine returns an empty timeline
|
||||
-- until rules are seeded in a follow-up slice (likely via
|
||||
-- /admin/rules, pairing with t-paliad-193 orphan-concept-seed).
|
||||
--
|
||||
-- See docs/design-litigation-planner-2026-05-26.md §18.1.
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. Audit reason (required by mig 079 trigger for any UPDATE on
|
||||
-- paliad.deadline_rules — step 4 reassigns 16 rules).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 134: t-paliad-292 Slice B1 — Berufung unification, collapse 3 UPC appeal proceeding_types into upc.apl.unified + appeal_target discriminator',
|
||||
true);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Schema additions
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN appeal_target text NULL;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD CONSTRAINT proceeding_types_appeal_target_chk
|
||||
CHECK (appeal_target IS NULL OR appeal_target IN (
|
||||
'endentscheidung',
|
||||
'kostenentscheidung',
|
||||
'anordnung',
|
||||
'schadensbemessung',
|
||||
'bucheinsicht'
|
||||
));
|
||||
|
||||
COMMENT ON COLUMN paliad.proceeding_types.appeal_target IS
|
||||
'Top-level appeal-target marker. NULL on non-appeal proceedings. '
|
||||
'Reserved for future variants — today only the unified upc.apl row '
|
||||
'has this NULL (the actual per-rule target set lives on '
|
||||
'paliad.deadline_rules.applies_to_target).';
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN applies_to_target text[] NULL;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_applies_to_target_chk
|
||||
CHECK (
|
||||
applies_to_target IS NULL
|
||||
OR applies_to_target <@ ARRAY[
|
||||
'endentscheidung',
|
||||
'kostenentscheidung',
|
||||
'anordnung',
|
||||
'schadensbemessung',
|
||||
'bucheinsicht'
|
||||
]::text[]
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.applies_to_target IS
|
||||
'Set of appeal_target slugs this rule applies to. NULL on rules '
|
||||
'that don''t belong to an appeal proceeding. The engine filters '
|
||||
'by CalcOptions.AppealTarget — rules whose applies_to_target '
|
||||
'contains the requested slug are emitted; others are suppressed.';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Insert the unified upc.apl row.
|
||||
--
|
||||
-- Inherits default_color from the merits row (the most-used appeal
|
||||
-- track today). sort_order follows the cluster of UPC proceedings;
|
||||
-- placed just before upc.apl.merits's old slot so the chip-grouped
|
||||
-- picker UI lands Berufung in a sensible position. Tweakable later
|
||||
-- without a migration.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
INSERT INTO paliad.proceeding_types (
|
||||
code, name, name_en, description, jurisdiction, category,
|
||||
default_color, sort_order, is_active, display_order,
|
||||
appeal_target
|
||||
)
|
||||
SELECT
|
||||
'upc.apl.unified',
|
||||
'Berufungsverfahren',
|
||||
'Appeal',
|
||||
'Vereinheitlichtes Berufungsverfahren — wählen Sie anschließend, '
|
||||
'worauf die Berufung sich richtet (Endentscheidung, '
|
||||
'Kostenentscheidung, Anordnung, Schadensbemessung, Bucheinsicht).',
|
||||
'UPC',
|
||||
'fristenrechner',
|
||||
default_color,
|
||||
sort_order,
|
||||
true,
|
||||
display_order,
|
||||
NULL
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = 'upc.apl.merits';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Audit-first RAISE NOTICE pass.
|
||||
--
|
||||
-- Lists every rule row that will be reassigned + every proceeding_type
|
||||
-- row that will be archived. The migration runs to completion either
|
||||
-- way; the operator reads the notices to confirm scope before the
|
||||
-- next migration in the chain.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
upc_apl_id int;
|
||||
rules_touched int := 0;
|
||||
procs_archived int := 0;
|
||||
BEGIN
|
||||
SELECT id INTO upc_apl_id
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = 'upc.apl.unified';
|
||||
RAISE NOTICE '[mig 134] new upc.apl.unified proceeding_type_id = %', upc_apl_id;
|
||||
|
||||
RAISE NOTICE '[mig 134] Rules to reassign to upc.apl.unified with applies_to_target:';
|
||||
FOR rec IN
|
||||
SELECT dr.id AS rule_id,
|
||||
pt.code AS old_proceeding,
|
||||
dr.submission_code,
|
||||
dr.name
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||
AND dr.is_active = true
|
||||
ORDER BY pt.code, dr.sequence_order
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 134] % % % (%)',
|
||||
rec.old_proceeding, rec.submission_code, rec.name, rec.rule_id;
|
||||
rules_touched := rules_touched + 1;
|
||||
END LOOP;
|
||||
RAISE NOTICE '[mig 134] Total rules to reassign: %', rules_touched;
|
||||
|
||||
RAISE NOTICE '[mig 134] Proceeding_types to archive (is_active=false):';
|
||||
FOR rec IN
|
||||
SELECT id, code, name
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||
ORDER BY sort_order
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 134] % % (id=%)', rec.code, rec.name, rec.id;
|
||||
procs_archived := procs_archived + 1;
|
||||
END LOOP;
|
||||
RAISE NOTICE '[mig 134] Total proceeding_types to archive: %', procs_archived;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. Reassign rule rows.
|
||||
--
|
||||
-- Stamp applies_to_target by source proceeding code, then point all
|
||||
-- 16 rules at the new upc.apl row.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- 4a. upc.apl.merits → applies_to_target = {endentscheidung}
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = ARRAY['endentscheidung']::text[]
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.merits'
|
||||
AND dr.is_active = true;
|
||||
|
||||
-- 4b. upc.apl.cost → applies_to_target = {kostenentscheidung}
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = ARRAY['kostenentscheidung']::text[]
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.cost'
|
||||
AND dr.is_active = true;
|
||||
|
||||
-- 4c. upc.apl.order → applies_to_target = {anordnung}
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = ARRAY['anordnung']::text[]
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.order'
|
||||
AND dr.is_active = true;
|
||||
|
||||
-- 4d. Reassign all 16 rules to the new upc.apl.unified proceeding_type row.
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.unified'
|
||||
)
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 5. Archive the 3 old proceeding_types.
|
||||
--
|
||||
-- NEVER DELETE — historical project_event_choices and project FKs
|
||||
-- (paliad.projects.proceeding_type_id) may still reference these IDs.
|
||||
-- The is_active=false flag stops them appearing in the picker but
|
||||
-- preserves FK integrity for historical reads.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = false
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 6. Post-migration sanity check.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
unified_count int;
|
||||
archived_count int;
|
||||
target_distribution record;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO unified_count
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified' AND dr.is_active = true;
|
||||
RAISE NOTICE '[mig 134] post: rules on unified upc.apl.unified = % (expected 16)', unified_count;
|
||||
IF unified_count <> 16 THEN
|
||||
RAISE EXCEPTION '[mig 134] FAILED — expected 16 rules on upc.apl.unified, got %', unified_count;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO archived_count
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||
AND is_active = false;
|
||||
RAISE NOTICE '[mig 134] post: archived old appeal proceeding_types = % (expected 3)', archived_count;
|
||||
IF archived_count <> 3 THEN
|
||||
RAISE EXCEPTION '[mig 134] FAILED — expected 3 archived types, got %', archived_count;
|
||||
END IF;
|
||||
|
||||
FOR target_distribution IN
|
||||
SELECT unnest(applies_to_target) AS target, COUNT(*) AS n
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified' AND dr.is_active = true
|
||||
GROUP BY unnest(applies_to_target)
|
||||
ORDER BY 1
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 134] post: applies_to_target=% count=%',
|
||||
target_distribution.target, target_distribution.n;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- TODO (follow-up slice, not in 134):
|
||||
--
|
||||
-- Seed rules for Schadensbemessung-as-appeal + Bucheinsicht-as-appeal.
|
||||
-- m's 2026-05-26 decision: distinct rule sets, NOT shared with merits.
|
||||
-- - Schadensbemessung: anchor on R.118.4 decision; conjecture 2/4-month
|
||||
-- merits-style track but distinct legal basis.
|
||||
-- - Bucheinsicht: anchor on R.142 (Lay-open-books decision); conjecture
|
||||
-- 15-day track per R.220.2 + R.224.2.b.
|
||||
-- Can pair with t-paliad-193 orphan-concept-seed if m wants a combined
|
||||
-- editorial pass via /admin/rules.
|
||||
-- ---------------------------------------------------------------
|
||||
@@ -1,8 +0,0 @@
|
||||
-- 135_primary_party_check — DOWN
|
||||
--
|
||||
-- Drops the CHECK constraint added in 135.up. No data revert needed
|
||||
-- — the column stays text, the four-value vocab is enforced only by
|
||||
-- application code thereafter.
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_primary_party_chk;
|
||||
@@ -1,92 +0,0 @@
|
||||
-- 135_primary_party_check — Slice B3, m/paliad#124 §18.3
|
||||
--
|
||||
-- Tightens paliad.deadline_rules.primary_party from free-text to a
|
||||
-- CHECK constraint over the canonical four-value vocabulary
|
||||
-- (claimant / defendant / court / both). NULL stays valid for the
|
||||
-- 78 cross-cutting orphan concept seeds (Wiedereinsetzung,
|
||||
-- Versäumnisurteil-Einspruch, Schriftsatznachreichung,
|
||||
-- Weiterbehandlung) — they have no proceeding_type_id binding so
|
||||
-- they're outside the calculator's path; loosening the CHECK to
|
||||
-- "IS NULL OR IN (…)" keeps them valid without backfill gymnastics.
|
||||
--
|
||||
-- Audit-first: the DO block RAISEs NOTICE for every non-conforming
|
||||
-- row before adding the CHECK, and RAISEs EXCEPTION if any dirty
|
||||
-- rows are found so the operator can decide a manual cleanup path.
|
||||
-- Live audit (Supabase, 2026-05-26 §18.0) confirmed zero dirty rows
|
||||
-- on the current corpus: 26 claimant + 26 defendant + 38 court +
|
||||
-- 63 both + 78 NULL = 231 total, all in the canonical vocab. The
|
||||
-- audit pass stays in the migration for safety against future drift
|
||||
-- (e.g. a rule editor write that bypassed the application-layer
|
||||
-- validation hook this slice also adds).
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
dirty_count int := 0;
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 135] primary_party audit pass — non-conforming rows:';
|
||||
FOR rec IN
|
||||
SELECT dr.id, dr.name, dr.primary_party,
|
||||
pt.code AS proceeding_code
|
||||
FROM paliad.deadline_rules dr
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE dr.is_active = true
|
||||
AND dr.primary_party IS NOT NULL
|
||||
AND dr.primary_party NOT IN ('claimant', 'defendant', 'court', 'both')
|
||||
ORDER BY pt.code NULLS LAST, dr.name
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 135] % % primary_party=% (rule=%)',
|
||||
COALESCE(rec.proceeding_code, '<orphan>'),
|
||||
rec.name,
|
||||
rec.primary_party,
|
||||
rec.id;
|
||||
dirty_count := dirty_count + 1;
|
||||
END LOOP;
|
||||
IF dirty_count > 0 THEN
|
||||
RAISE EXCEPTION '[mig 135] FAILED — % rule(s) carry non-canonical primary_party values. '
|
||||
'Manual cleanup required: update each row to one of '
|
||||
'''claimant'', ''defendant'', ''court'', ''both'', or NULL. '
|
||||
'See the NOTICE lines above for the offending rows.', dirty_count;
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 135] audit clean — proceeding with CHECK constraint';
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- Add the CHECK constraint. NULL stays valid; the four canonical
|
||||
-- values are the only allowed non-NULL forms.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_primary_party_chk
|
||||
CHECK (
|
||||
primary_party IS NULL
|
||||
OR primary_party IN ('claimant', 'defendant', 'court', 'both')
|
||||
);
|
||||
|
||||
COMMENT ON CONSTRAINT deadline_rules_primary_party_chk
|
||||
ON paliad.deadline_rules IS
|
||||
'Slice B3 (mig 135, m/paliad#124 §18.3) — canonical four-value '
|
||||
'vocab for primary_party (claimant / defendant / court / both). '
|
||||
'NULL allowed for cross-cutting orphan concept seeds (78 rows in '
|
||||
'live corpus as of mig 135). See pkg/litigationplanner.PrimaryParties '
|
||||
'for the in-code vocabulary.';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- Post-migration distribution check — informational NOTICE only.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 135] post: primary_party distribution after constraint add:';
|
||||
FOR rec IN
|
||||
SELECT COALESCE(primary_party, '<NULL>') AS party, COUNT(*) AS n
|
||||
FROM paliad.deadline_rules
|
||||
WHERE is_active = true
|
||||
GROUP BY primary_party
|
||||
ORDER BY party
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 135] % count=%', rec.party, rec.n;
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -1,19 +0,0 @@
|
||||
-- 136_procedural_events_additive (down) — Slice B.1, t-paliad-273
|
||||
--
|
||||
-- Safe to run at any point in B.1's lifetime. Up does NOT touch
|
||||
-- paliad.deadline_rules, so dropping the new tables + columns loses no
|
||||
-- application data — every source row in deadline_rules is intact and
|
||||
-- authoritative through the dual-write window.
|
||||
--
|
||||
-- Reverse order: drop indexes implicitly via DROP TABLE, drop the two
|
||||
-- deadlines link columns first (their FKs target procedural_events +
|
||||
-- sequencing_rules), then drop the three new tables in FK-safe order
|
||||
-- (sequencing_rules → procedural_events → legal_sources).
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
DROP COLUMN IF EXISTS procedural_event_id,
|
||||
DROP COLUMN IF EXISTS sequencing_rule_id;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.sequencing_rules;
|
||||
DROP TABLE IF EXISTS paliad.procedural_events;
|
||||
DROP TABLE IF EXISTS paliad.legal_sources;
|
||||
@@ -1,488 +0,0 @@
|
||||
-- 136_procedural_events_additive — Slice B.1, t-paliad-273 / m/paliad#93
|
||||
--
|
||||
-- ADDITIVE ONLY. Creates the three new tables that split today's
|
||||
-- paliad.deadline_rules into its three latent concepts (per the
|
||||
-- 2026-05-25 inventor design + 2026-05-26 B.0 re-validation):
|
||||
--
|
||||
-- 1. paliad.legal_sources — the source-of-law citations
|
||||
-- (DE.PatG.102, UPC.RoP.220.1, …)
|
||||
-- 2. paliad.procedural_events — the procedural-event templates
|
||||
-- (Rechtsbeschwerdebegründung, etc.;
|
||||
-- successor of `submission_code`)
|
||||
-- 3. paliad.sequencing_rules — the timing + trigger + condition
|
||||
-- mechanics (today's per-row data)
|
||||
--
|
||||
-- and adds two nullable link columns on paliad.deadlines so B.2's
|
||||
-- dual-write phase has somewhere to point.
|
||||
--
|
||||
-- The migration does NOT touch paliad.deadline_rules. The legacy table
|
||||
-- stays intact and authoritative for reads until B.3 flips the cutover.
|
||||
-- deadlines.rule_id stays in place (read by the calculator + projection
|
||||
-- service). No app code is changed by this migration; B.2 introduces
|
||||
-- the dual-write that wires services to the new tables.
|
||||
--
|
||||
-- Backfill plan (cf. design §5.1 + B.0 findings §7):
|
||||
-- * legal_sources <- DISTINCT legal_source FROM deadline_rules WHERE
|
||||
-- legal_source IS NOT NULL. pretty_de/pretty_en
|
||||
-- LEFT NULL for now (legalSourcePretty() in Go
|
||||
-- continues to materialise them on read; a future
|
||||
-- slice backfills them via a Go shim).
|
||||
-- * procedural_events <-
|
||||
-- (a) DISTINCT ON (submission_code) FROM deadline_rules WHERE
|
||||
-- submission_code IS NOT NULL — picks the lowest-id rule per
|
||||
-- code as the procedural-event identity source.
|
||||
-- (b) one synthetic procedural_event per NULL-submission_code
|
||||
-- rule, code = 'null.' || substring(replace(id::text,'-',''),1,8).
|
||||
-- m's pick (paliadin instruction 2026-05-26): mint synthetic
|
||||
-- codes so every deadline_rules row ends up with a
|
||||
-- procedural_events row, preserving the 1:1 sequencing-rule
|
||||
-- backfill and keeping the NOT NULL FK on
|
||||
-- sequencing_rules.procedural_event_id intact.
|
||||
-- * sequencing_rules <- 1:1 from deadline_rules. The new row inherits
|
||||
-- the source row's id so that any existing
|
||||
-- paliad.deadlines.rule_id FK target stays resolvable through
|
||||
-- the dual-write window (design §5.1 step 4).
|
||||
-- * deadlines.procedural_event_id + sequencing_rule_id <- joined from
|
||||
-- sequencing_rules on the inherited id.
|
||||
--
|
||||
-- Design deviations (intentional, documented):
|
||||
-- - procedural_events.event_kind is NULLABLE (design proposed NOT NULL
|
||||
-- with 'other' fallback). Today 89 deadline_rules rows have NULL
|
||||
-- event_type — these are "structural / parent-only rows in the
|
||||
-- proceeding tree" per B.0 §1. Forcing them to 'other' would lose
|
||||
-- semantics. A later slice can tighten this to NOT NULL after the
|
||||
-- 78+11 NULLs are reclassified.
|
||||
-- - legal_sources.pretty_de / pretty_en are NULLABLE (design proposed
|
||||
-- NOT NULL). Materialising them requires the Go-side
|
||||
-- legalSourcePretty() function — out of scope for a SQL migration.
|
||||
-- The Go read path continues to compute them on the fly from
|
||||
-- legal_source / citation; a future slice (Go shim driven from
|
||||
-- internal/services/submission_vars.go:619) backfills them.
|
||||
-- - submission_drafts is NOT modified. The design proposes adding
|
||||
-- procedural_event_id there too (§4.1 §5.1 step 6) but the B.1
|
||||
-- instruction scope is explicit: tables + deadlines columns only.
|
||||
-- submission_drafts continues to key off submission_code text.
|
||||
--
|
||||
-- Audit pattern follows mig 135 (Slice B3): PRE-pass counts what we
|
||||
-- expect to write, BACKFILL runs the SELECT-INSERTs, POST-pass verifies
|
||||
-- row counts and FK integrity. Any mismatch RAISE EXCEPTIONs and the
|
||||
-- transaction rolls back — operator sees the NOTICE lines and the
|
||||
-- failed assertion message.
|
||||
--
|
||||
-- See: docs/design-procedural-events-model-2026-05-25.md §4 + §5
|
||||
-- docs/design-procedural-events-b0-findings-2026-05-26.md §7
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. PRE pass — snapshot what we're about to backfill
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_rules int;
|
||||
v_codes_nn int;
|
||||
v_codes_distinct int;
|
||||
v_codes_null int;
|
||||
v_legal_distinct int;
|
||||
v_concept_linked int;
|
||||
v_dups int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_rules FROM paliad.deadline_rules;
|
||||
SELECT COUNT(*) INTO v_codes_nn FROM paliad.deadline_rules WHERE submission_code IS NOT NULL;
|
||||
SELECT COUNT(DISTINCT submission_code) INTO v_codes_distinct
|
||||
FROM paliad.deadline_rules WHERE submission_code IS NOT NULL;
|
||||
SELECT COUNT(*) INTO v_codes_null FROM paliad.deadline_rules WHERE submission_code IS NULL;
|
||||
SELECT COUNT(DISTINCT legal_source) INTO v_legal_distinct
|
||||
FROM paliad.deadline_rules WHERE legal_source IS NOT NULL;
|
||||
SELECT COUNT(*) INTO v_concept_linked FROM paliad.deadline_rules WHERE concept_id IS NOT NULL;
|
||||
|
||||
RAISE NOTICE '[mig 136] PRE: deadline_rules=%, with_submission_code=%, distinct_codes=%, null_codes=%, distinct_legal_sources=%, concept_linked=%',
|
||||
v_rules, v_codes_nn, v_codes_distinct, v_codes_null, v_legal_distinct, v_concept_linked;
|
||||
|
||||
-- Defensive: refuse to run if multi-row submission_codes have crept
|
||||
-- back in. B.0 (2026-05-26) found zero; mig 134 + 135 do not add
|
||||
-- any. If this CHECK ever fires the backfill arithmetic below
|
||||
-- breaks silently (one PE per code becomes ambiguous), so abort.
|
||||
SELECT COUNT(*) INTO v_dups FROM (
|
||||
SELECT submission_code
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code IS NOT NULL
|
||||
GROUP BY submission_code
|
||||
HAVING COUNT(*) > 1
|
||||
) d;
|
||||
IF v_dups > 0 THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED PRE: % submission_code value(s) appear on >1 deadline_rules row. '
|
||||
'The B.0 audit (2026-05-26) found zero. If you are seeing this, a rule was added that '
|
||||
'duplicates an existing submission_code (or the _archived_litigation.* rows returned). '
|
||||
'Decide whether the new schema collapses them (multiple sequencing rules → one '
|
||||
'procedural event) or whether each row gets its own code, then update this migration '
|
||||
'or the offending data before re-running.', v_dups;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. CREATE TABLE paliad.legal_sources
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.legal_sources (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
citation text NOT NULL UNIQUE,
|
||||
jurisdiction text NOT NULL,
|
||||
pretty_de text,
|
||||
pretty_en text,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.legal_sources IS
|
||||
'Source-of-law citations (DE.PatG.102, UPC.RoP.220.1, …). One row per '
|
||||
'distinct citation shorthand. pretty_de/pretty_en backfilled by a '
|
||||
'future Go-driven slice; until then NULL and the Go service ('
|
||||
'internal/services/submission_vars.go:619 legalSourcePretty) computes '
|
||||
'the human-readable form on read from the citation. Slice B.1 t-paliad-273.';
|
||||
|
||||
CREATE INDEX legal_sources_jurisdiction_idx ON paliad.legal_sources(jurisdiction);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. CREATE TABLE paliad.procedural_events
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.procedural_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
name_en text NOT NULL DEFAULT '',
|
||||
description text,
|
||||
event_kind text,
|
||||
primary_party_default text,
|
||||
legal_source_id uuid REFERENCES paliad.legal_sources(id),
|
||||
concept_id uuid REFERENCES paliad.deadline_concepts(id),
|
||||
lifecycle_state text NOT NULL DEFAULT 'published',
|
||||
draft_of uuid REFERENCES paliad.procedural_events(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.procedural_events IS
|
||||
'Procedural-event templates — the "what kind of step is this in the '
|
||||
'proceeding" hat of the legacy paliad.deadline_rules row. One row per '
|
||||
'unique submission_code, plus one synthetic row per NULL-submission_code '
|
||||
'rule (code prefix "null."). Slice B.1 t-paliad-273.';
|
||||
|
||||
COMMENT ON COLUMN paliad.procedural_events.event_kind IS
|
||||
'filing|reply|hearing|decision|order|other. NULLABLE for now — 89 '
|
||||
'rules in the live corpus have NULL event_type (structural / parent-only '
|
||||
'rows in the proceeding tree). A future slice can tighten to NOT NULL '
|
||||
'after these are reclassified.';
|
||||
|
||||
COMMENT ON COLUMN paliad.procedural_events.concept_id IS
|
||||
'Optional reference to a deadline_concepts row. N:1 — one concept may '
|
||||
'be shared by many procedural events (e.g. "Berufungsfrist" attaches to '
|
||||
'all four court-specific Berufung procedural events). Do NOT add UNIQUE.';
|
||||
|
||||
CREATE INDEX procedural_events_concept_id_idx ON paliad.procedural_events(concept_id);
|
||||
CREATE INDEX procedural_events_event_kind_idx ON paliad.procedural_events(event_kind);
|
||||
CREATE INDEX procedural_events_lifecycle_idx ON paliad.procedural_events(lifecycle_state);
|
||||
CREATE INDEX procedural_events_legal_source_idx ON paliad.procedural_events(legal_source_id);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. CREATE TABLE paliad.sequencing_rules
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.sequencing_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
procedural_event_id uuid NOT NULL REFERENCES paliad.procedural_events(id),
|
||||
proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
parent_id uuid REFERENCES paliad.sequencing_rules(id),
|
||||
trigger_event_id bigint REFERENCES paliad.trigger_events(id),
|
||||
duration_value integer NOT NULL DEFAULT 0,
|
||||
duration_unit text NOT NULL DEFAULT 'months',
|
||||
timing text DEFAULT 'after',
|
||||
alt_duration_value integer,
|
||||
alt_duration_unit text,
|
||||
alt_rule_code text,
|
||||
anchor_alt text,
|
||||
combine_op text,
|
||||
condition_expr jsonb,
|
||||
primary_party text,
|
||||
sequence_order integer NOT NULL DEFAULT 0,
|
||||
is_spawn boolean NOT NULL DEFAULT false,
|
||||
spawn_label text,
|
||||
spawn_proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
is_bilateral boolean NOT NULL DEFAULT false,
|
||||
is_court_set boolean NOT NULL DEFAULT false,
|
||||
priority text NOT NULL DEFAULT 'mandatory',
|
||||
rule_code text,
|
||||
rule_codes text[],
|
||||
deadline_notes text,
|
||||
deadline_notes_en text,
|
||||
choices_offered jsonb,
|
||||
applies_to_target text[],
|
||||
lifecycle_state text NOT NULL DEFAULT 'published',
|
||||
draft_of uuid REFERENCES paliad.sequencing_rules(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.sequencing_rules IS
|
||||
'Sequencing-rule mechanics — the "how and when does this fire" hat of '
|
||||
'the legacy paliad.deadline_rules row. 1:1 with deadline_rules during '
|
||||
'the dual-write window; the id is inherited from deadline_rules.id so '
|
||||
'paliad.deadlines.rule_id FKs continue to resolve transitively. '
|
||||
'Slice B.1 t-paliad-273.';
|
||||
|
||||
COMMENT ON COLUMN paliad.sequencing_rules.primary_party IS
|
||||
'Per-rule override of procedural_events.primary_party_default. Same '
|
||||
'four-value vocab as deadline_rules.primary_party (mig 135 CHECK). '
|
||||
'NULL = use procedural-event default. A future slice can add the '
|
||||
'same CHECK here.';
|
||||
|
||||
CREATE INDEX sequencing_rules_pe_proc_lifecycle_idx
|
||||
ON paliad.sequencing_rules(procedural_event_id, proceeding_type_id, lifecycle_state);
|
||||
CREATE INDEX sequencing_rules_parent_id_idx ON paliad.sequencing_rules(parent_id);
|
||||
CREATE INDEX sequencing_rules_trigger_event_idx ON paliad.sequencing_rules(trigger_event_id);
|
||||
CREATE INDEX sequencing_rules_proceeding_type_idx ON paliad.sequencing_rules(proceeding_type_id);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. ALTER paliad.deadlines — add link columns
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id),
|
||||
ADD COLUMN sequencing_rule_id uuid REFERENCES paliad.sequencing_rules(id);
|
||||
|
||||
COMMENT ON COLUMN paliad.deadlines.procedural_event_id IS
|
||||
'NULLABLE link to the procedural event this deadline instantiates. '
|
||||
'Added Slice B.1 (mig 136). B.2 dual-write populates it on every new '
|
||||
'deadline; B.3 cutover flips reads to use this instead of rule_id. '
|
||||
'rule_id stays in place until B.4 destructive drop.';
|
||||
COMMENT ON COLUMN paliad.deadlines.sequencing_rule_id IS
|
||||
'NULLABLE link to the sequencing rule. Same lifecycle as '
|
||||
'procedural_event_id — added Slice B.1, dual-written B.2, read in B.3, '
|
||||
'rule_id dropped in B.4.';
|
||||
|
||||
CREATE INDEX deadlines_procedural_event_id_idx ON paliad.deadlines(procedural_event_id);
|
||||
CREATE INDEX deadlines_sequencing_rule_id_idx ON paliad.deadlines(sequencing_rule_id);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 5. BACKFILL — legal_sources
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
INSERT INTO paliad.legal_sources (citation, jurisdiction)
|
||||
SELECT DISTINCT
|
||||
legal_source AS citation,
|
||||
COALESCE(NULLIF(split_part(legal_source, '.', 1), ''), 'other') AS jurisdiction
|
||||
FROM paliad.deadline_rules
|
||||
WHERE legal_source IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 6. BACKFILL — procedural_events
|
||||
-- (a) codes-bearing branch: DISTINCT ON (submission_code) picks the
|
||||
-- lowest-id (tie-break sequence_order) deadline_rules row as the
|
||||
-- identity source per the design's §5.1 step 3.
|
||||
-- (b) NULL-code branch: one synthetic row per rule, code minted from
|
||||
-- the rule id's first 8 hex chars (sans dashes) — m's pick
|
||||
-- 2026-05-26 (paliadin instruction).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- (a) codes-bearing rules → one procedural_events row per distinct code
|
||||
INSERT INTO paliad.procedural_events
|
||||
(code, name, name_en, description, event_kind, primary_party_default,
|
||||
legal_source_id, concept_id, lifecycle_state, published_at, is_active)
|
||||
SELECT
|
||||
src.submission_code,
|
||||
src.name,
|
||||
src.name_en,
|
||||
src.description,
|
||||
src.event_type,
|
||||
src.primary_party,
|
||||
ls.id,
|
||||
src.concept_id,
|
||||
src.lifecycle_state,
|
||||
src.published_at,
|
||||
src.is_active
|
||||
FROM (
|
||||
SELECT DISTINCT ON (submission_code)
|
||||
submission_code, name, name_en, description, event_type,
|
||||
primary_party, concept_id, legal_source, lifecycle_state,
|
||||
published_at, is_active
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code IS NOT NULL
|
||||
ORDER BY submission_code, id, sequence_order
|
||||
) src
|
||||
LEFT JOIN paliad.legal_sources ls ON ls.citation = src.legal_source;
|
||||
|
||||
-- (b) NULL-code rules → one synthetic procedural_events row each
|
||||
INSERT INTO paliad.procedural_events
|
||||
(code, name, name_en, description, event_kind, primary_party_default,
|
||||
legal_source_id, concept_id, lifecycle_state, published_at, is_active)
|
||||
SELECT
|
||||
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8) AS code,
|
||||
dr.name,
|
||||
dr.name_en,
|
||||
dr.description,
|
||||
dr.event_type,
|
||||
dr.primary_party,
|
||||
ls.id,
|
||||
dr.concept_id,
|
||||
dr.lifecycle_state,
|
||||
dr.published_at,
|
||||
dr.is_active
|
||||
FROM paliad.deadline_rules dr
|
||||
LEFT JOIN paliad.legal_sources ls ON ls.citation = dr.legal_source
|
||||
WHERE dr.submission_code IS NULL;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 7. BACKFILL — sequencing_rules
|
||||
-- 1:1 with deadline_rules. id inherited so deadlines.rule_id FKs
|
||||
-- continue to resolve through the dual-write window (design §5.1
|
||||
-- step 4). procedural_event_id resolved by JOIN on the (real or
|
||||
-- synthetic) code.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
INSERT INTO paliad.sequencing_rules
|
||||
(id, procedural_event_id, proceeding_type_id, parent_id, trigger_event_id,
|
||||
duration_value, duration_unit, timing,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
|
||||
combine_op, condition_expr, primary_party, sequence_order,
|
||||
is_spawn, spawn_label, spawn_proceeding_type_id,
|
||||
is_bilateral, is_court_set, priority,
|
||||
rule_code, rule_codes, deadline_notes, deadline_notes_en,
|
||||
choices_offered, applies_to_target,
|
||||
lifecycle_state, draft_of, published_at, is_active,
|
||||
created_at, updated_at)
|
||||
SELECT
|
||||
dr.id,
|
||||
pe.id,
|
||||
dr.proceeding_type_id,
|
||||
dr.parent_id,
|
||||
dr.trigger_event_id,
|
||||
dr.duration_value, dr.duration_unit, dr.timing,
|
||||
dr.alt_duration_value, dr.alt_duration_unit, dr.alt_rule_code, dr.anchor_alt,
|
||||
dr.combine_op, dr.condition_expr, dr.primary_party, dr.sequence_order,
|
||||
dr.is_spawn, dr.spawn_label, dr.spawn_proceeding_type_id,
|
||||
dr.is_bilateral, dr.is_court_set, dr.priority,
|
||||
dr.rule_code, dr.rule_codes, dr.deadline_notes, dr.deadline_notes_en,
|
||||
dr.choices_offered, dr.applies_to_target,
|
||||
dr.lifecycle_state,
|
||||
-- draft_of is a self-FK on deadline_rules; preserve as a self-FK on
|
||||
-- sequencing_rules since the inherited ids are stable across both.
|
||||
dr.draft_of,
|
||||
dr.published_at, dr.is_active,
|
||||
dr.created_at, dr.updated_at
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.procedural_events pe
|
||||
ON pe.code = COALESCE(
|
||||
dr.submission_code,
|
||||
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 8. BACKFILL — paliad.deadlines link columns
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.deadlines d
|
||||
SET procedural_event_id = sr.procedural_event_id,
|
||||
sequencing_rule_id = sr.id
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE d.rule_id = sr.id;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 9. POST pass — integrity assertions
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_dr_total int;
|
||||
v_dr_codes_distinct int;
|
||||
v_dr_codes_null int;
|
||||
v_dr_legal_distinct int;
|
||||
v_pe_total int;
|
||||
v_sr_total int;
|
||||
v_ls_total int;
|
||||
v_orphan_pe int;
|
||||
v_dup_synthetic int;
|
||||
v_deadlines_linked int;
|
||||
v_deadlines_total int;
|
||||
v_pe_missing_ls int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_dr_total FROM paliad.deadline_rules;
|
||||
SELECT COUNT(DISTINCT submission_code)
|
||||
INTO v_dr_codes_distinct FROM paliad.deadline_rules WHERE submission_code IS NOT NULL;
|
||||
SELECT COUNT(*) INTO v_dr_codes_null FROM paliad.deadline_rules WHERE submission_code IS NULL;
|
||||
SELECT COUNT(DISTINCT legal_source)
|
||||
INTO v_dr_legal_distinct FROM paliad.deadline_rules WHERE legal_source IS NOT NULL;
|
||||
SELECT COUNT(*) INTO v_pe_total FROM paliad.procedural_events;
|
||||
SELECT COUNT(*) INTO v_sr_total FROM paliad.sequencing_rules;
|
||||
SELECT COUNT(*) INTO v_ls_total FROM paliad.legal_sources;
|
||||
SELECT COUNT(*) INTO v_deadlines_total FROM paliad.deadlines;
|
||||
SELECT COUNT(*) INTO v_deadlines_linked FROM paliad.deadlines WHERE procedural_event_id IS NOT NULL;
|
||||
|
||||
-- a. procedural_events row count = distinct_codes + null_codes
|
||||
IF v_pe_total <> v_dr_codes_distinct + v_dr_codes_null THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: procedural_events count mismatch — got %, expected % (% distinct codes + % null-code rules)',
|
||||
v_pe_total, v_dr_codes_distinct + v_dr_codes_null, v_dr_codes_distinct, v_dr_codes_null;
|
||||
END IF;
|
||||
|
||||
-- b. sequencing_rules row count = deadline_rules row count (1:1)
|
||||
IF v_sr_total <> v_dr_total THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: sequencing_rules count mismatch — got %, expected % (1:1 with deadline_rules)',
|
||||
v_sr_total, v_dr_total;
|
||||
END IF;
|
||||
|
||||
-- c. legal_sources row count = distinct legal_source in deadline_rules
|
||||
IF v_ls_total <> v_dr_legal_distinct THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: legal_sources count mismatch — got %, expected % (distinct legal_source)',
|
||||
v_ls_total, v_dr_legal_distinct;
|
||||
END IF;
|
||||
|
||||
-- d. every sequencing_rules row's procedural_event_id resolves
|
||||
SELECT COUNT(*)
|
||||
INTO v_orphan_pe
|
||||
FROM paliad.sequencing_rules sr
|
||||
LEFT JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE pe.id IS NULL;
|
||||
IF v_orphan_pe > 0 THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: % sequencing_rules row(s) have no resolving procedural_event_id', v_orphan_pe;
|
||||
END IF;
|
||||
|
||||
-- e. no two synthetic codes collide (would have crashed the INSERT
|
||||
-- via UNIQUE, but assert again for clarity — collision among 78
|
||||
-- UUIDs at 8 hex chars is ~6e-7 probability)
|
||||
SELECT COUNT(*)
|
||||
INTO v_dup_synthetic
|
||||
FROM (
|
||||
SELECT code, COUNT(*) AS n
|
||||
FROM paliad.procedural_events
|
||||
WHERE code LIKE 'null.%'
|
||||
GROUP BY code
|
||||
HAVING COUNT(*) > 1
|
||||
) d;
|
||||
IF v_dup_synthetic > 0 THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: % synthetic codes collided. '
|
||||
'Re-run with a longer substring (16 hex chars instead of 8) '
|
||||
'or full uuid in the code-mint expression.', v_dup_synthetic;
|
||||
END IF;
|
||||
|
||||
-- f. every procedural_events.legal_source_id either resolves or is
|
||||
-- NULL (NULL is fine — 119 of 231 rules have NULL legal_source)
|
||||
SELECT COUNT(*)
|
||||
INTO v_pe_missing_ls
|
||||
FROM paliad.procedural_events pe
|
||||
LEFT JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id
|
||||
WHERE pe.legal_source_id IS NOT NULL
|
||||
AND ls.id IS NULL;
|
||||
IF v_pe_missing_ls > 0 THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: % procedural_events row(s) reference a missing legal_sources id', v_pe_missing_ls;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[mig 136] POST: legal_sources=%, procedural_events=%, sequencing_rules=%, deadlines=% (% linked)',
|
||||
v_ls_total, v_pe_total, v_sr_total, v_deadlines_total, v_deadlines_linked;
|
||||
RAISE NOTICE '[mig 136] integrity OK — backfill complete. '
|
||||
'deadline_rules untouched (1:1 with sequencing_rules; '
|
||||
'ready for B.2 dual-write).';
|
||||
END $$;
|
||||
@@ -1,18 +0,0 @@
|
||||
-- 137_proceeding_role_labels — DOWN
|
||||
--
|
||||
-- Drops the 4 role-label columns. Backfilled data is lost on
|
||||
-- down-migration; that's acceptable because the frontend renderer
|
||||
-- falls back to the default labels ("Klägerseite" / "Beklagtenseite")
|
||||
-- when the columns are absent.
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS role_reactive_label_en;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS role_reactive_label_de;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS role_proactive_label_en;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS role_proactive_label_de;
|
||||
@@ -1,137 +0,0 @@
|
||||
-- 137_proceeding_role_labels — t-paliad-301, m/paliad#132
|
||||
--
|
||||
-- Bug A fix: per-proceeding role labels so the Verfahrensablauf side
|
||||
-- selector can render "Berufungskläger / Berufungsbeklagter" for the
|
||||
-- unified UPC Berufung tile instead of the generic "Klägerseite /
|
||||
-- Beklagtenseite".
|
||||
--
|
||||
-- Four new optional columns on paliad.proceeding_types. NULL on a
|
||||
-- column falls back to the language-default ("Klägerseite" / "Claimant
|
||||
-- side" / "Beklagtenseite" / "Defendant side") in the frontend renderer.
|
||||
-- Only the proceedings whose role-naming actually differs get a backfill.
|
||||
--
|
||||
-- Live-DB audit (mcp__supabase__execute_sql) before drafting:
|
||||
-- - paliad.proceeding_types has 14 columns; the 4 target columns do
|
||||
-- NOT exist (zero name collisions).
|
||||
-- - Zero triggers on paliad.proceeding_types. No audit_reason
|
||||
-- setup needed.
|
||||
-- - No updated_at / created_at on the table — DO NOT include
|
||||
-- timestamp UPDATEs (lesson from mig 134 HOTFIX 3).
|
||||
--
|
||||
-- ADDITIVE ONLY. ALTER + UPDATE statements; no CHECK constraints
|
||||
-- (the columns are free-text labels, validated at the application layer).
|
||||
-- Down migration drops the 4 columns.
|
||||
--
|
||||
-- See m/paliad#132 for the full design rationale + the role-label
|
||||
-- matrix per proceeding code.
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Schema additions
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN role_proactive_label_de text NULL;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN role_proactive_label_en text NULL;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN role_reactive_label_de text NULL;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN role_reactive_label_en text NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.proceeding_types.role_proactive_label_de IS
|
||||
'DE label for the proactive (claimant-equivalent) side of this '
|
||||
'proceeding. NULL = renderer falls back to "Klägerseite". '
|
||||
't-paliad-301 / m/paliad#132 Bug A.';
|
||||
COMMENT ON COLUMN paliad.proceeding_types.role_proactive_label_en IS
|
||||
'EN label for the proactive side. NULL = "Claimant side".';
|
||||
COMMENT ON COLUMN paliad.proceeding_types.role_reactive_label_de IS
|
||||
'DE label for the reactive (defendant-equivalent) side. NULL = '
|
||||
'"Beklagtenseite".';
|
||||
COMMENT ON COLUMN paliad.proceeding_types.role_reactive_label_en IS
|
||||
'EN label for the reactive side. NULL = "Defendant side".';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Audit-first NOTICE pass.
|
||||
--
|
||||
-- Lists which proceeding_types are about to receive a backfill so
|
||||
-- the operator sees the scope before the UPDATE fires. NULL columns
|
||||
-- on every other row stay NULL (the frontend falls back to defaults).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
backfill_count int := 0;
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 137] Proceedings that will receive role-label backfill:';
|
||||
FOR rec IN
|
||||
SELECT code, name
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code IN ('upc.apl.unified', 'upc.rev.cfi', 'epa.opp.opd', 'epa.opp.boa')
|
||||
ORDER BY code
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 137] % %', rec.code, rec.name;
|
||||
backfill_count := backfill_count + 1;
|
||||
END LOOP;
|
||||
RAISE NOTICE '[mig 137] Total: % proceedings (others stay NULL → renderer default)', backfill_count;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Backfill.
|
||||
--
|
||||
-- Per the design matrix in m/paliad#132:
|
||||
-- - upc.apl.unified → Berufungskläger / Berufungsbeklagter / Appellant / Appellee
|
||||
-- - upc.rev.cfi → Antragsteller (Nichtigkeit) / Antragsgegner (Nichtigkeit) /
|
||||
-- Revocation claimant / Revocation defendant
|
||||
-- - epa.opp.opd → Einsprechende(r) / Patentinhaber(in) /
|
||||
-- Opponent / Patentee
|
||||
-- - epa.opp.boa → Einsprechende(r) / Patentinhaber(in) /
|
||||
-- Opponent / Patentee
|
||||
-- - (others) → stay NULL → frontend defaults
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Berufungskläger',
|
||||
role_reactive_label_de = 'Berufungsbeklagter',
|
||||
role_proactive_label_en = 'Appellant',
|
||||
role_reactive_label_en = 'Appellee'
|
||||
WHERE code = 'upc.apl.unified';
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Antragsteller (Nichtigkeit)',
|
||||
role_reactive_label_de = 'Antragsgegner (Nichtigkeit)',
|
||||
role_proactive_label_en = 'Revocation claimant',
|
||||
role_reactive_label_en = 'Revocation defendant'
|
||||
WHERE code = 'upc.rev.cfi';
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Einsprechende(r)',
|
||||
role_reactive_label_de = 'Patentinhaber(in)',
|
||||
role_proactive_label_en = 'Opponent',
|
||||
role_reactive_label_en = 'Patentee'
|
||||
WHERE code IN ('epa.opp.opd', 'epa.opp.boa');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. Post-migration NOTICE — informational only.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 137] post: backfilled role-label distribution:';
|
||||
FOR rec IN
|
||||
SELECT code,
|
||||
role_proactive_label_de,
|
||||
role_reactive_label_de
|
||||
FROM paliad.proceeding_types
|
||||
WHERE role_proactive_label_de IS NOT NULL
|
||||
ORDER BY code
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 137] % proactive=% reactive=%',
|
||||
rec.code, rec.role_proactive_label_de, rec.role_reactive_label_de;
|
||||
END LOOP;
|
||||
END $$;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user