Compare commits
150 Commits
mai/feynma
...
mai/lorenz
| Author | SHA1 | Date | |
|---|---|---|---|
| c4564b4031 | |||
| 7dae9b2216 | |||
| 99a72a744f | |||
| f9305d6108 | |||
| 7f72ee7b9e | |||
| d027b0874c | |||
| 7571e43078 | |||
| c7b48f6ea7 | |||
| 8f6cee5a83 | |||
| edc81bbbc2 | |||
| 08e20883a5 | |||
| 86946ba441 | |||
| 193b988798 | |||
| 1c45c93570 | |||
| 36bdfecb04 | |||
| 936c4967fd | |||
| 7decc5095f | |||
| b21ce6dd7b | |||
| 358c64d172 | |||
| 5d22e5db21 | |||
| 09615ec48e | |||
| 5431fcd3cd | |||
| 16ae2f0cf0 | |||
| 4c3d091280 | |||
| d6f5e0c97e | |||
| a55f45ebea | |||
| 6f77c8354c | |||
| b64d929586 | |||
| e30bfe89da | |||
| d8edea0f4c | |||
| 65617a5dcb | |||
| 7bfec310a0 | |||
| 253dc1d1b3 | |||
| 992b99c375 | |||
| 7afbf52f3e | |||
| 663ef64c62 | |||
| 5b81f2159e | |||
| 275cbd5e51 | |||
| 76cbc311ed | |||
| 0f142e07af | |||
| d7bb238e46 | |||
| 990cc2b797 | |||
| 650d30f99f | |||
| 6cddb2e587 | |||
| 8a814e3442 | |||
| 5f9a8b2ef4 | |||
| ee2caf9d79 | |||
| 88d5656a35 | |||
| 238c4d7cf0 | |||
| 32a620b788 | |||
| 9d73b91e05 | |||
| b966d7c8cd | |||
| 755a1042ff | |||
| c7fa0d6542 | |||
| 1f8230b264 | |||
| bd8ec42b80 | |||
| ec0ec32271 | |||
| 251f5a250f | |||
| 58a1abc6d8 | |||
| 7159443dcb | |||
| 119b06dcff | |||
| 1c915639b9 | |||
| 83a3d27fe0 | |||
| 79f6be3fc9 | |||
| b455df265e | |||
| 7d9935de60 | |||
| e9bcf3a7b6 | |||
| 1ad78918bc | |||
| 5e1f1fecf6 | |||
| 731e762919 | |||
| 581fbe7d92 | |||
| 8f5b83ec93 | |||
| 7c4bc39115 | |||
| adf377c2ca | |||
| f5eb84718a | |||
| 1255ee049f | |||
| 0105d35f0c | |||
| 0531e5dbf6 | |||
| 0099e2f28c | |||
| 3ba5727deb | |||
| d8f7745f86 | |||
| 98a51faa66 | |||
| b24063bee1 | |||
| d1314a46f9 | |||
| 968b0bc2da | |||
| cd1a70d08c | |||
| bdb3d8a425 | |||
| 30f7031e99 | |||
| 8e9cde6d52 | |||
| a3adb6b13b | |||
| ed4e731333 | |||
| b0a6b0998f | |||
|
|
54b227ce7b | ||
|
|
c2f1c29b10 | ||
|
|
17e96b7a1c | ||
|
|
84020022a6 | ||
|
|
7930ee0bdb | ||
|
|
7e57507a92 | ||
|
|
7da8802f9b | ||
|
|
91d3811276 | ||
|
|
483649d9d2 | ||
|
|
82888dea78 | ||
|
|
306bb11618 | ||
|
|
196f3f74a6 | ||
|
|
331efc8603 | ||
|
|
85d7dd497c | ||
|
|
335be29b23 | ||
|
|
0835be4a7f | ||
|
|
3e1bbd3c77 | ||
|
|
7057fe5d25 | ||
|
|
4a5d56d9e6 | ||
|
|
afd3aab2b2 | ||
|
|
49c260b888 | ||
|
|
12b35fc9fe | ||
|
|
ebcda13f88 | ||
|
|
487fec2672 | ||
|
|
f8cc86cd02 | ||
|
|
69544bf3fb | ||
|
|
7fef64159b | ||
|
|
7238b12b05 | ||
|
|
54cf7ac2f6 | ||
|
|
f4815a9f9a | ||
|
|
ce180123c3 | ||
|
|
7a35cad09f | ||
|
|
6058d21ce6 | ||
|
|
52caba51ec | ||
|
|
1faffb682e | ||
|
|
4b681792ab | ||
|
|
236bb3270e | ||
|
|
4670cd660a | ||
|
|
1e97eccaed | ||
|
|
3a41acee07 | ||
|
|
de4e133f03 | ||
|
|
0c12644563 | ||
|
|
5d9c62d858 | ||
|
|
188d8ec9ba | ||
|
|
d5a01e6682 | ||
|
|
02d4ac2f4e | ||
|
|
ae1cba4e24 | ||
|
|
1e23745792 | ||
|
|
1782dfa910 | ||
|
|
936aca5925 | ||
|
|
0b47343aa3 | ||
|
|
f31307afcb | ||
|
|
aa112d2589 | ||
|
|
dc35d2da69 | ||
|
|
d2790a0461 | ||
|
|
97d49898b7 | ||
|
|
5b08bfcb96 | ||
|
|
fc048c578e |
@@ -47,9 +47,13 @@ Paliad — the patent paladin. All-in-one patent practice platform for HLC (form
|
||||
| `PALIAD_BASE_URL` | optional | Public origin used in email links. Defaults to `https://paliad.de`; override for staging/preview. |
|
||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_FROM_NAME` / `SMTP_USE_TLS` | for email | SMTP credentials for Paliad's transactional mail (reminders, invitations). Port 465 uses implicit TLS. `MailService` silently no-ops when any required var is missing — the server still boots for knowledge-platform-only deployments. |
|
||||
| `ANTHROPIC_API_KEY` | not used in PoC | Reserved for the eventual production-v1 Paliadin (the Anthropic Messages API path, see `docs/design-paliadin-2026-05-07.md` §2). The Phase 0 PoC (t-paliad-146) does NOT use this — it shells out to a local `claude` CLI via tmux instead, which uses m's existing Claude Code subscription. Set this env var only after the PoC validates and we cut over to the API-backed path. The earlier "Phase H Frist-Extraktion" reservation is dead — that feature is deferred separately (memory `b6a11b55…`). |
|
||||
| `PALIADIN_SESSION_PREFIX` | optional (default `paliad-paliadin`) | Prefix for the per-user tmux session names the Paliadin service uses (t-paliad-155). Each Paliad user gets their own session named `<prefix>-<userid8>` (first 8 hex chars of the user's UUID); conversation history accumulates per visit, `ResetSession` kills the session entirely. The persona + response protocol now live in `~/.claude/skills/paliadin/SKILL.md` (installed via `scripts/install-paliadin-skill`) — no in-process system prompt is sent. |
|
||||
| `PALIADIN_SESSION_PREFIX` | optional (default `paliad-paliadin`) | Prefix for the per-user tmux session names the legacy Paliadin service uses (t-paliad-155). Each Paliad user gets their own session named `<prefix>-<userid8>` (first 8 hex chars of the user's UUID); conversation history accumulates per visit, `ResetSession` kills the session entirely. **Skill source-of-truth moved to `m/mAi` under `skills/aichat/paliadin/` (m's 2026-05-13 decision, t-paliad-194).** The aichat backend owns installation on mRiver via its own deploy doc (`m/mAi/docs/reference/aichat-deploy.md`). Legacy `LocalPaliadinService` (PoC) and `RemotePaliadinService` (shim) still rely on `~/.claude/skills/paliadin/SKILL.md` being present on the target host — install it manually from the aichat repo until those paths are retired. |
|
||||
| `PALIADIN_REMOTE_CWD` | shim env (default `/home/m/dev/paliad`) | Working directory `paliadin-shim` uses when spawning the long-lived `claude` pane on mRiver. Must be the paliad repo root so claude picks up `.mcp.json` (project-scoped Supabase MCP); without it, the SKILL.md SQL recipes have no DB tool. Set on mRiver only — paliad's Go side never reads this. |
|
||||
| `PALIADIN_RESPONSE_DIR` | optional (default `/tmp/paliadin`) | Directory where Claude writes its per-turn response files. The Go service polls this directory for `{turn_id}.txt` files. |
|
||||
| `PALIADIN_RESPONSE_DIR` | optional (default `/tmp/paliadin`) | Directory where Claude writes its per-turn response files. The Go service polls this directory for `{turn_id}.txt` files. (Legacy `LocalPaliadinService` path only — aichat owns its own response dir at `/tmp/aichat/paliadin/`.) |
|
||||
| `PALIADIN_BACKEND` | optional (default `legacy`) | Selects which Paliadin backend boots (t-paliad-194 / m/paliad#38 Phase B). `legacy` keeps the existing tree (`PALIADIN_REMOTE_HOST` → SSH shim, else local tmux, else disabled). `aichat` opts into the centralized `m/mAi#207` backend on mRiver — `RemotePaliadinService`/`LocalPaliadinService` are bypassed and `AichatPaliadinService` issues HTTP calls instead. Parallel paths during the migration window; flip back is one env-var change. |
|
||||
| `AICHAT_URL` | required when `PALIADIN_BACKEND=aichat` | Aichat service root (typically `http://100.99.98.203:8765` over Tailscale; see `m/mAi/docs/reference/aichat-deploy.md`). No trailing slash needed. |
|
||||
| `AICHAT_TOKEN` | required when `PALIADIN_BACKEND=aichat` | Raw bearer token registered for paliad's app_id in aichat's `tokens.yaml`. Distributed via Dokploy secret per Q11 (age-encrypted at rest). |
|
||||
| `AICHAT_PERSONA` | optional (default `paliadin`) | Persona id to target. Override only when running a non-default deploy (e.g. staging persona). |
|
||||
|
||||
> *Note on Paliadin gating (t-paliad-146):* there is **no** `PALIADIN_ENABLED` env var. Access is gated in code via `services.PaliadinOwnerEmail` (currently `matthias.siebels@hoganlovells.com`). Every other authenticated user gets a 404 on `/paliadin` and `/admin/paliadin`. This means the routes register on every paliad deploy (including paliad.de prod), but only m can reach them — and even then, prod only works if the host has `tmux` + a `claude` CLI in PATH (which the Dokploy container does not). PoC remains a laptop-only feature; the gate is in the code, not the deploy.
|
||||
| `FIRM_NAME` | optional (default `HLC`) | Display name of the firm Paliad is being branded for in this deployment. Read once at process start by `internal/branding.Name` (Go) and inlined into client bundles by `frontend/build.ts` (TypeScript). Powers every user-facing surface — landing hero, page titles, login hint, Downloads page, footer, invitation/reminder email bodies. The `ALLOWED_EMAIL_DOMAINS` whitelist is a separate concern (real DNS domains, not display name) and rotates independently. |
|
||||
|
||||
@@ -147,7 +147,15 @@ func main() {
|
||||
Calculator: services.NewDeadlineCalculator(holidays),
|
||||
Users: users,
|
||||
Fristenrechner: services.NewFristenrechnerService(rules, holidays, courts),
|
||||
EventDeadline: services.NewEventDeadlineService(pool, services.NewDeadlineCalculator(holidays), holidays, courts),
|
||||
EventDeadline: services.NewEventDeadlineService(
|
||||
pool,
|
||||
services.NewDeadlineCalculator(holidays),
|
||||
holidays,
|
||||
courts,
|
||||
services.NewFristenrechnerService(rules, holidays, courts),
|
||||
),
|
||||
EventTrigger: services.NewEventTriggerService(pool, rules, holidays, courts),
|
||||
RuleEditor: services.NewRuleEditorService(pool, rules),
|
||||
Courts: courts,
|
||||
DeadlineSearch: services.NewDeadlineSearchService(pool),
|
||||
EventCategory: nil, // wired below; cross-link order matters
|
||||
@@ -168,35 +176,61 @@ func main() {
|
||||
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
|
||||
Pin: services.NewPinService(pool, projectSvc),
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
|
||||
}
|
||||
|
||||
// Paliadin backend selection (t-paliad-146 + t-paliad-151):
|
||||
// PALIADIN_REMOTE_HOST set → RemotePaliadinService (ssh to mRiver)
|
||||
// else: local tmux available → LocalPaliadinService (PoC path)
|
||||
// else: DisabledPaliadinService (handlers still 404 for non-owners
|
||||
// via the gate; for m, RunTurn returns ErrPaliadinDisabled
|
||||
// which surfaces as a friendly error).
|
||||
// Paliadin backend selection.
|
||||
//
|
||||
// All three implement services.Paliadin; the per-request handler
|
||||
// gate (requirePaliadinOwner) is unchanged and applies to every
|
||||
// backend.
|
||||
if remoteHost := os.Getenv("PALIADIN_REMOTE_HOST"); remoteHost != "" {
|
||||
cfg, err := buildPaliadinRemoteConfig(remoteHost)
|
||||
// PALIADIN_BACKEND (t-paliad-194 / m/paliad#38):
|
||||
// "aichat" → AichatPaliadinService (HTTP client of the
|
||||
// centralized aichat backend on mRiver,
|
||||
// shipped in m/mAi#207 Phase A).
|
||||
// "legacy" / unset / etc → fall through to the pre-aichat tree:
|
||||
// PALIADIN_REMOTE_HOST set → RemotePaliadinService (ssh shim)
|
||||
// else: local tmux available → LocalPaliadinService (PoC path)
|
||||
// else → DisabledPaliadinService
|
||||
//
|
||||
// The aichat path is opt-in for the migration window so a flip
|
||||
// back is one env-var change. Once aichat soaks, legacy can be
|
||||
// retired in a follow-up slice.
|
||||
//
|
||||
// All four implementations satisfy services.Paliadin; the per-
|
||||
// request handler gate (requirePaliadinOwner) is unchanged.
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("PALIADIN_BACKEND"))) {
|
||||
case "aichat":
|
||||
cfg, err := buildAichatPaliadinConfig(jwtSecret)
|
||||
if err != nil {
|
||||
log.Fatalf("paliadin: remote config: %v", err)
|
||||
log.Fatalf("paliadin: aichat config: %v", err)
|
||||
}
|
||||
svcBundle.Paliadin = services.NewAichatPaliadinService(pool, users, cfg)
|
||||
log.Printf("paliadin: aichat mode → %s persona=%s (owner=%s, rls=%s)",
|
||||
cfg.BaseURL, cfg.Persona, services.PaliadinOwnerEmail,
|
||||
rlsModeLabel(cfg.JWTSecret))
|
||||
default:
|
||||
if remoteHost := os.Getenv("PALIADIN_REMOTE_HOST"); remoteHost != "" {
|
||||
cfg, err := buildPaliadinRemoteConfig(remoteHost)
|
||||
if err != nil {
|
||||
log.Fatalf("paliadin: remote config: %v", err)
|
||||
}
|
||||
svcBundle.Paliadin = services.NewRemotePaliadinService(pool, users, cfg)
|
||||
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
|
||||
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
|
||||
} else if _, err := exec.LookPath("tmux"); err == nil {
|
||||
sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
|
||||
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
|
||||
local := services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
|
||||
// Late-response janitor — patches rows when Claude writes the
|
||||
// response file after the 60 s pollForResponse window expires.
|
||||
// Runs for the process lifetime; cleaned up when bgCtx
|
||||
// cancels on SIGTERM.
|
||||
local.StartJanitor(bgCtx)
|
||||
svcBundle.Paliadin = local
|
||||
log.Printf("paliadin: local tmux mode (owner=%s, janitor=on)", services.PaliadinOwnerEmail)
|
||||
} else {
|
||||
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
|
||||
log.Printf("paliadin: disabled (no PALIADIN_REMOTE_HOST, no local tmux; owner=%s)",
|
||||
services.PaliadinOwnerEmail)
|
||||
}
|
||||
svcBundle.Paliadin = services.NewRemotePaliadinService(pool, users, cfg)
|
||||
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
|
||||
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
|
||||
} else if _, err := exec.LookPath("tmux"); err == nil {
|
||||
sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
|
||||
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
|
||||
svcBundle.Paliadin = services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
|
||||
log.Printf("paliadin: local tmux mode (owner=%s)", services.PaliadinOwnerEmail)
|
||||
} else {
|
||||
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
|
||||
log.Printf("paliadin: disabled (no PALIADIN_REMOTE_HOST, no local tmux; owner=%s)",
|
||||
services.PaliadinOwnerEmail)
|
||||
}
|
||||
// Wire ApprovalService into the entity services so Create / Update /
|
||||
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
|
||||
@@ -367,3 +401,49 @@ func cmpOr(s, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// buildAichatPaliadinConfig assembles an AichatPaliadinConfig from the
|
||||
// environment for PALIADIN_BACKEND=aichat (t-paliad-194 / m/paliad#38).
|
||||
//
|
||||
// Required:
|
||||
//
|
||||
// AICHAT_URL — service root (e.g. http://100.99.98.203:8765).
|
||||
// AICHAT_TOKEN — raw bearer token paliad's app_id is registered
|
||||
// under in aichat's tokens.yaml (see m/mAi
|
||||
// docs/reference/aichat-deploy.md).
|
||||
//
|
||||
// Optional:
|
||||
//
|
||||
// AICHAT_PERSONA — persona id; defaults to "paliadin".
|
||||
//
|
||||
// jwtSecret comes from the same SUPABASE_JWT_SECRET that auth.NewClient
|
||||
// already requires at boot — never empty when we reach this code path.
|
||||
// It's threaded in so the aichat service can mint per-turn user-scoped
|
||||
// JWTs (folded-in t-paliad-156 work).
|
||||
func buildAichatPaliadinConfig(jwtSecret string) (services.AichatPaliadinConfig, error) {
|
||||
cfg := services.AichatPaliadinConfig{
|
||||
BaseURL: strings.TrimRight(os.Getenv("AICHAT_URL"), "/"),
|
||||
BearerToken: os.Getenv("AICHAT_TOKEN"),
|
||||
Persona: cmpOr(os.Getenv("AICHAT_PERSONA"), services.DefaultAichatPersona),
|
||||
JWTSecret: []byte(jwtSecret),
|
||||
}
|
||||
if cfg.BaseURL == "" {
|
||||
return cfg, fmt.Errorf("AICHAT_URL must be set when PALIADIN_BACKEND=aichat")
|
||||
}
|
||||
if cfg.BearerToken == "" {
|
||||
return cfg, fmt.Errorf("AICHAT_TOKEN must be set when PALIADIN_BACKEND=aichat")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// rlsModeLabel labels the boot log so the operator can confirm whether
|
||||
// the per-user JWT mint is active. "per-user" means we're handing the
|
||||
// claude pane user-scoped claims; "service-role" means we're not (no
|
||||
// SUPABASE_JWT_SECRET) and the skill will reject queries rather than
|
||||
// run as supabase_admin.
|
||||
func rlsModeLabel(secret []byte) string {
|
||||
if len(secret) == 0 {
|
||||
return "service-role"
|
||||
}
|
||||
return "per-user"
|
||||
}
|
||||
|
||||
86
cmd/server/main_paliadin_backend_test.go
Normal file
86
cmd/server/main_paliadin_backend_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildAichatPaliadinConfig pins the env-driven wiring used by the
|
||||
// PALIADIN_BACKEND=aichat path in main(). It guards three things:
|
||||
//
|
||||
// 1. Required vars (AICHAT_URL, AICHAT_TOKEN) must be set — otherwise
|
||||
// boot fails fast with a clear error message.
|
||||
// 2. AICHAT_PERSONA defaults to "paliadin" so a misconfigured deploy
|
||||
// doesn't silently route to a different persona.
|
||||
// 3. The JWT secret threads through so per-turn JWT mint is on by
|
||||
// default (folded-in t-paliad-156 work).
|
||||
//
|
||||
// We can't unit-test the switch{} block in main() directly without
|
||||
// invoking the rest of boot, so this test exercises the helper that
|
||||
// branch calls — the same surface a Phase B regression would hit.
|
||||
func TestBuildAichatPaliadinConfig(t *testing.T) {
|
||||
t.Run("rejects empty URL", func(t *testing.T) {
|
||||
t.Setenv("AICHAT_URL", "")
|
||||
t.Setenv("AICHAT_TOKEN", "tok")
|
||||
_, err := buildAichatPaliadinConfig("secret")
|
||||
if err == nil || !strings.Contains(err.Error(), "AICHAT_URL") {
|
||||
t.Errorf("err = %v; want AICHAT_URL complaint", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects empty token", func(t *testing.T) {
|
||||
t.Setenv("AICHAT_URL", "http://aichat.test")
|
||||
t.Setenv("AICHAT_TOKEN", "")
|
||||
_, err := buildAichatPaliadinConfig("secret")
|
||||
if err == nil || !strings.Contains(err.Error(), "AICHAT_TOKEN") {
|
||||
t.Errorf("err = %v; want AICHAT_TOKEN complaint", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaults persona to paliadin", func(t *testing.T) {
|
||||
t.Setenv("AICHAT_URL", "http://aichat.test/")
|
||||
t.Setenv("AICHAT_TOKEN", "tok")
|
||||
t.Setenv("AICHAT_PERSONA", "")
|
||||
cfg, err := buildAichatPaliadinConfig("secret")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if cfg.Persona != "paliadin" {
|
||||
t.Errorf("persona = %q; want paliadin", cfg.Persona)
|
||||
}
|
||||
if cfg.BaseURL != "http://aichat.test" {
|
||||
t.Errorf("base url trailing slash leaked: %q", cfg.BaseURL)
|
||||
}
|
||||
if string(cfg.JWTSecret) != "secret" {
|
||||
t.Errorf("JWT secret not threaded; got %q", string(cfg.JWTSecret))
|
||||
}
|
||||
if cfg.BearerToken != "tok" {
|
||||
t.Errorf("BearerToken = %q; want tok", cfg.BearerToken)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("honours AICHAT_PERSONA override", func(t *testing.T) {
|
||||
t.Setenv("AICHAT_URL", "http://aichat.test")
|
||||
t.Setenv("AICHAT_TOKEN", "tok")
|
||||
t.Setenv("AICHAT_PERSONA", "custom-paliadin")
|
||||
cfg, err := buildAichatPaliadinConfig("secret")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if cfg.Persona != "custom-paliadin" {
|
||||
t.Errorf("persona = %q; want custom-paliadin", cfg.Persona)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRLSModeLabel(t *testing.T) {
|
||||
if got := rlsModeLabel(nil); got != "service-role" {
|
||||
t.Errorf("nil → %q; want service-role", got)
|
||||
}
|
||||
if got := rlsModeLabel([]byte{}); got != "service-role" {
|
||||
t.Errorf("empty → %q; want service-role", got)
|
||||
}
|
||||
if got := rlsModeLabel([]byte("x")); got != "per-user" {
|
||||
t.Errorf("non-empty → %q; want per-user", got)
|
||||
}
|
||||
}
|
||||
@@ -34,5 +34,12 @@ services:
|
||||
- PALIADIN_REMOTE_USER=${PALIADIN_REMOTE_USER}
|
||||
- PALIADIN_SSH_PRIVATE_KEY=${PALIADIN_SSH_PRIVATE_KEY}
|
||||
- PALIADIN_KNOWN_HOSTS=${PALIADIN_KNOWN_HOSTS}
|
||||
# aichat Phase B (t-paliad-194 / m/paliad#38). Set PALIADIN_BACKEND=aichat
|
||||
# to route Paliadin through the centralized aichat backend on mRiver.
|
||||
# Legacy default (unset / "legacy") keeps the existing RemotePaliadinService path.
|
||||
- PALIADIN_BACKEND=${PALIADIN_BACKEND:-legacy}
|
||||
- AICHAT_URL=${AICHAT_URL:-}
|
||||
- AICHAT_TOKEN=${AICHAT_TOKEN:-}
|
||||
- AICHAT_PERSONA=${AICHAT_PERSONA:-paliadin}
|
||||
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred
|
||||
restart: unless-stopped
|
||||
|
||||
799
docs/audit-fristen-logic-2026-05-13.md
Normal file
799
docs/audit-fristen-logic-2026-05-13.md
Normal file
@@ -0,0 +1,799 @@
|
||||
# Audit — Fristen logic (rules, triggers, conditionals)
|
||||
|
||||
**Author:** pauli (inventor)
|
||||
**Date:** 2026-05-13
|
||||
**Task:** t-paliad-157 (reactivated 2026-05-13 21:23 with broader scope)
|
||||
**Phase:** 1 of 3 — Audit. Phase 2 = iterative refinement against m. Phase 3 = ship.
|
||||
**Branch:** `mai/pauli/fristen-logic-audit` (fresh from `origin/main` @ `7d9935d`).
|
||||
**Status:** AUDIT READY FOR REVIEW — m gates the audit → Phase 2 transition.
|
||||
|
||||
m's framing (paliad/head 11:46):
|
||||
|
||||
> the main roadmap thing now is "Fristen". We need the full "Fristen logic" and I am happy to work together with an AI to further design it. Most of it should be "straightforward" as specific events trigger specific deadlines, sometimes multiple and sometimes conditional. It is all in the Rules so we should be able to manage.
|
||||
|
||||
The audit answers: **is m's mental model already encodable in the existing data model, and where are the gaps?**
|
||||
|
||||
Short answer: the rule corpus is substantially richer than the brief implied, **three parallel deadline-generation systems coexist** (with overlapping responsibilities), and the main friction is *managing* rules (SQL-only today) rather than the expressive grammar of the rules themselves.
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live (live DB + live code, not migration files)
|
||||
|
||||
Live state queried via `mcp__supabase__execute_sql` against the `paliad` schema on the youpc Supabase Postgres. Code reads against `mai/pauli/fristen-logic-audit` baseline (origin/main @ `7d9935d`).
|
||||
|
||||
### 0.1 Rule corpus is ~5× richer than the brief implied
|
||||
|
||||
| Table | Rows | Note |
|
||||
|---|---|---|
|
||||
| `paliad.proceeding_types` | **27** | 20 `category='fristenrechner'` + 7 `category='litigation'`. All 27 carry rules. |
|
||||
| `paliad.deadline_rules` | **172** | 132 against fristenrechner codes + 40 against litigation codes. |
|
||||
| `paliad.deadline_concepts` | **56** | The "noun" layer (Klageerwiderung, Berufungsschrift, …) above rules. |
|
||||
| `paliad.event_category_concepts` | **153** | Cascade-leaf → concept junction (with optional `proceeding_type_code` for context-conditional outcomes). |
|
||||
| `paliad.deadline_concept_event_types` | **32** | Concept → event_type default suggestion (per jurisdiction). |
|
||||
| `paliad.trigger_events` | **110** | youpc.org legacy import. Used by the "Was kommt nach…" mode. |
|
||||
| `paliad.event_deadlines` | **77** | trigger_event → deadline_row, with `combine_op` ∈ {min,max} for composite leads. |
|
||||
| `paliad.event_types` | **40+** | Concrete event types (upc_oral_hearing, upc_statement_of_defence, …). |
|
||||
| `paliad.event_categories` | **125** (103 leaves) | Cascade taxonomy. Already audited in t-paliad-166. |
|
||||
| `paliad.courts` | **41** | Forum picker for holiday-calendar regime resolution. |
|
||||
| `paliad.holidays` | **55** | Seed of public holidays + court closures. |
|
||||
| `paliad.deadlines` (live) | **26** | Persisted deadline instances. **Only 1 has `rule_id`.** |
|
||||
| `paliad.project_events` | **89** | Audit-log entries. |
|
||||
| `paliad.events` | **does not exist** | The brief mentioned `paliad.events`; the actual audit table is `paliad.project_events`. |
|
||||
|
||||
### 0.2 Three parallel deadline-generation systems exist today
|
||||
|
||||
| Pipeline | Data source | Calculator | Wire surface |
|
||||
|---|---|---|---|
|
||||
| **A — Proceeding-driven** | `paliad.deadline_rules` (172 rows) | `FristenrechnerService.Calculate(proceedingCode, triggerDate, opts)` (`internal/services/fristenrechner.go:139`) | POST `/api/tools/fristenrechner` (Pathway A wizard, Pathway B cascade, SmartTimeline projection via `ProjectionService.computeProjections`). |
|
||||
| **B — Single-rule (subset of A)** | Same table | `FristenrechnerService.CalculateRule` (around line 480) | POST `/api/tools/fristenrechner/calculate-rule` (Pathway B cascade card-click → inline calc). |
|
||||
| **C — Event-driven (youpc legacy)** | `paliad.trigger_events` + `paliad.event_deadlines` (separate tables) | `EventDeadlineService.Calculate(triggerEventID, triggerDate, courtID)` (`internal/services/event_deadline_service.go:92`) | POST `/api/tools/event-deadlines` (Pathway A wizard's "Was kommt nach…" trigger panel, `frontend/src/client/fristenrechner.ts:833`). |
|
||||
|
||||
Pipelines A and C have **disjoint data**, **disjoint capability sets**, and **overlapping intent**. See §2 for the full picture; §6.1 calls out the redundancy.
|
||||
|
||||
### 0.3 m's "it is all in the Rules so we should be able to manage" — the false premise
|
||||
|
||||
The rule corpus IS in one table (`paliad.deadline_rules`) — 172 rows, 32 columns, expressive. **But there is no application-level rule-management surface.** Every rule edit today is a SQL migration: `internal/db/migrations/{009,012,028,029,031,040,043,044,050,068,…}_*.up.sql`. The Calculate engine reads what's in the table, but the table is seeded by developers, not by m or any user.
|
||||
|
||||
m's "we should be able to manage" reads as a call for a first-class rule-editor in the app (see §8). That's the biggest unfilled deliverable in his framing.
|
||||
|
||||
### 0.4 Production data is sparse — demand-side largely empty
|
||||
|
||||
- **11/11 live projects have NULL `proceeding_type_id`** (per kelvin's t-paliad-178 §0 audit; re-confirmed). The projection pipeline (`projection_service.computeProjections:813`) early-returns when this is NULL, so the SmartTimeline forecast doesn't fire for any production project today.
|
||||
- **Only 1/26 live deadlines has `rule_id` populated.** The rule → deadline linkage is barely exercised. Most deadlines were created manually (free-text title + due_date) before the rule-anchored flow existed.
|
||||
- **89 project_events**: structural milestones + audit-log entries. No tight coupling to rule_ids today.
|
||||
- **trigger_events / event_deadlines** carry 110+77 youpc-legacy rows. Whether they are exercised in production needs Pathway-A "Was kommt nach…" telemetry; out of audit scope.
|
||||
|
||||
### 0.5 Anchor files
|
||||
|
||||
Backend services that consume / produce rules:
|
||||
- `internal/services/fristenrechner.go` — Pipeline A + B. The main calculator. **735 LoC.**
|
||||
- `internal/services/deadline_calculator.go` — pure date-math used by Pipeline C.
|
||||
- `internal/services/deadline_rule_service.go` — CRUD-ish read API. `List`, `GetRuleTree`, `Get`. Hydrates `ConceptDefaultEventTypeID` from `deadline_concept_event_types` for the create-form's Typ chip.
|
||||
- `internal/services/event_deadline_service.go` — Pipeline C. **~300 LoC.**
|
||||
- `internal/services/deadline_service.go` — persistence of `paliad.deadlines` instances.
|
||||
- `internal/services/event_category_service.go` — cascade leaf → concept resolution (t-paliad-133).
|
||||
- `internal/services/projection_service.go` — SmartTimeline (consumes Pipeline A).
|
||||
- `internal/services/holidays.go` + `courts.go` — non-working-day adjustment.
|
||||
|
||||
Handlers:
|
||||
- `internal/handlers/fristenrechner.go` — wires Pipelines A/B/C to HTTP routes.
|
||||
- `internal/handlers/deadlines.go` — paliad.deadlines persistence.
|
||||
- `internal/handlers/deadline_rules_db.go` — admin-style rule list endpoint (read-only).
|
||||
|
||||
Key migration history (rule corpus evolution):
|
||||
- **009** (342 LoC) — initial seed (Tier 1, hand-coded).
|
||||
- **012** (230 LoC) — Fristenrechner seed extension.
|
||||
- **028** (353 LoC) — youpc.org rules import (Pipeline C tables).
|
||||
- **029** (128 LoC) — Tier 1 rule fixes.
|
||||
- **031** (193 LoC) — Tier 2 ports (more proceedings).
|
||||
- **037 + 038** — concept layer addition.
|
||||
- **040** (449 LoC) — concept seed + backfill.
|
||||
- **043** (348 LoC) — DE_INF_OLG / DE_INF_BGH split (instance dimension).
|
||||
- **044** (280 LoC) — DPMA proceedings.
|
||||
- **048 + 049** — event_categories taxonomy (cascade).
|
||||
- **050** — `is_bilateral` backfill (4 rules).
|
||||
- **052** — Determinator ROP coverage audit fixes.
|
||||
- **068** — `is_optional` column.
|
||||
- **073 + 074** — `deadline_concept_event_types` (concept → event_type config layer).
|
||||
|
||||
Net rule-related migrations: **>20 files, >3000 LoC of SQL.** The rule corpus has accreted across many small migrations; no single canonical seed.
|
||||
|
||||
If anything in this audit conflicts with the live state, the live state wins.
|
||||
|
||||
---
|
||||
|
||||
## 1. The rule shape today — `paliad.deadline_rules` column-by-column
|
||||
|
||||
**32 columns.** Most are used; a few are vestigial. Every column verified against live row distribution.
|
||||
|
||||
### 1.1 Identity + relations
|
||||
|
||||
| Column | Type | Nullable | Role |
|
||||
|---|---|---|---|
|
||||
| `id` | uuid PK | NO | Primary key. Referenced by `paliad.deadlines.rule_id`, `paliad.deadline_rules.parent_id` (self-FK), `paliad.deadline_rules.condition_rule_id` (self-FK; unused — see §1.6). |
|
||||
| `proceeding_type_id` | int FK → `proceeding_types.id` | YES | Almost always set; NULL would mean a cross-proceeding rule but **no live rule is NULL** today. |
|
||||
| `parent_id` | uuid self-FK | YES | Rule depends on parent's calculated date as anchor. **108 / 172 rules have parent_id set** (= 63%). Forms a forest, one tree per proceeding. |
|
||||
| `concept_id` | uuid FK → `deadline_concepts.id` | YES | Links the rule to a concept (cross-proceeding noun). **171 / 172 rules linked** (= 99.4%); the one un-linked rule is a stray. |
|
||||
|
||||
### 1.2 Identity strings + labels
|
||||
|
||||
| Column | Note |
|
||||
|---|---|
|
||||
| `code` | Rule-local code (e.g. `inf.sod`, `ccr.amend`). Used by `AnchorOverrides` map keys (rule_code → date). Mostly unique within a proceeding. |
|
||||
| `name` (NOT NULL) | DE display name. |
|
||||
| `name_en` (NOT NULL, default `''`) | EN display name. Empty for some older rules; UI falls back to `name`. |
|
||||
| `description` | Optional long-form. Sparse. |
|
||||
| `rule_code` | The *legal-citation* rule code (e.g. `RoP.23.1`, `§276(1) ZPO`). The UI shows this as the `RuleRef`. NOT the rule's identity — `code` is. |
|
||||
| `legal_source` | Structured citation (e.g. `UPC.RoP.23.1`). Added by mig 038 + 040. **171/172 rules have it.** |
|
||||
| `deadline_notes` / `deadline_notes_en` | Free-text legal-context notes shown in the UI. |
|
||||
| `spawn_label` | Used with `is_spawn=true`: human label for "spawned rule" pattern. |
|
||||
|
||||
### 1.3 The math: anchor + offset + adjustment
|
||||
|
||||
| Column | Note |
|
||||
|---|---|
|
||||
| `duration_value` (NOT NULL, default 0) | Integer offset. `0` = court-set / root anchor / filed-with-parent (see §4). |
|
||||
| `duration_unit` (NOT NULL, default `months`) | Live values: `days`, `months`, `weeks`. **No `working_days`** in live data (`EventDeadlineService` supports it; `deadline_rules` does not). |
|
||||
| `timing` (default `after`) | Live value: **only `after`** in every row. `before` semantic is theoretically there but unused by Pipeline A. (Pipeline C honours `before` via `applyDuration`.) |
|
||||
| `anchor_alt` | Single live value: `priority_date`. Used by exactly **one rule**: `EP_GRANT.ep_grant.publish` (Art. 93 EPÜ, 18mo from priority). Otherwise NULL → use parent's date / triggerDate. |
|
||||
| `alt_duration_value` / `alt_duration_unit` / `alt_rule_code` | Swap-on-flag: when condition_flag is satisfied, the rule renders against the alt values instead of base. Used by UPC_INF `inf.reply` and `inf.rejoin` for the with_ccr swap (RoP.029.a / RoP.029.d). |
|
||||
|
||||
### 1.4 Conditional gating
|
||||
|
||||
| Column | Note |
|
||||
|---|---|
|
||||
| `condition_flag` | `text[]` array. **4 distinct value-sets live**: `[with_amend]`, `[with_cci]`, `[with_ccr]`, `[with_ccr, with_amend]`. Only on UPC_INF + UPC_REV (the 2 richest proceedings). Semantics: rule renders iff **every** element of the array is in caller's `Flags` set. AND semantics; **no OR/NOT today**. |
|
||||
| `condition_rule_id` | uuid self-FK to another rule. **0 / 172 rows populated**. Dead column. Was intended as "rule X applies only if rule Y was triggered" but never wired up. |
|
||||
|
||||
### 1.5 Party + bilateral
|
||||
|
||||
| Column | Note |
|
||||
|---|---|
|
||||
| `primary_party` | Live values: `claimant`, `defendant`, `both`, `court`. Drives the timeline column / row color. NULL allowed. |
|
||||
| `is_bilateral` (NOT NULL, default false) | When `primary_party='both'`, this column tells the renderer whether to **mirror the rule into both party columns** in the timeline (true), or **resolve to one side via perspective + appeal_filed_by** (false). Backfilled by mig 050 — only 4 rules carry `true`: DE_NULL r79, DE_NULL r116, EPA_OPP r79, EPA_APP r116. |
|
||||
|
||||
### 1.6 Flags + lifecycle
|
||||
|
||||
| Column | Note |
|
||||
|---|---|
|
||||
| `is_mandatory` (NOT NULL, default true) | "User must address this." Surfaces in UI badge. |
|
||||
| `is_optional` (NOT NULL, default false) | Added by mig 068. **Distinct from is_mandatory** — semantics today: "the save-modal pre-unchecks these rows; the timeline still renders them." Live: e.g. UPC_INF `inf.cost_app` (RoP.151 Antrag auf Kostenentscheidung) — visible-but-defaulted-off. Naming is confusing (is_mandatory=true + is_optional=true would be self-contradictory); see §6.3. |
|
||||
| `is_spawn` (NOT NULL, default false) | Marks the rule as a "spawn" — emitted when its parent decision fires, but the spawn itself starts a NEW timeline branch (e.g. Appeal off Decision). Used by 8 live rules: APP/AMD/CCR cross-proceeding spawns. **Spawn execution is half-wired**: `projection_service.go:896-901` notes "Cross-proceeding spawn — the calculator can return rules from another proceeding type (Appeal off Decision). We don't have that rule in our map; skip the dependency annotation but still surface the row." — i.e. the row appears in the response but the dependency-annotation graph breaks. |
|
||||
| `is_active` (NOT NULL, default true) | Soft-delete. All 172 live rules have `is_active=true`; soft-delete unused so far. |
|
||||
| `sequence_order` (NOT NULL, default 0) | Calculator walks rules in this order. Must be consistent with topological order on `parent_id` (parents before children). |
|
||||
| `created_at` / `updated_at` | timestamps. |
|
||||
| `event_type` (text, nullable) | One of `decision`, `filing`, `hearing`, `order`. **A category, NOT an FK** to `paliad.event_types`. Distinct from concept-level event_type linkage in §3. |
|
||||
|
||||
### 1.7 Vestigial / under-used
|
||||
|
||||
- `condition_rule_id` — 0 rows populated. Dead column.
|
||||
- `description` — sparse, used as fallback notes.
|
||||
- `is_mandatory` vs `is_optional` — overlapping semantics that need a clean re-think (§6.3).
|
||||
|
||||
---
|
||||
|
||||
## 2. Trigger model today — events to deadlines
|
||||
|
||||
There are **three parallel paths** from a user-observable event to a calculated deadline list. Understanding the redundancy is the most important takeaway of this audit.
|
||||
|
||||
### 2.1 Path A — Proceeding-driven (the main spine)
|
||||
|
||||
Caller: `/tools/fristenrechner` Pathway A (wizard), Pathway B B1 leaf click + B2 search, `ProjectionService.computeProjections` (SmartTimeline).
|
||||
|
||||
Flow:
|
||||
1. User (or projection) picks a **proceeding_code** (e.g. `UPC_INF`) and a **trigger_date**.
|
||||
2. `FristenrechnerService.Calculate(proceedingCode, triggerDate, opts)` runs.
|
||||
3. Calculator loads `deadline_rules WHERE proceeding_type_id = $pt AND is_active`.
|
||||
4. Walks rules in `sequence_order`. For each:
|
||||
- Apply `condition_flag` gate (suppress if flags missing AND alt_duration_value is NULL; otherwise swap to alt_*).
|
||||
- Resolve anchor: `anchor_alt='priority_date'` → use priorityDate; else `parent_id` → parent's computed date; else triggerDate.
|
||||
- Apply `AnchorOverrides[rule_code]` if user set an override.
|
||||
- 4-bucket court-set classification (§4).
|
||||
- Calculate offset, apply holiday/weekend adjustment via `HolidayService`, store in `computed[code]` map.
|
||||
5. Returns `UIResponse{Deadlines: []UIDeadline}` — the full timeline.
|
||||
|
||||
Strengths:
|
||||
- Rich (condition flags, parent chains, anchor_alt, override map, court-set semantics).
|
||||
- Single source of truth for /tools/fristenrechner + SmartTimeline.
|
||||
- Backed by 172 rules across 27 proceedings.
|
||||
|
||||
Weaknesses:
|
||||
- Returns the **whole proceeding** every call. No "give me only the rules triggered by event X" mode.
|
||||
- Cross-proceeding spawn (is_spawn rules) is half-wired (§1.6).
|
||||
- `condition_flag` is AND-only; no OR, NOT, or compound expression.
|
||||
|
||||
### 2.2 Path B — Single-rule (subset of A)
|
||||
|
||||
Caller: Pathway B cascade-card click → inline calc panel.
|
||||
|
||||
Flow:
|
||||
1. User clicks a concept card; system picks the rule_id linked to that concept (via `event_category_concepts → deadline_rules`).
|
||||
2. POST `/api/tools/fristenrechner/calculate-rule` with `{rule_id, trigger_date, flags?}`.
|
||||
3. `FristenrechnerService.CalculateRule` walks the rule's parent chain only (no siblings), returns one `RuleCalculation`.
|
||||
|
||||
Strengths:
|
||||
- Lightweight (no full-proceeding compute).
|
||||
- Lets the cascade UI surface "click → see this rule's date" without rebuilding the whole timeline.
|
||||
|
||||
Weaknesses:
|
||||
- Doesn't include side-effects (sibling rules in the proceeding that the user might also care about).
|
||||
- Shares the same expressiveness limits as Path A.
|
||||
|
||||
### 2.3 Path C — Event-driven (youpc legacy, redundant)
|
||||
|
||||
Caller: Pathway A wizard's "Was kommt nach…" tab; `frontend/src/client/fristenrechner.ts:833` calls POST `/api/tools/event-deadlines`.
|
||||
|
||||
Flow:
|
||||
1. User picks a **trigger_event** (e.g. "Klageerhebung UPC", "Berufungsschrift OLG", from a 110-row picker list).
|
||||
2. POST `/api/tools/event-deadlines` with `{triggerEventID, triggerDate, courtID}`.
|
||||
3. `EventDeadlineService.Calculate` loads `paliad.event_deadlines WHERE trigger_event_id = $te`.
|
||||
4. For each row: apply `duration_value × duration_unit (+ timing: before/after)`. Supports `working_days` unit (Path A doesn't). Handles `alt_duration_value × combine_op (min/max)` composite leads.
|
||||
5. Returns flat list of computed deadlines + rule_codes.
|
||||
|
||||
Strengths:
|
||||
- Has the `before` timing semantic (Path A doesn't use it).
|
||||
- Has `working_days` unit (Path A doesn't have it).
|
||||
- Has `combine_op` (min/max) for composite duration math (Path A doesn't).
|
||||
- Trigger-event picker is more discoverable than "pick a proceeding": user says "Klageerhebung happened on date X, what comes after?" without first navigating to the proceeding tree.
|
||||
|
||||
Weaknesses:
|
||||
- **Disjoint corpus.** The 77 `event_deadlines` rows do NOT join to `paliad.deadline_rules`. Changing a rule in Path A doesn't update Path C.
|
||||
- **No parent_id chains.** Each event_deadline is a single-leg calc off the trigger date. No multi-stage timelines.
|
||||
- **No condition flags.** No with_ccr / with_amend gating.
|
||||
- **No SmartTimeline integration.** ProjectionService only knows Path A.
|
||||
- **Origin:** youpc.org ported (mig 028). Implicitly "legacy", but actively wired.
|
||||
|
||||
### 2.4 The concept layer (orthogonal to all three paths)
|
||||
|
||||
`paliad.deadline_concepts` (56 rows) is the **noun layer** that lets the cascade + search talk about "Klageerwiderung" without knowing which of the 9 jurisdiction-specific Klageerwiderung rules it means. Every rule has `concept_id` (171/172); every cascade leaf has zero or more `event_category_concepts` rows linking to concepts (153 rows, 100 distinct leaves of 103 → 97% coverage).
|
||||
|
||||
`paliad.deadline_concept_event_types` (32 rows, added mig 073/074) maps `(concept_id, jurisdiction) → event_type_id` so when the user creates a Deadline via the form by picking a Regel, the system can pre-fill the Typ chip with the canonical event_type. This is a **CONFIG layer, not a trigger model** — it doesn't say "when event X fires, these deadlines spawn." See §6.4.
|
||||
|
||||
### 2.5 Multi-deadline triggers
|
||||
|
||||
m's "specific events trigger specific deadlines, sometimes multiple" is implemented via **`parent_id` chains in Path A**. One root event (e.g. UPC_INF `inf.soc` = Klageerhebung) triggers a tree of dependent rules. Today the deepest live chain is **3 levels**:
|
||||
|
||||
```text
|
||||
inf.soc (root, anchor)
|
||||
├─ inf.sod (3mo after, Klageerwiderung)
|
||||
│ ├─ inf.def_to_ccr ([with_ccr], 2mo after sod, Erwiderung auf CCR)
|
||||
│ │ └─ inf.reply_def_ccr ([with_ccr], 2mo after, Replik auf Erwid CCR)
|
||||
│ │ └─ inf.rejoin_reply_ccr ([with_ccr], 1mo after, Duplik)
|
||||
│ ├─ inf.app_to_amend ([with_ccr,with_amend], 2mo after sod, Antrag Patentänderung)
|
||||
│ │ ├─ inf.def_to_amend ([with_ccr,with_amend], 2mo after, Erwiderung)
|
||||
│ │ └─ inf.reply_def_amd ([with_ccr,with_amend], 1mo after Reply, Replik Amend)
|
||||
│ ├─ inf.reply (with_ccr → 2mo after sod RoP.029.a; without_ccr → swap to alt)
|
||||
│ └─ inf.rejoin (with_ccr → 1mo after reply RoP.029.d)
|
||||
└─ inf.interim (court-set, Zwischenverfahren)
|
||||
└─ inf.oral (court-set, Mündliche Verhandlung)
|
||||
└─ inf.decision (court-set, Entscheidung)
|
||||
└─ inf.cost_app (1mo after decision, is_optional, Antrag Kostenentscheidung)
|
||||
```
|
||||
|
||||
15 rules, 4 condition-flag-gated, 4 court-set placeholders (inf.interim / inf.oral / inf.decision are 0-duration court-set; inf.soc is 0-duration root), 1 optional. The structural fidelity is high.
|
||||
|
||||
### 2.6 Conditional triggers — the AND-only ceiling
|
||||
|
||||
`condition_flag` is `text[]` with **AND-of-array** semantic. To render the rule, every flag in the array must be in the caller's `Flags` set.
|
||||
|
||||
Live flag space: `{with_amend, with_ccr, with_cci}` — three flags, four combinations used. The empty array is the unconditional default.
|
||||
|
||||
This is enough to express:
|
||||
- "with counterclaim for revocation" (with_ccr alone)
|
||||
- "with counterclaim for revocation AND with amendment" (with_ccr + with_amend)
|
||||
- "with counterclaim for infringement" (with_cci alone)
|
||||
|
||||
But not:
|
||||
- "with_ccr OR with_cci" — would need OR, today not supported. (Live workaround: duplicate rules with each gate.)
|
||||
- "NOT with_ccr" — also not supported.
|
||||
- Compound: "with_ccr AND NOT expedited".
|
||||
|
||||
§6 flags this as a real coverage gap.
|
||||
|
||||
---
|
||||
|
||||
## 3. The 27 proceeding types — what's covered, what's a stub
|
||||
|
||||
### 3.1 Inventory
|
||||
|
||||
| Category | Code | Jurisdiction | Rule count | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **fristenrechner** | DE_INF | DE | 9 | Verletzungsverfahren LG. |
|
||||
| | DE_INF_OLG | DE | 7 | Berufung OLG. |
|
||||
| | DE_INF_BGH | DE | 8 | Revision / NZB BGH. |
|
||||
| | DE_NULL | DE | 10 | Nichtigkeit BPatG. |
|
||||
| | DE_NULL_BGH | DE | 6 | Berufung BGH (Nichtigkeit). |
|
||||
| | DPMA_OPP | DPMA | 4 | DPMA Einspruch. |
|
||||
| | DPMA_BPATG_BESCHWERDE | DPMA | 5 | BPatG-Beschwerde nach DPMA. |
|
||||
| | DPMA_BGH_RB | DPMA | 4 | Rechtsbeschwerde BGH. |
|
||||
| | EPA_OPP | EPA | 8 | EPA Einspruch. |
|
||||
| | EPA_APP | EPA | 8 | EPA Beschwerde. |
|
||||
| | EP_GRANT | EPA | 7 | EP-Erteilung. One rule uses `anchor_alt='priority_date'`. |
|
||||
| | UPC_INF | UPC | **15** | Verletzung. Richest corpus. |
|
||||
| | UPC_REV | UPC | **15** | Nichtigkeit. Richest. |
|
||||
| | UPC_APP | UPC | 7 | Berufung UPC. |
|
||||
| | UPC_APP_ORDERS | UPC | 5 | Berufung gegen Anordnungen. |
|
||||
| | UPC_COST_APPEAL | UPC | 2 | Kostenberufung. |
|
||||
| | UPC_DAMAGES | UPC | 4 | Schadensbemessung. |
|
||||
| | UPC_DISCOVERY | UPC | 4 | Bucheinsicht. |
|
||||
| | UPC_PI | UPC | 4 | Einstweilige Maßnahmen. |
|
||||
| **litigation** | INF | UPC | 8 | Infringement. |
|
||||
| | REV | UPC | 7 | Revocation. |
|
||||
| | CCR | UPC | 7 | Counterclaim for Revocation. |
|
||||
| | APM | UPC | 4 | Provisional Measures. |
|
||||
| | APP | UPC | 8 | Appeal. |
|
||||
| | AMD | UPC | 2 | Application to Amend. |
|
||||
| | ZPO_CIVIL | DE | 4 | ZPO Civil. |
|
||||
|
||||
Total: **172 rules across 27 proceeding types** (132 fristenrechner + 40 litigation).
|
||||
|
||||
### 3.2 Litigation vs Fristenrechner — the dual-corpus problem
|
||||
|
||||
The **same conceptual proceeding** (e.g. UPC Infringement) appears twice in `paliad.proceeding_types`:
|
||||
|
||||
- `INF` (category=`litigation`) — 8 rules, generic UPC labels (Statement of Claim, Statement of Defence, Reply, Rejoinder, Oral Hearing, Interim Conference, Decision, Preliminary Objection).
|
||||
- `UPC_INF` (category=`fristenrechner`) — 15 rules, German labels + condition_flag variants.
|
||||
|
||||
The brief calls this out as "two parallel vocabularies." Live confirms:
|
||||
|
||||
- `paliad.projects.proceeding_type_id` accepts BOTH categories (no CHECK constraint enforces one or the other). Today all 11 projects are NULL anyway.
|
||||
- `FristenrechnerService.Calculate(proceedingCode, …)` is **category-agnostic** — pass it `INF` or `UPC_INF`, you get back the respective corpus's timeline. No category guard.
|
||||
- The Pathway-A wizard surfaces ONLY `category='fristenrechner'` codes (`internal/services/fristenrechner.go:735`: `WHERE category = 'fristenrechner' AND is_active = true`). So users can't pick `INF` from the wizard.
|
||||
- `ProjectionService.computeProjections` resolves `proj.ProceedingTypeID → code` and calls Calculate with whatever code is on the project. So a project with `INF` would render the 8-rule litigation timeline; a project with `UPC_INF` would render the 15-rule fristenrechner timeline.
|
||||
|
||||
**This is a latent footgun.** Whichever code lands on a project first dictates which corpus drives its SmartTimeline. The two corpuses disagree on:
|
||||
- Rule count (8 vs 15).
|
||||
- Granularity (litigation has 1 ccr.defence row; fristenrechner has 7 with_ccr/with_amend gated rows).
|
||||
- Language (litigation labels are English; fristenrechner German).
|
||||
|
||||
No code path treats this divergence intentionally. The likely intent at seed-time was:
|
||||
- `litigation` codes = "the project model's coarse type enum" (Mandant-level taxonomy).
|
||||
- `fristenrechner` codes = "the calculator's fine-grained variants".
|
||||
|
||||
But the actual schema doesn't enforce that contract. **Flagged as §6.2.**
|
||||
|
||||
### 3.3 Coverage observations
|
||||
|
||||
- **UPC corpus dominates fristenrechner.** 9 of the 20 fristenrechner codes are UPC (66 rules); 5 are DE (40); 3 are DPMA (13); 3 are EPA (23). Bias matches HLC's mandate mix.
|
||||
- **DE_INF_OLG, DE_INF_BGH, DE_NULL_BGH** were split out late (mig 043). The instance dimension (LG / OLG / BGH) is NOT on `paliad.projects`, so you can't currently derive whether a DE project is at first instance, OLG, or BGH from the project model. This blocks fine-grained Akte → proceeding-code mapping (cross-referenced in t-paliad-166 §4.2).
|
||||
- **EP_GRANT** is the only proceeding that uses `anchor_alt`. Other priority-date-anchored rules don't exist (yet).
|
||||
- **UPC_REV.with_cci** — the [with_cci] flag is used for "revocation action with counterclaim for infringement" — i.e. when the defendant in a revocation files a CCI. Only UPC_REV uses with_cci today (4 rules).
|
||||
|
||||
### 3.4 Concept linkage gaps
|
||||
|
||||
9 of 56 deadline_concepts have `rule_count = 0` — i.e. cascade-reachable concepts that produce zero calculated deadlines:
|
||||
|
||||
| Concept slug | Why it's empty |
|
||||
|---|---|
|
||||
| `counterclaim-for-revocation` | The CCR flow is modelled inside UPC_INF via `[with_ccr]` flag-gated rules, not as a separate concept-linked rule. |
|
||||
| `schriftsatznachreichung` | ZPO §296a "Schriftsatznachreichung" — cross-cutting concept, no rule encoding yet. |
|
||||
| `versaeumnisurteil-einspruch` | ZPO §339 "Einspruch gegen Versäumnisurteil" — no rule. |
|
||||
| `weiterbehandlung` | EPA Art. 121 EPÜ / R.135 — no rule. |
|
||||
| `wiedereinsetzung` | Re-establishment of rights — cross-cutting; no rule. |
|
||||
| `notice-of-defence-intention` | DE ZPO Verteidigungsanzeige — only ZPO_CIVIL has it; not linked. |
|
||||
| Plus 3 more sparse concepts. | |
|
||||
|
||||
For each, the cascade can route the user to the concept card, but the card has no rule pills underneath. This is a real coverage gap surfaced as §6.
|
||||
|
||||
---
|
||||
|
||||
## 4. Anchor semantics — the 4-bucket model
|
||||
|
||||
Encoded in `fristenrechner.go:272-369`. For each rule with `duration_value = 0`:
|
||||
|
||||
| Bucket | parent_id | court-determined? | Behaviour |
|
||||
|---|---|---|---|
|
||||
| **1. Root anchor** | NULL | no | Due date = trigger date. `IsRootEvent=true`. The proceeding's "day zero" (e.g. SoC filing). |
|
||||
| **2. Court-set absolute** | NULL | yes | Due date empty; UI shows "wird vom Gericht bestimmt". `IsCourtSet=true, IsCourtSetIndirect=false`. Used for top-level hearings / decisions that don't follow from another rule. |
|
||||
| **3. Court-set chained** | set | yes | Due date empty (court determines); ancestor anchor. `IsCourtSet=true`. Used for derivative court actions. |
|
||||
| **4. Filed-with-parent** | set | no | Inherits parent's calculated date. Used for "X is bundled into Y" (e.g. UPC_REV.rev.app_to_amend, rev.cc_inf — included in the Defence to revocation). |
|
||||
|
||||
For rules with `duration_value > 0`:
|
||||
|
||||
- **Override wins.** `AnchorOverrides[rule_code]` provided by user → use it; mark `IsOverridden=true`.
|
||||
- **Parent court-set + no override** → mark `IsCourtSet=true, IsCourtSetIndirect=true`. The rule isn't directly court-determined, but its anchor (the court-set parent) hasn't been bound yet. UI shows "unbestimmt".
|
||||
- **Otherwise:** baseDate = (anchor_alt=priority_date → priorityDate) || (parent_id → computed[parent.code]) || triggerDate. Add `duration_value × duration_unit`. Apply holiday adjustment. Done.
|
||||
|
||||
**Court-set detection** (`isCourtDeterminedRule` in calculator) keys on:
|
||||
- `primary_party='court'`, OR
|
||||
- `event_type ∈ {'hearing','decision','order'}`, OR
|
||||
- Heuristic name match (legacy from migration 028).
|
||||
|
||||
This is brittle — the boolean is computed from columns that aren't strictly designed for it. §6.5 suggests promoting a real `is_court_set` column.
|
||||
|
||||
### 4.1 `AnchorOverrides` — the override map
|
||||
|
||||
The override surface is the bridge between "calculated forecast" and "real ground truth." Two consumers:
|
||||
- **SmartTimeline (`ProjectionService.collectActualsForOverrides`)** — bind a real `paliad.deadlines` row's date back into the calculator: if a saved deadline has `rule_id=X` and `completed_at='2026-04-10'`, the next projection uses 2026-04-10 as the anchor for any rule whose parent is X.
|
||||
- **Pathway A wizard "Anchor edits"** — the user can override a per-rule date inline in the timeline (paliad-088 era feature). Applies to court-set rules where the user finally knows the decision date.
|
||||
|
||||
The override map propagates **downstream**: child rules see the override as their parent's date.
|
||||
|
||||
This is a strong, well-implemented mechanism. No gap.
|
||||
|
||||
---
|
||||
|
||||
## 5. Adjustment semantics — weekends, holidays, court calendars
|
||||
|
||||
### 5.1 `HolidayService.AdjustForNonWorkingDaysWithReason(endDate, country, regime)`
|
||||
|
||||
Called after every offset computation. Returns `(adjusted, _, wasAdjusted, reason)`.
|
||||
|
||||
- If endDate is a weekend → roll to next Monday. Reason: `kind=weekend, original_weekday`.
|
||||
- If endDate is a public holiday (region match in `paliad.holidays`) → roll to next business day. Reason: `kind=public_holiday, holidays=[…]`.
|
||||
- If endDate is inside a court vacation (regime-specific date range) → roll to first non-vacation business day. Reason: `kind=vacation, vacation_name, vacation_start, vacation_end`.
|
||||
|
||||
Live `paliad.holidays`: **55 rows**, mix of public holidays and vacation periods. `region` axis covers DE federal + state-specific + UPC court-specific.
|
||||
|
||||
### 5.2 `CourtService.CountryRegime(courtID, defaultCountry, defaultRegime)`
|
||||
|
||||
`paliad.courts` (41 rows) carries `country` and `regime` per court. Defaults via jurisdiction:
|
||||
- UPC-flavoured proceedings → DE+UPC (UPC München is the default venue).
|
||||
- DE proceedings → DE.
|
||||
- EPA / DPMA → DE.
|
||||
|
||||
Live regimes inferred from queries: DE state codes (BY, BW, …), UPC court-specific tags. No formal CHECK constraint listing valid regimes.
|
||||
|
||||
### 5.3 Working-day arithmetic — split between calculators
|
||||
|
||||
Pipeline C (`EventDeadlineService.addWorkingDays`) supports `duration_unit='working_days'`: step forward N business days, skipping weekends + holidays.
|
||||
|
||||
Pipeline A (`FristenrechnerService`) does NOT support working_days; only calendar days/weeks/months. Adjustment is post-hoc (compute the calendar date, then roll forward if it lands on a non-business day).
|
||||
|
||||
**The two calculators are not equivalent.** Some real-world deadlines are "10 working days after Z" — those can only be expressed in Pipeline C today. Cross-references §6.6.
|
||||
|
||||
---
|
||||
|
||||
## 6. Coverage gaps (the heart of the audit)
|
||||
|
||||
What m's mental model wants ("specific events trigger specific deadlines, sometimes multiple, sometimes conditional") that the data model cannot express today.
|
||||
|
||||
### 6.1 Two trigger systems — Pipeline A vs Pipeline C
|
||||
|
||||
**Symptom.** Two disjoint data corpuses (`deadline_rules` 172 vs `trigger_events`+`event_deadlines` 110+77) with overlapping intent. A change to a rule in Pipeline A doesn't propagate to Pipeline C. The user-facing "Was kommt nach…" tab (Pipeline C) renders different numbers than the wizard timeline (Pipeline A) for nominally-similar trigger events.
|
||||
|
||||
**Impact.** Pipeline C has capabilities Pipeline A lacks (`before` timing, `working_days` unit, `combine_op` min/max) — but no parent chains, no condition_flag, no court-set semantic. Choosing the "right" pipeline today means picking which subset of capabilities the user actually needs for that case.
|
||||
|
||||
**Root cause.** Pipeline C is a youpc.org port (mig 028). Pipeline A is paliad-native (mig 009 → 050 evolution). They were never reconciled.
|
||||
|
||||
### 6.2 Litigation vs fristenrechner corpus drift
|
||||
|
||||
**Symptom.** `paliad.projects.proceeding_type_id` accepts both `litigation` and `fristenrechner` codes. The same conceptual proceeding has rule corpuses of different size, granularity, and language depending on which category the project lands on.
|
||||
|
||||
**Impact.** SmartTimeline forecast for a project depends on which code is chosen at project-create time. Two HLC partners filing identical UPC infringement cases could see different timelines if one picked `INF` and the other `UPC_INF`.
|
||||
|
||||
**Root cause.** No CHECK constraint, no documentation, no UI guard. Likely intent: `litigation` for project-model coarse classification, `fristenrechner` for fine-grained calculator — but the contract was never formalised.
|
||||
|
||||
### 6.3 `is_mandatory` vs `is_optional` semantic overlap
|
||||
|
||||
**Symptom.** Two boolean columns with overlapping meaning. Current usage:
|
||||
- `is_mandatory=true, is_optional=false` — default (most rules).
|
||||
- `is_mandatory=true, is_optional=true` — surfaces in timeline but pre-unchecked in save-modal (only UPC_INF.inf.cost_app + a few others).
|
||||
- `is_mandatory=false` — unclear semantics today; sparsely used.
|
||||
|
||||
**Impact.** Confusing for both developers and future rule authors. A rule with `is_mandatory=false, is_optional=true` (legal "may file but not required") versus `is_mandatory=true, is_optional=true` (legal "should file but isn't a hard deadline") versus `is_mandatory=true, is_optional=false` (legal "must file") — the four-way matrix isn't well-defined.
|
||||
|
||||
**Root cause.** `is_optional` was added late (mig 068) as a UX hack ("pre-uncheck in save modal") rather than a semantic axis.
|
||||
|
||||
### 6.4 `deadline_concept_event_types` is a config layer, not a trigger model
|
||||
|
||||
**Symptom.** The table maps `(concept, jurisdiction) → event_type` for the create-form's chip suggestion. It DOES NOT support "when an event of type X fires, spawn deadlines for these rules."
|
||||
|
||||
**Impact.** m's "specific events trigger specific deadlines" implies a directional pipeline: user logs an event → system computes the deadlines that flow from it. That pipeline today exists only via:
|
||||
- Pipeline A's full-proceeding compute (heavy: gives everything, not just X's children).
|
||||
- Pipeline C's trigger_event picker (decoupled corpus).
|
||||
|
||||
There's no event_type-keyed entry point into Pipeline A. The cascade gets close — leaf → concept → rules — but stops at "show the cards"; firing the rules requires the user to manually click a card → calculate-rule.
|
||||
|
||||
**Root cause.** Pipeline A was designed proceeding-first (mig 009, 2024). The event-first paradigm came later via concepts (mig 037+) but never produced a dedicated trigger endpoint.
|
||||
|
||||
### 6.5 Court-set detection is heuristic
|
||||
|
||||
**Symptom.** `isCourtDeterminedRule()` decides court-set status from `primary_party='court' OR event_type IN ('hearing','decision','order') OR name-heuristic`. No dedicated boolean column.
|
||||
|
||||
**Impact.** False positives possible if a rule names "decision" but isn't court-set (e.g. "preliminary decision to amend"). False negatives possible if a court-set rule isn't tagged with one of these signals.
|
||||
|
||||
**Root cause.** Court-set semantic was never formalised as a first-class column. Inferred at runtime.
|
||||
|
||||
### 6.6 Pipeline A lacks `before`, `working_days`, `combine_op`
|
||||
|
||||
**Symptom.** Specific gaps in expressive power:
|
||||
- `before` timing: useful for "must be filed Y days BEFORE oral hearing." Pipeline C honours `timing='before'`; Pipeline A only renders `timing='after'` rules.
|
||||
- `working_days` unit: useful for procedural deadlines like UPC R.220.3 ("3 working days from notification"). Pipeline C supports it; Pipeline A doesn't.
|
||||
- `combine_op` (min/max): useful for "earlier of X or Y" (compound deadlines, e.g. EPC R.36 — "shortest of priority date+24mo or filing date+18mo"). Pipeline C supports it; Pipeline A doesn't.
|
||||
|
||||
**Impact.** Some legal deadlines can only be expressed in Pipeline C, fragmenting the rule corpus.
|
||||
|
||||
**Root cause.** Pipeline A grew from a "tree of forward offsets" model; backward / composite deadlines weren't anticipated.
|
||||
|
||||
### 6.7 Condition-flag grammar is AND-only
|
||||
|
||||
**Symptom.** `condition_flag` is `text[]` with AND semantics. No OR, no NOT, no nested expression.
|
||||
|
||||
**Impact.** Real legal scenarios that need OR (e.g. "rule X applies if CCR OR CCI is filed") get encoded as **two duplicate rules** today — one for each branch. Painful to maintain; easy to drift.
|
||||
|
||||
**Root cause.** The flag axis was designed for the small set of UPC variant flags (`with_ccr`, `with_amend`, `with_cci`); compound expressions weren't anticipated.
|
||||
|
||||
### 6.8 Cross-proceeding spawn is half-wired
|
||||
|
||||
**Symptom.** `is_spawn=true` rules exist (8 live), intended to express "when X happens in proceeding A, also trigger Y in proceeding B." The calculator code at `projection_service.go:896-901` explicitly notes: "Cross-proceeding spawn … We don't have that rule in our map; skip the dependency annotation but still surface the row."
|
||||
|
||||
**Impact.** A UPC_INF decision firing an APP proceeding (cross-proceeding) renders the spawned row, but the dependency-graph annotation breaks. SmartTimeline can't fully chain across proceedings.
|
||||
|
||||
**Root cause.** Cross-proceeding spawn was a late addition; the calculator's `ruleByID` map is per-proceeding, so it can't resolve spawns from other proceedings. Needs either a global rule index or a smarter resolver.
|
||||
|
||||
### 6.9 Nine orphan concepts with `rule_count=0`
|
||||
|
||||
Per §3.4: `counterclaim-for-revocation`, `schriftsatznachreichung`, `versaeumnisurteil-einspruch`, `weiterbehandlung`, `wiedereinsetzung`, `notice-of-defence-intention`, plus 3 more.
|
||||
|
||||
**Impact.** Cascade leaves can reach these concepts, but the user sees an empty result card. UX feels broken even though it's an unrelated coverage gap (no rules seeded yet).
|
||||
|
||||
**Root cause.** Cascade taxonomy was seeded ahead of the rule corpus for some concepts. The seed work never caught up.
|
||||
|
||||
### 6.10 No way to express "X is conditional on Y having fired"
|
||||
|
||||
**Symptom.** `condition_rule_id` exists as a column but is 0% populated. Was intended for "rule X applies only if rule Y was previously triggered" but never wired.
|
||||
|
||||
**Impact.** Today's flag mechanism (condition_flag) gates on **caller-supplied flags** (e.g. user toggles "with_ccr" in the UI). It doesn't gate on **runtime rule firing**. So you can't express "if the defendant filed Preliminary Objection (rule X), then rule Y is suspended for 2mo."
|
||||
|
||||
**Root cause.** Column added speculatively; never wired into the calculator.
|
||||
|
||||
### 6.11 The instance dimension (LG/OLG/BGH) isn't on `paliad.projects`
|
||||
|
||||
**Symptom.** The proceeding_types `DE_INF_OLG` / `DE_INF_BGH` exist, but a project can't carry "I'm at first instance" / "I'm on appeal at OLG" as data. The user has to manually pick a different `proceeding_type_id` if the case moves up the instances.
|
||||
|
||||
**Impact.** SmartTimeline forecast can't auto-advance from DE_INF → DE_INF_OLG when a Berufungsschrift fires on the actuals side.
|
||||
|
||||
**Root cause.** Project model treats proceeding-type as a static attribute, not a state machine.
|
||||
|
||||
### 6.12 No rule audit log
|
||||
|
||||
**Symptom.** Rules are modified by SQL migrations only. There's no `paliad.deadline_rule_audit` table tracking "rule X changed from 3mo to 2mo on 2026-04-15 by m, because Y." Migrations are technically the audit trail, but they aren't queryable in-app.
|
||||
|
||||
**Impact.** Rule-management UX (§8) needs an answer for "who changed this rule and why." Without an audit trail, rule-editing in-app is a step backward in compliance.
|
||||
|
||||
**Root cause.** Never needed before, because rules were never user-editable.
|
||||
|
||||
### 6.13 Zero deadline → rule linkage in live data
|
||||
|
||||
**Symptom.** Only **1 of 26** live deadlines has `rule_id` populated.
|
||||
|
||||
**Impact.** SmartTimeline's "anchor real deadlines into projection" feature (Pipeline A's strongest UX) is unusable on existing data. New deadlines saved via the wizard *do* get rule_id; legacy deadlines don't.
|
||||
|
||||
**Root cause.** Schema migrated incrementally; backfill never happened.
|
||||
|
||||
---
|
||||
|
||||
## 7. Extension proposals (one concrete change per §6 gap)
|
||||
|
||||
Each gap from §6 gets a concrete schema / service change, costed (migration + service + UI ripples).
|
||||
|
||||
### 7.1 Reconcile Pipelines A and C
|
||||
|
||||
**Proposal.** Migrate `paliad.event_deadlines` into `paliad.deadline_rules` with a new column `trigger_event_id` (nullable FK to `paliad.trigger_events`). A rule with `trigger_event_id NOT NULL` is event-triggered (Pipeline C semantics); with NULL it stays proceeding-triggered (Pipeline A).
|
||||
|
||||
Add the Pipeline-C-only columns to `deadline_rules`:
|
||||
- `timing` already exists; backfill non-NULL `before` values.
|
||||
- `combine_op` ∈ `{min, max, NULL}` — new column.
|
||||
- `working_days` as a valid `duration_unit` value — already a string column, no schema change.
|
||||
|
||||
Then deprecate Pipelines C, redirecting `/api/tools/event-deadlines` to the unified calculator.
|
||||
|
||||
**Cost.**
|
||||
- Migration: 1 file, ~120 LoC SQL (column adds + data move + idx).
|
||||
- Service: `FristenrechnerService.Calculate` extends to honour `timing='before'`, `working_days`, `combine_op`. ~80 LoC Go.
|
||||
- Service: `EventDeadlineService` either deletes (clean) or proxies to FristenrechnerService (transitional).
|
||||
- Handler: `/api/tools/event-deadlines` either deletes or 302s.
|
||||
- Frontend: `client/fristenrechner.ts:833` — the "Was kommt nach…" tab can call the unified endpoint.
|
||||
- Tests: a fresh table-driven test fixture covers the union behaviour.
|
||||
|
||||
**Ripple.** No data loss; trigger_event_id is additive. Frontend mostly transparent.
|
||||
|
||||
### 7.2 Formalise litigation vs fristenrechner contract
|
||||
|
||||
**Proposal.** Two options:
|
||||
- **(a) Hard-split.** Add `CHECK constraint` to `paliad.projects.proceeding_type_id`: only `category='litigation'` codes allowed. Migrate the 8-rule litigation corpus to be the canonical "project-level proceeding type". Move the fine-grained `category='fristenrechner'` rules under each litigation code via a new `variant` column.
|
||||
- **(b) Soft-merge.** Drop the `category` discriminator entirely. Every proceeding_type carries its full rule corpus. The dual-corpus today (8-rule INF + 15-rule UPC_INF) merges into ONE 15-rule UPC_INF, with the project model referencing only the rich variant.
|
||||
|
||||
**Cost.** (a) is invasive — migration to move 40 litigation-corpus rules under the fristenrechner codes; (b) is less invasive but means projects switch to picking `UPC_INF` instead of `INF`.
|
||||
|
||||
**Recommendation.** **(b)**. The dual-corpus is legacy from a project-model + calculator-model that grew separately. One canonical proceeding_type per case is cleaner.
|
||||
|
||||
**Ripple.** Project-create form picker changes from "INF / REV / CCR / APM / APP / AMD / ZPO_CIVIL" to the full 20-code fristenrechner picker (or a curated subset). t-paliad-166's mapping helper becomes unnecessary.
|
||||
|
||||
### 7.3 Clean up `is_mandatory` vs `is_optional`
|
||||
|
||||
**Proposal.** Replace both with a single `deadline_kind` enum:
|
||||
- `mandatory` — must be addressed.
|
||||
- `recommended` — should be addressed (pre-checked in save-modal but not required).
|
||||
- `optional` — may be addressed (pre-unchecked in save-modal).
|
||||
- `informational` — never saves as a deadline, surfaces as info.
|
||||
|
||||
Backfill: `is_mandatory=true, is_optional=false → mandatory`; `is_mandatory=true, is_optional=true → optional`; `is_mandatory=false → recommended`.
|
||||
|
||||
**Cost.** Migration ~30 LoC SQL. Service: `UIDeadline` exposes `Kind` instead of `IsMandatory`+`IsOptional`. Frontend: badge logic + save-modal pre-check.
|
||||
|
||||
### 7.4 Add a real event-driven trigger endpoint
|
||||
|
||||
**Proposal.** `POST /api/tools/event-trigger` with `{event_type_slug, trigger_date, project_id?}`. Resolves:
|
||||
1. `event_types.slug → event_types.id`
|
||||
2. `deadline_concept_event_types.event_type_id → concept_id` (per jurisdiction from project or explicit)
|
||||
3. `deadline_rules.concept_id → rules`
|
||||
4. Calculate the rules + their parent chains via Pipeline A.
|
||||
|
||||
Returns just the rules that flow from this event (filtered Pipeline A response).
|
||||
|
||||
**Cost.** Handler + service method, ~100 LoC. No schema change; uses existing junction.
|
||||
|
||||
**Ripple.** Lets the cascade UI offer "I just logged this event — here are the deadlines that follow" in one click. Also unlocks Phase-H-style email parsing → deadline spawn.
|
||||
|
||||
### 7.5 Promote court-set to a real column
|
||||
|
||||
**Proposal.** Add `is_court_set boolean NOT NULL DEFAULT false` to `paliad.deadline_rules`. Backfill from the heuristic. Calculator reads the column instead of inferring.
|
||||
|
||||
**Cost.** Migration ~20 LoC SQL (incl. backfill DO$$ block). Service: 1-line change in `isCourtDeterminedRule`.
|
||||
|
||||
**Ripple.** Faster + correct + no behaviour surprise. Cheap win.
|
||||
|
||||
### 7.6 Pipeline A gains `before` / `working_days` / `combine_op`
|
||||
|
||||
Covered in §7.1 (reconciliation).
|
||||
|
||||
### 7.7 Compound condition grammar
|
||||
|
||||
**Proposal.** Replace `condition_flag text[]` with `condition_expr jsonb`. Schema:
|
||||
|
||||
```json
|
||||
{"op":"and", "args":[{"flag":"with_ccr"},{"op":"not","args":[{"flag":"expedited"}]}]}
|
||||
```
|
||||
|
||||
Backfill: `['with_ccr','with_amend']` → `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`.
|
||||
|
||||
**Cost.** Migration with backfill ~80 LoC. Service: small recursive evaluator (~50 LoC Go). UI: condition picker for rule-editor (§8) — more involved.
|
||||
|
||||
**Ripple.** Future rule authors can express OR / NOT cleanly. No data drift; backward-compatible eval.
|
||||
|
||||
### 7.8 Wire cross-proceeding spawn
|
||||
|
||||
**Proposal.** Change `DeadlineRuleService.List(proceedingTypeID *int)` to allow a "follow spawn" mode that returns rules from spawned proceedings as well. Or: in `projection_service.computeProjections`, when a rule has `is_spawn=true` and the calculator returns a row from a different proceeding code, load the target proceeding's rule corpus lazily.
|
||||
|
||||
**Cost.** Service: ~50 LoC. Calculator: ~30 LoC. Risk: cycle prevention (don't infinite-loop A→B→A).
|
||||
|
||||
**Ripple.** SmartTimeline can fully chain across proceedings. The dependency-annotation breakage at `projection_service.go:896-901` resolves.
|
||||
|
||||
### 7.9 Seed the 9 orphan concepts with rules
|
||||
|
||||
**Proposal.** Per concept, add 1–3 rules to the appropriate proceeding_types. e.g. `wiedereinsetzung` → UPC R.320.1 (`UPC_INF.wiedereinsetzung`), EPA R.136 (`EPA_OPP.wiedereinsetzung`), DE PatG §123 (`DE_INF.wiedereinsetzung`).
|
||||
|
||||
**Cost.** Per orphan concept: ~20 LoC SQL. Total ~150 LoC across 9 concepts. Legal review required per rule.
|
||||
|
||||
**Ripple.** Cascade no longer dead-ends. This is the "coverage" gap m's t-paliad-167 explicitly called for.
|
||||
|
||||
### 7.10 Wire `condition_rule_id` or drop it
|
||||
|
||||
**Proposal.** Either:
|
||||
- (a) Implement: when calculator walks rules, gate a rule's render on `condition_rule_id`'s presence in the `computed` map.
|
||||
- (b) Drop the dead column.
|
||||
|
||||
**Recommendation.** **(b)**. The semantic is rarely needed; `condition_flag` covers most variant cases. Future need can resurrect.
|
||||
|
||||
### 7.11 Add `instance_level` to `paliad.projects`
|
||||
|
||||
**Proposal.** New column `instance_level text` ∈ `{first, appeal_olg, appeal_bgh, NULL}`. Combined with `proceeding_type.code` + `jurisdiction`, lets us derive `DE_INF_OLG` vs `DE_INF` from a project.
|
||||
|
||||
**Cost.** Migration ~10 LoC SQL. Project form: new picker. SmartTimeline forecast: small refactor in `proceedingCodeForProject`.
|
||||
|
||||
### 7.12 Rule audit log
|
||||
|
||||
**Proposal.** New table `paliad.deadline_rule_audit (id, rule_id, changed_by, changed_at, before_json, after_json, reason text)`. Trigger on UPDATE/INSERT/DELETE captures the diff. Required if §8 lands.
|
||||
|
||||
**Cost.** Migration ~40 LoC SQL (table + trigger). Read API for compliance review.
|
||||
|
||||
### 7.13 Backfill `rule_id` on existing deadlines
|
||||
|
||||
**Proposal.** One-time migration: for each `paliad.deadlines` row, fuzzy-match `title` against `paliad.deadline_concepts.aliases` + `paliad.deadline_rules.name`, link the highest-confidence match, leave low-confidence unlinked.
|
||||
|
||||
**Cost.** Migration ~100 LoC SQL. Run once.
|
||||
|
||||
**Ripple.** SmartTimeline anchor-from-actuals starts working for existing data. Bigger UX win than it sounds.
|
||||
|
||||
---
|
||||
|
||||
## 8. Rule-management UX — does m need an in-app rule editor?
|
||||
|
||||
m's "all in the Rules so we should be able to manage" reads as a direct ask.
|
||||
|
||||
### 8.1 The case for an in-app rule editor
|
||||
|
||||
- **Today: SQL migration only.** Every rule add/edit/disable requires a developer to write a migration, get reviewed, merge, deploy. The feedback loop is hours-to-days.
|
||||
- **Domain experts ≠ developers.** m is the rule expert. He shouldn't need to write `INSERT INTO paliad.deadline_rules (proceeding_type_id, code, name, duration_value, …)` SQL.
|
||||
- **Coverage gaps are persistent** (§3.4, §6.9). They stay open longer because the workflow is high-friction.
|
||||
- **Real-world law changes.** Procedural rules update (e.g. UPC R.49 just had a 2026-Q1 revision). Capturing those in SQL migrations is fragile.
|
||||
|
||||
### 8.2 The case against
|
||||
|
||||
- **Compliance / audit.** Rules are legal infrastructure. Any user-edit must be auditable, reviewable, reversible.
|
||||
- **Schema complexity.** 32 columns with semantic nuances (court-set heuristic, parent_id topology, condition_flag grammar). Naive form UI = footgun heaven.
|
||||
- **Cross-rule validation.** parent_id chains must remain DAGs. sequence_order must be topologically consistent. condition_flag values must be in a valid vocabulary. No live constraint catches all of these today.
|
||||
- **Build cost.** A real rule-editor with audit log, validation, preview, dry-run, and rollback is 4–6 PRs of work.
|
||||
|
||||
### 8.3 Three options
|
||||
|
||||
| Option | Description | Effort | When right |
|
||||
|---|---|---|---|
|
||||
| **(A) Status quo: SQL only** | Keep migrations as the rule-edit surface. Build tooling around migration authoring (mAi-assisted SQL gen, schema validators). | Low (~1 sprint of tooling). | If m's rule velocity is < 1 edit/week and audit trail is non-negotiable. |
|
||||
| **(B) Read-only admin surface** | Add `/admin/rules` page that lists rules, lets m search/filter/inspect. No edits in-app; "edit this rule" links to a Gitea issue template that drafts the migration. | Medium (~1 PR backend listing + 1 PR frontend). | If the friction is "I can't see what's in there" more than "I can't change what's there". |
|
||||
| **(C) Full rule editor** | `/admin/rules/{id}/edit` with form, validation, audit log, preview-on-trigger-date, "ship draft" → migration generator. | High (~4-6 PRs). | If m is genuinely going to edit rules weekly and the rule corpus is going to grow significantly. |
|
||||
|
||||
### 8.4 Inventor recommendation
|
||||
|
||||
**Start with (B), graduate to (C).**
|
||||
|
||||
- (B) immediately removes the "I can't see what's in there" friction, which today requires running SQL by hand or asking a developer. Low risk.
|
||||
- (B) makes the rule corpus discoverable inside the app — which is itself a win for transparency and for spotting coverage gaps (§3.4).
|
||||
- The Gitea-issue handoff preserves the audit trail and review workflow.
|
||||
- Once the corpus is browsable, the "I keep wanting to edit this thing" pressure tells us whether (C) is worth building.
|
||||
- **(C) without (B) is over-engineering** — we'd be building the form before we know which fields are actually edited often.
|
||||
|
||||
Hard requirement for (C) if we get there: `paliad.deadline_rule_audit` table (§7.12) with mandatory `reason` field, reviewer workflow, and migration-export so changes still land in version control.
|
||||
|
||||
§9 Q5 surfaces this for m's call.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions for m (Phase 2 steering)
|
||||
|
||||
These are the 10–15 picks for m to make before Phase 2 starts.
|
||||
|
||||
**Q1 — Reconciliation of Pipelines A and C.** §6.1 + §7.1. Three options:
|
||||
- (a) Merge into one table (recommended; ~120 LoC migration + 80 LoC Go).
|
||||
- (b) Keep both but document the contract (cheap, but the drift continues).
|
||||
- (c) Deprecate Pipeline C entirely (deletes "Was kommt nach…" tab — UX loss).
|
||||
|
||||
**Q2 — Litigation vs fristenrechner corpus.** §6.2 + §7.2. Two options:
|
||||
- (a) Hard-split with CHECK constraint + rule migration (invasive).
|
||||
- (b) Soft-merge: drop the category discriminator, projects use fristenrechner codes only (recommended).
|
||||
|
||||
**Q3 — `is_mandatory` / `is_optional` cleanup.** §6.3 + §7.3. Pick the 4-value enum (`mandatory` / `recommended` / `optional` / `informational`) or keep the two booleans with formal docs.
|
||||
|
||||
**Q4 — Event-driven trigger endpoint.** §6.4 + §7.4. Build `POST /api/tools/event-trigger` (concept-keyed) now, or defer until rule corpus is reconciled?
|
||||
|
||||
**Q5 — Rule-management UX.** §8. Pick:
|
||||
- (A) status quo SQL only,
|
||||
- (B) read-only admin surface (recommended start),
|
||||
- (C) full editor with audit log.
|
||||
|
||||
**Q6 — Compound condition grammar.** §6.7 + §7.7. Move to `condition_expr jsonb` with AND/OR/NOT, or stay with `condition_flag text[]` AND-only and live with duplicate rules?
|
||||
|
||||
**Q7 — Cross-proceeding spawn.** §6.8 + §7.8. Wire it (let SmartTimeline chain across proceedings), or accept the current half-wired state?
|
||||
|
||||
**Q8 — Orphan concept seed.** §3.4 + §7.9. Priority order for the 9 missing-rule concepts? My guess: wiedereinsetzung > schriftsatznachreichung > versaeumnisurteil > weiterbehandlung > others. Legal review per concept.
|
||||
|
||||
**Q9 — Instance level on `paliad.projects`.** §6.11 + §7.11. Add `instance_level` column to support the DE_INF / DE_INF_OLG / DE_INF_BGH ladder, or accept that users manually re-pick proceeding_type on appeal?
|
||||
|
||||
**Q10 — Backfill `rule_id` on existing deadlines.** §6.13 + §7.13. Run the one-time fuzzy-match migration, or live with the broken anchor-from-actuals on legacy rows?
|
||||
|
||||
**Q11 — `working_days` and `before` semantics in Pipeline A.** §5.3 + §6.6. Add (recommended) or live without them?
|
||||
|
||||
**Q12 — Court-set as a real column.** §6.5 + §7.5. Promote (cheap win), or keep the heuristic?
|
||||
|
||||
**Q13 — Drop `condition_rule_id` dead column.** §1.6 + §7.10. Drop or wire?
|
||||
|
||||
**Q14 — Phase 2 cadence.** How should we structure the iterative refinement? Options:
|
||||
- (a) m drives via the worker pane — m raises concrete cases ("counterclaim with amendment in expedited proceedings"), worker proposes encoding, commits incrementally.
|
||||
- (b) Inventor (pauli) drafts a Phase 2 design for the §7 extensions in priority order m picks here, m gates.
|
||||
- (c) Mixed: m picks the top 2 from §9 (Q1–Q13) for Phase 2, the rest deferred to Phase 3.
|
||||
|
||||
**Q15 — Phase 3 framing.** Once Phase 2 lands the data-model changes, is the goal:
|
||||
- (a) Build the rule editor (§8 option C), or
|
||||
- (b) Backfill coverage gaps (§7.9), or
|
||||
- (c) Wire SmartTimeline cross-proceeding chains (§7.8), or
|
||||
- (d) Some other priority m has in mind?
|
||||
|
||||
---
|
||||
|
||||
## AUDIT READY FOR REVIEW
|
||||
|
||||
Awaiting m's go/no-go on §9 Q1–Q15 before Phase 2 starts. Inventor (pauli) parks after this commit — no implementation kickoff, no other-skill autoload, m gates the audit → Phase 2 transition.
|
||||
|
||||
Recommended Phase 2 worker: depends on m's Q14 pick. If (a) interactive pair-prog, then pauli or feynman. If (b) inventor design pass, pauli has the freshest context. If (c) mixed, pauli for design, hand off to a Sonnet coder for each landed extension. **NOT cronus per memory directive 2026-05-06.**
|
||||
704
docs/design-determinator-row-cascade-2026-05-13.md
Normal file
704
docs/design-determinator-row-cascade-2026-05-13.md
Normal file
@@ -0,0 +1,704 @@
|
||||
# Design — Determinator B1 row-by-row cascade (replaces breadcrumb drilldown)
|
||||
|
||||
**Author:** pauli (inventor)
|
||||
**Date:** 2026-05-13
|
||||
**Task:** t-paliad-166
|
||||
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
|
||||
**Gitea:** m/paliad#25 (re-opened by m's 2026-05-13 11:17 comment).
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live (before designing)
|
||||
|
||||
CLAUDE.md, mai-memory and the task brief can all be stale by days. Every anchor below is verified against the live codebase or live DB on `mai/pauli/determinator-b1-row-by` (baseline `adf377c` — main as of Slice 1 of t-paliad-179 merge).
|
||||
|
||||
### 0.1 The Pathway B markup today
|
||||
|
||||
`frontend/src/fristenrechner.tsx:227-310` is the Pathway B shell. Four functionally different layers are stacked with four visually different treatments. Live, in source order:
|
||||
|
||||
| Layer | Element | Affordance | Visual |
|
||||
|---|---|---|---|
|
||||
| **L1 Mode** | `.fristen-mode-toggle` | `role=radiogroup` with two `<input type="radio">` | Radio buttons. Tree vs Filter. |
|
||||
| **L2 Perspective** | `.fristen-perspective-bar` | Three `<button>` chips | Pill chips. Kläger / Beklagter / Beide. |
|
||||
| **L3 Inbox** | `.fristen-inbox-bar` | Four `<button>` chips | Pill chips. CMS / beA / Posteingang / Alle. |
|
||||
| **L4 Cascade** | `.fristen-b1-cascade` | Breadcrumb + question + button-grid (drill-down) | Cards in a grid, breadcrumb above. |
|
||||
|
||||
Below L4 sits `.fristen-b1-results` — the concept-card list that narrows as the cascade descends. That's content, not a decision layer.
|
||||
|
||||
**m's critique is exact:** L1/L2/L3/L4 are all "narrow the deadline-rule space" steps with the same conceptual weight, but the user sees a radio, two pill strips, and a card grid. The cascade itself (L4) hides previous steps behind a breadcrumb — so when you've drilled three levels deep you can no longer see "I picked CMS → vom Gericht → Hinweisbeschluss" in one glance unless you read tiny breadcrumb crumbs.
|
||||
|
||||
### 0.2 The cascade engine today
|
||||
|
||||
`frontend/src/client/fristenrechner.ts:2405-2574` (`renderB1Cascade`). For a given `?b1=<slug>`:
|
||||
|
||||
1. Build `trail = buildBreadcrumb(roots, currentSlug)`. The trail is the ancestors of the current node.
|
||||
2. Render `<nav class="fristen-b1-breadcrumb">` = root-reset + `›`-separated crumb buttons.
|
||||
3. Render `<p class="fristen-b1-question">` = the current node's `step_question_de` (or `"Was ist passiert?"` at root).
|
||||
4. Render `<div class="fristen-b1-buttons">` = child nodes as button cards (icon + label, `--leaf` modifier on terminal nodes).
|
||||
5. Render `<button class="fristen-b1-step-back">` = "← Eine Stufe zurück".
|
||||
|
||||
Drilling = `navigateB1(child.slug)` = `pushState` + `renderB1Cascade(child.slug)`. The previous question disappears; only the breadcrumb crumb survives as text. **There is no "row of answered decisions."**
|
||||
|
||||
### 0.3 Where narrowing happens today
|
||||
|
||||
`fristenrechner.ts:2509-2522` filters cascade children by two predicates before rendering:
|
||||
|
||||
- `inboxFilterAllowsForums(c.forums)` — hides nodes whose `forums` tag doesn't match `activeForumOnPage()`. The active forum is resolved at `fristenrechner.ts:2960-2970` with a three-input precedence chain:
|
||||
1. **Inbox chip** (`cms` → `upc`, `bea` / `posteingang` → `de`). User override beats everything.
|
||||
2. **Ad-hoc chip** from Step 1's explore-mode bypass (`upc` / `de` / `epa` / `dpma`).
|
||||
3. **Project context** (`project.proceeding_type_id` → `proceeding_types.code` → prefix → `upc` / `de` / `epa` / `dpma`).
|
||||
- `perspectiveAllowsParty(c.party)` — hides leaves whose `party` tag contradicts the perspective chip. t-paliad-164 already auto-fills the chip from `project.our_side`.
|
||||
|
||||
**So project-driven narrowing for the FORUM axis is shipped.** What m is asking for in this task is (a) generalize the pattern so MORE rows get pre-answered, (b) make the answered-state visible in the same row format, (c) hide rows whose answer is fully implied (UPC project + L3 Inbox).
|
||||
|
||||
### 0.4 The taxonomy and rule corpus
|
||||
|
||||
Live data, `paliad.event_categories` (recursive tree, t-paliad-133):
|
||||
|
||||
- **6 root buckets** under `(root)`: `cms-eingang` ("Von wem ist das Schriftstück?"), `muendl-verhandlung` ("Mündliche Verhandlung"), `beschluss-entscheidung` ("Beschluss / Entscheidung"), `frist-verpasst` ("Frist verpasst"), `ich-moechte-einreichen` ("Ich möchte etwas einreichen"), `sonstiges` (terminal leaf).
|
||||
- **103 leaves total.** 91 carry a `forums` tag (`upc` / `de` / `epa` / `dpma`); 12 are neutral. 16 leaves carry a `party` tag — all under `ich-moechte-einreichen.*` (claimant / defendant) — the perspective filter touches outgoing filings only, never incoming Gegenseiten-Schriftstücke (which are symmetric: you receive what the other side sent regardless of who you are).
|
||||
- Cascade depth varies 2–4 levels. Slug encodes the path with dots, e.g. `cms-eingang.gegenseite.upc-inf.klageschrift` is 4 segments deep.
|
||||
|
||||
`paliad.proceeding_types`:
|
||||
|
||||
- **20 `category='fristenrechner'` codes** (the wizard / B1 cascade vocabulary): `UPC_INF`, `UPC_REV`, `UPC_APP`, `UPC_APP_ORDERS`, `UPC_COST_APPEAL`, `UPC_DAMAGES`, `UPC_DISCOVERY`, `UPC_PI`, `DE_INF`, `DE_INF_OLG`, `DE_INF_BGH`, `DE_NULL`, `DE_NULL_BGH`, `DPMA_OPP`, `DPMA_BPATG_BESCHWERDE`, `DPMA_BGH_RB`, `EPA_OPP`, `EPA_APP`, `EP_GRANT`.
|
||||
- **7 `category='litigation'` codes** (the project model's vocabulary): `INF`, `REV`, `CCR`, `APM`, `APP`, `AMD`, `ZPO_CIVIL`. All `jurisdiction='UPC'` except `ZPO_CIVIL`.
|
||||
- **The two vocabularies overlap conceptually but not row-wise.** Mapping `litigation_code × jurisdiction → fristenrechner_code` is required for Akte-derived narrowing beyond the 4-letter forum prefix. The brief lists this mapping; the live data confirms it's the only path.
|
||||
|
||||
`paliad.deadline_rules.condition_flag` — 4 distinct flag-sets live in production: `[with_amend]`, `[with_cci]`, `[with_ccr]`, `[with_ccr, with_amend]`. Only on `UPC_INF` and `UPC_REV`. This is a Determinator-style variant axis the cascade does not surface today; out of scope for this design.
|
||||
|
||||
### 0.5 Live state of `paliad.projects`
|
||||
|
||||
| Column | Live data shape | Used by today's cascade? |
|
||||
|---|---|---|
|
||||
| `court` | **Free-text.** 4 non-null values across 4 rows: `LG München I` (1), `UPC` (2), `UPC CoA` (1). 7 rows NULL. | No. |
|
||||
| `proceeding_type_id` | FK → `proceeding_types.id`. **11/11 live rows are NULL.** | Yes — `forumFromProject` reads it, but it never fires in production today. |
|
||||
| `our_side` | enum `claimant` / `defendant` / `both` / `court` / NULL. | Yes — t-paliad-164 perspective chip predefine. |
|
||||
| `counterclaim_of` | uuid FK self-reference. | No (relevant for SmartTimeline, not Determinator). |
|
||||
| `filing_date` / `grant_date` | dates. | No (relevant to Verfahrensablauf wizard). |
|
||||
|
||||
**Critical caveat:** 11/11 live projects have NULL `proceeding_type_id`. Until that's backfilled (a separate cleanup), Akte-driven narrowing degrades to "no opinion" for every existing project. The design honours this — silent degrade, no failed-load toast, the cascade simply doesn't narrow. m locked this v1 behaviour with kelvin on 2026-05-13.
|
||||
|
||||
### 0.6 Anchor files for the implementer
|
||||
|
||||
- `frontend/src/fristenrechner.tsx:227-310` — Pathway B markup (the four-layer mess).
|
||||
- `frontend/src/client/fristenrechner.ts:2405-2574` — `renderB1Cascade`.
|
||||
- `frontend/src/client/fristenrechner.ts:2914-3081` — forum + perspective narrowing engine (`activeForumOnPage`, `inboxFilterAllowsForums`, `perspectiveAllowsParty`, `applyOurSidePredefine`).
|
||||
- `frontend/src/styles/global.css:1636-1822` — `.fristen-pathway-shell`, `.fristen-mode-toggle`, `.fristen-b1-breadcrumb`, `.fristen-b1-question`, `.fristen-b1-buttons`, `.fristen-b1-button`, `.fristen-b1-step-back` (the visuals this design overhauls).
|
||||
- `frontend/src/styles/global.css:1965-2065` — `.fristen-inbox-bar`, `.fristen-perspective-bar`, `.fristen-inbox-chip` (the chip strip rules).
|
||||
- `frontend/src/client/views/verfahrensablauf-core.ts` (t-paliad-179) — pure-functional core, verified to carry **zero** Pathway B / cascade code. The lift is clean; this design is independent of it.
|
||||
|
||||
### 0.7 Adjacent design docs
|
||||
|
||||
- `docs/design-tools-cleanup-2026-05-12.md` (kelvin, t-paliad-178). Slice 1 of that shipped today; Slice 2 (Step 0 toggle + Akte auto-derivation on `/tools/fristenrechner`) is adjacent and will share the `litigation_code × jurisdiction → fristenrechner_code` mapping with this design.
|
||||
- `docs/research-determinator-coverage-2026-05-08.md` (curie, t-paliad-167). Identified leaves missing from the cascade. Out of scope here — this design is the UX shell that any future coverage additions will land into.
|
||||
|
||||
If any of these conflict with what the task brief or memory asserts, **the live state wins** and the brief is the bug — flagged in §13 for m.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision + the three pillars
|
||||
|
||||
m's framing (2026-05-13 11:17):
|
||||
|
||||
> When I select a project, it should already narrow down the options (at least if it is a court proceeding). If it is a UPC proceeding, there is no need to show "non-UPC options"; this starts with the "how did you receive it?" which - for the UPC - will always be the UPC CMS.
|
||||
>
|
||||
> Not only is the different format for the levels of the questions weird (this needs an overhaul!), also there is no narrowing at all. I already described before that I want each decision on the tree to remain visible (one row per decision, it may be more compact than the active question was) and then go through things until there are only the least possible options left.
|
||||
|
||||
Three pillars, intertwined:
|
||||
|
||||
### Pillar 1 — Project-driven narrowing
|
||||
Pre-fill or hide decision rows whose answer is implied by the project. UPC project → "Wo kam es an?" is implied (CMS). Project with `our_side` → perspective implied. Project with `proceeding_type_id` → cascade root narrows to the matching forum (and deeper, if mappable).
|
||||
|
||||
### Pillar 2 — Visual hierarchy overhaul
|
||||
All decision layers are **the same primitive**: a row with a question label, an answer-area, and an inline "ändern" affordance. Whether the layer is mode-toggle, perspective, inbox, or a cascade level, the visual shape is identical. The active layer expands inside its row; inactive (answered) layers compact to a single line.
|
||||
|
||||
### Pillar 3 — Row-by-row persistent cascade
|
||||
Replace breadcrumb drilldown with stacked rows. Each answered decision stays visible as a compact row. The active question is the only row that expands. The cascade builds top-to-bottom; the user sees every choice they made in one glance, and the answered rows act as their own affordances for "ändern".
|
||||
|
||||
The pillars interact:
|
||||
|
||||
- Pillar 3 (row layout) needs to know what to skip (Pillar 1 narrowing). A skipped row can render as a compact "(aus Akte) UPC CMS" pseudo-row, or be absent. We pick per row in §5.
|
||||
- Pillar 2 (visual hierarchy) defines how *answered* vs *active* vs *skipped-but-shown* rows look. The four-different-treatments mess gets resolved by a single `.fristen-row` primitive.
|
||||
- Pillar 1 (narrowing) also affects *initial state*: in Akte-mode, several rows may render as already-answered on page load. The cascade jumps to the first un-answered row.
|
||||
|
||||
---
|
||||
|
||||
## 2. The row primitive
|
||||
|
||||
The whole new layout is built from one element shape. Call it `.fristen-row` (the existing `.fristen-b1-*` class names get retired or rebased).
|
||||
|
||||
```text
|
||||
┌─ .fristen-row ──────────────────────────────────────────────────────┐
|
||||
│ .fristen-row-num .fristen-row-label .fristen-row-edit │
|
||||
│ [1] Wie suchen? [ändern] │
|
||||
│ .fristen-row-body │
|
||||
│ ✓ Schritt-für-Schritt │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Three states:
|
||||
|
||||
### 2.1 `state="active"` — the user is answering this row
|
||||
|
||||
```text
|
||||
┌─ .fristen-row.is-active ────────────────────────────────────────────┐
|
||||
│ [3] Von wem ist das Schriftstück? │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ ⚖️ │ │ 🏛️ │ │ ✉️ │ │
|
||||
│ │ Vom Gericht │ │ Von der │ │ Vom Patent- │ │
|
||||
│ │ │ │ Gegenseite │ │ amt │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
│ ← zurück │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Same chip-style buttons regardless of which row it is. Mode pick = two big chips. Perspective = three chips. Inbox = four chips. Cascade step = N chips, one per child node. Leaf cascade chips get a subtle modifier (`.fristen-row-chip--leaf`) so the user can see "this one ends the cascade".
|
||||
|
||||
### 2.2 `state="answered"` — the user has picked, but the answer is below
|
||||
|
||||
```text
|
||||
┌─ .fristen-row.is-answered ──────────────────────────────────────────┐
|
||||
│ [1] Wie suchen? ✓ Schritt-für-Schritt │
|
||||
│ [ändern] │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Single line. The label, the picked answer, an "ändern" affordance. Click anywhere on the row (or the explicit ändern link) re-opens the row as active and drops every row below it. (This matches the existing breadcrumb-click semantic: jumping back to an ancestor invalidates descendants.)
|
||||
|
||||
### 2.3 `state="prefilled"` — derived from the project (or other auto-source)
|
||||
|
||||
```text
|
||||
┌─ .fristen-row.is-answered.is-prefilled ─────────────────────────────┐
|
||||
│ [2] Ich vertrete ✓ Klägerseite │
|
||||
│ aus Akte: HL-2024-001 [ändern] │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Visually identical to `is-answered` but carries a small "aus Akte: <reference>" tag and a slightly muted background. Clicking ändern flips it to active (and drops the prefilled marker — the user has now made an explicit choice).
|
||||
|
||||
This generalises t-paliad-164's perspective predefine: same shape, same hint, same override-by-click semantics. The hint becomes a row-level token rather than a one-off `<span>` next to the chip strip.
|
||||
|
||||
### 2.4 `state="hidden"` — row is implied by an earlier pre-fill
|
||||
|
||||
A row that adds no information given upstream rows can be omitted entirely. e.g. UPC project → forum is `upc` → inbox row's only valid answer is "CMS" → the row simply doesn't render. We **do not** render a `is-hidden` placeholder; the absence is the affordance. (This is m's "no need to show non-UPC options".)
|
||||
|
||||
The first user-actionable row floats up under the prefilled stack.
|
||||
|
||||
### 2.5 Why one primitive
|
||||
|
||||
The current four-layer mess works against m because each layer looks like a different *kind* of question. The row primitive collapses that: every decision row carries the same "label + answer + ändern" anatomy. The user reads top-to-bottom; the answered rows stack as a paper trail; the active row is the only thing that demands interaction.
|
||||
|
||||
This also implicitly solves the row-count tax of m's "see your selections" ask: the rows compact to ~28px each when answered, so even a deep cascade keeps the active question in the upper third of the viewport.
|
||||
|
||||
---
|
||||
|
||||
## 3. Answered / active / prefilled / hidden — visual treatment
|
||||
|
||||
Concrete CSS sketch (Slice 1 will tune; this is the contract):
|
||||
|
||||
| Token | Active | Answered | Prefilled | Hidden |
|
||||
|---|---|---|---|---|
|
||||
| `min-height` | auto (chips wrap) | `28px` | `28px` | 0 (not rendered) |
|
||||
| `background` | `var(--surface-card)` | `transparent` | `color-mix(var(--color-accent) 4%, transparent)` | n/a |
|
||||
| `border-left` | `4px solid var(--color-accent)` | none | `4px solid var(--color-accent-faded)` | n/a |
|
||||
| `font-weight` (label) | 600 | 500 | 500 | n/a |
|
||||
| `font-weight` (answer) | n/a | 600 | 600 | n/a |
|
||||
| `cursor` | default | pointer (whole row) | pointer (whole row) | n/a |
|
||||
| `ändern` affordance | hidden | shown on hover + always on focus-within | always shown | n/a |
|
||||
| Row number badge | accent-filled | outlined | outlined (faded) | n/a |
|
||||
|
||||
**No `::before { inset: 0 }` overlay tricks.** The whole-row click is wired via a JS handler that calls `reopenRow(idx)` and skips clicks on `<a>` / `<button>` inside the row body — same pattern as `.entity-table` and the project-detail Verlauf items (CLAUDE.md anchor under "Whole-card / whole-row click").
|
||||
|
||||
Active vs answered transition: when the user picks an answer in an active row, the row collapses to `is-answered` and the **next un-prefilled row materialises as active**. The DOM is preserved across the transition (row stack is one container with `data-state` attribute switched on each row); the chip set inside the answered row replaces with the single ✓-prefixed answer span.
|
||||
|
||||
For the prefilled state's "aus Akte: <reference>" tag — reference comes from `project.reference` (e.g. `HL-2024-001`), falling back to the first 8 chars of `project.id` if no reference. Click on the reference tag is a navigation shortcut to the project (open in new tab — keeps the Fristenrechner state intact).
|
||||
|
||||
---
|
||||
|
||||
## 4. Project-driven narrowing — data mapping
|
||||
|
||||
What can we derive from a selected project, and where does each derivation land?
|
||||
|
||||
### 4.1 Mapping table
|
||||
|
||||
| Derivation | Source column(s) | Maps to | Pre-fills row | Hides row? |
|
||||
|---|---|---|---|---|
|
||||
| **Forum** (upc / de / epa / dpma) | `proceeding_type_id` → `proceeding_types.code` prefix. Fallback: `court` free-text contains UPC/LG/OLG/BGH/BPatG/EPA/DPMA. | Cascade filter (existing `inboxFilterAllowsForums`). | "Wo kam es an?" if forum=UPC (→ CMS). DE: prefills nothing (beA vs Posteingang is a Postal Realität, not on the project). | UPC: yes. DE/EPA/DPMA: no. |
|
||||
| **Perspective** | `project.our_side` ∈ {claimant, defendant} | Cascade filter (existing `perspectiveAllowsParty`). | "Ich vertrete" → Klägerseite / Beklagtenseite. `both` / `court` / NULL: no prefill. | No — even when prefilled, the row stays visible (the user needs to see "ah yes, I'm the Beklagte here"). |
|
||||
| **Proceeding type** | `proceeding_type_id` + jurisdiction → fristenrechner code via `mapLitigationToFristenrechner()` (new helper, shared with t-paliad-178 Slice 2) | Cascade depth: prunes root buckets that don't apply, and prunes inner buckets to those matching the proceeding code. e.g. UPC + INF → only `cms-eingang.gegenseite.upc-inf.*`, `cms-eingang.gericht.urteil-upc-cfi`, etc. | Pre-collapses cascade sub-branches; surfaces deeper-leaf rows directly when only one path applies. | Hides intermediate cascade rows whose only child matches the derived code. |
|
||||
| **Counterclaim** | `counterclaim_of IS NOT NULL` | Implies `with_ccr` / `with_cci` condition flag context. | Not a cascade row today — surfaces as a `condition_flag` chip on the wizard. **Out of scope for this design**; flagged in §13 Q6. | n/a |
|
||||
| **Filing / grant dates** | `filing_date`, `grant_date` | Wizard anchor pre-fill. | Not a cascade row. Out of scope. | n/a |
|
||||
|
||||
### 4.2 Detail: the litigation → fristenrechner mapping
|
||||
|
||||
t-paliad-178 §0 and the task brief both call out: `project.proceeding_type_id` points at the **7 `litigation` codes** (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL). The cascade speaks **`fristenrechner` codes** (UPC_INF, DE_INF, ...). A small mapping is needed:
|
||||
|
||||
```text
|
||||
INF + UPC → UPC_INF
|
||||
INF + DE → DE_INF (first instance; OLG/BGH not derivable from project)
|
||||
REV + UPC → UPC_REV
|
||||
REV + DE → DE_NULL
|
||||
CCR + UPC → UPC_INF + condition_flag=[with_ccr] (linked via parent's proceeding)
|
||||
CCR + DE → DE_NULL (German Nichtigkeit IS the counterclaim equivalent)
|
||||
APP + UPC → UPC_APP
|
||||
APP + DE → DE_INF_OLG | DE_NULL_BGH (ambiguous — needs court or instance hint; degrade)
|
||||
APM + UPC → UPC_PI
|
||||
AMD + UPC → UPC_INF + condition_flag=[with_amend]
|
||||
ZPO_CIVIL + DE → ZPO civil only; ignore for cascade (no fristenrechner code)
|
||||
```
|
||||
|
||||
The mapping lives in **one** place — a new `internal/services/proceeding_mapping.go` (or the same shared helper t-paliad-178 Slice 2 introduces). The frontend gets the **resolved fristenrechner code** plus `condition_flag` array as part of the project payload (`ProjectOption.derived_fristenrechner_code` + `.derived_condition_flags`).
|
||||
|
||||
**Honest about degrade:** the mapping isn't always 1:1. APP+DE is ambiguous, ZPO_CIVIL has no analogue, and projects without `proceeding_type_id` (all 11 live ones today) get no derivation at all. The cascade falls back to forum-only narrowing in every ambiguous case. **Never silent FK promotion.**
|
||||
|
||||
### 4.3 Detail: court free-text fallback
|
||||
|
||||
When `proceeding_type_id` is NULL but `court` has a recognisable substring:
|
||||
|
||||
```text
|
||||
court contains "UPC" → forum=upc
|
||||
court contains "BPatG" → forum=de (Nichtigkeit / DPMA-Beschwerde)
|
||||
court contains "BGH" → forum=de
|
||||
court contains "OLG" → forum=de
|
||||
court contains "LG" → forum=de
|
||||
court contains "EPA" / "EPO" → forum=epa
|
||||
court contains "DPMA" → forum=dpma
|
||||
otherwise → no narrowing
|
||||
```
|
||||
|
||||
This is a UX nicety, not a correctness mechanism. The fuzzy match always loses to a real `proceeding_type_id` if both are set. Surfaces as the prefilled-row reference tag: "Forum: UPC (aus Gericht: UPC CoA)".
|
||||
|
||||
### 4.4 What the cascade hides given a forum
|
||||
|
||||
`event_categories.forums` is the live signal:
|
||||
|
||||
- 91/103 leaves carry a forum tag.
|
||||
- 12 are neutral (cross-cutting: `frist-verpasst`, `sonstiges`, some Mündl-Verhandlung leaves, court actions).
|
||||
|
||||
With `forum=upc` active, ~73 leaves drop from the cascade. The user sees the same root buckets (cms-eingang / muendl / beschluss / frist-verpasst / ich-moechte-einreichen / sonstiges) but each bucket's children list collapses to the upc-relevant subset. **This is already wired today; the design doesn't change the filter, only its visual presentation.**
|
||||
|
||||
The new contribution: when a non-leaf bucket reduces to a single descendant chain (e.g. UPC project → `cms-eingang` → `gegenseite` → `upc-inf` is the only chain), the cascade should optionally **auto-walk** the chain and surface the leaf parent's siblings directly. §5 below.
|
||||
|
||||
### 4.5 What the cascade hides given perspective
|
||||
|
||||
Currently only the 16 `ich-moechte-einreichen.*` leaves carry `party` tags. So perspective filters outgoing-filing nodes only. Incoming `cms-eingang.gegenseite.*` nodes don't have party tags — receiving from the opposing side is symmetric (you receive what they sent, regardless of who you are). This is correct and doesn't need fixing.
|
||||
|
||||
**Design implication:** the perspective row is *always* visible (rows can never be `is-hidden` based on perspective alone), even when prefilled, because its filter affects user-write decisions that the user might still want to override. Match t-paliad-164.
|
||||
|
||||
---
|
||||
|
||||
## 5. What gets pre-answered, hidden, or skipped-but-shown
|
||||
|
||||
A concrete matrix per row, given live data + the rules above:
|
||||
|
||||
| Row | Question | Pre-fill source | UPC project | DE project | EPA / DPMA project | No project (ad-hoc) | No project (zero ctx) |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **R0 Mode** | Wie suchen? | none | active | active | active | active | active |
|
||||
| **R1 Perspective** | Ich vertrete | `project.our_side` | prefilled iff `our_side` ∈ {claimant, defendant}; else active | same | same (rare for EPA/DPMA — usually only `court` or NULL) | active | active |
|
||||
| **R2 Inbox** | Wo kam es an? | forum derivation | **hidden** (forum=upc ⇒ CMS implied) | active (beA vs Posteingang) | active | active | active |
|
||||
| **R3 Bucket** | Was ist passiert? | none — user always picks the bucket | active | active | active | active | active |
|
||||
| **R4..Rn Cascade** | per-node `step_question_de` | proceeding-code derivation can pre-walk a single-child chain | optionally auto-walks single-child chains | same | same | active | active |
|
||||
|
||||
Notes:
|
||||
|
||||
- **R0 Mode**: kept active in all cases. The user always picks Tree vs Filter (or skips R0 entirely if we ditch the mode toggle — see §6). The mode pick is meta and not derivable from the project.
|
||||
- **R1 Perspective**: a project with `our_side='both'` is rare but legitimate; it lands as active. `'court'` is even rarer (m's project model includes a "we are the court" perspective for hypothetical training scenarios). For now: `court` → active row.
|
||||
- **R2 Inbox**: m's literal ask. UPC → hidden. DE → active (because beA vs Posteingang is meaningful for downstream Phase-0 manual workflows even if the cascade filter doesn't care). EPA/DPMA → active (e.g. EPA online filing vs Post). The "Alle" chip stays for "I don't know yet".
|
||||
- **R3 Bucket**: the 6 root buckets are always shown. Even with a derived proceeding code, the user still has to say "I'm here because I received something / mündl. Verhandlung / Urteil / etc." This is too coarse to derive.
|
||||
- **R4..Rn Cascade auto-walk**: when a derived proceeding code reduces a bucket's children to a single chain, the cascade should pre-walk that chain. e.g. UPC + INF + `cms-eingang` bucket → only `gegenseite.upc-inf.*` chain survives → R4 `gegenseite` is pre-answered (with the "aus Akte" badge), R5 jumps directly to `upc-inf` (also pre-answered), and R6 is the active question "Welcher Schriftsatz?". The user sees four R-rows (R0, R1 prefilled, R3 picked, R4 prefilled, R5 prefilled, R6 active) — clean paper trail of inference + one active question.
|
||||
|
||||
**Important constraint:** auto-walk is **descendants-of-the-picked-bucket only**. R3 (bucket) is always active because the bucket is the user's intent. We never auto-pick the bucket. So a UPC project doesn't pre-pick "cms-eingang" for you; it just makes the sub-cascade efficient once you've said "cms-eingang".
|
||||
|
||||
### 5.1 Compact summary diagram — UPC INF project drilling into a cms-eingang opposing-side schriftsatz
|
||||
|
||||
```text
|
||||
┌─ Step 1: Akte (Step 1 surface, above Pathway B) ────────────────────┐
|
||||
│ Akte: HL-2024-001 — Acme v. Globex (UPC INF) [Andere Akte] │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
┌─ [1] Wie suchen? ✓ Schritt-für-Schritt [ändern]┐
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
┌─ [2] Ich vertrete ✓ Klägerseite [ändern]┐
|
||||
│ aus Akte: HL-2024-001│
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
⨯ Row R2 (Inbox) hidden — UPC implies CMS
|
||||
┌─ [3] Was ist passiert? ✓ CMS-Eingang [ändern]┐
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
┌─ [4] Von wem ist das Schriftstück? ✓ Von der Gegenseite [ändern]┐
|
||||
│ aus Akte (UPC INF impliziert)│
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
┌─ [5] Welches Verfahren? ✓ UPC Verletzungsverfahren │
|
||||
│ aus Akte: HL-2024-001 │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
┌─ [6] Welcher Schriftsatz wurde eingereicht? (active, awaiting pick)│
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Klageschrift │ │ Klageerwiderung │ │ Replik │ │
|
||||
│ │ (R.13) │ │ + Widerklagen │ │ │ │
|
||||
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
|
||||
│ ... (rest of UPC_INF Schriftsätze) │
|
||||
│ │
|
||||
│ ← zurück │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Six rows. Three user picks (mode, bucket, leaf). Three Akte-derived prefills. One R2 absent. The user sees their full decision path at a glance.
|
||||
|
||||
For comparison, today's UI: the user clicks four times into the cascade, the top of the page is two chip strips and a radio they didn't touch, the breadcrumb at the top of `.fristen-b1-cascade` shows three crumb buttons in 12pt text, and there's no inline indication that the cascade is narrower than the full taxonomy. m's "no narrowing at all" is the literal reading of what's visible.
|
||||
|
||||
### 5.2 Compact summary diagram — DE project drilling into the same
|
||||
|
||||
```text
|
||||
┌─ [1] Wie suchen? ✓ Schritt-für-Schritt [ändern]┐
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
┌─ [2] Ich vertrete ✓ Klägerseite [ändern]┐
|
||||
│ aus Akte: HL-2024-002│
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
┌─ [3] Wo kam es an? (active, awaiting pick)┐
|
||||
│ │
|
||||
│ ┌──────┐ ┌──────────────┐ ┌──────┐ │
|
||||
│ │ beA │ │ Posteingang │ │ Alle │ │
|
||||
│ └──────┘ └──────────────┘ └──────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
... and the cascade continues below once R3 is answered.
|
||||
```
|
||||
|
||||
R2 (Inbox) is active because beA vs Posteingang is a real distinction for German projects. The forum is already known (`de`), so the cascade below R3 will be DE-only — but the user still tells us *how* the document arrived.
|
||||
|
||||
### 5.3 Compact summary diagram — abstract / no-Akte mode
|
||||
|
||||
```text
|
||||
┌─ [1] Wie suchen? (active, awaiting pick)┐
|
||||
│ │
|
||||
│ ┌────────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Schritt-für-Schritt │ │ Filter / Suche │ │
|
||||
│ │ (Entscheidungsbaum) │ │ │ │
|
||||
│ └────────────────────────┘ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
No prefills, no hidden rows. Every row is asked. Full taxonomy.
|
||||
|
||||
---
|
||||
|
||||
## 6. Filter / Suche mode — coexistence with the cascade
|
||||
|
||||
Today's mode toggle (radio) is a UX wart: it's the only radio on the page, it looks unlike everything else, and it sits at the top of Pathway B as if it were a primary axis.
|
||||
|
||||
Two options to fold it into the row model:
|
||||
|
||||
### Option A — Mode is R0, a row like any other
|
||||
|
||||
The mode toggle becomes the first row in the stack. Two chips. Pick determines what populates below: tree picker → R3 + cascade. Filter picker → R3 collapses into a search input + result list. The row stays visible (you can switch mid-flow via ändern), but the chrome is consistent.
|
||||
|
||||
Pros: simple, every decision is a row, the page reads top-to-bottom.
|
||||
Cons: adds one always-active row to every flow including the "I know what I'm doing, just give me search" use case.
|
||||
|
||||
### Option B — Mode is an escape hatch, not a row
|
||||
|
||||
Filter is positioned as "ich weiß schon, wonach ich suche" — a small link / icon at the top of Pathway B that toggles between cascade and search. No R0 row. Default = cascade. Click → search replaces the row stack.
|
||||
|
||||
Pros: fewer rows, less for the common case to scan past.
|
||||
Cons: more discoverable than the current radio? unclear. "Where did the radio go?" is a question.
|
||||
|
||||
### Option C — Filter as a *bottom-of-stack* affordance
|
||||
|
||||
Cascade is the only top-down flow. Below the cascade results, a "Sie wissen schon den Namen? → direkt suchen" link / row appears. Search is a graceful fallback, not a peer mode.
|
||||
|
||||
Pros: gives cascade the primary surface, search becomes a tool for "wait, I know better".
|
||||
Cons: discoverability of search is reduced for power users who DO know.
|
||||
|
||||
**Inventor's pick:** Option B. The radio is dead weight, and the search use case is "I know the name; let me skip the cascade" — that's an escape hatch, not a peer axis. Visually: a small `🔍` icon-button at the top-right of Pathway B titled "Direkt suchen". Click expands a search input that replaces the row stack; result list appears below; "← Zurück zum Entscheidungsbaum" returns to the row stack with prior state preserved.
|
||||
|
||||
But this is design-question territory — m's call. §13 Q1.
|
||||
|
||||
---
|
||||
|
||||
## 7. Mobile + responsive
|
||||
|
||||
The row primitive is naturally responsive: rows stack vertically by default. Width concerns only the chip set inside an active row.
|
||||
|
||||
### 7.1 Breakpoints
|
||||
|
||||
`paliad` already uses 640 / 768 / 1023 px breakpoints. The rows live inside `.fristen-pathway-shell` which is already a column-flex.
|
||||
|
||||
| Width | Row chrome | Chip layout (active row) |
|
||||
|---|---|---|
|
||||
| ≥ 1024px | full label + answer + ändern on one line, badge left | chips in a 3-column grid (or auto-fill min 220px) |
|
||||
| 768–1023px | same | chips in a 2-column grid |
|
||||
| 640–767px | label + answer on line 1, ändern on line 2 right-aligned | chips in a 1-column stack |
|
||||
| < 640px | label on line 1, answer on line 2, ändern as `›` icon right-aligned | chips full-width, single column |
|
||||
|
||||
### 7.2 Active-row collapse on tap (mobile-only)
|
||||
|
||||
On `< 768px`, the row stack scrolls; the active row's chip set can be long (e.g. 9 Schriftsatz children). When the user picks an answer, the page autoscrolls so the next active row is at the top of the viewport. This is the same pattern as the Akte picker (Step 1) and existing form flows.
|
||||
|
||||
### 7.3 What we don't do on mobile
|
||||
|
||||
- **No drawer / modal for the cascade.** The whole point of the row stack is being able to see history at a glance; collapsing into a separate surface defeats it.
|
||||
- **No fly-out for ändern.** Tap on an answered row's ändern affordance simply re-activates the row in place.
|
||||
- **No "next" button.** Picking a chip advances automatically; mobile doesn't need an extra tap to confirm.
|
||||
|
||||
---
|
||||
|
||||
## 8. "Neu starten" / Reset semantics
|
||||
|
||||
Three flavours of reset, all need a home:
|
||||
|
||||
### 8.1 Reset the whole cascade (every row to empty)
|
||||
|
||||
Today: clicking the breadcrumb's "Pfad zurücksetzen" root crumb. In the new layout: a small `↺ Pfad zurücksetzen` link at the top of the row stack, right of the heading. Clicking it:
|
||||
- Drops every cascade row (R3+).
|
||||
- Leaves R0 (Mode), R1 (Perspective prefilled), R2 (Inbox if visible) as they are — those are "context", not "the user's investigation".
|
||||
- Re-activates R3.
|
||||
|
||||
Optional behaviour (per Q9): a confirm-dialog if the user has drilled ≥ 3 cascade levels deep. Probably overkill; current breadcrumb root-click is destructive without confirm. Match existing semantic.
|
||||
|
||||
### 8.2 Drop just one decision (ändern semantic)
|
||||
|
||||
Built into every answered row's `[ändern]` affordance and clicking on the row body. Effect: that row reverts to active; every row below it drops; URL ?b1= shortens to that row's prefix.
|
||||
|
||||
This is the workhorse of the row stack — m's "you can see your selections" UX implies "you can also rewind to any of them at any time". Built-in.
|
||||
|
||||
### 8.3 Drop the Akte-derived prefills
|
||||
|
||||
Trickier: if the user clicks ändern on a `is-prefilled` row, the prefill is overridden. But what about "I want to ignore my Akte entirely for this exercise"? The Akte itself is bound at the Step 1 surface, above Pathway B. Clicking "Andere Akte" at the Step 1 summary unbinds the Akte and drops all `is-prefilled` markers. The cascade rows that were `is-answered` because they were prefilled now revert to `is-active` (or, if the user had already explicitly overridden via ändern, stay answered with no `is-prefilled` flag).
|
||||
|
||||
This semantic already half-exists for t-paliad-164's perspective predefine; we generalise it to every prefilled row. Implementation: hold a `prefillSources: Map<rowID, "akte" | "user">` and re-derive on Akte unbind / change.
|
||||
|
||||
### 8.4 The "Neu starten" button at the bottom
|
||||
|
||||
A second affordance at the bottom of the results area, after the user has reached a leaf and is reading concept-cards. "Andere Frist nachschlagen?" → reset to R3. Optional but discoverable; today's UI lacks an equivalent, so this is a small UX win.
|
||||
|
||||
---
|
||||
|
||||
## 9. Search affordance integration
|
||||
|
||||
Tied to §6's mode-toggle question. Two integration points:
|
||||
|
||||
### 9.1 Search panel placement (Option B from §6)
|
||||
|
||||
The `🔍 Direkt suchen` button lives at the top-right of `.fristen-pathway-shell`. Click → animates the row stack out (or simply replaces it), shows a search input row with a single text field + result list below. ESC or "← Zurück zum Entscheidungsbaum" returns; row stack restores via URL state.
|
||||
|
||||
The search is the existing `?q=` + B2 chips flow — we don't rebuild it, just relocate its entry point. Existing forum-filter chip row stays inside the search panel.
|
||||
|
||||
### 9.2 Inline search on each cascade row (rejected)
|
||||
|
||||
An alternative: each cascade row's chip list gets a tiny "filter chips" input at the top. Reject. Adds chrome to every active row for a feature most users don't need.
|
||||
|
||||
### 9.3 "I searched but want to see the path" round-trip
|
||||
|
||||
When the user lands on a leaf via search, optionally show "Im Entscheidungsbaum öffnen → " — clicking restores the row stack with all ancestor rows pre-answered (which is what the cascade's slug already encodes). This is a small extra: lets a search-first user verify "yes, this is the leaf I thought, here's the proceeding context I missed".
|
||||
|
||||
---
|
||||
|
||||
## 10. Slicing for the coder pass
|
||||
|
||||
Three slices, each independently shippable, mergeable in order:
|
||||
|
||||
### Slice 1 — Visual hierarchy + row-by-row layout (no narrowing change)
|
||||
|
||||
Replaces the four-layer mess with the row primitive. **No backend or DB changes.** The narrowing engine stays the same (existing forum + perspective filters fire); the visual presentation moves from breadcrumb + chip strips + radio → row stack.
|
||||
|
||||
In scope:
|
||||
- New `.fristen-row` CSS primitive (with `.is-active`, `.is-answered`, `.is-prefilled` modifiers).
|
||||
- Refactor `renderB1Cascade` into a row-stack renderer (`renderRowStack(rows: RowSpec[])`).
|
||||
- Migrate L1 (mode) / L2 (perspective) / L3 (inbox) / L4..n (cascade) all to row instances.
|
||||
- "ändern" semantic = re-activate row, drop rows below, push history state.
|
||||
- Reset link at top of stack.
|
||||
- i18n keys for row labels.
|
||||
|
||||
Out of scope for Slice 1:
|
||||
- Project-derived proceeding-code narrowing (the `mapLitigationToFristenrechner` helper).
|
||||
- Auto-walk single-child cascade chains.
|
||||
- Hide-R2-on-UPC behaviour (Slice 2 — needs the proceeding mapping helper anyway).
|
||||
- Search affordance relocation (Slice 3).
|
||||
|
||||
Outcome: same data, same narrowing, **vastly better visual narrative**. The user can finally see their decision path. m's pillar 2 + 3 are addressed.
|
||||
|
||||
### Slice 2 — Project-driven narrowing depth
|
||||
|
||||
Adds the `litigation_code × jurisdiction → fristenrechner_code` mapping and uses it to:
|
||||
- Pre-fill the proceeding-type sub-cascade rows (R5 in the §5.1 diagram).
|
||||
- Hide R2 (Inbox) when project is UPC.
|
||||
- Auto-walk single-child chains.
|
||||
- Add the "aus Akte: <reference>" tag on prefilled rows.
|
||||
|
||||
This is where Pillar 1 fully lands. Depends on Slice 1's row primitive.
|
||||
|
||||
Includes a small backend helper (shared with t-paliad-178 Slice 2 if both ship in parallel): `internal/services/proceeding_mapping.go` exposes `MapLitigationToFristenrechner(litCode string, jurisdiction string) (fristenCode string, conditionFlags []string, ok bool)`.
|
||||
|
||||
Outcome: an Akte-bound user starts the cascade with three rows already answered, and only one or two active questions remain to drill to the leaf.
|
||||
|
||||
### Slice 3 — Search affordance + mobile polish
|
||||
|
||||
Relocates the mode-toggle / search affordance per §6 Option B. Adds the responsive breakpoints from §7. Polishes the autoscroll-to-active behaviour on mobile.
|
||||
|
||||
Mobile-only fixes ride here so Slices 1+2 can be reviewed by m at desktop width first.
|
||||
|
||||
### Why this order
|
||||
|
||||
- Slice 1 is purely visual. m can see the row stack and validate the layout BEFORE we change any narrowing semantic. If m hates the row primitive, we revert one PR. (We won't — but the option matters.)
|
||||
- Slice 2 is the heavy correctness lift. It depends on the mapping helper, on Akte payload extensions, and on careful Test_DATABASE_URL integration tests.
|
||||
- Slice 3 is final polish. Independently mergeable, lowest risk.
|
||||
|
||||
Each slice is roughly:
|
||||
- Slice 1: 1 frontend PR (~700 LoC TSX + CSS + client). No backend, no migrations.
|
||||
- Slice 2: 1 mixed PR (~150 LoC Go + 300 LoC client). No migrations.
|
||||
- Slice 3: 1 frontend PR (~150 LoC).
|
||||
|
||||
---
|
||||
|
||||
## 11. Tradeoffs flagged
|
||||
|
||||
### 11.1 Row stack is taller than the current shell
|
||||
|
||||
A deep cascade (4 levels) plus 3 prefilled rows + R0 = 8 rows. Each ~28px compact + the active row's chip body (200–400px depending on chip count) + spacing → ~600–800px tall. The current shell is ~400px tall in the same scenario. Mitigation: rows are compact (28px), active-row autoscrolling keeps the chip set in view on mobile, and the visual narrative wins. m's ask explicitly trades vertical space for visibility.
|
||||
|
||||
### 11.2 "Aus Akte" tags are slightly noisy
|
||||
|
||||
Three rows showing "aus Akte: HL-2024-001" reads a bit redundant. Mitigation: only the first prefilled row shows the reference; subsequent rows show "(aus Akte)" without the reference. Saves vertical noise, keeps the source visible once.
|
||||
|
||||
### 11.3 Auto-walk single-child chains can confuse
|
||||
|
||||
The user picks "cms-eingang" → suddenly two rows materialise pre-answered. Looks magical. Mitigation: the two rows are clearly `is-prefilled` with an "aus Akte (UPC INF impliziert)" tag, and ändern is available on each. After the user has done it twice, the inference becomes a feature; before, a tooltip on first-render ("Diese Schritte ergeben sich aus Ihrer Akte") could help (deferred for v2 — see Q11).
|
||||
|
||||
### 11.4 Removing the radio mode-toggle is a behavioural change
|
||||
|
||||
Existing power users may know the radio. Mitigation: the new `🔍 Direkt suchen` icon-button at the top of Pathway B is a visible affordance; URL ?mode=filter still works as deep-link. Soft transition.
|
||||
|
||||
### 11.5 11/11 live projects have NULL `proceeding_type_id`
|
||||
|
||||
Slice 2's narrowing literally doesn't fire in production today. We're building UX that requires data nobody has yet. Mitigation: graceful degrade (forum-only narrowing via court free-text fuzzy match — already a feature today). Backfill of `proceeding_type_id` is a separate follow-up (see Q13).
|
||||
|
||||
### 11.6 The mapping table in §4.2 has ambiguities
|
||||
|
||||
APP+DE → ambiguous; ZPO_CIVIL → no analogue; CCR ↔ counterclaim modeling is fragile. Mitigation: every ambiguous case degrades to "no narrowing" — the row stays active rather than incorrectly pre-filled. Better silent than wrong.
|
||||
|
||||
### 11.7 ändern-on-an-ancestor invalidates descendants
|
||||
|
||||
Same as today's breadcrumb-click semantic — clicking a non-current crumb drops cascade depth. **No data is lost** (you can re-walk the cascade), but if the user was reading concept-cards at a leaf, those cards disappear. Mitigation: when ändern is clicked on an answered row, before dropping descendants, brief inline confirmation? Or just match today's behaviour (drop immediately). Inventor recommends match-today; Q12.
|
||||
|
||||
### 11.8 The row primitive may be over-engineered
|
||||
|
||||
A single visual primitive for four functionally different layers is a strong opinion. If a future cascade layer (e.g. variant chips for `condition_flag`) doesn't fit the primitive shape, we have to either extend the primitive or break the consistency. Mitigation: the primitive is shape (label + answer-area + ändern), not behaviour — variant chips fit because they're also "pick one (or several)". The contract is loose enough.
|
||||
|
||||
---
|
||||
|
||||
## 12. Files the implementer will touch (Slice 1 only)
|
||||
|
||||
### 12.1 Frontend
|
||||
|
||||
- **`frontend/src/fristenrechner.tsx:227-310`** — Pathway B markup. Replace `.fristen-mode-toggle` + `.fristen-perspective-bar` + `.fristen-inbox-bar` + `.fristen-b1-cascade` with a single `.fristen-row-stack` container. Add minimal scaffolding rows for mode / perspective / inbox / cascade-host. Keep `.fristen-b1-results` below — unchanged.
|
||||
- **`frontend/src/client/fristenrechner.ts:2405-2574`** — Refactor `renderB1Cascade` into `renderRowStack(rows)`. The row spec is a discriminated union: `{kind: "mode" | "perspective" | "inbox" | "cascade", state: "active" | "answered" | "prefilled", question, options[], picked?}`. Rendering is one function per state; one switch on `kind` for the options builder.
|
||||
- **`frontend/src/client/fristenrechner.ts:2914-3081`** — `inboxFilterAllowsForums` + `perspectiveAllowsParty` unchanged (Slice 1 is visual-only).
|
||||
- **`frontend/src/client/fristenrechner.ts:initInboxFilter`** + perspective init — same handlers, new DOM targets.
|
||||
- **`frontend/src/client/i18n.ts`** — ~20 new keys under `deadlines.row.*` (row labels, ändern affordance, prefilled tag, reset link, "next active" autoscroll-target announce).
|
||||
- **`frontend/src/styles/global.css:1636-1822` + `:1965-2065`** — Retire `.fristen-mode-toggle`, `.fristen-perspective-bar`, `.fristen-inbox-bar`, `.fristen-b1-breadcrumb`, `.fristen-b1-question`, `.fristen-b1-buttons`, `.fristen-b1-button*`. Add `.fristen-row-stack`, `.fristen-row`, `.fristen-row-num`, `.fristen-row-label`, `.fristen-row-answer`, `.fristen-row-edit`, `.fristen-row-body`, `.fristen-row-chip`, `.fristen-row-chip--leaf`, `.is-active`, `.is-answered`, `.is-prefilled`.
|
||||
|
||||
### 12.2 Backend
|
||||
|
||||
No backend changes for Slice 1. The existing `/api/tools/fristenrechner/event-categories` and `/api/tools/fristenrechner/search` endpoints are unchanged.
|
||||
|
||||
### 12.3 Tests
|
||||
|
||||
- Pure-TS unit tests for `buildRowStack(currentState)` if extracted (table-driven: given URL state + Akte payload, output the RowSpec[]).
|
||||
- Playwright smoke (post-deploy): land on Pathway B with `?path=b&project=<uuid>`, verify R1 prefilled with "aus Akte", R2 hidden for UPC project, ändern on R1 reopens, ändern on bucket drops cascade depth.
|
||||
|
||||
### 12.4 Anchoring back
|
||||
|
||||
t-paliad-164 perspective predefine code is the precedent. Re-read it before implementing — same hint mechanism, same override semantics, generalised.
|
||||
|
||||
t-paliad-178 Slice 2 (Step 0 toggle + Akte auto-derivation) is parallel; coordinate on the shared `proceeding_mapping.go` helper file (Slice 2 of this task introduces it; t-paliad-178 Slice 2 can adopt or vice versa, depending on which lands first).
|
||||
|
||||
---
|
||||
|
||||
## 13. Open questions for m
|
||||
|
||||
These are inventor's calls flagged for m's gate. Picking is on m, not the coder.
|
||||
|
||||
**Q1 — Mode-toggle disposition.** Three options in §6: (A) R0 row, (B) escape-hatch icon-button [inventor's pick], (C) bottom-of-stack affordance. Pick one or specify another.
|
||||
|
||||
**Q2 — UPC project: hide R2 entirely or show as compact prefilled?**
|
||||
- Hide entirely (inventor's pick — matches m's "no need to show non-UPC options").
|
||||
- Show as compact `[2] Wo kam es an? ✓ UPC CMS [ändern] aus Akte` row — verbose but explicit.
|
||||
|
||||
**Q3 — Auto-walk single-child cascade chains?**
|
||||
- Yes, materialise R4..Rn-1 as prefilled (inventor's pick — strong UX, but feels magical first time).
|
||||
- No, the user always picks their way down even when only one child applies (slower, more predictable).
|
||||
- Yes-but-only-when-≥-2-rows-collapse (tradeoff).
|
||||
|
||||
**Q4 — "ändern" affordance shape on an answered row.**
|
||||
- Hover-revealed link "ändern" (inventor's pick — keeps row clean by default).
|
||||
- Always-visible pencil icon (more discoverable but more chrome).
|
||||
- Whole-row click is the only handle (cleanest, but no visible affordance — newcomers won't discover it).
|
||||
|
||||
**Q5 — Drop confirmation when ändern invalidates descendants?**
|
||||
- No (match today's breadcrumb-click — inventor's pick).
|
||||
- Yes, when ≥ 3 cascade levels would be dropped.
|
||||
- Always — even a one-row drop confirms.
|
||||
|
||||
**Q6 — Counterclaim awareness in the cascade.**
|
||||
`project.counterclaim_of IS NOT NULL` implies `[with_ccr]` or `[with_cci]` condition flag depending on the parent's proceeding code. Should this surface as a prefilled row (e.g. "Variante: with_ccr"), or only as a backend filter on the result concept cards (silent)?
|
||||
- Surface as a prefilled row (transparency — user sees the variant is active).
|
||||
- Silent backend filter (no row tax, but mystery narrowing).
|
||||
- Out of scope for this design — handle in a separate variant-chip task.
|
||||
|
||||
**Q7 — R0 mode-pick deep link.**
|
||||
If a user lands on `?path=b` without `?mode=`, do we default to tree or to "no R0 picked yet"?
|
||||
- Default to tree, R0 prefilled (today's behaviour — silent).
|
||||
- R0 active until the user picks (more explicit, but adds one extra click for the common case).
|
||||
|
||||
**Q8 — Prefilled-row override permanence.**
|
||||
After the user clicks ändern on a prefilled R1 (perspective) and explicitly picks "Beklagter" instead of the Akte's "Kläger", does this override persist if they re-bind the same Akte?
|
||||
- No, re-bind re-applies (today's behaviour — clean, but overrides feel ephemeral).
|
||||
- Yes, store override per-Akte in localStorage (sticky overrides — UX-friendly, but new state).
|
||||
|
||||
**Q9 — Reset confirm.**
|
||||
A "Pfad zurücksetzen" link at the top of the row stack — confirm dialog?
|
||||
- No confirm — match today's breadcrumb root-click (inventor's pick).
|
||||
- Confirm if cascade depth ≥ 3.
|
||||
- Always confirm.
|
||||
|
||||
**Q10 — Search escape-hatch position.**
|
||||
Per §6 / §9, the `🔍 Direkt suchen` button sits at the top-right of Pathway B.
|
||||
- Top-right (inventor's pick — discoverable, doesn't push down the row stack).
|
||||
- Below the row stack, after results.
|
||||
- As a permanent row at the bottom of the stack.
|
||||
|
||||
**Q11 — First-visit tooltip on auto-walked rows.**
|
||||
"Diese Schritte ergeben sich aus Ihrer Akte" tooltip on the first prefilled-from-mapping row, dismissed forever on first close?
|
||||
- Yes (helps onboarding).
|
||||
- No (extra chrome; the "aus Akte" tag is enough).
|
||||
- Inline help-icon (?) link to a docs page (longer-form).
|
||||
|
||||
**Q12 — Concept cards live below the row stack today. Should they collapse / hide when the user reopens an ancestor row (ändern)?**
|
||||
- Collapse/hide on ändern, repopulate when the cascade reaches a leaf again (inventor's pick — matches the "no orphan content" rule).
|
||||
- Keep visible as last-known until cascade resolves to a new leaf.
|
||||
|
||||
**Q13 — Backfill `paliad.projects.proceeding_type_id`?**
|
||||
11/11 live rows are NULL. Slice 2's narrowing depends on this. Should the Slice 2 PR also include a one-off Akte-edit nudge ("Projekt-Setup vervollständigen: Verfahrensart fehlt"), or do we wait until m manually fills them in over time?
|
||||
- Inline "Verfahrensart ergänzen" link on Akten with NULL proceeding_type_id.
|
||||
- Backfill script (inferring from `court` free-text where unambiguous).
|
||||
- Defer entirely; live with degraded narrowing until users fill it organically.
|
||||
|
||||
**Q14 — Reorder rows so prefilled stack at top, user-picked at bottom?**
|
||||
The §5.1 diagram orders rows R0..Rn in their natural cascade sequence (mode → perspective → inbox → bucket → cascade depth). The prefilled rows happen to be R1, R4, R5 (not contiguous). Alternative: visually float all prefilled rows to a single "aus Akte" group at the top, with user-picked rows below. Tradeoff: cleaner separation vs. losing the temporal narrative of the decision path.
|
||||
- Keep natural order (inventor's pick — narrative wins).
|
||||
- Group prefilled at top.
|
||||
|
||||
**Q15 — Should `Filter / Suche` mode also see Akte prefills?**
|
||||
If the user enters search mode with a project bound, do we silently scope results to the project's forum, or show the full taxonomy?
|
||||
- Scope (consistent with cascade narrowing — inventor's pick).
|
||||
- Don't scope (search is a "I know what I'm looking for" mode; the project is incidental).
|
||||
- Scope with a visible toggle "Auch andere Foren anzeigen".
|
||||
|
||||
---
|
||||
|
||||
## DESIGN READY FOR REVIEW
|
||||
|
||||
Awaiting m's go/no-go on the questions in §13 before the coder shift starts. Inventor (pauli) parks after this commit — no implementation kickoff, no other-skill autoload, head gates the transition.
|
||||
|
||||
Recommended implementer: pattern-fluent Sonnet coder. The row primitive is straightforward CSS + a small state machine refactor; the precedent code (t-paliad-164 + t-paliad-133 cascade engine) is well-understood. **NOT cronus per memory directive 2026-05-06.**
|
||||
1078
docs/design-fristen-phase2-2026-05-15.md
Normal file
1078
docs/design-fristen-phase2-2026-05-15.md
Normal file
File diff suppressed because it is too large
Load Diff
607
docs/design-project-chart-2026-05-09.md
Normal file
607
docs/design-project-chart-2026-05-09.md
Normal file
@@ -0,0 +1,607 @@
|
||||
# Design — Project Timeline / Chart (visualisation layer above SmartTimeline)
|
||||
|
||||
**Author:** faraday (inventor)
|
||||
**Date:** 2026-05-09
|
||||
**Task:** t-paliad-177
|
||||
**Issue:** m/paliad#35
|
||||
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live (before designing)
|
||||
|
||||
Before anchoring the design, I checked the live state — CLAUDE.md / memory / issue body can drift, the live system can't.
|
||||
|
||||
- **SmartTimeline data substrate is shipped through Slice 4.** `internal/services/projection_service.go:287 (For)` returns `([]TimelineEvent, ProjectionMeta, error)`. The wire envelope (`ResponseEnvelope`) is `{events: TimelineEvent[], lanes: LaneInfo[]}` — `Lanes` is the load-bearing primitive for parent-node aggregation (one column per direct child case / patent / litigation). `LevelPolicy` already differentiates `self_plus_ccr` (Case) / `child_case` (Patent) / `child_patent` (Litigation) / `child_litigation` (Client). Recent commits 7da8802, 7e57507, 7930ee0 confirm — design merge is on `main` (b4f4b3 baseline as of this branch).
|
||||
- **Frontend renderer for the SmartTimeline is `frontend/src/client/views/shape-timeline.ts` (960 LoC, hand-rolled DOM via `document.createElement`).** It already implements: vertical flow, parallel-track CSS-grid for CCR (`renderParallelTracks`), lane-strip CSS-grid for parent-node aggregation (`renderLaneStrip`), click-to-anchor inline editor, `[Track ▼]` chip, lane-filter chip multiselect, lookahead toggle. The "horizontal Gantt" mode m's brief asks about does **not** exist.
|
||||
- **No chart library is in the repo.** `package.json` has only `@types/bun`. No D3, no Chart.js, no Apache ECharts, no plotly, no chartjs-node-canvas. Frontend is hand-rolled DOM/SVG via the custom TSX renderer described in `.claude/CLAUDE.md`. Adding a runtime dep would need m's explicit approval (per global rules).
|
||||
- **No PDF / image-export pipeline exists either.** `internal/services/caldav_ical.go` generates VCALENDAR strings (BEGIN:VCALENDAR / BEGIN:VEVENT) for CalDAV PUT bodies, but there is no public iCal-feed download endpoint, no headless-browser dep (`chromedp` not in `go.sum`), no Go PDF lib. The only existing `Content-Disposition: attachment` header is in `internal/handlers/files.go` for the Gitea Downloads proxy.
|
||||
- **Custom Views render shapes are list / cards / calendar.** `internal/services/render_spec.go` declares `RenderShape` = `ShapeList | ShapeCards | ShapeCalendar`. **There is no `ShapeTimeline` registered yet** — t-paliad-169 §8.6 reserved the slot but didn't claim it. A new chart shape would extend this enum and grow `frontend/src/views.tsx` host accordingly.
|
||||
- **Mobile breakpoints in use today are 640px / 720px / 768px / 1023px** (`frontend/src/styles/global.css`). Lime green primary token is `--color-accent: var(--hlc-lime)` with light/dark variants and a `--color-accent-fg` foreground token. There is `@media print` already in the stylesheet — printing is on the table.
|
||||
- **Project hierarchy depth in prod = 4 levels, 11 projects total.** A loaded Patent at the upper end has 5 child cases; a hypothetical Client could have 100+ matters. Any chart layout must answer "how does this look on a page with 5 cases × 30 events" and "with 100+ matters" — see §10.
|
||||
|
||||
If the live state above contradicts a memory or issue note, the live state wins.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision + scope
|
||||
|
||||
m's brief (verbatim 2026-05-09 18:32):
|
||||
|
||||
> One could chose to show the timeline in one or in separate columns and with different colors even... bigger feature development but ... a project timeline / chart would be nice in general. So we need to make some considerations on how to design one. Another aspect to this is vertical or horizontal... and an export functionality would also be great.
|
||||
|
||||
The **Project Timeline / Chart** is the *visualisation layer* above the SmartTimeline data substrate. Where SmartTimeline answers "what is the data", the Chart answers "how does the lawyer want to see it today, on what surface, in what shape, exported to whom".
|
||||
|
||||
### What this design covers
|
||||
|
||||
| Axis | Choices |
|
||||
|---|---|
|
||||
| **Layout direction** | Vertical (today) / Horizontal Gantt-strip / Hybrid |
|
||||
| **Column model** | Single-column flow / Multi-column (lanes — already in substrate) |
|
||||
| **Visual customisation** | Color schemes per track / kind / status / party; density modes (compact/standard/spacious); status pill / kind chip / shape variants |
|
||||
| **Export** | SVG (vector) / PNG (raster) / PDF (browser-print or rasterised) / CSV (data) / JSON (data) / iCal (deadlines+appointments feed) |
|
||||
| **Surfaces** | Verlauf-tab embed (existing) / `/projects/{id}/chart` standalone full-page / `RenderShape="timeline"` Custom Views |
|
||||
|
||||
### What stays
|
||||
|
||||
- **`projection_service.go` is the only data source.** No new query path. The chart is a presentation-level concern; data composition is solved.
|
||||
- **`shape-timeline.ts` (vertical DOM renderer) stays** as the embed default for the Verlauf tab. We add modes alongside it; we don't tear it out.
|
||||
- **`paliad.deadlines`, `paliad.appointments`, `paliad.project_events`, `paliad.deadline_rules`** schemas — unchanged. Zero migrations in this design.
|
||||
- **Color tokens (`--color-accent`, `--color-bg-lime-tint`, …)** — anchor every chart palette, light/dark mode + WCAG follow for free.
|
||||
|
||||
### Out of scope (v1 of this feature)
|
||||
|
||||
- **Cross-matter chart on `/projects` list page** — bundled under the Custom-Views path (§8.3) once `RenderShape="timeline"` lands. Not v1.
|
||||
- **Live collaborative cursors / annotation pins** — presentation features for a later phase, not for shipping the chart itself.
|
||||
- **Rich-text editing of chart entries from inside the chart canvas** — clicks deep-link to existing detail pages. Edit-in-place is the SmartTimeline's anchor affordance and stays there.
|
||||
- **Server-side PDF rendering via headless browser** — adding `chromedp` introduces a Chromium runtime dependency on the Dokploy compose host. Recommend client-side `window.print()` for v1; revisit only if user feedback says "PDFs differ across employees' browsers". See §7.3 for the trade-off in full.
|
||||
- **Theming UI for end users to pick palettes** — v1 gives a small fixed palette set; a colour-picker is v2 nice-to-have only if real users ask for it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Renderer choice — SVG for the Gantt mode, DOM for the flow mode
|
||||
|
||||
This is the load-bearing call. Five candidates surveyed:
|
||||
|
||||
| Renderer | Pros | Cons | Fit |
|
||||
|---|---|---|---|
|
||||
| **DOM/CSS grid** (existing) | Accessible by default; themable via CSS vars; free dark-mode + i18n; exportable via `window.print()` | Hard to do continuous date-axis math (Gantt scaling); heavy reflow on resize; html-to-PNG via foreignObject is browser-quirky | Best for vertical flow ✓ |
|
||||
| **SVG hand-rolled** | Vector by construction → free SVG / PNG export via canvas drawImage; precise positioning math; one paint call; printable | Manual ARIA scaffolding; no automatic text-wrapping; need a layout pass | Best for horizontal Gantt ✓ |
|
||||
| **`<canvas>`** | Top performance for 1000+ nodes | Zero accessibility; manual hit-testing for clicks; export needs separate path | Overkill for our scale (≤150 nodes typical) ✗ |
|
||||
| **D3.js** | Battle-tested abstractions for axes / scales | ~250 KB minified, runtime data-driven DOM mutation conflicts with our IIFE-bundle pattern, would need m's package approval | Overkill, runtime cost ✗ |
|
||||
| **SVG + foreignObject for text** | Vector with native HTML text wrapping | Spotty PDF and Safari support; defeats the export-for-free pitch | Avoid ✗ |
|
||||
|
||||
### 2.1 Recommendation
|
||||
|
||||
**Two renderers coexist.** Same data, different DOM:
|
||||
|
||||
- **`shape-timeline.ts`** (existing DOM/CSS grid, vertical) keeps powering the Verlauf-tab embed — it's small, accessible, themed.
|
||||
- **`shape-timeline-chart.ts`** (new SVG) powers the standalone `/projects/{id}/chart` page in horizontal Gantt mode. Hand-rolled, no library, ~500 LoC for v1.
|
||||
|
||||
The horizontal Gantt page is also where the export buttons live (§7) — exporting a vertical DOM list is "open browser print and cmd-P" already, no new code needed; the Gantt is the genuinely new surface and brings PDF/SVG/PNG with it.
|
||||
|
||||
### 2.2 Why hand-rolled SVG over D3
|
||||
|
||||
We have ≤150 nodes per project, two axes (date + lane), three primitives (bar, dot, label) and one expanding need (zoom + pan, eventually). D3 ships ~250 KB to give us scales + axis generators + zoom. Our scale is `(date - earliestDate) / dayWidthPx`, a one-liner; our axis is a year/quarter tick generator, ~30 LoC; pan + zoom is `addEventListener("wheel"|"pointermove")`, ~50 LoC. The lift to write it ourselves is real but small, the runtime cost saving is real, and we keep the single-file IIFE bundle pattern intact.
|
||||
|
||||
If we ever hit "the layout math is too painful to maintain", D3-only-the-axis-helper or an `axes.ts` module is a refactor we can do then. v1 ships without.
|
||||
|
||||
### 2.3 What hand-rolled SVG looks like
|
||||
|
||||
One root SVG element, three layered groups:
|
||||
|
||||
```
|
||||
<svg viewBox="0 0 W H">
|
||||
<defs>
|
||||
<pattern id="weekend"…/> # weekend background stripe
|
||||
<linearGradient id="proj"…/> # projected-row gradient
|
||||
</defs>
|
||||
<g class="chart-grid"> # lane separators + date-axis ticks + today rule
|
||||
<g class="chart-bars"> # one rect/g per event
|
||||
<g class="chart-labels"> # text labels (kind chip, title)
|
||||
<g class="chart-overlay"> # tooltip + selection scrim
|
||||
</svg>
|
||||
```
|
||||
|
||||
Coordinates are computed by a `layout(events, lanes, viewport)` pure function — testable, deterministic, the same on screen and on export.
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout — vertical (existing) + horizontal (new)
|
||||
|
||||
### 3.1 Vertical (DOM, existing — no changes)
|
||||
|
||||
Embedded on `/projects/{id}` Verlauf tab. Today's `shape-timeline.ts` flow with date column / event card right column, "Heute →" rule, parallel tracks for CCR, lane-strip for parent-node aggregation. Nothing changes in this design — I'm explicit about that so the implementer doesn't accidentally rewrite working code.
|
||||
|
||||
### 3.2 Horizontal Gantt-strip (SVG, new)
|
||||
|
||||
The `/projects/{id}/chart` page. Time on the X axis, lanes on the Y axis. Each lane is a horizontal row; events plot as either a dot (point-in-time: deadline due-date, milestone, appointment) or a bar (range: future-projected sequence between two anchors, or appointment with end_at). Today's rule = vertical line.
|
||||
|
||||
```
|
||||
←──────── 2026 ────────→ 2027 ─────→
|
||||
┌────────────────────────────────────────┐
|
||||
Self │ ✓ ●─────●────────────● ░──░──░──░ │
|
||||
Hauptverf. │ Klage Antw. HV R29a R29c │
|
||||
│ ↑Heute │
|
||||
├────────────────────────────────────────┤
|
||||
Widerklage │ ⊕──────░───░──░ │
|
||||
(CCR) │ Filed R29d R32 │
|
||||
│ │
|
||||
└────────────────────────────────────────┘
|
||||
Date axis: Q1 Q2 Q3 Q4 Q1 Q2 Q3
|
||||
│ │
|
||||
└ year border └ Today rule (lime)
|
||||
```
|
||||
|
||||
### 3.3 Layout invariants (both modes)
|
||||
|
||||
These rules must hold across both renderers — they're the contract that lets us swap modes without surprising the user:
|
||||
|
||||
1. **Past = left/below; Future = right/above; Today = lime separator.** Vertical: future at top per existing convention. Horizontal: future on right per Gantt convention. The convention flip is fine because the "today" lime separator orients the user instantly.
|
||||
2. **One row = one event** in vertical; **one bar/dot = one event** in horizontal. We never group two events into one mark. Lane (column in horizontal, parallel-track-column in vertical) is the only grouping primitive.
|
||||
3. **`Kind` drives shape / glyph; `Status` drives color saturation; `Track` drives column placement.** This composes orthogonally — see §5.
|
||||
|
||||
### 3.4 Hybrid not in v1
|
||||
|
||||
A "compact horizontal-strip-on-top + vertical-detail-below" hybrid (think Gmail conversation view but for matters) is a tempting third mode. **Not in v1** — adds a third renderer with no clear user request behind it. Revisit if a partner asks "I want both at once".
|
||||
|
||||
### 3.5 Single-column vs multi-column on horizontal
|
||||
|
||||
Multi-column = lanes, identical to the substrate's `LaneInfo` already. The horizontal Gantt **always multi-lanes** when there's more than one lane; collapsing all events into one row just to give a "single-column" version produces visual chaos with overlapping bars on the same date. The `[Track ▼]` filter (existing) lets the user collapse to a single track if they want a single-row view. So:
|
||||
|
||||
- **Substrate has 1 lane** (Case-level, no CCR): single horizontal row.
|
||||
- **Substrate has 2+ lanes** (Case + CCR sub-project, OR Patent / Litigation / Client level): horizontal multi-lane Gantt with one row per lane.
|
||||
|
||||
This mirrors the lane-mode the vertical renderer already uses (`renderLaneStrip`) — same data shape, different rendering.
|
||||
|
||||
---
|
||||
|
||||
## 4. Column model — extend `LaneInfo`, no new substrate concept
|
||||
|
||||
The substrate already discriminates lanes via `levelPolicy(projectType)` returning `LaneAxis`. The chart inherits that vocabulary for free.
|
||||
|
||||
### 4.1 What the chart adds
|
||||
|
||||
Two read-only filters at chart mount time, both client-side (no backend changes):
|
||||
|
||||
```ts
|
||||
interface ChartViewState {
|
||||
layout: "vertical" | "horizontal"; // default "horizontal" on /chart, "vertical" on Verlauf
|
||||
columns: "auto" | "single" | "lanes"; // "auto" reads lanes.length from substrate
|
||||
density: "compact" | "standard" | "spacious";
|
||||
palette: "default" | "high-contrast" | "print" | "kind-coded" | "track-coded";
|
||||
zoom: number; // px-per-day; default 4
|
||||
range?: { from: string; to: string }; // ISO; defaults to substrate's earliest..latest+30d
|
||||
}
|
||||
```
|
||||
|
||||
`columns="auto"` is the default — the substrate decides. `columns="single"` collapses everything into one row (useful when comparing dates across CCR + parent on horizontal). `columns="lanes"` forces lane mode even when only one lane exists (useful for screenshot consistency).
|
||||
|
||||
### 4.2 What the chart does not add to the substrate
|
||||
|
||||
**No new lane axis.** If the brief later wants "lanes per party" (claimant vs defendant) or "lanes per court country", that becomes a new `LaneAxis` value in `levelPolicy` — substrate work, not chart work. The chart is a render of whatever lanes the substrate produced.
|
||||
|
||||
This boundary is important: the chart can be improved / re-skinned / re-renderered without touching the data layer, and substrate improvements (new lane axes, new event kinds) automatically reach both renderers.
|
||||
|
||||
---
|
||||
|
||||
## 5. Color schemes
|
||||
|
||||
The brief asks for *"different colors even"*. Three palette dimensions are useful — and they're orthogonal, so a user picks one at a time.
|
||||
|
||||
### 5.1 Palette presets (built-in, fixed)
|
||||
|
||||
| Preset | What's color-coded by | Use case |
|
||||
|---|---|---|
|
||||
| **`default`** | Lane (`--color-accent` for parent, neutral grey for CCR/parent_context) | Embed in Verlauf, partner glance |
|
||||
| **`kind-coded`** | Event kind (deadline = blue, appointment = amber, milestone = lime, projected = soft-grey) | "Show me what's a hearing vs a deadline at a glance" |
|
||||
| **`track-coded`** | Track tag (parent / counterclaim / parent_context — three distinct hues) | CCR-heavy projects where the track is the most important axis |
|
||||
| **`high-contrast`** | Status only (done = green ✓; open = amber; overdue = red; predicted = light-grey) | Print-friendly, accessibility-first, screenshot for client |
|
||||
| **`print`** | Black / white / one-stripe-pattern (no color at all) | Faxable, b&w-printable, redactable |
|
||||
|
||||
All five palettes are CSS custom-property *swaps* on the chart root — the renderer reads `var(--chart-bar-deadline)`, the palette CSS file defines what each is. No JS branching in the renderer.
|
||||
|
||||
### 5.2 Token surface (CSS vars)
|
||||
|
||||
```css
|
||||
.smart-timeline-chart {
|
||||
--chart-bar-deadline: var(--color-accent);
|
||||
--chart-bar-appointment: #f5a623;
|
||||
--chart-bar-milestone: var(--hlc-midnight);
|
||||
--chart-bar-projected: var(--color-text-subtle);
|
||||
--chart-bar-overdue: #d62828;
|
||||
|
||||
--chart-track-parent: var(--color-accent);
|
||||
--chart-track-counterclaim: #6e8a8c; /* desaturated teal */
|
||||
--chart-track-parent-context: var(--color-text-subtle);
|
||||
|
||||
--chart-today-rule: var(--color-accent);
|
||||
--chart-grid-line: var(--color-border);
|
||||
--chart-bg: var(--color-bg);
|
||||
--chart-bg-weekend: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.smart-timeline-chart[data-palette="kind-coded"] {
|
||||
/* override --chart-bar-* — track tokens stay neutral so kind dominates */
|
||||
--chart-track-parent: var(--color-text-subtle);
|
||||
--chart-track-counterclaim: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.smart-timeline-chart[data-palette="print"] {
|
||||
--chart-bar-deadline: #000;
|
||||
--chart-bar-appointment: #555;
|
||||
--chart-bar-milestone: #000;
|
||||
--chart-bar-projected: #aaa;
|
||||
/* …and so on; the palette is a pure CSS swap */
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Why no per-user color picker in v1
|
||||
|
||||
A per-user palette picker is a feature with a long tail (storage in user prefs, defaults vs overrides, migration when palette tokens change names, theme conflicts with light/dark). The fixed-preset surface answers 90 % of "I want different colors" with 10 % of the cost. If real users say "I want my-firm-blue", we add a v2 admin-level palette override (`paliad.firm_palette` row keyed by `FIRM_NAME`).
|
||||
|
||||
### 5.4 Light / dark / print
|
||||
|
||||
Existing dark-mode flip works automatically — the chart palette tokens *reference* `--color-*` family which is already dark-mode-aware. No extra surface. `@media print` overrides force the `print` palette regardless of the user-selected one — a print-out is always b&w-friendly.
|
||||
|
||||
---
|
||||
|
||||
## 6. Density + visual variants
|
||||
|
||||
### 6.1 Density modes
|
||||
|
||||
```ts
|
||||
type Density = "compact" | "standard" | "spacious";
|
||||
```
|
||||
|
||||
- `compact`: lane height 24px, bar height 12px, label inline-only (no description). Use for "1000-row birds-eye" lane mode.
|
||||
- `standard` (default): lane height 40px, bar height 20px, label + status pill.
|
||||
- `spacious`: lane height 64px, bar height 28px, label + pill + description below.
|
||||
|
||||
CSS-driven via `[data-density="…"]` on the chart root. The bar & dot SVG geometry is computed from a single `--lane-height` var; switching density is a re-layout pass, not a re-render.
|
||||
|
||||
### 6.2 Status / kind / shape variants
|
||||
|
||||
The visual encoding stays consistent with `shape-timeline.ts`:
|
||||
|
||||
| Kind | Vertical glyph | Horizontal mark |
|
||||
|---|---|---|
|
||||
| `deadline` | `…` / `!` (open / overdue) | Filled circle on due date; ring around it for "open" |
|
||||
| `appointment` | `▢` | Bar from `start_at` to `end_at` (or fixed-width if same-day) |
|
||||
| `milestone` | `⊕` | Diamond at the date |
|
||||
| `projected` | `░` | Hatched circle (predicted), dashed-circle (court_set), amber-outlined (predicted_overdue) |
|
||||
|
||||
Colour saturation drives `Status` independently: done = full color; open = lighter; predicted = 50% opacity; overdue = red overlay.
|
||||
|
||||
The CSS for the vertical mode already has these variants — the SVG mode replicates them via `<circle>` / `<rect>` + `fill` / `stroke-dasharray` attributes. Same visual language across modes is a non-negotiable.
|
||||
|
||||
---
|
||||
|
||||
## 7. Export pipeline
|
||||
|
||||
This is the most-requested part of the brief. Five formats; client-side only (no Go PDF dep, no headless browser).
|
||||
|
||||
### 7.1 The five formats
|
||||
|
||||
| Format | Content | Path | Why this path |
|
||||
|---|---|---|---|
|
||||
| **SVG** | Vector chart as-rendered | Browser: `new XMLSerializer().serializeToString(svgEl)` → Blob → download | Free — SVG IS our render. |
|
||||
| **PNG** | Raster chart at 2× device pixel ratio | Browser: SVG → `<img>` → `<canvas>.drawImage` → `canvas.toBlob()` | One stdlib API call chain. |
|
||||
| **PDF** | Print-formatted page | `window.print()` with `@media print` stylesheet; user picks "Save as PDF" | Reuses browser's hardened PDF engine — no Go PDF dep, no Chromium pinned to Dokploy. |
|
||||
| **CSV** | Tabular data, flat | Server: `GET /api/projects/{id}/timeline.csv` → text/csv | Cleanest for "Excel this" use case. |
|
||||
| **JSON** | Data-as-stored | Server: `GET /api/projects/{id}/timeline?format=json` (existing endpoint, alt content type) | Zero new code beyond a `Content-Disposition: attachment`. |
|
||||
| **iCal** | Deadlines + appointments as VEVENT | Server: `GET /api/projects/{id}/timeline.ics` reusing `caldav_ical.go` formatter | Lawyers can subscribe in Outlook / Apple Calendar. |
|
||||
|
||||
### 7.2 Why client-side for SVG/PNG/PDF, server-side for CSV/JSON/iCal
|
||||
|
||||
- **SVG/PNG/PDF need the rendered pixel layout.** Client has it, server doesn't (without a headless browser). Doing it on the client is a 30 LoC flow per format using stdlib browser APIs.
|
||||
- **CSV/JSON/iCal are pure data.** Server-side they hit the existing `ProjectionService` and stream straight to the client. CSV is `encoding/csv`; JSON is `json.Marshal`; iCal reuses the existing string-builder. Three new handlers, ~120 LoC total.
|
||||
|
||||
### 7.3 Why NOT server-side PDF
|
||||
|
||||
The clean alternative is "spin up `chromedp` on the Dokploy compose host, render the chart page, return PDF". Trade-off:
|
||||
|
||||
- Pro: one canonical PDF render, works the same regardless of user's browser.
|
||||
- Con: adds a Chromium runtime dep to the paliad Docker image (~150 MB), spins up a child process per export, opens an attack surface (someone exports a hostile SVG → Chromium handles it → CVE), and needs a queue (PDF render is 1-3s; a clicky user can DoS the box).
|
||||
|
||||
Browser print, by contrast, is in-process, free, sandboxed, and produces fine-looking PDFs. It loses pixel-perfect cross-browser parity, but lawyers care about content, not subpixel kerning.
|
||||
|
||||
**Recommend client-side print for v1.** Revisit if lawyers complain about cross-browser PDF differences. Adding `chromedp` later is a one-PR move; designing it into v1 risks shipping infra weight we may never need.
|
||||
|
||||
### 7.4 Print-mode CSS
|
||||
|
||||
The PDF path needs a robust `@media print`:
|
||||
|
||||
- Fix the chart to fit on landscape A4 (1100 × 760 px viewport).
|
||||
- Force `palette="print"`.
|
||||
- Hide chrome (sidebar, footer, header → `.print-hide` class on existing layout).
|
||||
- Show project metadata (title, parties, court, proceeding type) as a printed header.
|
||||
- Page-break logic: each lane group fits on one page; if a lane has too many events, split horizontally by year.
|
||||
|
||||
This print stylesheet can be extracted as `frontend/src/styles/chart-print.css` so it's auditable separately from the screen styles.
|
||||
|
||||
### 7.5 Export menu UI
|
||||
|
||||
Single button on the chart page header opens a menu:
|
||||
|
||||
```
|
||||
[ ⤓ Export ▼ ]
|
||||
├─ SVG (Vektorgrafik)
|
||||
├─ PNG (Bild, 2× HiDPI)
|
||||
├─ PDF (Drucken)
|
||||
├─ ───
|
||||
├─ CSV (Excel-Tabelle)
|
||||
├─ JSON (Rohdaten)
|
||||
└─ iCal (.ics — Outlook / Apple)
|
||||
```
|
||||
|
||||
Translated via existing i18n (`projects.detail.chart.export.*`). One menu, one keyboard shortcut (`Cmd+E` / `Ctrl+E`) opens it.
|
||||
|
||||
### 7.6 What's exported in CSV
|
||||
|
||||
Flat schema, one row per `TimelineEvent`:
|
||||
|
||||
```
|
||||
project_id,project_title,kind,status,track,lane_id,lane_label,date,
|
||||
title,description,rule_code,depends_on_rule_code,depends_on_date,
|
||||
sub_project_id,sub_project_title,bubble_up,deadline_id,appointment_id,
|
||||
project_event_id
|
||||
```
|
||||
|
||||
Columns mirror the wire `TimelineEvent` struct. UTF-8 with BOM (Excel-DE compat). Date format ISO-8601.
|
||||
|
||||
### 7.7 What's exported in JSON
|
||||
|
||||
The wire `ResponseEnvelope` directly: `{events: TimelineEvent[], lanes: LaneInfo[], meta: ProjectionMeta, exported_at, exported_by, project_id}`. Stable JSON schema; `meta` lets a future re-importer reconstruct the projection state exactly.
|
||||
|
||||
### 7.8 What's exported in iCal
|
||||
|
||||
Only `kind IN ("deadline", "appointment")` (projected rows are not stable enough to commit to a calendar). VEVENT block per row reuses `caldav_ical.go` formatter; UID is `paliad-deadline-<id>@paliad.de` so re-export overwrites prior subscription. Future projected rows omitted by design — they would clutter every lawyer's Outlook with rule_code-derived events that may or may not fire on the predicted date.
|
||||
|
||||
---
|
||||
|
||||
## 8. Surfaces — three places the chart shows up
|
||||
|
||||
### 8.1 Verlauf tab embed (`/projects/{id}` — existing)
|
||||
|
||||
Vertical DOM mode only (existing `shape-timeline.ts`). Density `standard`. Palette `default`. Lane count obeys substrate. **No changes** in this design — the embed stays exactly as it is. The chart-mode opt-in lives below the tab.
|
||||
|
||||
A new "**Als Chart anzeigen ↗**" link in the SmartTimeline header opens `/projects/{id}/chart` in a new tab. Optionally (Q3 below) we could host a chart inline with a `[Layout: ▽ Vertikal | ▷ Horizontal]` toggle.
|
||||
|
||||
### 8.2 Standalone `/projects/{id}/chart` (new)
|
||||
|
||||
Full-page surface optimized for the horizontal SVG renderer. Layout:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
│ Siemens AG ./. Huawei — EP3456789 — UPC-CFI München │
|
||||
│ Verfahrenstyp: UPC-Verletzung Anker: Klageschrift @ 2026-04-29 │
|
||||
│ │
|
||||
│ [Layout ▷] [Spalten Auto] [Dichte Standard] [Palette Default] [Export ⤓]│
|
||||
├───────────────────────────────────────────────────────────────────────┤
|
||||
│ ━━━━ FilterBar (existing primitive) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
├───────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──── Horizontal SVG chart (full bleed) ───┐ │
|
||||
│ │ │ │
|
||||
│ │ ←─── 2026 ────→ 2027 ────→ │ │
|
||||
│ │ Self ●─●───●──── ░──░──░ │ │
|
||||
│ │ CCR ⊕────░───░──░ │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
URL convention: `/projects/{id}/chart?layout=horizontal&palette=default&density=standard&zoom=4`. State persists in URL so the link is shareable and copy-pasteable. localStorage caches the last chosen state per user as the default.
|
||||
|
||||
### 8.3 Custom Views shape (`shape="timeline"`)
|
||||
|
||||
Registers `ShapeTimeline RenderShape = "timeline"` in `internal/services/render_spec.go` and adds a corresponding `frontend/src/client/views/shape-timeline-chart.ts` view-host wrapper that adapts a `ViewRow[]` → `TimelineEvent[]` array. This unlocks **cross-project timelines** as a Custom View — "all my UPC matters" or "everything where I'm in the team" rendered as one chart.
|
||||
|
||||
ViewRow → TimelineEvent is a lossy shim: `kind` and `track` map directly; `date` reuses `event_date`; cross-project lanes are auto-derived from `project_id`. Projected rows are not surfaced from `ViewService` (it doesn't run the calculator) — Custom Views show actuals only. We document that limitation, ship the shape, and revisit later if needed.
|
||||
|
||||
This is **§8.3's gating**: the standalone page (§8.2) and embed (§8.1) ship before the Custom Views shape. The shape is Slice 4 — last, optional, lower-priority.
|
||||
|
||||
---
|
||||
|
||||
## 9. Mobile behaviour
|
||||
|
||||
Three breakpoints, one rule:
|
||||
|
||||
| Width | Vertical embed | Standalone chart |
|
||||
|---|---|---|
|
||||
| ≥1024 px (desktop) | Existing | Horizontal SVG, full-bleed |
|
||||
| 640–1023 px (tablet) | Existing | Horizontal SVG, narrower viewport, density auto-switches to compact |
|
||||
| <640 px (phone) | Existing | **Force vertical** — horizontal Gantt on phone is unreadable |
|
||||
|
||||
The "force vertical on phone" rule is enforced server-side via the Accept-CH `Sec-CH-UA-Mobile` header (defensive) and client-side via `window.matchMedia("(max-width: 640px)")`. The user can override but the default flips.
|
||||
|
||||
A horizontal-on-phone variant with `overflow-x: scroll` is technically possible but UX-poor — date axis disappears off-screen, lawyer can't see context. Force vertical, force collapsing of lanes into stacked sections, keep the export menu reachable.
|
||||
|
||||
---
|
||||
|
||||
## 10. Performance
|
||||
|
||||
### 10.1 Current numbers
|
||||
|
||||
- Patent (5 child cases × 30 events) = 150 nodes typical
|
||||
- Client (100+ matters) = 100s of lane rows; aggregation already sub-filters to milestones-only at Client level → <500 nodes
|
||||
- Backend projection cost: ~285 ms cold cache for one project (per t-paliad-169 §13). Backend is not the bottleneck.
|
||||
|
||||
### 10.2 Where each renderer caps
|
||||
|
||||
| Renderer | Comfortable | Stressed | Breaks |
|
||||
|---|---|---|---|
|
||||
| DOM grid (vertical) | ≤300 nodes | 300-1000 (sluggish reflow) | 1000+ (frame drops on scroll) |
|
||||
| Hand-rolled SVG | ≤1000 nodes | 1000-3000 (slow zoom / pan) | 3000+ (paint cost) |
|
||||
| Canvas (not chosen) | ≤10 000 nodes | — | — |
|
||||
|
||||
We're sitting in the **comfortable band for both** for any plausible Paliad project. Numbers above 1000 happen only in pathological "show all my Client's matters" scenarios — and those are bound by levelPolicy aggregation already (Client-level Custom Views).
|
||||
|
||||
### 10.3 Mitigations if a real project exceeds the comfort zone
|
||||
|
||||
- **Lookahead cap** (existing): `?lookahead=N` keeps projected nodes capped at 7 by default (50 max). Future-only, doesn't help if there are 1000 actuals.
|
||||
- **Date-range filter**: chart shows only events in a date window (defaults `earliest..latest+30d` — no implicit cap). For pathological cases, user can narrow the range.
|
||||
- **Lane filter** (existing): hide / dim selected lanes on multi-lane render.
|
||||
|
||||
If a single matter genuinely has 1000+ actuals, the user has a deeper data-discipline problem and the right answer is to escalate, not to optimize a chart for it.
|
||||
|
||||
### 10.4 SVG paint budget
|
||||
|
||||
A 200-event chart in horizontal mode is ~600 SVG primitives (200 bars/dots × 3 elements: shape + label + tooltip-trigger). One initial paint = <50 ms on a low-end laptop. Subsequent zoom / pan re-runs the layout fn (10 ms) and re-attributes existing nodes (no re-create) — fast. We do not need virtualization in v1.
|
||||
|
||||
---
|
||||
|
||||
## 11. Phasing — 4 sequential slices
|
||||
|
||||
Each slice independently shippable. m's go/no-go gate after each.
|
||||
|
||||
### Slice 1 — Standalone `/projects/{id}/chart` page + horizontal SVG renderer (no exports yet)
|
||||
|
||||
What lands:
|
||||
|
||||
- New page route `GET /projects/{id}/chart` (handler `internal/handlers/chart_pages.go`, ~50 LoC). Reuses existing project gate.
|
||||
- New `frontend/src/projects-chart.tsx` page TSX (renders shell + mount target). ~100 LoC.
|
||||
- New `frontend/src/client/views/shape-timeline-chart.ts` SVG renderer (~500 LoC). Pure-function `layout(events, lanes, viewport)` + `paint(layout, palette, root)`.
|
||||
- Reuses the existing `GET /api/projects/{id}/timeline` endpoint — no backend change.
|
||||
- Mode toggle on Verlauf tab: `[Als Chart anzeigen ↗]` link → opens `/chart`.
|
||||
- Default palette + standard density + auto columns. **No** export, **no** palette picker, **no** density picker yet — controls render as inert chips.
|
||||
|
||||
What it gives m: the horizontal Gantt rendering, end-to-end. Lawyer can open `/chart`, see the matter in horizontal layout, share the URL.
|
||||
|
||||
### Slice 2 — Export pipeline (SVG / PNG / PDF / CSV / JSON / iCal)
|
||||
|
||||
What lands:
|
||||
|
||||
- Client-side: `frontend/src/client/views/chart-export.ts` (~150 LoC) handling SVG → PNG conversion, PDF print invocation, blob downloads. Three new i18n keys per format.
|
||||
- Server-side: `internal/handlers/projection.go` gains 3 new handlers — `handleProjectTimelineCSV`, `handleProjectTimelineJSON` (alt `?format=json` on existing), `handleProjectTimelineICS`. Each ~30 LoC.
|
||||
- New `frontend/src/styles/chart-print.css` for `@media print` and palette swap.
|
||||
- Export menu UI on chart page header.
|
||||
|
||||
What it gives m: every export format the brief asked for, no infra additions, lawyer-shareable PDFs.
|
||||
|
||||
### Slice 3 — Density + palette + zoom controls
|
||||
|
||||
What lands:
|
||||
|
||||
- Density toggle (`compact / standard / spacious`) — pure CSS-var + `[data-density]` attr swap, no re-fetch.
|
||||
- Palette picker (`default / kind-coded / track-coded / high-contrast / print`) — same pattern.
|
||||
- Zoom in / out controls + pan (mousewheel + drag).
|
||||
- Date-range narrower (FilterBar `time` axis already exists — wire it to chart viewport).
|
||||
- localStorage persistence per-user-per-project.
|
||||
|
||||
What it gives m: full visual customisation per the brief.
|
||||
|
||||
### Slice 4 — Custom Views integration (`shape="timeline"`)
|
||||
|
||||
What lands:
|
||||
|
||||
- Register `ShapeTimeline RenderShape = "timeline"` in `internal/services/render_spec.go` + validator.
|
||||
- New `frontend/src/client/views/shape-timeline-cv.ts` view-host adapter. Reuses Slice 1's renderer; adapts `ViewRow[]` to `TimelineEvent[]`.
|
||||
- `frontend/src/views.tsx` shape-switcher gets the 4th button.
|
||||
- Documented limitation: projected rows not surfaced in Custom Views.
|
||||
|
||||
What it gives m: "all my UPC matters as one chart" via Custom Views — cross-project chart on the existing CV substrate.
|
||||
|
||||
### What's NOT in any slice (v2 nice-to-haves)
|
||||
|
||||
- Per-user palette picker beyond fixed presets.
|
||||
- Server-side PDF render via `chromedp`.
|
||||
- Live collaborative cursors / annotation pins.
|
||||
- Animation / transitions when zoom changes.
|
||||
- Hybrid layouts (compact-strip + detail-list).
|
||||
- Color-coding with custom user-defined rules.
|
||||
|
||||
---
|
||||
|
||||
## 12. Files implementer will touch (Slice 1 only)
|
||||
|
||||
**Backend (Go):**
|
||||
- `internal/handlers/chart_pages.go` — new, ~50 LoC. `handleProjectChartPage(w, r)` returns the rendered TSX shell. Auth + project visibility gates as on `/projects/{id}`.
|
||||
- `internal/handlers/handlers.go` — register `GET /projects/{id}/chart`.
|
||||
|
||||
**Frontend (TS / TSX):**
|
||||
- `frontend/src/projects-chart.tsx` — new, ~100 LoC. Page shell with mount target + page-level controls scaffold (chips inert in Slice 1).
|
||||
- `frontend/src/client/views/shape-timeline-chart.ts` — new, ~500 LoC. SVG renderer:
|
||||
- `layout(events: TimelineEvent[], lanes: LaneInfo[], viewport: Viewport): ChartLayout` — pure function returning bar/dot positions + axis ticks + today-rule x.
|
||||
- `paint(layout: ChartLayout, palette: Palette, root: SVGSVGElement): void` — DOM-mutates the root.
|
||||
- `mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle` — composes layout + paint + interaction (click → deep-link, hover → tooltip).
|
||||
- `frontend/src/client/projects-chart.ts` — new, ~150 LoC. Page boot: fetch `/api/projects/{id}/timeline`, mount renderer, wire URL state ↔ control chips (inert), wire SmartTimeline embed's `[Als Chart anzeigen ↗]` link from `frontend/src/client/projects-detail.ts`.
|
||||
- `frontend/src/styles/global.css` — `.smart-timeline-chart-*` CSS additions, ~120 LoC. Including the palette token swap CSS but not yet wired to a picker.
|
||||
- `frontend/src/client/i18n.ts` — ~25 keys under `projects.detail.chart.*` (page title, control labels, default-palette-name, etc.) DE+EN.
|
||||
- `frontend/build.ts` — register the new page bundle.
|
||||
|
||||
**Tests:**
|
||||
- `frontend/src/client/views/shape-timeline-chart.test.ts` — new, pure-function tests for `layout()` (ranges, tick generation, lane stacking, today-rule positioning, undated-row handling).
|
||||
|
||||
Slices 2-4 are scoped in §11; coder picks them up after m's gate.
|
||||
|
||||
---
|
||||
|
||||
## 13. Trade-offs flagged
|
||||
|
||||
- **SVG accessibility.** Hand-rolled SVG needs explicit ARIA scaffolding (`role="img"` + `<title>` + `<desc>` per group, `aria-label` per event mark) to be screen-reader-readable. This is real implementation work — DOM mode gets it for free. Mitigation: lockdown `role` and label conventions in the renderer and test with VoiceOver / NVDA before Slice 1 merges.
|
||||
- **Print-CSS quirks.** `window.print()` PDFs will look slightly different across Chrome / Safari / Firefox. Lawyers comparing two exports may notice. Mitigation: documentation states "use Chrome for archival exports". Pursue chromedp only if real complaints surface.
|
||||
- **No virtualization in v1.** A 1000-event chart is not virtualized — every node is in the DOM/SVG tree. Mitigation: existing levelPolicy aggregation + lookahead caps keep node counts bounded for plausible projects. Add virtualization only if a real project exceeds the comfort band.
|
||||
- **Two renderers means two paths to maintain.** A bug in vertical-mode rendering doesn't auto-fix the horizontal mode. Mitigation: both render the **same** `TimelineEvent` / `LaneInfo` data; the discriminator is just the layout fn. Rendering bugs tend to be in shared event-mark visual tokens (color, status pill) which CSS-token-swap centralizes anyway.
|
||||
- **Custom Views adapter is lossy.** Cross-project chart in CV doesn't show projected rows. Some users might expect them. Mitigation: in-page tooltip on first CV-chart open: "Custom Views show actual events only. Open the project's `/chart` for projected rules." A future v2 could push the projection through ViewService but the substrate redesign is non-trivial.
|
||||
- **Date-range default.** Defaulting to `earliest_event..latest_event+30d` means a matter with one ancient deadline forces the whole span on every render. Mitigation: clamp default range to `today-1y..today+1y`, with a chip for "Alles anzeigen" to expand. Keeps the typical render compact.
|
||||
- **`/chart` URL collision.** `/projects/{id}/chart` doesn't conflict with any existing route, but adding `/chart` at the project level forces the route table to stay tidy. Defensive: implementer greps `internal/handlers/handlers.go` before adding to confirm no collision.
|
||||
- **Browser-print PDF on Safari shows the menu bar.** Cosmetic; print stylesheet's `@page` directive helps, but Safari ignores some rules. Mitigation: documentation; lawyer-facing exports recommend Chrome.
|
||||
|
||||
---
|
||||
|
||||
## 14. Open questions for m
|
||||
|
||||
Listed with my (inventor) pick where I have one — m decides.
|
||||
|
||||
**Q1 — Default landing on `/projects/{id}/chart`: horizontal Gantt or vertical (with a toggle)?**
|
||||
My pick: horizontal Gantt as the default. The whole reason `/chart` exists is the horizontal mode; defaulting to vertical would make it a duplicate of Verlauf. Add a `[Layout ▷|▽]` toggle for users who want vertical-on-bigscreen.
|
||||
|
||||
**Q2 — Should the chart page replace Verlauf when accessed at desktop width, or stay a separate URL?**
|
||||
My pick: separate URL. Verlauf is the "scan & action" tab (click rows to mark deadlines done, add notes). Chart is the "share & overview" surface. Conflating them risks losing the inline-action affordance Verlauf was built for.
|
||||
|
||||
**Q3 — Should the chart be embeddable inside the Verlauf tab (with a layout toggle), or only standalone?**
|
||||
My pick: standalone in Slice 1; if user feedback says "I want to see horizontal on the project page directly", add the embed in a follow-up slice. Embedding doubles render cost on every project page open and creates layout pressure on the existing tab UI.
|
||||
|
||||
**Q4 — Chromedp / server-side PDF: rule out for v1, or design in?**
|
||||
My pick: rule out. Browser-print PDFs are good enough; Chromium-on-Dokploy is a heavy dep. Keep the door open by abstracting the export-button handler so a future server-side path is a one-route addition.
|
||||
|
||||
**Q5 — Color palette presets: ship the full 5 in Slice 3, or just `default` + `print` for safety?**
|
||||
My pick: ship all 5. The palette mechanism is just CSS-var swaps; adding the other three is hours of design polish, not weeks of work. More options give more lawyers their preferred read.
|
||||
|
||||
**Q6 — iCal export: only deadlines + appointments (recommendation), or include projected too?**
|
||||
My pick: only deadlines + appointments. Subscribing to a calendar that fills with rule_code-derived predicted dates that never fire would erode trust. Future projected = visualisation only, never calendar artifacts.
|
||||
|
||||
**Q7 — Custom Views integration (`shape="timeline"`): Slice 4 priority, or descope?**
|
||||
My pick: keep as Slice 4 but explicit go/no-go after Slice 3 ships. The cross-project chart is a *cool* demo but not in the original brief — descoping if real users haven't asked is fine.
|
||||
|
||||
**Q8 — Date-range default on `/chart`: data-driven (`earliest..latest+30d`) or fixed (`today-1y..today+1y`)?**
|
||||
My pick: fixed `today-1y..today+1y`, with a chip "Alles anzeigen" expanding. Old matters with one historical deadline shouldn't force a 5-year span on first render.
|
||||
|
||||
**Q9 — Should the chart support project comparison (chart 2-3 projects side-by-side)?**
|
||||
My pick: no — out of scope for this feature. That's a Custom Views job (multi-project query → chart shape), not a per-project surface concern.
|
||||
|
||||
**Q10 — Should we expose a permalink that captures *zoom + range + palette + density + lane-filter*?**
|
||||
My pick: yes, via URL query params (already designed in §8.2). Sharing a chart-URL via WhatsApp / email then renders the same view for the recipient.
|
||||
|
||||
**Q11 — Mobile: vertical-only fallback, or horizontal-with-scroll?**
|
||||
My pick: vertical-only on phones (<640px). Horizontal-with-scroll loses the date axis off-screen. Tablet (640-1023px) keeps horizontal in compact density.
|
||||
|
||||
**Q12 — On the SmartTimeline (Verlauf embed), do we also add an inline horizontal mode (Q3 follow-up)?**
|
||||
My pick: NO in v1. The standalone `/chart` is the new surface; Verlauf stays vertical. Adding both modes inline-Verlauf doubles the test matrix without clear user demand yet.
|
||||
|
||||
---
|
||||
|
||||
## 15. Recommendation for implementer
|
||||
|
||||
Pattern-fluent Sonnet coder. Slice 1 is the heaviest (new SVG renderer, new page, new TSX shell). Slice 2 needs careful CSS print-mode tuning — best paired with browser-screenshot iteration. Slice 3 is mostly CSS-token plumbing + UI controls. Slice 4 is the lightest if Slice 1 left the renderer well-decomposed.
|
||||
|
||||
Before Slice 1, the coder should sketch the `layout(events, lanes, viewport)` function on paper / a tests file — that's where the math lives, and getting it right deterministically is the difference between "works" and "subtle render glitches in obscure date ranges". Pure-function with table-driven tests for `layout()` is the correct approach.
|
||||
|
||||
Faraday (this worktree) parks. Not pre-emptively flipping to coder — m gates.
|
||||
|
||||
---
|
||||
|
||||
**DESIGN READY FOR REVIEW**
|
||||
739
docs/design-smart-timeline-2026-05-08.md
Normal file
739
docs/design-smart-timeline-2026-05-08.md
Normal file
@@ -0,0 +1,739 @@
|
||||
# Design — SmartTimeline (Verlauf-tab redesign)
|
||||
|
||||
**Author:** lagrange (inventor)
|
||||
**Date:** 2026-05-08
|
||||
**Task:** t-paliad-169
|
||||
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live (before designing)
|
||||
|
||||
Before anchoring the design, I checked the live state — CLAUDE.md / memory / issue body can drift, the live system can't.
|
||||
|
||||
- **Verlauf today** is `frontend/src/projects-detail.tsx:74-101` — `<ul className="entity-events" id="project-events-list">` rendered from `paliad.project_events` via `loadEvents(id)` at `client/projects-detail.ts:305`. Pure audit log: `event_type` distribution in prod is 100 % administrative — `deadline_completed/updated/created/...`, `note_created`, `appointment_*`, `checklist_*`, `project_type_changed`, `our_side_changed`, `deadlines_imported`. No "future-tense" or "off-script" events surface anywhere on the project page today.
|
||||
- **Projection logic** lives in `internal/services/fristenrechner.go:Calculate(ctx, proceedingCode, triggerDateStr, opts CalcOptions)` returning a `UIResponse{Deadlines []UIDeadline}` keyed by `rule_code`. `CalcOptions.AnchorOverrides map[string]string` lets callers replace any rule's date and downstream rules re-anchor — already the load-bearing primitive for "actual dates anchor downstream projections" (t-paliad-131 Phase A).
|
||||
- **`paliad.deadline_rules`** carries 172 active rules across 19 fristenrechner proceeding types (UPC×8, DE×5, EPA×2, EP×1, DPMA×3). `condition_flag text[]` already drives counterclaim cross-flows: `with_ccr` enables 7 UPC_INF cross-flow rules (Defence-to-CCR R.29.a, Application to amend R.30.1, Defence to App-to-amend R.32.1, Reply to Defence-to-CCR R.29.d, Rejoinder R.29.e, +2). `with_amend` / `with_cci` work on UPC_REV.
|
||||
- **`paliad.projects.our_side`** column exists (added in t-paliad-164) but is **null on every live row today**. The CCR perspective-flip the cascade implements via Determinator B1 (t-paliad-167) is not yet exercised by real data.
|
||||
- **CCR is not a separate project today.** It's a flag (`with_ccr=true`) on a parent UPC_INF project. m's vision asks us to revisit that.
|
||||
- **FilterBar** (`frontend/src/client/filter-bar/`, riemann's t-paliad-163 Phase 1) ships with axis stubs `deadline_event_type` + `project_event_kind` already wired into `BarState` and `AxisKey` — Phase 2 is supposed to fill them in. The SmartTimeline's facet set is exactly the kind of thing those stubs were left pending for.
|
||||
- **Project hierarchy in prod** is the canonical 4-level shape: Client (`Siemens AG`) → Litigation (`Siemens ./. Huawei`) → Patent (`EP3456789`) → Case (`UPC-CFI München — Klage Siemens ./. Huawei`). 11 projects total.
|
||||
- **t-paliad-168 deliverable 3 is dropped** per task brief — there will be no separate Verfahrensablauf-as-its-own-tab on the project page. The wizard's projection logic is the SmartTimeline's future-skeleton feeder.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision + scope
|
||||
|
||||
m's vision (verbatim 2026-05-08 23:02):
|
||||
|
||||
> The Verlauf tab inside the case should hold past + future events. If we know the proceeding type, there is a timeline. We adapt the Verfahrensablauf logic and fix dates for things when they happened. A smart timeline. If a counterclaim is filed, that is also included. Hold it flexible — add events regardless of whether they fit the normal course.
|
||||
|
||||
The **SmartTimeline** is one composed view that answers *"what has happened in this matter, what is happening now, and what is on the standard road from here"*. Three time-zones in one widget:
|
||||
|
||||
| Zone | What it shows | Data source |
|
||||
|---|---|---|
|
||||
| **Past** | Filings, decisions, appointments, audit milestones — all dated, anchored to reality | `paliad.deadlines` (status=`done`) ∪ `paliad.appointments` (start_at < today) ∪ `paliad.project_events` (selected `timeline_kind`) |
|
||||
| **Now** | Open deadlines + appointments today | same tables, today-bracket |
|
||||
| **Future (predicted)** | Standard-course rules from `deadline_rules` projected forward, faded — only those without an actual `paliad.deadlines` row yet | `fristenrechner.Calculate` against project's proceeding type + trigger anchor |
|
||||
| **Future (off-script)** | User-added events that don't fit the standard tree (counterclaim filed, ad-hoc Anhörung, party amendment) | `paliad.deadlines` with `source='off_script'` ∪ child counterclaim sub-project's actuals ∪ `project_events` with `timeline_kind` |
|
||||
|
||||
### What changes
|
||||
|
||||
- The `tab=history` panel on `/projects/{id}` becomes a SmartTimeline component that renders all four zones in one column.
|
||||
- The audit-only Verlauf view does not disappear — it survives as a "Audit-Log" sub-toggle inside the SmartTimeline ("Alle Audit-Events anzeigen") and on the existing `/admin/audit-log` page (t-paliad-071).
|
||||
- The existing FilterBar primitive grows two facets (`timeline_track`, `timeline_status`) and re-uses three (`time`, `personal_only`, `deadline_event_type`).
|
||||
|
||||
### What stays
|
||||
|
||||
- Step 2 third-card + sidebar entry from t-paliad-168 are unaffected — the standalone Verfahrensablauf wizard at `/tools/fristenrechner` remains a knowledge-platform tool.
|
||||
- `paliad.project_events` keeps its full audit-log role for `/admin/audit-log`.
|
||||
- `paliad.deadlines` + `paliad.appointments` schemas don't migrate (only one optional column added; details in §2).
|
||||
- The existing "Inkl. Unterprojekte" toggle on the project page stays — the SmartTimeline reads child events through it.
|
||||
|
||||
### Out of scope (v1)
|
||||
|
||||
- Horizontal-Gantt rendering. We pick a vertical timeline; Gantt is a future shape (t-paliad-144 substrate already supports `shape` switching, so adding a Gantt shape is later, not now).
|
||||
- Outlook/Exchange sync. CalDAV stays the only sync path.
|
||||
- Cross-matter timelines (e.g. "everything happening on EP3456789 across Siemens ./. Huawei AND any related opposition"). The patent-level aggregation in §5 is a step in that direction but cross-matter view is a separate task.
|
||||
- Rendering documents (Schriftsätze) on the timeline. That's the t-paliad-17 Incoming-Submission workflow, separate.
|
||||
|
||||
---
|
||||
|
||||
## 2. Data model
|
||||
|
||||
**Recommendation: virtual view, ONE optional column.** No new top-level table for v1. The four zones above are computed at read time from the existing tables. The single schema change is a nullable `timeline_kind text` column on `paliad.project_events` so a subset of audit rows can opt into surfacing as timeline content.
|
||||
|
||||
### 2.1 Why no new `timeline_events` table
|
||||
|
||||
A first-instinct design would materialise a new `paliad.timeline_events` table with columns `(project_id, kind, date, title, status, source_track, rule_code?, actual_deadline_id?, …)`. I recommend against it for v1:
|
||||
|
||||
1. **Three of the four zones already have authoritative tables.** `paliad.deadlines` is the source-of-truth for legal deadlines (with completion + approval state); `paliad.appointments` for hearings + court dates; `paliad.project_events` for audit. Forcing a copy into `timeline_events` creates a sync problem on every mutation.
|
||||
2. **The future-projected zone is a function of proceeding-type + trigger date + actual anchors** — not stored data. Materialising it would require invalidation on every `paliad.deadlines` change. Cheaper to recompute per request: 19 proceeding types × at most ~15 rules = ~285 ms with cold pg cache, well under the page-render budget. Re-uses the cached `FristenrechnerService` (already memoised per request via service instantiation).
|
||||
3. **t-paliad-144 set the precedent** that ViewService composes per request without materialising. The SmartTimeline is a project-scoped instance of the same pattern.
|
||||
|
||||
If load testing later shows the projection cost matters, we materialise into a `paliad.projected_timeline_cache` table indexed by (project_id, rule_code) — but design that when load shows it, not now.
|
||||
|
||||
### 2.2 The one column added
|
||||
|
||||
```sql
|
||||
-- migration NNN_project_events_timeline_kind.up.sql
|
||||
ALTER TABLE paliad.project_events
|
||||
ADD COLUMN timeline_kind text NULL;
|
||||
|
||||
-- nullable + no CHECK — enum lives in code (services/projection_service.go).
|
||||
-- Value space (v1):
|
||||
-- 'milestone' — a structural event worth pinning to the timeline
|
||||
-- (counterclaim_filed, third_party_intervened,
|
||||
-- party_amendment, our_side_changed, scope_change)
|
||||
-- 'custom_milestone' — free-text user-added event
|
||||
-- NULL — audit only (default, all existing rows)
|
||||
|
||||
CREATE INDEX project_events_timeline_kind_idx
|
||||
ON paliad.project_events (project_id, timeline_kind)
|
||||
WHERE timeline_kind IS NOT NULL;
|
||||
```
|
||||
|
||||
Existing event types stay `NULL` — they remain audit-only and don't clutter the timeline. New write paths (counterclaim-link, off-script milestone) set the column on insert.
|
||||
|
||||
### 2.3 The discriminated `TimelineEvent` shape
|
||||
|
||||
Composed in `internal/services/projection_service.go` (new). One Go struct, one TS mirror. Frontend renders without knowing where each row came from:
|
||||
|
||||
```go
|
||||
type TimelineEvent struct {
|
||||
Kind string // "deadline" | "appointment" | "milestone" | "projected"
|
||||
Status string // "done" | "open" | "overdue" | "court_set" | "predicted" | "off_script"
|
||||
Track string // "parent" | "counterclaim" | "child:<project_id>" | "off_script"
|
||||
Date *time.Time // nil = undated (court-set + counterclaim-pending)
|
||||
|
||||
Title string
|
||||
Description string
|
||||
RuleCode string // empty when not deadline-rule-derived
|
||||
|
||||
// Provenance — exactly one is non-nil for actual rows; both nil for projected.
|
||||
DeadlineID *uuid.UUID
|
||||
AppointmentID *uuid.UUID
|
||||
ProjectEventID *uuid.UUID
|
||||
|
||||
// For projected rows (Kind=="projected") — the rule it came from, for
|
||||
// the click-to-anchor affordance (§6).
|
||||
DeadlineRuleID *uuid.UUID
|
||||
DeadlineRuleParty string // 'claimant' | 'defendant' | 'court' | 'both'
|
||||
|
||||
// For child-track rows — the sub-project this event belongs to.
|
||||
SubProjectID *uuid.UUID
|
||||
SubProjectTitle string
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Read path
|
||||
|
||||
```
|
||||
GET /api/projects/{id}/timeline?
|
||||
from=...&to=...&direct_only=true|false&
|
||||
tracks=parent,counterclaim,...&kinds=deadline,appointment,projected,...
|
||||
```
|
||||
|
||||
The handler:
|
||||
1. Calls `ProjectionService.For(ctx, projectID, opts)` which:
|
||||
- Loads the project (proceeding_type_id, our_side, parent chain).
|
||||
- Loads child counterclaim sub-projects (if any — see §4).
|
||||
- Loads `paliad.deadlines` (project_id IN [self, child counterclaims]) → emits Kind=deadline rows.
|
||||
- Loads `paliad.appointments` (same) → emits Kind=appointment rows.
|
||||
- Loads `paliad.project_events WHERE timeline_kind IS NOT NULL` → emits Kind=milestone rows.
|
||||
- For each (project, child) with a proceeding_type_id, calls `FristenrechnerService.Calculate` with `AnchorOverrides` derived from completed actuals → emits Kind=projected rows for any rule that does **not** have a matching `paliad.deadlines.rule_id` row.
|
||||
- Sorts by Date ASC, undated rows last (with secondary sort on rule sequence_order so undated court-set rows preserve the standard course's order).
|
||||
|
||||
Visibility is inherited via existing `visibilityPredicate` on each underlying service — no new RLS surface to design.
|
||||
|
||||
### 2.5 What does NOT need to change
|
||||
|
||||
- `paliad.deadlines` schema — unchanged. (The existing `original_due_date`, `source`, and the AnchorOverrides plumbing already cover "actual date anchors downstream", §6.)
|
||||
- `paliad.appointments` — unchanged.
|
||||
- `paliad.deadline_rules` — unchanged. The existing `condition_flag text[]` keeps doing its job.
|
||||
- `paliad.projects` — unchanged. (See §4 for the counterclaim sub-project shape: it uses existing columns.)
|
||||
|
||||
---
|
||||
|
||||
## 3. UI mockup — three states
|
||||
|
||||
The SmartTimeline replaces the current `<ul className="entity-events">` block (~30 lines of TSX) with a vertically-flowing two-column timeline:
|
||||
|
||||
- Left column: date (or "Datum offen" placeholder).
|
||||
- Right column: stacked card per event with a status icon, title, kind chip, and (for actuals) a deep-link to `/deadlines/{id}` etc. Same `.entity-event` row contract as today (cf. CLAUDE.md whole-card click rule), no `::before` overlay.
|
||||
|
||||
A horizontal "**Heute →**" rule separates past from future. Past goes below (most-recent first), future above (chronological). Today's events sit on the rule.
|
||||
|
||||
### 3.1 State A — empty / no proceeding type set
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ SmartTimeline [Filter ▼] [+ Eintrag] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Noch keine Ereignisse erfasst. │
|
||||
│ │
|
||||
│ Setze einen Verfahrenstyp im Projekt-Header, um den │
|
||||
│ Standardverlauf als Vorhersage zu sehen, oder lege │
|
||||
│ einen Eintrag manuell an. │
|
||||
│ │
|
||||
│ [+ Frist anlegen] [+ Termin anlegen] [+ Meilenstein] │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The empty state actively guides toward the two unlocks: setting a proceeding type (enables future-projection) or adding manual events (works without one).
|
||||
|
||||
### 3.2 State B — UPC_INF, infringement-only
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ SmartTimeline Verfahrenstyp: UPC-Verletzung [Filter ▼] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Zukunft (vorhergesagt) │
|
||||
│ ───────────────────────────── │
|
||||
│ 2027-02-20 ░ Hauptverhandlung │
|
||||
│ ░ wird vom Gericht bestimmt [Datum setzen] │
|
||||
│ ─ │
|
||||
│ 2026-12-02 ░ Duplik (RoP.029.c) [voraussichtlich]│
|
||||
│ 2026-11-02 ░ Replik (RoP.029.b) [voraussichtlich]│
|
||||
│ 2026-08-31 ░ Klageerwiderung (RoP.023) [voraussichtlich]│
|
||||
│ │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━ Heute (2026-05-08) ━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ │
|
||||
│ Vergangenheit │
|
||||
│ ───────────────────────────── │
|
||||
│ 2026-04-29 ✓ Klageschrift zugestellt (Anker) │
|
||||
│ 2026-04-25 ✓ Akte angelegt (Audit) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- `░` (faded) = projected, `✓` = done, `!` = overdue (red), `…` = open (amber), `▢` = court-set (dashed border).
|
||||
- "Datum setzen" on the Hauptverhandlung row is the click-to-anchor affordance (§6).
|
||||
- "voraussichtlich" pill is the projected-status visual; tooltip explains "Anhand des Standardverlaufs aus dem Fristenrechner berechnet".
|
||||
- Filter chip selector reveals the FilterBar primitive directly above the list (collapsed by default to reduce noise on first load — same affordance riemann shipped on /inbox).
|
||||
|
||||
### 3.3 State C — UPC_INF + Counterclaim (CCR-Subprojekt)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SmartTimeline Verfahrenstyp: UPC-Verletzung [Track ▼ Beide] [Filter ▼] │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Verletzung (Klägerseite) ┊ Widerklage (Beklagtenseite, CCR) │
|
||||
│ ──────────────────────────────────────┊──────────────────────────────────────│
|
||||
│ Zukunft (vorhergesagt) │
|
||||
│ 2027-02-20 ░ Hauptverhandlung ┊ │
|
||||
│ [Datum setzen] ┊ │
|
||||
│ 2027-01-29 ░ Rejoinder R.29.e ┊ 2026-12-29 ░ Rejoinder R.32.3 │
|
||||
│ 2026-12-29 ░ Reply to Defence-CCR ┊ │
|
||||
│ 2026-11-29 ░ Defence to App-amend ┊ 2026-11-29 ░ Reply to Defence-amend│
|
||||
│ 2026-10-31 ░ Defence to CCR (R.29a)┊ 2026-09-30 ░ Defence to amend │
|
||||
│ 2026-08-31 ░ Klageerwiderung mit CCR┊ │
|
||||
│ ┊ │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━ Heute (2026-05-08) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ ┊ │
|
||||
│ Vergangenheit ┊ │
|
||||
│ 2026-04-29 ✓ Klageschrift zugestellt┊ ⊕ Widerklage angekündigt │
|
||||
│ ┊ (off-script, 2026-05-02) │
|
||||
│ 2026-04-25 ✓ Akte angelegt ┊ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Two parallel tracks — left is the parent infringement, right is the linked counterclaim sub-project (see §4).
|
||||
- `[Track ▼]` chip toggles between "Beide" (default when a CCR sub-project exists), "Nur Verletzung", "Nur Widerklage".
|
||||
- "⊕" marks an off-script milestone (the counterclaim was *announced* before being formally filed — a `project_events` row with `timeline_kind='custom_milestone'`).
|
||||
- Mobile: stacks vertically with collapsible per-track headers.
|
||||
|
||||
---
|
||||
|
||||
## 4. Counterclaim shape — sub-project, defended
|
||||
|
||||
m's framing offered two shapes. Inventor recommendation: **sub-project**. Trade-off explicit.
|
||||
|
||||
### 4.1 The choice
|
||||
|
||||
| | **Sub-project (recommended)** | **Same-project, parallel proceeding-overlay** |
|
||||
|---|---|---|
|
||||
| Project rows | One per proceeding (parent INF + child CCR) | One project, two proceeding-types attached |
|
||||
| `our_side` flip | Independent on the child (parent: claimant; child: defendant in CCR-on-validity, claimant on CCR-of-infringement) | Needs a "perspective per proceeding" sub-table |
|
||||
| Determinator routing (t-paliad-167) | Existing — child gets its own cascade | Needs proceeding-aware routing inside one project |
|
||||
| Project tree (t-paliad-149) | Naturally appears as a nested node | Same-row, no tree change |
|
||||
| Dashboard per-project counts | Each gets its own count | Mixing — needs new "by-proceeding" aggregator |
|
||||
| Visibility / RLS | Inherits `can_see_project` cascade | Same |
|
||||
| CCR Number from CMS | Stored on child's `case_number` | Stored on parent in a new `case_numbers jsonb` |
|
||||
| New schema | None (uses existing project + parent_id) | New `project_proceedings` join table |
|
||||
|
||||
### 4.2 Why sub-project
|
||||
|
||||
- **Cheap.** Zero schema migration. The hierarchy already supports arbitrary nesting (4 types: client / litigation / patent / case — but `parent_id` is type-agnostic).
|
||||
- **Consistent with the data we just built.** t-paliad-164 our_side, t-paliad-149 project tree, t-paliad-167 Determinator cascade, t-paliad-168 deadline-rule jurisdiction defaults all assume "one project = one proceeding perspective". Counterclaim being a sub-project just means we keep that assumption.
|
||||
- **CCR Number.** The counterclaim has its own CCR number in the UPC CMS — which means it is in fact a separate proceeding artifact, not just a phase of the parent. Modeling it as a separate project row with its own `case_number` reflects reality. The "case-complex-wise" closeness m asks about is the parent_id link, not collapsing them into one row.
|
||||
- **Independent timeline math.** UPC R.49(2) puts CCI / app-to-amend "as part of" Defence to revocation — but that just means zero-duration filed-with-parent. The downstream re-anchoring is independent in each tree.
|
||||
|
||||
### 4.3 The link
|
||||
|
||||
A new optional FK on `paliad.projects`:
|
||||
|
||||
```sql
|
||||
-- migration NNN_projects_counterclaim_of.up.sql
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN counterclaim_of uuid NULL
|
||||
REFERENCES paliad.projects(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX projects_counterclaim_of_idx
|
||||
ON paliad.projects (counterclaim_of)
|
||||
WHERE counterclaim_of IS NOT NULL;
|
||||
|
||||
-- A project can be EITHER a parent (counterclaim_of IS NULL) OR a
|
||||
-- counterclaim against another project (counterclaim_of points at it),
|
||||
-- but not both. Enforced by a CHECK on the union of FKs (see §10).
|
||||
```
|
||||
|
||||
`parent_id` keeps the standard hierarchy (the counterclaim child still lives under the same patent / litigation tree). `counterclaim_of` is an *additional* relation expressing "this project is the CCR against project X". The two are both set on a counterclaim sub-project.
|
||||
|
||||
### 4.4 Creating a counterclaim from the timeline
|
||||
|
||||
The "+ Eintrag" button on the parent's SmartTimeline opens a typed-add modal (§7). Picking type=`Counterclaim` (UPC) creates a child project with:
|
||||
|
||||
- `parent_id` = parent's parent (so CCR appears as a sibling under the patent, not a grandchild — debatable; see §11 Q4).
|
||||
- `counterclaim_of` = parent project id.
|
||||
- `proceeding_type_id` = `UPC_REV` (CCR-on-validity is the standard case; UPC_CCI is the rarer R.49.2.b path).
|
||||
- `our_side` = inverted from parent (parent claimant → child defendant, parent defendant → child claimant).
|
||||
- `title` = `<patent> — Widerklage (CCR)` auto-suggested.
|
||||
|
||||
The same flow applies to `case_amend` (UPC R.30 application to amend) — a separate child sub-project. *Whether to model R.30 as a child project or as a flag on the parent is open: amendments are usually just a flag in our existing model. Default v1 = stay as flag, do **not** create a sub-project for application-to-amend; only formal counterclaims (CCR / CCI) get sub-projects.*
|
||||
|
||||
### 4.5 What the parent's SmartTimeline shows for the child
|
||||
|
||||
When `counterclaim_of` exists pointing at this project, the SmartTimeline renders a parallel right-track with the child's events (limited to `kind IN ('deadline','appointment','milestone')` — child's projected rows are also included). User can collapse/hide the child track via the `[Track ▼]` chip.
|
||||
|
||||
The child's own SmartTimeline shows its own events as the primary track plus the parent as a left-side faded-context track (so the lawyer working on the CCR can see what's happening on the main proceeding without leaving the page).
|
||||
|
||||
---
|
||||
|
||||
## 5. Parent-node aggregation rule
|
||||
|
||||
What does the SmartTimeline render at higher levels of the project hierarchy? The four levels we have today:
|
||||
|
||||
### 5.1 Per-level rendering
|
||||
|
||||
| Level | Default render | Why |
|
||||
|---|---|---|
|
||||
| **Case** (UPC-CFI X) | Full SmartTimeline of self + parallel-track for any linked CCR sub-project. All zones, all kinds. | The lawyer working a single proceeding sees everything in one view. |
|
||||
| **Patent** (EP3456789) | Lanes — one per child case. Each lane shows only `kind IN ('deadline','milestone')` + status `IN ('done','open','overdue')`. Projected rows hidden by default (unfold-per-lane on click). | A patent typically has 1-3 active cases (CFI + CoA + opposition). Showing all projected rows from every case = overwhelming. Showing actuals + structural milestones gives the matter-level view. |
|
||||
| **Litigation** (Siemens ./. Huawei) | Lanes — one per child patent's primary case (most-recently-active case). Show only `kind='milestone'` + status=`done` + per-case "next due" pill. | Litigation level is portfolio-of-patents-against-this-defendant. Useful to see when each patent's current proceeding is, not the granular deadlines. |
|
||||
| **Client** (Siemens AG) | Default = matter list (existing project tree). Behind a "Timeline-Ansicht" toggle, lanes = one per litigation. Shows only `kind='milestone'` + status=`done`. | Client level can have 100+ matters. A timeline across all is meaningless. The toggle makes it discoverable for the partner who wants the bird's-eye view. |
|
||||
|
||||
### 5.2 The single rule
|
||||
|
||||
> Each level removes one tier of detail and adds one tier of grouping. Going up: fewer kinds rendered, fewer statuses surfaced, more lanes.
|
||||
|
||||
| Level | Kinds | Statuses | Lanes |
|
||||
|---|---|---|---|
|
||||
| Case | all | all | self + CCR child |
|
||||
| Patent | deadline + milestone | done + open + overdue | one per child case |
|
||||
| Litigation | milestone | done | one per child patent |
|
||||
| Client | milestone (toggle) | done | one per child litigation |
|
||||
|
||||
This rule is implementable as a single `levelPolicy(projectType)` function in `ProjectionService` returning a `(kinds, statuses, lane_grouping)` triple. All four cases share the same render component; only the input filter varies.
|
||||
|
||||
### 5.3 Off-script events at higher levels
|
||||
|
||||
Off-script milestones (counterclaim filed, party amendment, scope change) are first-class at every level — they're the events m most cares about seeing at the litigation/patent overview. The "milestone" kind survives the level filter at all levels.
|
||||
|
||||
### 5.4 Not in v1
|
||||
|
||||
Cross-matter aggregation (e.g. "all my UPC matters, one timeline") is a Custom-View concern (t-paliad-144 substrate). The SmartTimeline is project-scoped; cross-project goes through `/views/{slug}` with a sources=`timeline` ViewSpec. Phase 5+, after t-paliad-163 Phase B lands.
|
||||
|
||||
---
|
||||
|
||||
## 6. Date-anchoring + reflow semantics
|
||||
|
||||
### 6.1 The rule (explicit)
|
||||
|
||||
> An actual date — recorded as a `paliad.deadlines.due_date` (status `done`) or `paliad.appointments.start_at` (in the past) or a milestone date — anchors every downstream projected event whose parent rule is the corresponding deadline_rule. The reflow propagates one parent-step at a time, until the next actual takes over or the chain bottoms out.
|
||||
|
||||
In other words: the existing `AnchorOverrides` mechanism in `FristenrechnerService.Calculate` is exactly the load-bearing primitive. The SmartTimeline's `ProjectionService` builds the override map at request time:
|
||||
|
||||
```go
|
||||
overrides := map[string]string{}
|
||||
for _, d := range completedDeadlines {
|
||||
if d.RuleCode == "" || d.CompletedAt == nil { continue }
|
||||
overrides[d.RuleCode] = d.CompletedAt.Format("2006-01-02")
|
||||
}
|
||||
// Court-set rules pick up the actual date too — set when the user enters
|
||||
// "Hauptverhandlung fand statt am ..." via the inline anchor affordance.
|
||||
opts := CalcOptions{AnchorOverrides: overrides, Flags: flagsForProject(p)}
|
||||
result := frist.Calculate(ctx, p.ProceedingCode, p.TriggerDate, opts)
|
||||
```
|
||||
|
||||
### 6.2 The UI affordance
|
||||
|
||||
Each projected row carries a `[Datum setzen]` link (or full-row click on tap-targets). Click → inline date input expands inline. On submit:
|
||||
|
||||
- If the row corresponds to a `deadline_rules` entry that has a *real* deadline (not court-set), the action creates a `paliad.deadlines` row with `rule_id` set, `due_date=entered`, `original_due_date=projected`, `source='anchor'`, `status='done'`, `completed_at=entered`. (The "anchor" source is new; existing values are `manual`, `rule`, `import`. v1 adds `'anchor'` to the existing CHECK list.) This is the "we just learned the parent fact" path.
|
||||
- If the row is court-set (decision / hearing / order), the action creates a `paliad.appointments` row with `start_at=entered`, `appointment_type='hearing'|'decision'|'order'` derived from the rule's `event_type`. The appointment links back to `rule_code` via a new optional FK column `paliad.appointments.deadline_rule_id` (nullable; existing rows stay null).
|
||||
- Either way, the next read recomputes the projection with the new override and downstream rows reflow.
|
||||
|
||||
### 6.3 Editing an actual date later
|
||||
|
||||
If the user clicks an existing actual row's date, the inline editor PATCHes the underlying record (`/api/deadlines/{id}` or `/api/appointments/{id}`), and the next read re-projects.
|
||||
|
||||
### 6.4 What happens to overdue projected rows
|
||||
|
||||
A projected row whose date is in the past but no actual exists yet renders as "vorhergesagt — überfällig" (faded amber). Clicking it lets the user either (a) anchor it as actual on a different date, or (b) explicitly mark "ist nicht eingetreten / wurde verschoben" — which writes a `project_events` row with `event_type='rule_skipped'` + `timeline_kind='milestone'` so the audit trail records the decision.
|
||||
|
||||
---
|
||||
|
||||
## 7. Off-script event UX
|
||||
|
||||
The cardinal constraint: "We must hold it flexible — add events regardless of whether they fit the normal course." Off-script events are first-class.
|
||||
|
||||
### 7.1 The "+ Eintrag" CTA
|
||||
|
||||
Persistent button in the SmartTimeline header. Click → typed-add modal:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Neuer Eintrag im SmartTimeline │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Was ist passiert? (oder wird passieren?) │
|
||||
│ │
|
||||
│ ◯ Frist → /deadlines/new │
|
||||
│ ◯ Termin → /appointments/new │
|
||||
│ ◯ Widerklage (CCR) → Anlegen Sub-Akte │
|
||||
│ ◯ Anwendung auf Änderung (R.30) → Flag setzen │
|
||||
│ ◯ Schriftsatz / Order → Off-script │
|
||||
│ ◯ Eigener Meilenstein → Off-script (frei) │
|
||||
│ │
|
||||
│ [ Abbrechen ] [ Weiter ▶ ] │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The visible options depend on the project's `proceeding_type_id`. UPC_INF gets the CCR + R.30 routes; UPC_REV gets CCI; DE_INF gets none of these. The "Schriftsatz / Order" + "Eigener Meilenstein" routes are universal.
|
||||
|
||||
### 7.2 The off-script branch
|
||||
|
||||
For "Schriftsatz / Order" and "Eigener Meilenstein" — a small form:
|
||||
|
||||
```
|
||||
Off-script Meilenstein
|
||||
|
||||
Titel: [Widerklage angekündigt durch Beklagten ]
|
||||
Datum: [2026-05-02]
|
||||
Beschreibung: [Schreiben des Beklagtenanwalts vom 02.05., … ]
|
||||
Verknüpfung: ☐ Frist daraus erzeugen ☐ Termin daraus erzeugen
|
||||
Sichtbar in: ◉ Diese Akte ◯ Diese Akte + Eltern
|
||||
↑ Will it bubble up to higher levels?
|
||||
|
||||
[ Abbrechen ] [ Speichern ]
|
||||
```
|
||||
|
||||
On submit, writes a `paliad.project_events` row with:
|
||||
|
||||
- `event_type='off_script_milestone'` (new value in the event_type enum-ish CHECK; today's CHECK is open-ended text — confirm during impl).
|
||||
- `timeline_kind='custom_milestone'`.
|
||||
- `event_date=entered`.
|
||||
- `description=...`.
|
||||
- `metadata={"track": "parent" | "off_script", "links": [...]}`.
|
||||
|
||||
The optional checkboxes "Frist daraus erzeugen / Termin daraus erzeugen" open the standard deadline/appointment-create flow with the milestone's data prefilled and the milestone's id linked via metadata for audit trail.
|
||||
|
||||
### 7.3 Curated catalogue per proceeding type (NICE TO HAVE)
|
||||
|
||||
A small lookup table `paliad.timeline_event_catalogue (proceeding_type_id, kind, slug, name_de, name_en, primary_party)` could surface in the modal as a "Häufige Ereignisse" section above the universal "Eigener Meilenstein" route. Examples:
|
||||
|
||||
- UPC_INF: Counterclaim Filed, Third Party Intervention, Hearing Postponement, Cost Decision Issued
|
||||
- UPC_REV: Application to Amend Filed, Substantive Decision, Costs Order
|
||||
- DE_INF: Hinweisbeschluss Issued, Verteidigungsanzeige, Termin Hauptverhandlung, Versäumnisurteil
|
||||
|
||||
The catalogue is a v2 nice-to-have. v1 ships with "Eigener Meilenstein" as the universal escape hatch and the few proceeding-specific routes named above (CCR, CCI, R.30) hardcoded on the modal.
|
||||
|
||||
---
|
||||
|
||||
## 8. Filter facets — first-pass refinement
|
||||
|
||||
Refining the task brief's first-pass list against the FilterBar API (riemann's `BarState` / `AxisKey`). Each axis maps to either a universal axis (already shipped), an existing per-source stub (riemann left ready), or a new one.
|
||||
|
||||
### 8.1 Reused universal axes (already in BarState)
|
||||
|
||||
- **`time`** (universal, chip cluster + custom range) — past 30/90d, next 30/90/any/custom. Default = `any`. Re-used verbatim; no work.
|
||||
- **`personal_only`** (universal, chip) — re-used. "Nur meine Einträge" — `created_by=me`. Behavior same as on `/events` (t-paliad-128).
|
||||
|
||||
### 8.2 New per-source axes (extend `AxisKey`)
|
||||
|
||||
```ts
|
||||
// frontend/src/client/filter-bar/types.ts — additions
|
||||
export type AxisKey =
|
||||
| …existing…
|
||||
| "timeline_kind" // multi-select chip cluster
|
||||
| "timeline_status" // multi-select chip cluster
|
||||
| "timeline_track" // multi-select chip cluster
|
||||
;
|
||||
|
||||
export interface BarState {
|
||||
…existing…
|
||||
timeline_kind?: ("deadline" | "appointment" | "milestone" | "projected")[];
|
||||
timeline_status?: ("done" | "open" | "overdue" | "court_set" | "predicted" | "off_script")[];
|
||||
timeline_track?: ("parent" | "counterclaim" | string /* child:<projectid> */)[];
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 The facet set on the SmartTimeline surface
|
||||
|
||||
The surface declares this `axes` array when it mounts the bar:
|
||||
|
||||
```ts
|
||||
mountFilterBar(host, {
|
||||
axes: [
|
||||
"time", // universal — past/future filter
|
||||
"timeline_kind", // deadline | appointment | milestone | projected
|
||||
"timeline_status", // done | open | overdue | court_set | predicted | off_script
|
||||
"timeline_track", // parent | counterclaim | child:<id>
|
||||
"personal_only", // optional — toggle "nur meine Einträge"
|
||||
"deadline_event_type", // existing stub — wired in t-paliad-117 multi-select
|
||||
"shape", // timeline (default) | list | cards
|
||||
"sort", // chronological asc/desc
|
||||
"density", // comfortable | compact
|
||||
],
|
||||
surfaceKey: "project-smart-timeline",
|
||||
systemViewSlug: "project-timeline",
|
||||
…
|
||||
});
|
||||
```
|
||||
|
||||
### 8.4 Defaults
|
||||
|
||||
- `time = any`
|
||||
- `timeline_kind = [deadline, appointment, milestone]` (projected hidden by default — the user opts in via chip; reduces noise on first load when most projects don't have a proceeding type set)
|
||||
- `timeline_status = [done, open, overdue, off_script]` (predicted + court_set hidden by default if `projected` kind is hidden — the chip group is one logical "show future" toggle)
|
||||
- `timeline_track = all available`
|
||||
- `shape = timeline`
|
||||
- `sort = date_desc` (most recent first; matches today's Verlauf default)
|
||||
- `density = comfortable`
|
||||
|
||||
### 8.5 The "show future" macro
|
||||
|
||||
Most users will only want one toggle: "Zukunft anzeigen". We render that as a primary chip pair next to `time`:
|
||||
|
||||
```
|
||||
[ Vergangenheit | Heute | Zukunft ] ← primary toggle
|
||||
```
|
||||
|
||||
Internally this maps to `time + timeline_kind` (Vergangenheit hides projected, Zukunft shows projected, Heute is just today). Power users can drill into the granular axes via the bar.
|
||||
|
||||
### 8.6 What riemann's port (t-paliad-170) needs to know
|
||||
|
||||
Riemann is porting FilterBar onto the Verlauf surface in parallel. Three things they need:
|
||||
|
||||
1. **Three new axis keys** (`timeline_kind`, `timeline_status`, `timeline_track`). They render as chip clusters — the same primitive `chipRow + chipBtn` riemann already factored.
|
||||
2. **`shape: "timeline"`** is a new render shape. Existing shapes are `list | cards | calendar` (t-paliad-144). We pick "timeline" as a 4th shape so the FilterBar's shape switcher lets the user collapse to `list` (compact audit log) or `cards` (chronological card grid) without losing the data. Implementation = new `frontend/src/client/views/shape-timeline.ts` mirroring the other shape files. Out of scope for t-paliad-170 (riemann ports the bar, not the new shape).
|
||||
3. **The `timeline_track` axis options are dynamic** — they depend on whether the project has a counterclaim child. The bar already supports lazy axes (the `project` axis pattern in `axes.ts:30` — `"populated lazily"`). `timeline_track` follows the same shape: surface fetches available tracks at mount, passes them to the bar.
|
||||
|
||||
---
|
||||
|
||||
## 9. Verfahrensablauf-logic sharing — extract, don't import
|
||||
|
||||
**Recommendation: extract into a shared module first.**
|
||||
|
||||
### 9.1 The decision
|
||||
|
||||
The wizard's projection logic is currently in two places:
|
||||
|
||||
1. `internal/services/fristenrechner.go:Calculate(...)` — the canonical Go implementation. Already returns a `UIResponse{Deadlines []UIDeadline}` keyed by rule_code, supports `AnchorOverrides`. ~1000 lines, tested.
|
||||
2. `frontend/src/client/fristenrechner.ts:calculate()` — the frontend wrapper that POSTs `/api/tools/fristenrechner` and handles flags + overrides. ~3500 lines including the wizard UI, but the projection-relevant slice is small (call + render).
|
||||
|
||||
The SmartTimeline's `ProjectionService.For(projectID)` needs the *Go calculator*, not the frontend code path. So the question is really: *do we add a new Go service that wraps `FristenrechnerService.Calculate` for projects?*
|
||||
|
||||
Yes — a thin adapter, not a parallel implementation.
|
||||
|
||||
### 9.2 The adapter
|
||||
|
||||
```go
|
||||
// internal/services/projection_service.go (new, ~200 LoC)
|
||||
|
||||
type ProjectionService struct {
|
||||
db *sqlx.DB
|
||||
fristen *FristenrechnerService
|
||||
deadlines *DeadlineService
|
||||
appointments *AppointmentService
|
||||
projects *ProjectService
|
||||
courts *CourtService
|
||||
}
|
||||
|
||||
// For builds a SmartTimeline for one project (and its CCR child if any).
|
||||
// Composes the four zones described in §1; returns sorted TimelineEvent[].
|
||||
func (s *ProjectionService) For(ctx context.Context, projectID uuid.UUID, opts ProjectionOpts) ([]TimelineEvent, error) {
|
||||
p, err := s.projects.GetVisible(ctx, projectID, opts.ViewerID)
|
||||
// ...
|
||||
children := s.projects.LoadCounterclaimChildrenVisible(ctx, projectID, opts.ViewerID)
|
||||
|
||||
actuals := s.collectActuals(ctx, []uuid.UUID{p.ID, children...}) // dl + appt + milestones
|
||||
overrides := buildAnchorOverrides(actuals)
|
||||
|
||||
var projected []TimelineEvent
|
||||
if p.ProceedingTypeCode != "" && p.TriggerDate != nil {
|
||||
proj := s.fristen.Calculate(ctx, p.ProceedingTypeCode, p.TriggerDate.Format("2006-01-02"),
|
||||
CalcOptions{AnchorOverrides: overrides, Flags: flagsFor(p), CourtID: p.CourtID})
|
||||
projected = projectionToTimeline(proj, p, actuals)
|
||||
}
|
||||
// (same for each child counterclaim)
|
||||
|
||||
return mergeAndSort(actuals, projected, opts.LevelPolicy), nil
|
||||
}
|
||||
```
|
||||
|
||||
The adapter does not duplicate the calculator — it calls `FristenrechnerService.Calculate` exactly once per (project, child). Same code path as `/api/tools/fristenrechner` uses today; same tests cover both.
|
||||
|
||||
### 9.3 What the standalone wizard keeps
|
||||
|
||||
`/tools/fristenrechner` continues to use `FristenrechnerService.Calculate` directly — it's a knowledge-platform tool, not a project-scoped view. It does not gain anchoring affordances or off-script events. The projection there is hypothetical ("if you start a UPC_INF on date X, here's the timeline"), not project-actual.
|
||||
|
||||
`ProjectionService` is a project-scoped composition layer; it lives one level above `FristenrechnerService` in the dependency graph.
|
||||
|
||||
### 9.4 The test split
|
||||
|
||||
- `fristenrechner_test.go` keeps testing the calculator (duration math, AnchorOverrides, CourtID resolution).
|
||||
- `projection_service_test.go` (new) tests the composition: mixing actuals + projected, level policy, counterclaim child merging, sort order.
|
||||
|
||||
---
|
||||
|
||||
## 10. Phasing — 4 sequential slices
|
||||
|
||||
Each slice is independently shippable and reviewable. m's go/no-go gate after each.
|
||||
|
||||
### Slice 1 — SmartTimeline skeleton (no projection yet)
|
||||
|
||||
What lands:
|
||||
|
||||
- New `internal/services/projection_service.go` with `For()` returning only actuals (deadlines + appointments + opted-in `project_events`). No `fristenrechner` call yet.
|
||||
- Migration `NNN_project_events_timeline_kind.up.sql` adds the optional column + partial index (§2.2).
|
||||
- New endpoint `GET /api/projects/{id}/timeline?…` returning `[]TimelineEvent`.
|
||||
- `frontend/src/client/projects-detail.ts:loadEvents` rewritten to call `/timeline` instead of `/events`. The current Verlauf list is replaced by the new vertical timeline component (`client/views/shape-timeline.ts` — new file, ~300 LoC).
|
||||
- "+ Eintrag" CTA in the timeline header (modal partially implemented — only "Eigener Meilenstein" route lit; CCR / R.30 / Frist / Termin routes are link buttons to existing flows).
|
||||
- "Audit-Log anzeigen" toggle that switches to the legacy chronological list rendering (`paliad.project_events` ALL — not just `timeline_kind IS NOT NULL`).
|
||||
|
||||
What it gives m: a working SmartTimeline showing past actuals + open/upcoming deadlines + appointments + off-script milestones, with the audit log surviving as a toggle. No future-projection yet.
|
||||
|
||||
### Slice 2 — Future-projection + click-to-anchor
|
||||
|
||||
What lands:
|
||||
|
||||
- `ProjectionService.For` calls `FristenrechnerService.Calculate` and emits projected rows.
|
||||
- Click-to-anchor inline date editor (§6.2). New endpoint `POST /api/projects/{id}/timeline/anchor` taking `{rule_code, actual_date, kind?}` and writing the appropriate `paliad.deadlines` (`source='anchor'`) or `paliad.appointments` (`deadline_rule_id` FK new) row.
|
||||
- Migration `NNN_appointments_deadline_rule_id.up.sql` adds the optional FK on appointments + extends `paliad.deadlines.source` CHECK to include `'anchor'`.
|
||||
- "voraussichtlich" / "Datum vom Gericht" status pills + projected-row CSS (faded + dashed border for court-set).
|
||||
- New "Zukunft anzeigen" macro chip pair (§8.5).
|
||||
- `event_type='rule_skipped'` write path for the "ist nicht eingetreten" decision (§6.4).
|
||||
|
||||
What it gives m: predicted future course based on standard timeline; click to fix any date when something happens; downstream reflows automatically.
|
||||
|
||||
### Slice 3 — Counterclaim sub-project
|
||||
|
||||
What lands:
|
||||
|
||||
- Migration `NNN_projects_counterclaim_of.up.sql` — the new `counterclaim_of` FK + index + the CHECK (a project either has counterclaim_of OR is parent — not both — to keep the invariant clean).
|
||||
- "+ Eintrag → Widerklage (CCR)" route in the modal (§7.1) — creates child project with auto-suggested `our_side` flip, `proceeding_type_id`, and title, then navigates to it for the user to fill in `case_number`.
|
||||
- `ProjectionService` loads CCR children + emits parallel-track rows.
|
||||
- `[Track ▼]` chip in the header — reads `available_tracks` from the timeline response.
|
||||
- The two-column rendering on State C (§3.3).
|
||||
- `paliad.project_events` audit row written on counterclaim creation (`event_type='counterclaim_created'`, `timeline_kind='milestone'`).
|
||||
|
||||
What it gives m: counterclaims as proper sub-projects, parallel timelines, CCR perspective-flip works end-to-end.
|
||||
|
||||
### Slice 4 — Parent-node aggregation
|
||||
|
||||
What lands:
|
||||
|
||||
- `levelPolicy(projectType)` in `ProjectionService` — kinds/statuses/lane filter per level (§5.1).
|
||||
- Lane-grouped rendering at Patent / Litigation / Client levels.
|
||||
- "Timeline-Ansicht" toggle on Client-level project page (default off; lanes-of-litigations when on).
|
||||
- Off-script milestones bubble up to higher levels via the `metadata.bubble_up: true` flag (§7.2 form's "Sichtbar in: Diese Akte + Eltern" checkbox).
|
||||
|
||||
What it gives m: portfolio-level timelines without overload — the bird's-eye view he asked about.
|
||||
|
||||
### What's NOT in any slice
|
||||
|
||||
- Curated per-proceeding event catalogue (§7.3) — v2 nice-to-have.
|
||||
- Gantt rendering — separate `shape: "gantt"` follow-up.
|
||||
- Cross-matter timeline — Custom Views path.
|
||||
- Outlook integration — out of scope.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions for m
|
||||
|
||||
Listed with my (inventor) pick where I have one — m decides.
|
||||
|
||||
**Q1 — Counterclaim sub-project vs proceeding-overlay (§4).** I recommend sub-project. Confirm before Slice 3 design lock.
|
||||
|
||||
**Q2 — Should `our_side` flip automatically on counterclaim sub-project creation?** My pick: yes, default-flip with a "Stimmt nicht?" toggle on the create modal. The R.49.2.b CCI is the edge case (parent claimant → child claimant in CCI of the *separate* infringement claim), but the standard CCR-on-validity always inverts. Default-flip + toggle handles both.
|
||||
|
||||
**Q3 — Should `paliad.deadlines.source` gain `'anchor'` or should we re-use `'manual'`?** My pick: new `'anchor'` value — separates "user-typed-it-in" from "user-recorded-an-actual-after-projection-fired" for analytics + future automated import (Outlook event → anchor).
|
||||
|
||||
**Q4 — Counterclaim sub-project's `parent_id` — under the patent (sibling to parent case) or under the parent case (grandchild)?** My pick: under the patent (sibling). The CCR is its own proceeding with its own case_number; modeling it as a sibling to the parent infringement, both under the patent, mirrors how UPC CMS sees them. Grandchild placement would imply CCR is "part of" the parent case which it structurally isn't.
|
||||
|
||||
**Q5 — Off-script milestone bubble-up default.** My pick: default-on for `event_type IN ('counterclaim_created', 'third_party_intervention', 'scope_change')`; default-off for `event_type='custom_milestone'`. Form has the override checkbox in either case.
|
||||
|
||||
**Q6 — Should `/tools/fristenrechner` keep its standalone existence?** Brief says yes — knowledge tool, separate from project context. My pick: yes, agree. It stays.
|
||||
|
||||
**Q7 — Application-to-amend (UPC R.30) as sub-project or flag?** My pick: stay as flag (`with_amend`). Amendments are not a separate proceeding artifact in the CMS — they ride on the parent's record. The cross-flow rules already activate via `condition_flag`.
|
||||
|
||||
**Q8 — On the parent's SmartTimeline, do CCR rows mix into one column or stay in a parallel right-track?** My pick: parallel right-track when both are populated; collapses into one column on mobile (vertical stacking with sub-headers per track). The `[Track ▼]` chip lets desktop users opt into single-column mode.
|
||||
|
||||
**Q9 — Court-set anchor (Hauptverhandlung) creates a `paliad.appointments` row or a `paliad.deadlines` row?** My pick: `paliad.appointments` — it's an appointment, not a deadline. The new `appointments.deadline_rule_id` FK preserves the link back to the rule for downstream re-anchoring.
|
||||
|
||||
**Q10 — Is `timeline_kind` the right column name?** Alternatives: `is_timeline_milestone bool`, `surface_on_timeline bool`. My pick: keep `timeline_kind text NULL` because it lets us distinguish `milestone` (structural) from `custom_milestone` (free-form) without a second column.
|
||||
|
||||
**Q11 — Should the SmartTimeline be the only view of the project's events?** Or do we keep a "klassisch (chronologisch)" sidebar tab? My pick: SmartTimeline as the only Verlauf tab; "Audit-Log anzeigen" toggle inside the timeline reveals the chronological rendering. m uses `/admin/audit-log` (t-paliad-071) for the cross-project audit query.
|
||||
|
||||
**Q12 — Patent-level "matter list vs lane timeline" default.** My pick: lanes by default at Patent + Litigation; matter list by default at Client. The Litigation level has 1-3 child patents typically → 1-3 lanes is fine. Client can have 100+ → lanes are a toggle.
|
||||
|
||||
---
|
||||
|
||||
## 12. Files implementer will touch (Slice 1 only)
|
||||
|
||||
Aggregated for the coder shift kickoff:
|
||||
|
||||
**Backend (Go):**
|
||||
- `internal/services/projection_service.go` — new, ~250 LoC.
|
||||
- `internal/handlers/projection.go` — new, GET /api/projects/{id}/timeline, ~80 LoC.
|
||||
- `internal/handlers/handlers.go` — register the new route.
|
||||
- `internal/db/migrations/NNN_project_events_timeline_kind.{up,down}.sql` — new.
|
||||
|
||||
**Frontend (TS / TSX):**
|
||||
- `frontend/src/client/views/shape-timeline.ts` — new render shape, ~300 LoC.
|
||||
- `frontend/src/client/projects-detail.ts:loadEvents` — replace with timeline fetch.
|
||||
- `frontend/src/projects-detail.tsx:74-101` — replace Verlauf markup with `<div id="project-smart-timeline">`.
|
||||
- `frontend/src/styles/global.css` — `.smart-timeline-*` styles, ~150 LoC.
|
||||
- `frontend/src/client/i18n.ts` — ~30 keys under `projects.detail.smarttimeline.*`.
|
||||
|
||||
**Tests:**
|
||||
- `internal/services/projection_service_test.go` — new (live-DB integration test, skipped without `TEST_DATABASE_URL`).
|
||||
- `internal/services/projection_service_unit_test.go` — pure-function tests (sort, level policy, override-build).
|
||||
|
||||
Slices 2-4 are scoped in §10; coder picks them up after m's gate.
|
||||
|
||||
---
|
||||
|
||||
## 13. Trade-offs flagged
|
||||
|
||||
- **Per-request projection cost.** Recomputing on every Verlauf load is fine for a single project. If m navigates to a Client-level lane view with 50 child litigations × 3 cases each, that's 150 calculator invocations. Mitigation: lane-rendering at Litigation+Client levels excludes `kind='projected'` by default (§5), so the calculator is only called on the leaf rendering. Watch in production; add per-(project, hash(overrides)) cache if needed.
|
||||
- **Migration order across active workers.** riemann is on t-paliad-170 (FilterBar Verlauf port) in parallel. Slice 1 must merge **after** their port because Slice 1 mounts the bar with new axis keys. Coordinate via head before Slice 1 PR opens.
|
||||
- **Sub-project counterclaim adds a tier.** The project tree gets deeper (Patent → Case + Patent → CCR-Sub-Case as siblings). Existing tree visualisation in t-paliad-149 handles arbitrary depth, but the per-card "in 3 children" badge needs to count the CCR child correctly — verify in Slice 3.
|
||||
- **`appointments.deadline_rule_id`** is a backward-pointing FK that doesn't exist yet. Adding it in Slice 2 is clean (nullable, no backfill needed). Just flagging that this ties appointments to deadline_rules where they previously had no link.
|
||||
- **Anchor write path can race.** Two users clicking "Datum setzen" on the same row simultaneously could both write `paliad.deadlines` rows. Mitigation: server-side check `WHERE NOT EXISTS (SELECT 1 FROM paliad.deadlines WHERE project_id=... AND rule_id=...)` before insert, otherwise PATCH the existing row. Standard pattern.
|
||||
- **What if the proceeding type changes mid-flight?** The user changes `paliad.projects.proceeding_type_id` after deadlines have been calculated. Existing actuals stay (they have `rule_id` FK pointing to the OLD rule tree). Projected rows recompute against the NEW rule tree; rule_codes that don't exist in the new tree drop out. This is the same behaviour today — flagging because the SmartTimeline makes it more visible.
|
||||
|
||||
---
|
||||
|
||||
## 14. Recommendation for implementer
|
||||
|
||||
Pattern-fluent Sonnet coder. Slice 1 is largely boilerplate (new service + handler + render shape). Slice 2 needs the calculator integration which is well-trodden (t-paliad-131 Phase A shipped overrides). Slice 3 needs the sub-project FK design (one careful migration) and the parallel-track CSS. Slice 4 is render-policy logic, low-risk.
|
||||
|
||||
Lagrange (this worktree) parks. NOT pre-emptively flipping to coder — m gates.
|
||||
|
||||
---
|
||||
|
||||
**DESIGN READY FOR REVIEW**
|
||||
569
docs/design-tools-cleanup-2026-05-12.md
Normal file
569
docs/design-tools-cleanup-2026-05-12.md
Normal file
@@ -0,0 +1,569 @@
|
||||
# Design — Tools surface cleanup (Fristenrechner vs Verfahrensablauf split)
|
||||
|
||||
**Author:** kelvin (inventor)
|
||||
**Date:** 2026-05-12
|
||||
**Task:** t-paliad-178
|
||||
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live (before designing)
|
||||
|
||||
CLAUDE.md / memory / the task brief can all drift. Each anchor below is verified against the live codebase or DB on `mai/kelvin/inventor-tools-surface` (baseline commit `54b227c`).
|
||||
|
||||
- **One route + one TSX serve both nav entries today.** `/tools/fristenrechner` is the only registered page route (`internal/handlers/handlers.go:162`). Both sidebar entries (Fristenrechner + Verfahrensablauf) target the same Bun-built `dist/fristenrechner.html` and disambiguate purely through `?path=a` and a client-side active-class fix-up (`frontend/src/client/sidebar.ts:447 fixVerfahrensablaufActive`). Confirmed: the live HTML pulled from paliad.de (auth-gated → 302 to login, served-bytes match) is the shell rendered by `frontend/src/fristenrechner.tsx:87 renderFristenrechner`.
|
||||
- **The client runtime is 3 559 lines, not the 2 700+ quoted in the task brief.** `frontend/src/client/fristenrechner.ts` carries Step 1 / Step 2 / Step 3a / Pathway A wizard / Pathway B cascade + filter / search + cascade engines / column + timeline result-card renderers in one IIFE bundle (`Pathway` type at line 2315, `showPathway()` at line 2370, `showBMode()` at line 2406). Any "separate route" path must either lift code out of this bundle into a shared module or accept a larger duplicated bundle on the new page.
|
||||
- **Sidebar deep-link `?path=a` lands on Pathway A directly, NOT on the Akte picker.** I traced `initPathwayFork → readPathwayFromURL → showPathway("a")`: it sets `step1.style.display = "none"`, `step2.hidden = true`, `step3a.hidden = true`, `pathway-a.hidden = false`. The user sees the wizard's "Verfahrensart wählen" tile picker first. The task brief's phrasing — "still drops users at Step 1 (Akte-Picker)" — is the perceived UX from the wizard's own internal "wizard-step-1" labelled "Verfahrensart wählen". Mental model: two surfaces with the same nav label "Step 1" muddy intent; the fix m wants is structural (a dedicated route), not a JS bug fix.
|
||||
- **`paliad.projects.court` is a free-text column, NOT an FK to `paliad.courts`.** Confirmed in `information_schema.columns`. Live values: `LG München I` (1 row), `UPC` (2), `UPC CoA` (1). The task brief's "project has a court FK" is **wrong**; only `proceeding_type_id` is a real FK. The design must NOT silently auto-pick a `paliad.courts.id` from `projects.court` — fuzzy mapping is best-effort + always overridable, never silent.
|
||||
- **`paliad.projects.proceeding_type_id` points at `category='litigation'` rows (7 codes: INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL).** The Fristenrechner wizard accepts `category='fristenrechner'` codes (20 codes: UPC_INF, DE_INF, EPA_OPP, …). These overlap conceptually (`INF` is the abstract noun behind both `UPC_INF` and `DE_INF`) but are different rows. Auto-derivation needs a small mapping: `litigation_code × jurisdiction → fristenrechner_code`. Example: `INF + UPC → UPC_INF`. `INF + DE → DE_INF` (first instance). The instance dimension (LG / OLG / BGH) is **not** on `paliad.projects` today, so DE_INF_OLG / DE_INF_BGH cannot be inferred — only the first-instance code can be.
|
||||
- **`paliad.projects` carries no `priority_date` or `trigger_date` column.** It does have `filing_date` and `grant_date`. Only EP_GRANT.ep_grant.publish (Art. 93 EPÜ) is anchored on `priority_date` today (via `anchor_alt`). For Akte-driven prefill, `priority_date` stays blank by default and the user fills it.
|
||||
- **`paliad.projects.our_side` and `paliad.projects.counterclaim_of` exist** (already exploited by t-paliad-164 perspective-chip predefine and the parent-counterclaim link respectively). These two columns are the actual hooks for "consolidated timeline" vs "side-by-side lanes" — see §6.
|
||||
- **`deadline_rules.condition_flag` is a real text[] column with exactly 4 distinct value-sets in production:** `[with_amend]` (4 rows), `[with_cci]` (4), `[with_ccr]` (5), `[with_ccr, with_amend]` (4). Only `UPC_INF` (proceeding_type_id=8) and `UPC_REV` (proceeding_type_id=9) carry variant-flagged rules. Every other proceeding type renders a single canonical timeline today. **This is the hard data bound on the variant-chip design** — chips beyond these three flags would have no rules to flip and must be marked "future".
|
||||
- **Court-specific rule overrides do not exist as a mechanism.** `CourtID` in `CalcOptions` (`internal/services/fristenrechner.go:107`) only switches the holiday calendar (via `courts.CountryRegime`). There is no per-court rule branch. "UPC LD Mü vs LD Düsseldorf" overrides are NOT a thing — they'd need a new column on `deadline_rules`.
|
||||
- **Expedited-vs-standard distinctions do not exist** either. No `condition_flag` row matches an expedited concept. Adding one is a schema-and-seed change, out of scope here.
|
||||
- **Result rendering today** lives in `renderTimelineBody` and `renderColumnsBody` (`frontend/src/client/fristenrechner.ts:637 / :664`). The user toggles between the two with a radio (`#fristen-view-toggle`). Both renderers take a single `DeadlineResponse` and emit DOM strings; neither knows about "two timelines side by side". A consolidated-vs-lane view (§5–§6) is a renderer-level change, not a backend one.
|
||||
- **The Step 1/Step 2/Step 3a/Pathway A/B layout shipped under t-paliad-133 + t-paliad-168.** The "Verfahrensablauf einsehen" card (Step 2 third option, lines 215-223 of fristenrechner.tsx) was added in t-paliad-168 specifically to give the abstract-browse case a discoverable entry. If Verfahrensablauf moves to its own route, the third card becomes redundant (§9).
|
||||
|
||||
If any of these conflict with what the task brief asserts, **the live state wins** and the brief is the bug — flagged in §13 for m.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision + scope
|
||||
|
||||
m's framing (verbatim from the task brief):
|
||||
|
||||
> Users want to **either** (1) determine a deadline — possibly Akte-scoped, possibly abstract — **or** (2) browse a typical Verfahrensablauf abstractly with variant options.
|
||||
|
||||
The two intents are **fundamentally different**:
|
||||
|
||||
- **Determine a deadline** ends with a save (or a print, or a manual transcription) of a *specific* date attached to *something* — a project, or a sticky-note in the user's head.
|
||||
- **Browse a Verfahrensablauf** ends with the user understanding the *shape* of a proceeding — no date binding required.
|
||||
|
||||
Today both intents collapse onto one URL because the wizard infrastructure is shared. The cost: two sidebar entries pointing at the same shell, an active-class fix-up script (`fixVerfahrensablaufActive`), and a Step 1 ("Welche Akte?") frame that doesn't match the abstract-browse intent.
|
||||
|
||||
### Scope of this design
|
||||
|
||||
1. **Page surface split** — separate routes per intent. `/tools/fristenrechner` keeps the deadline-determination intent (Akte-scoped *or* abstract). `/tools/verfahrensablauf` becomes the dedicated abstract-browse surface with variant chips + side-by-side compare.
|
||||
2. **Step 0 "Abstrakt oder Akte?"** as the FIRST affordance on `/tools/fristenrechner`. Pick → narrows downstream inputs.
|
||||
3. **Akte-driven auto-derivation** — map project columns to wizard inputs and flag the gaps.
|
||||
4. **Variant chips + consolidated-vs-lane view** for `/tools/verfahrensablauf`.
|
||||
5. **Side-by-side compare** on `/tools/verfahrensablauf` (max 2 timelines for v1).
|
||||
6. **Sidebar labels + URL conventions** post-split.
|
||||
7. **Mobile responsive** plan.
|
||||
8. **What gets dropped** (Step 2 browse card, sidebar fix-up script).
|
||||
|
||||
### Explicitly out of scope (per task brief)
|
||||
|
||||
- Deadline-rule data-model changes (court-specific overrides, expedited-flag, new condition_flag values). Audited in §0, propose nothing here.
|
||||
- t-paliad-166 Determinator B1 cascade redesign — separate ticket, on-hold. Pathway B continues to exist inside `/tools/fristenrechner`; we note interplay in §11 but do not pre-empt.
|
||||
- t-paliad-157 Fristenrechner interactive-UX pair session — on-hold. The cleanup here may inform it, but we don't dictate it.
|
||||
- Project Verlauf tab (`/projects/{id}` → Verlauf). Stays as-is. SmartTimeline renders concrete-per-case via `internal/services/projection_service.go`; no Tool-side mirror.
|
||||
- New backend services. The split runs on the existing `POST /api/tools/fristenrechner` + `POST /api/tools/event-deadlines` endpoints; we add at most one helper for Akte → fristenrechner-code mapping.
|
||||
- Backend rule changes — touch the substrate only enough to verify what the design needs is already there.
|
||||
|
||||
---
|
||||
|
||||
## 2. Page surfaces + route split
|
||||
|
||||
m has already chosen **Option A** in the task brief: split by intent, separate URLs. The design here implements that choice. For honesty I also note the alternatives I considered and why A still wins after audit.
|
||||
|
||||
### 2.1 Three options weighed
|
||||
|
||||
| Option | URL shape | Trade-off | Verdict |
|
||||
|---|---|---|---|
|
||||
| **A — Two routes** | `/tools/fristenrechner` + `/tools/verfahrensablauf` | Clean mental model. Sidebar entries map 1:1 to URLs. `fixVerfahrensablaufActive` dies. Two HTML files; shared client code lifted into a module. | **Picked.** Aligns with intent split. |
|
||||
| **B — One route, `?mode=` fork** | `/tools/fristenrechner?mode=calc` vs `?mode=browse` | Single HTML bundle, no shared-module lift. But: sidebar entries still alias the same page; muddled intent stays in the user's head; we'd still need a Step 0 inside the calc mode. | Rejected by m. Verifies on second look: it just moves `?path=a` to `?mode=browse`, doesn't fix the problem. |
|
||||
| **C — Move into Patentglossar** | Verfahrensablauf renders inline on glossary pages | Discoverability shrinks. Glossary entries are concept-bounded; Verfahrensablauf is procedure-bounded. The two indexes don't map. | Rejected by m. |
|
||||
|
||||
### 2.2 Code-reuse strategy under Option A
|
||||
|
||||
The honest cost of splitting routes is shared-client-code duplication. Today `client/fristenrechner.ts` (3 559 LoC) bundles everything. The Verfahrensablauf-only surface needs:
|
||||
|
||||
- The proceeding-type tile picker (`UPC_TYPES`, `DE_TYPES`, `EPA_TYPES`, `DPMA_TYPES` arrays in `fristenrechner.tsx`).
|
||||
- The timeline + columns result renderers (`renderTimelineBody`, `renderColumnsBody`).
|
||||
- The `POST /api/tools/fristenrechner` calc invocation.
|
||||
- Court picker + holiday-calendar pickup (read-only).
|
||||
- DE/EN i18n for the timeline rows.
|
||||
|
||||
It does NOT need:
|
||||
|
||||
- Step 1 Akte picker / ad-hoc chip / Step 1 summary.
|
||||
- Step 2 file/happened/browse cards.
|
||||
- Step 3a outgoing-intent chooser.
|
||||
- Pathway B cascade + filter + perspective + inbox chips (~1 200 LoC).
|
||||
- Save-to-Akte modal.
|
||||
- Trigger-event mode (`mode-event-panel`).
|
||||
|
||||
**Plan:** lift the deadline-timeline core (proceeding picker + calc + render) into `frontend/src/client/views/verfahrensablauf-core.ts`. Both pages import it. Pathway B + Save modal + Step machinery stay in `client/fristenrechner.ts`. Estimated lifted surface: ~700–900 LoC. New code on `verfahrensablauf.ts` (variant chips + lane mode + compare): ~400–600 LoC.
|
||||
|
||||
This keeps the IIFE per-page bundle pattern intact (one entry per route in `frontend/build.ts:228`). No runtime npm dep added.
|
||||
|
||||
### 2.3 The two pages in one sentence each
|
||||
|
||||
- **`/tools/fristenrechner`** — Deadline determination. Optional Akte scope. Ends in "save / print / done".
|
||||
- **`/tools/verfahrensablauf`** — Procedural shape browser. No Akte. Ends in "now I understand the shape".
|
||||
|
||||
### 2.4 Sidebar
|
||||
|
||||
```text
|
||||
Werkzeuge
|
||||
Fristenrechner → /tools/fristenrechner
|
||||
Verfahrensablauf → /tools/verfahrensablauf
|
||||
Kostenrechner → /tools/kostenrechner
|
||||
…
|
||||
```
|
||||
|
||||
`fixVerfahrensablaufActive` deletes; the SSR-time `navItem` helper handles both active classes natively because the hrefs differ on pathname.
|
||||
|
||||
---
|
||||
|
||||
## 3. Step 0 "Abstrakt oder Akte?" on `/tools/fristenrechner`
|
||||
|
||||
m's lock-in: Step 0 comes FIRST. Today's Step 1 (Akte picker) forces the user to either commit to an Akte or escape via ad-hoc chips before anything else moves. Step 0 makes the binary choice explicit.
|
||||
|
||||
### 3.1 Affordance — three sketches considered
|
||||
|
||||
**Sketch A — Radio toggle (Recommended).**
|
||||
A pair-of-toggle at the top of the page, wide on desktop, stacked on mobile. The currently-active half expands into its full picker; the inactive half collapses to a slim header that the user can click to flip.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Schritt 0 — Wie wollen Sie die Frist bestimmen? │
|
||||
│ │
|
||||
│ ◉ Mit Akte verknüpfen ○ Abstrakt — ohne Akte │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ │
|
||||
│ 🔍 Akte suchen… │
|
||||
│ [Akte 1 · CLI-2024 — Foo GmbH vs Bar Ltd. — UPC LD Mü] │
|
||||
│ [Akte 2 · …] │
|
||||
│ ──── │
|
||||
│ + Neue Akte anlegen │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
When the user picks "Abstrakt":
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Schritt 0 — Wie wollen Sie die Frist bestimmen? │
|
||||
│ │
|
||||
│ ○ Mit Akte verknüpfen ◉ Abstrakt — ohne Akte │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ │
|
||||
│ Verfahrensart wählen: │
|
||||
│ [UPC] [DE] [EPA] [DPMA] ← jurisdiction picker (4 tabs) │
|
||||
│ (then proceeding-type tiles within the chosen tab) │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Why I'd recommend this:** the toggle is a single decision, declared up-front, with the consequence visible inline. No modal dismissal cost. Keyboard navigation natural. On mobile it stacks to two stacked rows where the active row expands and the inactive row stays a touch-target.
|
||||
|
||||
**Sketch B — Two big cards.** Like today's Step 2 cards but at the very top. Pro: pretty + tappable. Con: click-and-commit feels heavier than a toggle; "going back" reads as undoing a choice instead of flipping it.
|
||||
|
||||
**Sketch C — Modal-before-render.** Most decisive, also most annoying — the user can't even see the page before the dialog clears. Reject. (Modals interrupt; we want the user oriented before they're asked.)
|
||||
|
||||
### 3.2 URL state
|
||||
|
||||
Step 0 binds to `?mode=akte|abstract` in the URL.
|
||||
|
||||
- `?mode=akte&project=<uuid>` — Akte selected. Court / proceeding-type / our_side auto-derived (§4).
|
||||
- `?mode=abstract&forum=upc|de|epa|dpma` — abstract. Jurisdiction tab selected; proceeding-type tiles below.
|
||||
- `?mode=` absent — render Step 0 with no preselection.
|
||||
|
||||
Deep-link from `/projects/{id}` → "Frist berechnen" button passes `?mode=akte&project=<id>` and lands on Step 0 with Akte branch already filled.
|
||||
|
||||
`localStorage["paliad.fristen.mode"]` remembers the user's last choice for soft re-entry (the `PATHWAY_STORAGE_KEY` pattern already exists).
|
||||
|
||||
### 3.3 Removal of today's Step 2 fork (file / happened / browse)
|
||||
|
||||
With Step 0 making the intent binary, the file-vs-happened branching collapses into one wizard with two anchor sources:
|
||||
|
||||
- **Akte mode** — wizard pre-filled. After calc, the save CTA is "An Akte hängen". `?path=` machinery shrinks because Pathway A vs Pathway B becomes a wizard *step* (incoming-event vs outgoing-event), not a top-level path.
|
||||
- **Abstract mode** — wizard takes proceeding-type + date as today. After calc, save CTA disabled (no Akte to save against); `Drucken` remains.
|
||||
|
||||
The "Verfahrensablauf einsehen" card is gone from `/tools/fristenrechner` (its purpose lives on `/tools/verfahrensablauf` now — §9).
|
||||
|
||||
Pathway B (the cascade) is **kept** as a separate entry-flow inside Akte-mode for "Etwas ist passiert" — the t-paliad-166 redesign is on-hold and we don't pre-empt it. In abstract mode Pathway B is reachable via a "Frist aufgrund Ereignis (Determinator)" link in the result panel; the cascade itself unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 4. Akte-driven auto-derivation
|
||||
|
||||
When `mode=akte&project=<uuid>`, the wizard prefills as much as it honestly can from `paliad.projects`. The rest stays empty + visible.
|
||||
|
||||
### 4.1 Mapping table
|
||||
|
||||
| Wizard input | Project source | Confidence | Behaviour |
|
||||
|---|---|---|---|
|
||||
| **proceeding_type_code** (UPC_INF, DE_INF, …) | `proceeding_types.code` via `projects.proceeding_type_id` + jurisdiction disambiguation | medium-high | Best-effort pick + the proceeding-tile picker stays visible with the picked tile pre-selected. User can flip. |
|
||||
| **trigger_date** | None today | low | Always empty. User fills. |
|
||||
| **priority_date** (EP_GRANT only) | `projects.grant_date` or `projects.filing_date` (parent patent project's filing) | low-medium | Pre-fill only when the chosen proceeding is `EP_GRANT`. Field stays visible + editable. |
|
||||
| **court_id** | `projects.court` (free text) — fuzzy match against `paliad.courts.code` | low | Pre-select if string-match is exact-or-trivial-canon (e.g. `"UPC"` → `upc-cd-...`? **No** — too ambiguous; leave blank); else leave blank. Picker visible + required for UPC where holiday calendar differs. |
|
||||
| **our_side** (perspective chip) | `projects.our_side` | high | Already wired (t-paliad-164). Predefine + show "vorgegeben durch Akte" hint. |
|
||||
| **condition_flag** (with_ccr, with_cci, with_amend) | None today | low | Stays user-driven. Flag checkboxes appear conditionally on UPC_INF/UPC_REV. |
|
||||
| **counterclaim sibling info** | `projects.counterclaim_of` | medium | If set, the result panel shows a small "Verbundenes Verfahren: <parent>" line with a deep-link to the parent's Verlauf tab. Informational only — doesn't change calc. |
|
||||
|
||||
### 4.2 Litigation → fristenrechner code mapping
|
||||
|
||||
`projects.proceeding_type_id` points to `category='litigation'` rows. The wizard wants `category='fristenrechner'`. The mapping is multi-key:
|
||||
|
||||
| litigation code | jurisdiction | resolved fristenrechner code |
|
||||
|---|---|---|
|
||||
| `INF` | UPC | `UPC_INF` (id 8) |
|
||||
| `INF` | DE | `DE_INF` (id 12) — first instance only; OLG/BGH not derivable |
|
||||
| `REV` | UPC | `UPC_REV` (id 9) |
|
||||
| `REV` | DE | `DE_NULL` (id 13) |
|
||||
| `CCR` | UPC | `UPC_REV` (id 9) + `with_cci` flag suggested |
|
||||
| `APM` | UPC | `UPC_PI` (id 10) |
|
||||
| `APP` | UPC | `UPC_APP` (id 11) |
|
||||
| `AMD` | UPC | (no direct fristenrechner code; suggest UPC_INF with `with_amend`) |
|
||||
| `ZPO_CIVIL` | DE | `DE_INF` (id 12) — fallback |
|
||||
|
||||
The jurisdiction comes from `proceeding_types.jurisdiction` (UPC / DE / EPA / DPMA) on the project's own proceeding_type row, not from `projects.country` directly (which is a different axis — country of patent, not of forum).
|
||||
|
||||
Implementation: a helper `services.ResolveFristenrechnerCodeForProject(projectID)` returning `(code, confidence, reason)` so the UI can render "Vorgeschlagen: UPC_INF (aus Akte abgeleitet — Sie können umstellen)". Where confidence is `low`, no preselect — user picks.
|
||||
|
||||
### 4.3 Court free-text — no silent FK promotion
|
||||
|
||||
`projects.court` is a free-text field. Live values include `"UPC"` (ambiguous: which division?), `"UPC CoA"` (matches `upc-coa-luxembourg`), `"LG München I"` (matches `de-lg-muenchen1`). I deliberately do NOT auto-pick a `paliad.courts.id` from this string in v1: the cost of a wrong silent pick (a holiday-calendar mismatch invalidating a calculated date) is high; the benefit of saving one click is low. The Court picker stays visible and **required** for UPC proceedings (already today's behaviour via the `isCourtDeterminedRule` check in `internal/services/fristenrechner.go:779`).
|
||||
|
||||
If the free-text value matches a canonical `paliad.courts.code` exactly (case-insensitive), we *highlight* the matching option but do not auto-select. The user clicks to confirm.
|
||||
|
||||
Follow-up ticket worth filing (out of scope here): migrate `projects.court` from text to `court_id` FK. That'd land a real auto-derivation. Until then, this design treats it as a hint.
|
||||
|
||||
### 4.4 Edge case — Akte without a proceeding_type_id
|
||||
|
||||
11 of 11 live projects today have no `proceeding_type_id` set yet. Behaviour: the wizard renders with all proceeding-type tiles selectable, no preselect, no hint. Functionally identical to abstract mode but with the Akte locked for save-CTA. No error state — silent graceful degradation.
|
||||
|
||||
---
|
||||
|
||||
## 5. Variant chips on `/tools/verfahrensablauf`
|
||||
|
||||
The new dedicated route renders proceeding-shape with the user toggling "what variant am I looking at?". Variants are the live `condition_flag` mechanism.
|
||||
|
||||
### 5.1 Variants that exist today (audited live)
|
||||
|
||||
Only **UPC_INF** (id 8) and **UPC_REV** (id 9) carry `condition_flag` rules. The flags themselves:
|
||||
|
||||
- `with_ccr` — Klägerseite, infringement claim met with revocation counterclaim. Adds `inf.def_to_ccr`, `inf.reply`, `inf.reply_def_ccr`, `inf.rejoin`, `inf.rejoin_reply_ccr` (5 rules) to UPC_INF.
|
||||
- `with_cci` — Beklagtenseite on revocation answered with infringement counterclaim. Adds `rev.cc_inf`, `rev.def_cci`, `rev.reply_def_cci`, `rev.rejoin_cci` (4 rules) to UPC_REV.
|
||||
- `with_amend` — Patent amendment proposed. Adds `inf.app_to_amend`, `inf.def_to_amend`, `inf.reply_def_amd`, `inf.rejoin_amd` to UPC_INF; `rev.app_to_amend`, `rev.def_to_amend`, `rev.reply_def_amd`, `rev.rejoin_amd` to UPC_REV. Composes with `with_ccr` / `with_cci`.
|
||||
|
||||
Every other proceeding type (DE_INF, DE_NULL, EPA_OPP, EPA_APP, EP_GRANT, DPMA_*, UPC_APP, UPC_PI, UPC_DAMAGES, UPC_DISCOVERY, UPC_COST_APPEAL, UPC_APP_ORDERS) has zero `condition_flag` rules — only one canonical timeline.
|
||||
|
||||
### 5.2 Chip set per proceeding
|
||||
|
||||
Chips are conditionally rendered based on which flags exist on the selected proceeding's `condition_flag` rule rows.
|
||||
|
||||
```
|
||||
UPC_INF: [Standard] [+ Widerklage Nichtigkeit (with_ccr)] [+ Patentänderung (with_amend)]
|
||||
UPC_REV: [Standard] [+ Verletzungs-Widerklage (with_cci)] [+ Patentänderung (with_amend)]
|
||||
DE_INF, DE_NULL, EPA_OPP, …: (no chips, single timeline)
|
||||
```
|
||||
|
||||
Chips are **toggleable** (multi-select), not radio. Each chip toggles its flag on/off; the timeline reflows. Composite combinations (`with_ccr + with_amend`) render the union of rules. Toggling all chips off renders the base proceeding (no `condition_flag` rules).
|
||||
|
||||
Future flags (court-specific, expedited) — chips are **disabled and dimmed** with a tooltip "wird noch nicht unterstützt" when the proceeding has nothing to offer. We do NOT pre-render dead chips for proceedings without variants.
|
||||
|
||||
### 5.3 Consolidated vs lane view — the toggle m asked for
|
||||
|
||||
m's example: an infringement action triggers a counterclaim for revocation. Two ways to render:
|
||||
|
||||
**Consolidated** — One timeline. CCR-related events (the `with_ccr` flag) interleave with base UPC_INF events along the same vertical timeline. Colour-coded by `primary_party` (claimant / defendant / court). This is the current behaviour when `?flags=with_ccr` is set.
|
||||
|
||||
**Lane** — Two parallel columns. Column 1 = UPC_INF base timeline. Column 2 = UPC_REV timeline (the counterclaim's own proceeding). Rules anchored on shared trigger dates align horizontally.
|
||||
|
||||
Toggle UI sits beside the variant chips:
|
||||
|
||||
```
|
||||
[Standard] [+ Widerklage] | View: ◉ Konsolidiert ○ Spalten
|
||||
```
|
||||
|
||||
In v1, the lane view is only available when the user has selected a variant that implies a *second proceeding* — i.e., `UPC_INF + with_ccr` shows UPC_INF || UPC_REV side-by-side, `UPC_REV + with_cci` shows UPC_REV || UPC_INF. Same backend data, different paint.
|
||||
|
||||
For variants that DON'T imply a second proceeding (`with_amend` alone), the lane toggle is hidden — there's only one timeline.
|
||||
|
||||
### 5.4 URL state
|
||||
|
||||
`/tools/verfahrensablauf?proceeding=UPC_INF&flags=with_ccr,with_amend&view=lane&trigger_date=2026-05-12`
|
||||
|
||||
Trigger date is optional — without it, the timeline renders with relative offsets ("+3 Monate", "+6 Wochen") instead of absolute dates. This is the "browse shape" mode. With a trigger date the timeline becomes concrete.
|
||||
|
||||
`view=consolidated` (default) or `view=lane` toggles paint.
|
||||
|
||||
---
|
||||
|
||||
## 6. Side-by-side compare
|
||||
|
||||
The second variant axis. m wants to compare *two different proceeding types* OR *two variants of the same proceeding* side-by-side.
|
||||
|
||||
### 6.1 Affordance
|
||||
|
||||
A "Vergleichen" button next to the variant chips. Click → second proceeding picker slides in, second variant-chip row appears, two timelines render side-by-side.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Verfahren A: [UPC_INF ▾] Flags: [✓ with_ccr] [ with_amend]│
|
||||
│ Verfahren B: [UPC_REV ▾] Flags: [✓ with_cci] [ with_amend]│
|
||||
│ Trigger A: [2026-05-12] Trigger B: [synced ✓] │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ │
|
||||
│ Timeline A ║ Timeline B │
|
||||
│ ┌─ Klageerhebung ║ ┌─ Nichtigkeitsklage │
|
||||
│ │ 2026-05-12 ║ │ 2026-05-12 │
|
||||
│ ├─ Klageerwiderung ║ ├─ Klageerwiderung │
|
||||
│ │ 2026-08-12 (3M) ║ │ 2026-08-12 (3M) │
|
||||
│ … │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 Decisions
|
||||
|
||||
- **Max 2 timelines for v1.** Three+ would push the layout below mobile readability and add picker friction. The `counterclaim_of` example always pairs two proceedings; that's the common case.
|
||||
- **Synchronised date axis** by default (Trigger A = Trigger B). Toggle "Unabhängige Trigger-Daten" reveals a second date input. Synced is the right default because the most common compare is "what happens in both proceedings starting from the same Klageerhebung date".
|
||||
- **Independent variant chips per timeline.** Variant A's flags don't affect Variant B. The chips render per-column.
|
||||
- **Wide-screen primary.** Lane and compare views require ≥720px to be readable. Below that, stack vertically (Timeline A above Timeline B, full-width each). The synced-trigger constraint stays; users on small screens still get the compare, just stacked.
|
||||
- **Permalink-shareable.** `?compare=1&a_proceeding=UPC_INF&a_flags=with_ccr&b_proceeding=UPC_REV&b_flags=with_cci&trigger=2026-05-12&synced=true` — every chip + variant + trigger captured in URL. Copy-paste produces an identical render.
|
||||
|
||||
### 6.3 Lane view vs Compare view — are they the same thing?
|
||||
|
||||
Conceptually similar (two columns), but UX-distinct:
|
||||
|
||||
- **Lane view** is "one variant that implies two proceedings rendered together". The two columns are *logically linked* (e.g., `UPC_INF + with_ccr` always shows the same UPC_REV alongside).
|
||||
- **Compare view** is "the user picked two arbitrary proceedings + variants to look at together". The two columns are *independently chosen*.
|
||||
|
||||
In renderer terms they share the same DOM layout (CSS grid with 2 columns). The state differs: lane view's second proceeding is computed from the variant flag; compare view's second proceeding is user-picked. We implement them as one renderer with two state-entry points.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sidebar nav labels + URL conventions
|
||||
|
||||
### 7.1 Labels (post-cleanup)
|
||||
|
||||
Today: **Fristenrechner** + **Verfahrensablauf**.
|
||||
|
||||
Recommendation: keep the labels as-is. m's brief suggested alternatives ("Frist berechnen" / "Verfahrensabläufe") — I think the current labels are tighter:
|
||||
|
||||
- "Fristenrechner" is a known brand-term in the firm vocabulary (per the German-tool-names-as-brands convention in CLAUDE.md).
|
||||
- "Verfahrensablauf" reads as a noun "the procedural flow", which matches the abstract-browse intent better than the plural "Verfahrensabläufe" (which reads as "the catalogue of all flows").
|
||||
|
||||
But I flag this for m in §13 — the call is brand-strategic, not technical.
|
||||
|
||||
### 7.2 URL conventions
|
||||
|
||||
| Route | Key params | Purpose |
|
||||
|---|---|---|
|
||||
| `/tools/fristenrechner` | `mode=akte\|abstract` | Pick branch |
|
||||
| `/tools/fristenrechner?mode=akte&project=<uuid>` | + `path=outgoing\|happened` | Akte deadline determination |
|
||||
| `/tools/fristenrechner?mode=abstract&forum=upc&proceeding=UPC_INF&trigger_date=…` | + `flags=…` | Abstract deadline determination |
|
||||
| `/tools/verfahrensablauf` | `proceeding=…&flags=…&view=…&trigger_date=…` | Browse one proceeding-shape |
|
||||
| `/tools/verfahrensablauf?compare=1&a_proceeding=…&b_proceeding=…&…` | (per §6.2) | Compare two |
|
||||
|
||||
The `?path=a` query param dies entirely. The `fixVerfahrensablaufActive` function deletes. The localStorage key `paliad.fristen.pathway` is preserved (still used by Akte-mode Pathway A/B inside `/tools/fristenrechner`); it gets a sibling `paliad.fristen.mode`.
|
||||
|
||||
### 7.3 Bookmarkability + share
|
||||
|
||||
Both pages produce permalinks. Copy URL → paste in another browser → identical view (with same auth gate). The compare-view URL is particularly load-bearing for the "send your colleague a precomputed timeline" use case — it's how a PA quickly shows a counterpart "this is the shape we're looking at".
|
||||
|
||||
---
|
||||
|
||||
## 8. Mobile + responsive
|
||||
|
||||
Existing breakpoints in the codebase: 640px / 720px / 768px / 1023px (`frontend/src/styles/global.css`).
|
||||
|
||||
### 8.1 `/tools/fristenrechner`
|
||||
|
||||
- **≥720px:** Step 0 toggle horizontal. Akte search results in a list.
|
||||
- **<720px:** Step 0 toggle stacks (radio rows top-to-bottom). Akte list full-width.
|
||||
- **<480px:** Proceeding-tile picker (UPC / DE / EPA / DPMA tabs + tiles) wraps tiles to one column.
|
||||
|
||||
### 8.2 `/tools/verfahrensablauf`
|
||||
|
||||
- **≥1023px:** Lane view + compare view render side-by-side (CSS grid 2-col).
|
||||
- **720–1022px:** Lane view side-by-side; compare view stacks (Timeline A above Timeline B, full-width).
|
||||
- **<720px:** Both lane and compare stack vertically. Variant chips wrap to 2-3 rows.
|
||||
- **<480px:** Single-column always. Compare-view "Vergleichen" button still works but stacks the result rows.
|
||||
|
||||
### 8.3 Variant chips on mobile
|
||||
|
||||
Chips wrap with `flex-wrap`. Maximum 3 chips per row on a 360px viewport (each chip ≤ 110px wide); composite proceedings (UPC_INF, UPC_REV) fit 3 chips so this works.
|
||||
|
||||
### 8.4 What does NOT collapse on mobile
|
||||
|
||||
- The trigger-date input. Stays a single date picker (browser-native; iOS / Android already render their own UI).
|
||||
- The proceeding picker. Stays tiled (large tap targets).
|
||||
- The result rows (column + timeline views). Render unchanged from today; mobile already handles them.
|
||||
|
||||
---
|
||||
|
||||
## 9. What gets dropped
|
||||
|
||||
| Today | Post-cleanup |
|
||||
|---|---|
|
||||
| **Step 2 "Verfahrensablauf einsehen" card** | Deleted. The abstract-browse case has its own route. |
|
||||
| **Sidebar `?path=a` deep-link** | Deleted. `/tools/verfahrensablauf` replaces it. |
|
||||
| **`fixVerfahrensablaufActive()` function** | Deleted. Both sidebar entries map 1:1 to URLs; native SSR active-class works. |
|
||||
| **`localStorage["paliad.fristen.pathway"]`** | Preserved as-is. Still used inside Akte-mode Pathway A/B. |
|
||||
| **The Step 1/Step 2 fork on `/tools/fristenrechner`** | Replaced by Step 0 (Akte vs Abstract). Step 2's "file vs happened vs browse" becomes a wizard-internal branch, not a top-level page state. |
|
||||
| **Step 3a "outgoing-intent chooser" (File / Draft / Enter)** | Kept inside Akte-mode. The Draft option (`fristen-step3a-draft`) stays disabled as today (placeholder). |
|
||||
|
||||
The deletions sum to maybe 200–300 LoC out of `client/fristenrechner.ts`. The lift of `verfahrensablauf-core.ts` is the bigger reshape; net LoC churn around +500 / -300.
|
||||
|
||||
---
|
||||
|
||||
## 10. Slicing for the coder pass
|
||||
|
||||
Four slices, each independently mergeable. Slice 1 ships the structural split; Slices 2–4 layer features.
|
||||
|
||||
### Slice 1 — Route + shell split (foundation)
|
||||
|
||||
- New route `/tools/verfahrensablauf` registered in `internal/handlers/handlers.go`.
|
||||
- New handler `handleVerfahrensablaufPage` serves `dist/verfahrensablauf.html`.
|
||||
- New TSX `frontend/src/verfahrensablauf.tsx` — renders the proceeding-tile picker + result panel. No variant chips yet; no compare yet. Just the abstract-browse case factored out.
|
||||
- New client `frontend/src/client/verfahrensablauf.ts` — minimal: picker → calc → render. Imports from a new shared module `client/views/verfahrensablauf-core.ts`.
|
||||
- Sidebar `Sidebar.tsx:163-164` updated: second nav entry's href flips from `/tools/fristenrechner?path=a` to `/tools/verfahrensablauf`.
|
||||
- `client/sidebar.ts:447 fixVerfahrensablaufActive` deleted (and its call site at the bottom of `initSidebar`).
|
||||
- Step 2 "Verfahrensablauf einsehen" card markup in `frontend/src/fristenrechner.tsx` + its handler in `client/fristenrechner.ts` deleted.
|
||||
- Step 2's "browse" event handler at `fristen-step2-browse` removed; the path="a" branch in `showPathway` still exists for Akte-mode wizard re-use.
|
||||
- DE/EN i18n keys: `tools.verfahrensablauf.title`, `tools.verfahrensablauf.subtitle`, plus all the proceeding-tile labels (already exist — reused).
|
||||
- Build: add `renderVerfahrensablauf` import and `bun:write` step in `frontend/build.ts`.
|
||||
- Tests: Playwright smoke — `/tools/verfahrensablauf` renders, sidebar nav links work, no 404s, the old `?path=a` URL 302s to `/tools/verfahrensablauf` (back-compat for any bookmarked links).
|
||||
|
||||
**What does NOT change in Slice 1:** the existing `/tools/fristenrechner` page works exactly as today (Step 1 / Step 2 / Step 3a / Pathway A / Pathway B). Step 0 is Slice 2.
|
||||
|
||||
### Slice 2 — Step 0 on `/tools/fristenrechner`
|
||||
|
||||
- New Step 0 toggle component in `fristenrechner.tsx` (above today's Step 1).
|
||||
- `?mode=akte|abstract` URL param + `paliad.fristen.mode` localStorage hook.
|
||||
- "Abstract" branch reveals a new compact proceeding-tile picker inside the Step 0 frame (or scrolls to today's wizard-step-1).
|
||||
- "Akte" branch renders today's Step 1 (Akte search + ad-hoc chips).
|
||||
- Akte-driven auto-derivation (§4): a new service `ResolveFristenrechnerCodeForProject(projectID)` and frontend hook that preselects the proceeding tile + `our_side` chip + Court hint (highlight only, not pre-select).
|
||||
- Tests: Playwright smoke for the four state transitions (akte → abstract, abstract → akte, akte+project → akte-no-project, deep-link `?mode=abstract&forum=upc`).
|
||||
|
||||
### Slice 3 — Variant chips + consolidated/lane view
|
||||
|
||||
- Variant-chip strip on `/tools/verfahrensablauf` (`with_ccr`, `with_cci`, `with_amend` conditional on proceeding).
|
||||
- `?flags=` URL param.
|
||||
- Lane-vs-consolidated toggle. Lane view auto-enables when the variant implies a second proceeding (UPC_INF+with_ccr → UPC_REV; UPC_REV+with_cci → UPC_INF).
|
||||
- Lane renderer in `views/verfahrensablauf-core.ts` (CSS grid 2-col, shared trigger-date axis).
|
||||
- Tests: Playwright smoke for variant toggles + lane render + lane on mobile (stack).
|
||||
|
||||
### Slice 4 — Side-by-side compare
|
||||
|
||||
- "Vergleichen" button + second-proceeding picker.
|
||||
- `?compare=1&a_proceeding=…&b_proceeding=…&…` URL state.
|
||||
- Synced-trigger toggle; independent-trigger fallback.
|
||||
- Permalink test (copy URL → fresh tab → same render).
|
||||
- Mobile fallback (stacked).
|
||||
- Tests: Playwright smoke for compare entry, both timelines render, permalink roundtrip.
|
||||
|
||||
Each slice merges to main independently. Slice 1 is the bottleneck; once it's in, Slices 2–4 can ship in any order (Slice 2 only touches `/tools/fristenrechner`, Slices 3+4 only touch `/tools/verfahrensablauf`).
|
||||
|
||||
---
|
||||
|
||||
## 11. Tradeoffs flagged
|
||||
|
||||
### 11.1 Code duplication vs route clarity
|
||||
|
||||
The split forces ~700–900 LoC of client code into a shared module (`views/verfahrensablauf-core.ts`). That's lift work without user-visible benefit. The alternative (one big page with `?mode=`) saves the lift but keeps the muddled mental model that triggered this redesign in the first place. **Decision: pay the lift cost.** It's a one-time refactor; the navigation clarity is durable.
|
||||
|
||||
### 11.2 Step 0 vs Step 1 — perceived "extra step"
|
||||
|
||||
Today's flow: Akte picker (Step 1) → choose-intent cards (Step 2) → wizard. Tomorrow's flow: mode toggle (Step 0) → Akte picker OR abstract picker → wizard. Same number of clicks for the Akte case. One *fewer* click for the abstract case (you go straight to proceeding tiles instead of clicking "Verfahrensablauf einsehen" first). Net win.
|
||||
|
||||
### 11.3 Court free-text means imperfect auto-derivation
|
||||
|
||||
We can't reliably auto-pick `court_id` from `projects.court` until that column becomes an FK. The design leans on "highlight matching options" rather than silent preselect. The cost is one extra click. **File a follow-up ticket** to migrate `projects.court` → `court_id` FK; until then, no silent FK promotion.
|
||||
|
||||
### 11.4 Pathway B (Determinator cascade) stays inside Akte-mode
|
||||
|
||||
t-paliad-166 will redesign Pathway B as a row-by-row cascade. We don't pre-empt that. Pathway B remains reachable from Akte-mode's "Etwas ist passiert" card. In Abstract mode it's reachable through a "Frist aufgrund Ereignis" link in the result panel. Both paths stay; only the entry surface changes.
|
||||
|
||||
### 11.5 Variant chips disabled for non-UPC proceedings
|
||||
|
||||
Only UPC_INF and UPC_REV have `condition_flag` rules today. DE_INF, DE_NULL, EPA_OPP, etc. show no chips. This is honest — the data isn't there. If users ask for German "with/without counterclaim" variants, that's a `condition_flag` seed-data ticket, not a UX redesign.
|
||||
|
||||
### 11.6 Lane view assumes the second proceeding exists
|
||||
|
||||
`UPC_INF + with_ccr` lanes to `UPC_REV`. But `UPC_REV` itself is a full proceeding with its own deadlines anchored on a *separate* trigger date (the CCR filing date, not the SoC date). For v1 we render the second lane with the *same trigger date* as the primary — which is wrong-but-useful: the user sees the *shape* of the counterclaim's flow but the dates are nominal. A future iteration adds a "second trigger date" input for the lane. **Document this in the UI** with a small caveat: "Annahme: Widerklage zur gleichen Zeit eingelegt".
|
||||
|
||||
### 11.7 No state preserved across the route boundary
|
||||
|
||||
If a user is mid-calc on `/tools/fristenrechner` and clicks the sidebar's `/tools/verfahrensablauf`, their wizard state is lost. We don't try to bridge the two — they're different intents. The URL captures everything important; the user can pop back via the browser back button.
|
||||
|
||||
### 11.8 Print mode is the only export
|
||||
|
||||
No PDF, no SVG, no CSV export in this design. The existing `#fristen-print-btn` + `@media print` stylesheet handles it. m's broader chart-export design (`docs/design-project-chart-2026-05-09.md`) covers the export ambition for the project-level chart; this Tool-level surface keeps it simple.
|
||||
|
||||
---
|
||||
|
||||
## 12. Files implementer will touch (Slice 1 only)
|
||||
|
||||
This is the bottleneck slice. Slices 2–4 each add their own scope but Slice 1 defines the structural change.
|
||||
|
||||
**Backend (Go):**
|
||||
|
||||
- `internal/handlers/handlers.go:162` — add `protected.HandleFunc("GET /tools/verfahrensablauf", handleVerfahrensablaufPage)`.
|
||||
- `internal/handlers/fristenrechner.go` — add `handleVerfahrensablaufPage` (1-liner, serves `dist/verfahrensablauf.html`). Or split into its own file `internal/handlers/verfahrensablauf.go` for tidiness.
|
||||
- `internal/handlers/handlers.go` — add back-compat 302: `/tools/fristenrechner?path=a` → `/tools/verfahrensablauf` (preserves bookmarked links). A small middleware or an `init` redirect handler suffices.
|
||||
|
||||
**Frontend (TSX + TS):**
|
||||
|
||||
- `frontend/src/verfahrensablauf.tsx` — new file. ~250 LoC. Renders header + jurisdiction-tab picker + proceeding-tile picker + result panel container. No variant chips, no compare yet (those are Slices 3+4). Reuses `<PWAHead>`, `<Sidebar>`, `<Footer>`.
|
||||
- `frontend/src/client/verfahrensablauf.ts` — new file. ~150 LoC for Slice 1. Wires the picker → POST `/api/tools/fristenrechner` → render via shared module.
|
||||
- `frontend/src/client/views/verfahrensablauf-core.ts` — new file. The lifted code: `renderTimelineBody`, `renderColumnsBody`, the `calculateDeadlines` fetch wrapper, court picker, view-toggle. Imported by both `client/fristenrechner.ts` and `client/verfahrensablauf.ts`.
|
||||
- `frontend/src/client/fristenrechner.ts` — delete the Step 2 "browse" card handler (lines 2715-2717 today). Remove the `?path=a` interpretation as a top-level entry (still keep `path="a"` as an Akte-mode wizard pathway). Import calc + render from `views/verfahrensablauf-core.ts`.
|
||||
- `frontend/src/fristenrechner.tsx` — delete the `fristen-step2-browse` card markup (lines 215-223 today).
|
||||
- `frontend/src/components/Sidebar.tsx:163-164` — change href from `/tools/fristenrechner?path=a` to `/tools/verfahrensablauf`. Adjust the `currentPath` comparison to match the new pathname.
|
||||
- `frontend/src/client/sidebar.ts:447 fixVerfahrensablaufActive` — delete the function + its call site.
|
||||
|
||||
**Build:**
|
||||
|
||||
- `frontend/build.ts` — add `renderVerfahrensablauf` import (line 5-6 area), add `client/verfahrensablauf.ts` to `entrypoints` array (line 228 area), add the `Bun.write(join(DIST, "verfahrensablauf.html"), renderVerfahrensablauf())` step (line 355 area).
|
||||
|
||||
**i18n:**
|
||||
|
||||
- `frontend/src/client/i18n.ts` + `i18n-keys.ts` — add `tools.verfahrensablauf.title`, `tools.verfahrensablauf.subtitle`, `nav.verfahrensablauf` (already exists; re-verify the key still points at the right label).
|
||||
|
||||
**Tests:**
|
||||
|
||||
- Playwright smoke covering: `/tools/verfahrensablauf` renders, sidebar nav link active class lights up correctly without `fixVerfahrensablaufActive`, `/tools/fristenrechner?path=a` 302s, the calc roundtrip works on both routes, build artefacts emit both `fristenrechner.html` and `verfahrensablauf.html`.
|
||||
|
||||
**Out of Slice 1 (deferred to Slices 2-4):**
|
||||
|
||||
- Step 0 toggle on `/tools/fristenrechner` (Slice 2).
|
||||
- Akte-driven auto-derivation helper service (Slice 2).
|
||||
- Variant chips, lane view (Slice 3).
|
||||
- Compare view (Slice 4).
|
||||
|
||||
---
|
||||
|
||||
## 13. Open questions for m
|
||||
|
||||
1. **Sidebar label.** Keep "Verfahrensablauf" (current) or switch to "Verfahrensabläufe" (plural — reads as catalogue) or something else? Current label is unambiguous; plural risks reading as a list page.
|
||||
|
||||
2. **Akte-mode mapping with no `proceeding_type_id`.** 11/11 live projects have NULL proceeding_type_id. Akte-mode silently degrades to "pick proceeding manually". OK? Or should Akte-mode require a proceeding_type_id and force the user to set it on the project first?
|
||||
|
||||
3. **Court free-text → FK migration.** I'm flagging this as a follow-up but not designing it here. Want me to file a separate ticket so it's tracked, or fold it into Slice 2's scope?
|
||||
|
||||
4. **Lane view caveat for v1.** The second lane uses the same trigger date as the primary (so dates are nominal-but-wrong for a real-world CCR filed weeks later). UI caveat "Annahme: Widerklage zur gleichen Zeit eingelegt" is honest but adds clutter. Acceptable or do we hold lane view back until trigger-2 input lands?
|
||||
|
||||
5. **Compare view max columns.** v1 caps at 2. Three+ would be a richer compare ("UPC_INF vs DE_INF vs EPA_OPP for the same patent") but layout-hostile on anything <1280px. Confirm 2 for v1?
|
||||
|
||||
6. **Back-compat for `?path=a`.** I propose a 302 redirect so old bookmarked URLs work. Alternative: 410 Gone (harsh) or 200-with-deprecation-banner (chatty). 302 is the conventional move; confirm?
|
||||
|
||||
7. **Drop the "Verfahrensablauf einsehen" card from Step 2 entirely** vs keep it as a deep-link shortcut to `/tools/verfahrensablauf` from inside the Fristenrechner flow? I'm proposing drop; m signals?
|
||||
|
||||
8. **DE_INF / EPA_OPP / DPMA variants.** Today no `condition_flag` rules. Future seed-data tickets (out of scope here): with/without expedited, with/without amendment for EPA opposition, etc. Want a follow-up ticket filed for the seed-data work or wait for user feedback?
|
||||
|
||||
9. **Pathway B (Determinator) entry point in Abstract mode.** I propose a small "Frist aufgrund Ereignis" link in the result panel. Or hide it entirely from abstract mode? Today Pathway B is reachable from anywhere via `?path=b`.
|
||||
|
||||
10. **Implementer choice.** I'd recommend a coder familiar with `frontend/src/client/fristenrechner.ts` for Slice 1 since the bundle split is the load-bearing risk. Curie (t-paliad-086), cronus (t-paliad-088, t-paliad-110), noether (t-paliad-165) have all touched the file. Head decides.
|
||||
|
||||
---
|
||||
|
||||
**DESIGN READY FOR REVIEW**
|
||||
|
||||
Slice 1 is the structural foundation (route split, sidebar cleanup, code lift). Slices 2-4 layer Step 0 / variant chips / compare on top. Awaiting m's go/no-go before coder shift.
|
||||
469
docs/design-universal-filter-2026-05-08.md
Normal file
469
docs/design-universal-filter-2026-05-08.md
Normal file
@@ -0,0 +1,469 @@
|
||||
# Universal filter + view-mode primitive across all entity-views
|
||||
|
||||
**Issue:** m/paliad#23 (t-paliad-163)
|
||||
**Inventor:** riemann (mai/riemann/inventor-universal)
|
||||
**Date:** 2026-05-08
|
||||
**Status:** READY FOR REVIEW — no code yet, design only.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — the central position
|
||||
|
||||
m's framing is exactly right: "halfway there without custom views". The Custom Views substrate (t-paliad-144) is the missing primitive — it just hasn't been lifted from "a saved-view feature on /views/{slug}" up to "the bar that every list-shaped page reads from".
|
||||
|
||||
Concrete take:
|
||||
|
||||
- **Don't invent a new schema or a new query layer.** `internal/services/filter_spec.go` + `render_spec.go` + `view_service.go` already cover every axis the issue lists, and `POST /api/views/run` and `POST /api/views/{slug}/run` already accept ad-hoc spec overrides. The substrate's own comment says it: *"Phase B will route them here; Phase A1 leaves the wiring as a no-op for those pages."* (`internal/handlers/views.go:247`). t-paliad-163 is Phase B with a UX-shaped artifact at the front.
|
||||
- **Build one frontend `<FilterBar>` component** that consumes a `FilterSpec` + `RenderSpec` + a per-surface `axes[]` declaration, owns URL/local-state, and emits diffs. Drop it on every list-shaped surface. Each system page declares a base spec (= one of the existing `SystemView` definitions) and the supported axes.
|
||||
- **"Save current filter as named view" is one button** on the bar. It POSTs the effective spec to `/api/user-views`. The custom-view editor (`/views/new`, `/views/{slug}/edit`) becomes a power-user form for the same data the bar produces; the bar is the everyday entry point.
|
||||
- **/projects stays bespoke** (locked in t-paliad-149). Source⊥Shape orthogonality breaks for projects — they don't render as cards/calendar in the events sense, and `paliad.user_card_layouts` is a different primitive (per-card facts, not filters). The bar coexists with the `<details>`-chip cluster on /projects without subsuming it.
|
||||
|
||||
The migration is one surface at a time. /inbox first (no filter today, lowest blast radius), /events last (richest filter today, the proof point that the primitive can absorb it).
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live
|
||||
|
||||
Before designing on top of CLAUDE.md / memory / the issue body, I checked the live tree:
|
||||
|
||||
- **`paliad.user_views` (056) exists.** `paliad.user_card_layouts` (061) exists. **`paliad.user_view_layouts` does NOT exist** — the issue body's reference is a typo. Real names: `paliad.user_views` is the FilterSpec/RenderSpec store; `paliad.user_card_layouts` is the per-card-facts store for /projects only. `grep -rn user_view_layouts` returns nothing.
|
||||
- **`POST /api/views/run`** takes an inline `FilterSpec` and returns `ViewRunResult{rows, inaccessible_project_ids}` without touching the DB. (`internal/handlers/views.go:248`)
|
||||
- **`POST /api/views/{slug}/run`** accepts an optional `{filter: <override>}` body that overrides the saved/system spec for one run — does not mutate storage. (`internal/handlers/views.go:282`, `runRequest` at `:238`)
|
||||
- **5 SystemViews are already code-resident** (`dashboard`, `agenda`, `events`, `inbox`, `inbox-mine`) at `internal/services/system_views.go:35`-`156`. Their slugs are reserved against user-view collisions. Each carries a canonical `FilterSpec` + `RenderSpec`.
|
||||
- **3 render-shape components exist** in `frontend/src/client/views/`: `shape-list.ts`, `shape-cards.ts`, `shape-calendar.ts`. They take `(host, rows, render)` — pure config-driven dispatch.
|
||||
- **List shape supports density (compact|comfortable), 13 known columns, and sort.** Column registry at `internal/services/render_spec.go:99`: `["date","time","title","project","actor","status","rule","event_type","location","appointment_type","approval_status","decided_by","kind"]`. Sort: `date_asc | date_desc`.
|
||||
- **`attachEventTypeMultiSelectFilter`** in `frontend/src/client/event-types.ts` is a mature listbox-panel component (search + grouped checkboxes + URL round-trip + internal `onLangChange` subscription per t-paliad-117). The pattern to copy for project + appointment-type + status panels.
|
||||
- **`renderAgendaTimeline`** in `frontend/src/client/agenda-render.ts` is the day-grouped timeline used both by `/agenda` and dashboard inline; reusable.
|
||||
- **`.entity-table` row-click contract** is the project-wide rule (CLAUDE.md "Frontend conventions"). Any list-shape table must wire row-handlers that skip clicks on inner `<a>`/`<button>` and add `entity-table--readonly` when rows don't navigate. The bar must not regress this — it doesn't, because `shape-list.ts` already emits `entity-table--readonly` on its tables.
|
||||
|
||||
---
|
||||
|
||||
## 1. The 7 list-shaped surfaces today — what they each have
|
||||
|
||||
A factual map of who has what. The underlinings are the axes the issue calls out.
|
||||
|
||||
| Surface | Filter axes today | View modes | State store |
|
||||
|---|---|---|---|
|
||||
| **/agenda** (`client/agenda.ts`, 226 LoC) | type chip (deadlines/appointments/both), range chip (7/14/30/90d), event-type multi-select | timeline only | URL `?range=&types=&event_type=` |
|
||||
| **/events** (`client/events.ts`, 1083 LoC) — also `/deadlines`, `/appointments` via 302 redirect | type chip (deadline/appointment/all), status select (8 buckets), project select (single, with `__personal__` sentinel), event-type multi (deadline-only), appointment-type select (appointment-only) | cards / list / calendar | URL `?type=&view=&status=&project_id=&personal_only=&event_type=&type_filter=` |
|
||||
| **/inbox** (`client/inbox.ts`, 329 LoC) — both tabs | tab (pending-mine / mine), nothing else | list only | URL `?tab=` |
|
||||
| **/projects** (`client/projects.ts` + `client/projects-cards.ts`) | search input, 6 chips (scope/status/type/has-open-deadlines), `<details>` multi-select for status + type | tree / cards / flat | sessionStorage `paliad.projects.lastView` + URL overlay |
|
||||
| **/views/{slug}** (`client/views.ts`) | none in the viewer (only saved-spec); shape switcher (list/cards/calendar) | list / cards / calendar | URL path |
|
||||
| **dashboard** (`client/dashboard.ts`, inline Agenda + Letzte Aktivität) | none | inline timeline / inline list | none |
|
||||
| **/views/new \| /views/{slug}/edit** (`client/views-editor.ts`) | full FilterSpec form (sources / scope / time / shape / list density) | n/a — author surface | n/a |
|
||||
|
||||
The pattern m sees on `/inbox?tab=mine` is the natural endpoint of seven surfaces all building filters their own way: the surface that didn't have a filter author yet is also the surface with no filter chrome at all.
|
||||
|
||||
The good news: every axis on every surface is **already nameable in the FilterSpec / RenderSpec grammar** that `internal/services/filter_spec.go` ships. There's a one-to-one mapping; nothing has to be invented at the data layer.
|
||||
|
||||
---
|
||||
|
||||
## 2. What the universal primitive is — `<FilterBar>`
|
||||
|
||||
A single TypeScript component, mounted on a host `<div>`, parameterised by:
|
||||
|
||||
```ts
|
||||
interface FilterBarOpts {
|
||||
// Base spec — usually a SystemView's FilterSpec, fetched from /api/views/system.
|
||||
// For /views/{slug}, this is the user-view's saved filter_spec.
|
||||
baseFilter: FilterSpec;
|
||||
baseRender: RenderSpec;
|
||||
|
||||
// Which axes the surface supports. Universal axes always render;
|
||||
// per-surface axes render iff present in this list.
|
||||
axes: AxisKey[];
|
||||
|
||||
// Optional fixed predicates the surface refuses to let users tweak.
|
||||
// E.g. /inbox forces sources=[approval_request], not relaxable.
|
||||
pinned?: PartialFilterSpec;
|
||||
|
||||
// Where to write rows when filter changes. The bar runs the spec via
|
||||
// /api/views/run and hands the result back here for shape rendering.
|
||||
onResult: (res: ViewRunResult, effective: { filter: FilterSpec; render: RenderSpec }) => void;
|
||||
|
||||
// Optional URL-param namespace (defaults to the empty namespace).
|
||||
// Useful for embedding the bar twice on one page (dashboard inline)
|
||||
// without colliding ?time= / ?time2=. Phase 4 ramps this up if needed.
|
||||
urlNamespace?: string;
|
||||
|
||||
// Optional surface key — used as the localStorage key for view-mode
|
||||
// and density preferences ("paliad.bar.<surfaceKey>.prefs").
|
||||
surfaceKey: string;
|
||||
|
||||
// Optional sidebar slot — when present, "Save as view" + "Reset" are
|
||||
// rendered. Defaults to true on every surface except dashboard inline.
|
||||
showSaveAsView?: boolean;
|
||||
}
|
||||
|
||||
type AxisKey =
|
||||
| "project" // ← universal (always rendered if axes contains it; otherwise the chip is hidden)
|
||||
| "time" // ← universal
|
||||
| "personal_only" // ← universal
|
||||
| "deadline_status" // ← per-surface (deadline source only)
|
||||
| "deadline_event_type"
|
||||
| "appointment_type"
|
||||
| "approval_viewer_role"
|
||||
| "approval_status"
|
||||
| "approval_entity_type"
|
||||
| "project_event_kind"
|
||||
| "shape" // ← view-mode (list|cards|calendar)
|
||||
| "sort" // ← per-shape
|
||||
| "density" // ← list-shape only
|
||||
| "columns"; // ← list-shape only (advanced; popover with checkboxes)
|
||||
```
|
||||
|
||||
The bar's job:
|
||||
1. On mount, parse URL params (within `urlNamespace`) and `localStorage["paliad.bar.<surfaceKey>.prefs"]`, overlay them on `baseFilter` + `baseRender`, validate, and POST `/api/views/run` with the effective spec.
|
||||
2. Render chrome — chips for booleans / single-selects, popovers for multi-selects, segmented control for view-mode. Each control is a thin wrapper over an existing pattern (chip-row, multi-anchor + multi-panel, segment-control).
|
||||
3. On any change, re-validate, sync URL, sync localStorage (for prefs only — see §3), POST the spec again, hand the result + effective spec to `onResult`. The shape host renders.
|
||||
4. Expose two trailing actions (when `showSaveAsView`): **Speichern als Sicht** and **Zurücksetzen**.
|
||||
|
||||
What the bar is NOT:
|
||||
- Not a router. Pages still own their URL.
|
||||
- Not a layout system. Cards on /projects keep the `paliad.user_card_layouts` primitive (per-card facts) — that's orthogonal to filtering.
|
||||
- Not the renderer. The bar just hands `(rows, effectiveRender)` to one of `shape-list / shape-cards / shape-calendar`.
|
||||
- Not a substitute for the dedicated views editor. That stays for power-users who want full control (predicates, custom horizons, columns).
|
||||
|
||||
---
|
||||
|
||||
## 3. The 7 brief items — taking positions
|
||||
|
||||
### 3.1 Filter axes: which are universal, which are per-surface, how does the bar declare its supported axes?
|
||||
|
||||
**Universal** — render always when `axes` contains them (and the surface's pinned spec doesn't rule them out):
|
||||
- `project` — single-select with the existing `<select>` (Alle / Nur persönliche / each project, ltree-indented). On surfaces where multi-project would help later (system-wide views), the same control upgrades to a multi-select listbox-panel by adding a `multi: true` flag — postpone to phase C, single-select covers every surface today.
|
||||
- `time` — segmented chip group (`Heute · 7T · 30T · 90T · Alles · Anpassen`). Maps to `time.horizon`. "Anpassen" pops a date-range pair (`time.horizon = "custom"` + from/to). On /inbox the chip group reads "Heute · 7T · 30T · Alles" since approval queues are usually now-shaped — but the same control.
|
||||
- `personal_only` — boolean chip ("Nur eigene"). Active when `scope.personal_only=true`. Hidden when source set excludes deadline AND appointment (others don't honour personal_only).
|
||||
|
||||
**Per-surface** — declared in `axes`, controlled by which sources the spec uses:
|
||||
- `deadline_status` (chip cluster: "Offen · Überfällig · Erledigt · Alle") — only when `sources` includes deadline.
|
||||
- `deadline_event_type` (multi-select listbox-panel, reuses `attachEventTypeMultiSelectFilter`) — only when sources includes deadline.
|
||||
- `appointment_type` (single-select for now: hearing/meeting/consultation/deadline_hearing/Alle) — only when sources includes appointment.
|
||||
- `approval_viewer_role` (segmented chips: "Zur Genehmigung · Eigene Anfragen · Alle sichtbaren") — only when sources includes approval_request. This subsumes the /inbox tab.
|
||||
- `approval_status` (chip cluster: "Wartend · Entschieden · Alle") — only when sources includes approval_request.
|
||||
- `approval_entity_type` (chip pair: "Fristen · Termine") — only when sources includes approval_request.
|
||||
- `project_event_kind` (multi-select listbox-panel; the 13 `KnownProjectEventKinds`) — only when sources includes project_event. Powers the dashboard "Letzte Aktivität" filter.
|
||||
|
||||
**View-mode + per-shape** — declared in `axes`, but special:
|
||||
- `shape` — segmented chips (list/cards/calendar). Always rendered when `axes` contains `shape`; available shapes derived from `baseRender` + the surface's whitelist. The bar emits a transient render override (mirrors how `client/views.ts:171` does shape-switching today: it doesn't rerun, just re-renders).
|
||||
- `sort` — single-select (`date_asc | date_desc`).
|
||||
- `density` — segmented chip pair (Komfortabel / Kompakt) — list shape only, hidden otherwise.
|
||||
- `columns` — popover with checkbox list of `KnownListColumns` — list shape only, advanced opt-in.
|
||||
|
||||
**How the surface declares its axes:** an array. No higher-order component, no slot composition. Plain config. The bar's render is a switch over each axis key:
|
||||
|
||||
```ts
|
||||
mountFilterBar(host, {
|
||||
baseFilter: agendaSystemView.filter,
|
||||
baseRender: agendaSystemView.render,
|
||||
axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort"],
|
||||
surfaceKey: "agenda",
|
||||
onResult: ({rows, inaccessible_project_ids}, effective) => { ... },
|
||||
});
|
||||
```
|
||||
|
||||
Slot composition was considered. It's overkill — every existing chrome pattern paliad uses (chip cluster, multi-anchor popover, segmented control, `<select>`) is already in `frontend/src/styles/global.css`; there's nothing to plug or override. A flat axis-config keeps the bar a 600-LoC component, not a framework.
|
||||
|
||||
### 3.2 State model: URL vs in-memory vs hybrid
|
||||
|
||||
**Hybrid**, with a sharp split:
|
||||
|
||||
- **URL is canonical** for everything that affects which rows you see. That means: project (`?project=`), sources (`?sources=`), time (`?time=` for horizon, `?from=&to=` for custom), personal-only, every per-source predicate (`?deadline_status=`, `?event_type=`, `?appointment_type=`, `?approval_role=`, `?approval_status=`, `?approval_entity_type=`, `?project_event_kind=`), shape (`?shape=`), sort (`?sort=`). Bookmarkable, shareable, refresh-survives, deep-linkable from the dashboard or /inbox bell.
|
||||
- **localStorage holds preferences** that don't change rows: density (`?density=` is also a URL param when explicitly chosen, but absence falls through to localStorage default), default columns per surface (advanced opt-in), default shape per surface (only when the user has overridden the SystemView's default — first visit uses base). Keyed `paliad.bar.<surfaceKey>.prefs`. Mirrors the spirit of /projects' sessionStorage `paliad.projects.lastView` (t-paliad-149 Q1 lock-in) but at the right scope: the "what I prefer" sticks per surface, the "what this URL is showing" stays in the URL.
|
||||
- **No sessionStorage.** /projects' use was justified by tab restoration; for the bar, every interesting bit is in the URL (so back/forward + refresh + share both work). Adding a third tier would create the worst-of-three: state in URL ∪ session ∪ local, three places to look when something's off.
|
||||
|
||||
URL parameter names are stable and short. The bar exports a tiny URL codec (`encodeBarParams(filter, render) → URLSearchParams` and inverse) so the same params work whether the bar is on /agenda, /inbox, /events, or /views/{slug}.
|
||||
|
||||
The migration from /events' bespoke `?type=&view=&status=&project_id=&personal_only=&event_type=&type_filter=` to the bar's params is straightforward: each old param maps to a new one (or stays, when names already match — `?project_id`, `?personal_only`, `?event_type` are unchanged; `?type` becomes `?sources`; `?view` becomes `?shape`; `?status` and `?type_filter` become per-surface predicates). Server middleware on the legacy /events handler can rewrite old → new params for one release so existing bookmarks don't 404.
|
||||
|
||||
### 3.3 View-mode switcher — universal or per-surface? Sort-state ownership? Density?
|
||||
|
||||
**Universal.** The bar always owns the segmented `shape` control. The surface declares which shapes it whitelists (e.g. /inbox might whitelist `["list"]` and hide the switcher; /agenda might whitelist `["cards", "list", "calendar"]`). When the whitelist has only one entry the bar suppresses the chip; when ≥2 it renders.
|
||||
|
||||
**Sort lives in the bar's `RenderSpec.list.sort` / `cards.sort`.** Already exists in the schema. The list-shape table renderer is currently sort-by-config-only; promoting `<th>` clicks to update `RenderSpec.list.sort` is a one-line callback in the bar (`onListHeaderSort`) → server-side re-sort isn't needed because `shape-list.ts:16` already sorts in JS. **Sortable column headers become a list-shape feature owned by the bar**, not a per-surface concern.
|
||||
|
||||
**Density** is a list-shape config (`comfortable | compact`). The bar exposes the pair as a chip; `shape-list.ts` already supports both. Density on /inbox today is implicitly comfortable; toggling it to compact gives the user the activity-feed look on the inbox surface for free, which is the kind of small win the brief calls out.
|
||||
|
||||
**Multi-column sort** is out-of-scope for v1 — `shape-list.ts:16` does single-column sort, which matches every surface today. Add when a user asks.
|
||||
|
||||
### 3.4 Composability — drop-in API without forcing existing pages to refactor
|
||||
|
||||
The bar mounts onto an empty `<div>`. The surface's TSX changes are:
|
||||
- Replace the per-page filter chrome (chip cluster, selects, popovers, view-mode segment) with `<div id="filter-bar"></div>`.
|
||||
- Replace the per-page result rendering with `<div id="filter-bar-results"></div>`.
|
||||
- The page's `client/<surface>.ts` shrinks to: read `__PALIAD_<SURFACE>__` initial payload (or skip), call `mountFilterBar(host, opts)`, write `onResult` to dispatch into the matching shape component (already exist).
|
||||
|
||||
That's it. The page surface is reduced to ~50 LoC of orchestration around the bar; the bulk of `events.ts` (1083 LoC) drops to a baseline of ≈80 LoC after Phase 3 because the per-axis filter state, the project select populator, the language-hot-swap, the URL-sync, the type-visibility logic, the appointment-type filter logic, the calendar month-paging, and the cards-vs-list-vs-calendar dispatch all migrate into shared components: the bar (filter axes, view-mode, URL, language hot-swap), `shape-list.ts` (table), `shape-cards.ts` (cards), `shape-calendar.ts` (month grid).
|
||||
|
||||
The bar **does not own row interaction**. Row click → detail page is already a per-shape concern (`shape-list.ts` emits `entity-table--readonly`; the bar doesn't override that). Lifecycle actions (complete/reopen/approve/reject) are also per-shape — `shape-list.ts` will need a small extension to emit clickable-row tables on /events (so the existing complete-checkbox + reopen flow keeps working). That extension is one new render flag in `RenderSpec.list.row_action: "navigate" | "approve" | "complete-toggle" | "none"`, defaulting to navigate. Honest scope: this is a small `RenderSpec` schema bump (new optional field), not an axis change.
|
||||
|
||||
### 3.5 Reuse with the existing /views layout-spec — does the universal bar inherit, or does the spec become a special case of saved bar state?
|
||||
|
||||
**The latter.** m's hint ("halfway there without custom views") points at exactly this.
|
||||
|
||||
A **Custom View is the persisted form of a bar state.** When the user clicks "Speichern als Sicht" on /agenda, the bar gathers the effective `FilterSpec` + `RenderSpec`, prompts for name + slug + icon + show-count (a small modal — one form, four fields, mirroring `views-editor.ts`'s collectForm), and POSTs `/api/user-views`. The user is then redirected to `/views/{slug}` (or stays in place with a confirmation toast — see §3.7).
|
||||
|
||||
Conversely, **a SystemView is a code-resident bar state.** The bar already knows how to load one (`/api/views/system` → match slug). The "system pages" become surfaces whose default state happens to live in code instead of in `paliad.user_views`.
|
||||
|
||||
Implementation consequence:
|
||||
- `views-editor.ts` keeps existing for power users who want to edit predicates that the bar doesn't expose (e.g. pinning a `time.field = "created_at"` for an "audit-trail" view). The editor and the bar produce identical `FilterSpec` + `RenderSpec` JSON; they're alternate authoring UX.
|
||||
- `views.ts` (the `/views/{slug}` viewer) gains the bar above its rows. The bar renders with the saved spec as its base; the user can tweak axes (e.g. narrow the time horizon for a quick glance) — those tweaks are URL-overlays and don't mutate the saved spec until the user clicks "Aktualisieren" (a new affordance). This satisfies the brief's "halfway there" hint: today /views/{slug} renders a saved spec **statically**; with the bar, it becomes interactive without losing the saved-state semantics.
|
||||
|
||||
### 3.6 Migration path — phase one surface at a time, identify the hardest
|
||||
|
||||
The bar is shippable on one surface in one PR. Then each subsequent surface is its own small PR.
|
||||
|
||||
**Phase 1 — /inbox (the cold start).** Lowest blast radius: today /inbox has no filter chrome, only tabs. Replace tabs with the `approval_viewer_role` axis (the bar collapses two tabs into one chip cluster). Drop the bar with `axes: ["time", "approval_status", "approval_entity_type", "approval_viewer_role", "shape", "density", "sort"]`. Pin `sources: [approval_request]`. Density toggle gives the user a stream view m's "looks really bad" was diagnosing. URL contract: keep `?tab=` redirecting to `?approval_role=` for one release.
|
||||
|
||||
**Phase 2 — /agenda.** Already filter-shaped and the most readable orchestrator (226 LoC). Bar replaces the chip cluster + range chip + event-type popover. `axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort"]`. Default: shape="cards" (matching today's timeline default). The dashboard inline Agenda gets a stripped-down bar with `axes: ["time", "deadline_event_type"]` and `urlNamespace: "agenda"` (so the page-level bar on the dashboard doesn't collide with anything else if the dashboard adds another bar later for "Letzte Aktivität").
|
||||
|
||||
**Phase 3 — /events (the proof point).** Most complex filter today: type chip + status select + project select + personal-only + event-type multi + appointment-type select + cards/list/calendar. Every one of these axes is already nameable in FilterSpec/RenderSpec (verified §1). `axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort", "density"]`. The 5-card summary above the table (Heute / Diese Woche / Nächste Woche / Später / Überfällig) becomes a bar-driven facet: clicking a card sets `time.horizon` (or for "Überfällig", a special `deadline_status: ["overdue"]` predicate). Identifying /events as the hardest surface up front means the primitive's axis registry has to be wide enough on day 1; the design above already names every needed axis, so Phase 1's primitive is forward-compatible.
|
||||
|
||||
**Phase 4 — dashboard inline lists (Agenda + Letzte Aktivität).** The dashboard composes two tiny bars: one for Agenda (cards/list, narrow time horizon, no save-as-view), one for Letzte Aktivität (project_event source, density=compact, no save-as-view). Both use `urlNamespace` to keep params tidy.
|
||||
|
||||
**Phase 5 — /views/{slug}.** Add the bar above the rows. Saved spec → bar's base; URL overlays are transient until "Aktualisieren" persists them. The custom-view editor (`/views/new`, `/views/{slug}/edit`) stays for power users; "Speichern als Sicht" from the bar is the everyday path.
|
||||
|
||||
**Out of phasing:** /projects stays bespoke. The bar coexists on the page only if a future task adds it — today the chip cluster + tree/cards/flat segment are doing fine, and Source⊥Shape orthogonality breaks for projects (no ProjectSource in the substrate; no TreeShape in the substrate). t-paliad-149's locked-in choice stands.
|
||||
|
||||
**Hardest surface, identified:** /events. Phase 3 is the proof point. By designing the bar's axis registry against /events on day 1 (not retrofitting), Phase 1 (/inbox) and Phase 2 (/agenda) ship without redesign churn.
|
||||
|
||||
### 3.7 "Save current filter as named view" — making it trivial
|
||||
|
||||
The bar's trailing action is a single button: **Speichern als Sicht**. Click → small modal:
|
||||
|
||||
```
|
||||
┌─ Sicht speichern ─────────────────────┐
|
||||
│ Name [_________________] │
|
||||
│ Slug [_________________] (opt) │
|
||||
│ Icon [▼ Auswählen ] │
|
||||
│ □ Anzahl in der Sidebar zeigen │
|
||||
│ │
|
||||
│ [ Abbrechen ] [ Speichern ] │
|
||||
└───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
If slug is empty, derive from name (kebab-case) and validate against the regex + reserved-slug list client-side (mirrors `views-editor.ts:179`). On 409 (slug taken), show inline error and let the user adjust. On success, two affordances:
|
||||
- A toast "Als Sicht 'Heute überfällig' gespeichert. Zur Sicht wechseln?" with a link to `/views/{slug}`.
|
||||
- The new view automatically appears in the **Meine Sichten** sidebar group (t-paliad-144) on next page load (or sooner, if the bar emits a window event the sidebar listens to).
|
||||
|
||||
This means: every list-shaped surface gets "save current filter as named view" for free. No per-surface plumbing.
|
||||
|
||||
**"Aktualisieren" on /views/{slug}** is the symmetric write-back: when the user is viewing a saved view and tweaks the bar, a "Aktualisieren" button appears next to "Speichern als Sicht". Click → PATCH `/api/user-views/{id}` with the effective spec. Confirmation toast.
|
||||
|
||||
**"Zurücksetzen"** clears the URL overlay and re-renders with the base spec only.
|
||||
|
||||
---
|
||||
|
||||
## 4. Two harder questions worth surfacing now
|
||||
|
||||
### 4.1 The chip-vs-popover-vs-select tension
|
||||
|
||||
paliad has three patterns for "pick from a set" today:
|
||||
|
||||
- **Chip cluster** (e.g. /agenda type chip, /projects scope chip) — best for 2–4 mutually exclusive options. Always-visible, click-fast.
|
||||
- **`<select>`** (e.g. /events status, project, appointment-type) — best for 5–30 single-select options, especially when the option list is dynamic (project list grows).
|
||||
- **Listbox-panel popover** (e.g. event-type multi, /projects status/type `<details>`) — best for multi-select or for >30 options with search.
|
||||
|
||||
The bar must use the right pattern per axis to feel native, not regress one surface in service of another. My picks:
|
||||
|
||||
| Axis | Pattern | Why |
|
||||
|---|---|---|
|
||||
| project (single) | `<select>` | dynamic list; option count grows with the firm |
|
||||
| time | chip cluster + "Anpassen" overflow | 5 mutually exclusive presets cover 95% of usage |
|
||||
| personal_only | single chip | binary |
|
||||
| sources (when `axes` exposes it) | listbox-panel multi | 4 options but multi-select |
|
||||
| deadline_status | chip cluster | 4 options, mutually exclusive |
|
||||
| deadline_event_type | listbox-panel multi | 40+ options, search + grouped checkboxes (reuses event-types.ts pattern) |
|
||||
| appointment_type | chip cluster (4 + Alle) | small mutually-exclusive set |
|
||||
| approval_viewer_role | chip cluster | 3 mutually exclusive options |
|
||||
| approval_status | chip cluster | 4 options |
|
||||
| approval_entity_type | chip cluster | 2 options |
|
||||
| project_event_kind | listbox-panel multi | 13 options, multi-select |
|
||||
| shape | segmented control | 1-of-N, special UX (icon-only buttons) |
|
||||
| sort | `<select>` (small) | 2 options today, room for `title_asc/desc` later |
|
||||
| density | segmented control | binary, icon-shaped |
|
||||
|
||||
The point: the bar isn't one widget, it's a thin shell that delegates each axis to the right existing control. CSS reuse: `.agenda-chip` / `.events-view-btn` / `.akten-multi-trigger` / `.multi-anchor` / `.multi-panel` all stay; the bar just composes them.
|
||||
|
||||
### 4.2 Empty-state UX when an axis is invalid for the current sources
|
||||
|
||||
If the user clears all sources, every per-source axis becomes meaningless. Two options:
|
||||
- **Hide invalid axes.** Cleanest. Bar reacts to source changes by collapsing dependent chips. Risk: feels jumpy.
|
||||
- **Disable + tooltip.** Less jumpy but visually noisier.
|
||||
|
||||
Recommend **hide**, with one twist: the bar persists hidden-axis state in the URL anyway, so toggling sources back on restores the user's prior filter. This matches /events' existing behaviour (when type=appointment, event-type panel is hidden but its state persists in `?event_type=`).
|
||||
|
||||
---
|
||||
|
||||
## 5. RenderSpec extensions — one schema bump
|
||||
|
||||
The bar exposes capabilities that are already in `RenderSpec` (shape, sort, density, columns) plus one new field:
|
||||
|
||||
```go
|
||||
type ListConfig struct {
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
Sort SortOrder `json:"sort,omitempty"`
|
||||
Density ListDensity `json:"density,omitempty"`
|
||||
RowAction ListRowAction `json:"row_action,omitempty"` // NEW — "navigate" (default) | "complete_toggle" | "approve" | "none"
|
||||
}
|
||||
```
|
||||
|
||||
`RowAction` lets `shape-list.ts` know whether to wire an `entity-table--readonly` or to attach the existing checkbox / reopen / approve / reject buttons. Default `navigate` keeps the contract stable; system pages explicitly set `complete_toggle` (events list) and `approve` (inbox list).
|
||||
|
||||
This is the only schema change. Every other axis is already in the spec.
|
||||
|
||||
---
|
||||
|
||||
## 6. Hard requirements from the brief — addressed
|
||||
|
||||
- **`.entity-table` row-click contract.** The bar's list-shape table is rendered by `shape-list.ts:80` which already emits `entity-table--readonly`. When `RowAction="navigate"` the bar adds a row-handler that does `window.location.href = detailRoute(row)` and skips clicks on inner `<a>`/`<button>` (mirrors the existing `events.ts:wireRowHandlers` pattern). Whole-card / whole-row click → JS row-handler, never `::before` overlays (CLAUDE.md frontend conventions, t-paliad-102).
|
||||
- **No hour estimates.** Throughout this design.
|
||||
- **DE+EN bilingual.** Every new label gets a key under `views.bar.*` (single new namespace; ~25 keys for axes + ~10 for save modal + ~10 for empty/loading/error states). Keys are added to `frontend/src/client/i18n.ts`'s registry at the appropriate phase.
|
||||
- **Mobile.** The bar collapses to a single horizontal scroll row on `≤768px` (mirrors `.frist-summary-cards` mobile pattern). The "Speichern als Sicht" + "Zurücksetzen" actions move into a `<details>` "Mehr" affordance on mobile to keep the scrollable strip clean. Re-imagining mobile-list-mode is out of scope per the brief.
|
||||
|
||||
---
|
||||
|
||||
## 7. Trade-offs — the honest list
|
||||
|
||||
### What this design gains
|
||||
1. **One filter chrome across all list-shaped surfaces.** Users learn one bar, every surface respects it. Discoverability for "save as view" jumps from one surface (/views/new editor) to seven.
|
||||
2. **System pages become substrate clients.** `/api/views/run` (already shipped) becomes the canonical event-fetching path. Phase B from t-paliad-144 design lands.
|
||||
3. **`events.ts` shrinks ~10×.** Most of its 1083 lines are filter chrome + URL sync + view-mode dispatch — all now shared.
|
||||
4. **Save-as-view is universal.** Today only /views/new + /views/{slug}/edit can author saved views; after the migration, every page can.
|
||||
5. **/inbox gains filters and sort and density** as a free side effect of the migration — directly addressing m's "looks really bad" diagnosis.
|
||||
6. **Sortable column headers** become a substrate feature (small bar callback that updates `RenderSpec.list.sort`).
|
||||
7. **The schema barely moves** — one new optional field on `ListConfig`. Migrations not needed.
|
||||
|
||||
### What this design risks
|
||||
1. **One component holding many axes is at risk of bloat.** Mitigation: the bar is a flat axis-config (no slot composition, no HOC). 600 LoC ceiling enforced by the per-axis switch pattern. CSS reuse keeps the visual surface small.
|
||||
2. **The /events migration is the largest single PR.** 1083 LoC client → ≈100 LoC + ≈250 LoC of bar config + per-shape extensions. A regression on the 5-card summary or the deadline complete/reopen flow would be visible. Mitigation: Phase 3 is gated behind Phase 1 (/inbox) and Phase 2 (/agenda) shipping cleanly, and the design lands the `RowAction` schema bump in Phase 1 so `complete_toggle` is wired before /events arrives.
|
||||
3. **URL overlay on /views/{slug} creates two states.** Saved spec ≠ effective spec when the user has tweaked the bar. The "Aktualisieren" / "Speichern als Sicht" actions resolve which becomes canonical, but a user who navigates away with unsaved tweaks loses them. Mitigation: a `?dirty=1` URL marker + a small toast on first tweak ("Änderungen sind nicht gespeichert").
|
||||
4. **Two filter chromes coexist on /projects.** The bar doesn't subsume the chip cluster (Source⊥Shape break). Future visual unification would standardise the chip pattern between the two — out of scope here.
|
||||
5. **Hidden-axis URL state.** Persisting `?event_type=` even when sources excludes deadline can confuse a user reading their URL. Acceptable: matches /events' current behaviour and is reversible by toggling the source back. The alternative (pruning URL params on source change) loses the user's prior state on a quick re-toggle.
|
||||
6. **i18n hot-swap correctness.** Every dynamic populator must subscribe to `onLangChange` (the t-paliad-117 lesson). The bar handles this once internally for every axis; surfaces don't need to wire it per-page.
|
||||
7. **Default per-surface defaults can drift from SystemView.** The bar reads `localStorage` for prefs (e.g. preferred shape on /agenda). If a user toggles a pref then a SystemView default changes, the user's pref wins. Mitigation: `localStorage` only stores explicit overrides, not the base value, so changes to the SystemView's base flow through for users who haven't overridden.
|
||||
8. **Two storage primitives ("user_views" + "user_card_layouts") could be confusing.** Names are similar; they store different things. Mitigation: documentation. The bar only ever reads/writes `paliad.user_views`. /projects' card-layout is a separate, narrow concern that stays bespoke.
|
||||
|
||||
### Reversibility
|
||||
- The bar is purely additive. Phase 1 doesn't touch /agenda or /events. If after Phase 1 the bar feels wrong, /inbox can revert to its prior chrome by reverting one PR. Phase 2 only ships after Phase 1 holds.
|
||||
- The new `RenderSpec.list.row_action` field is optional with a `navigate` default; existing rows continue to render correctly.
|
||||
- The URL contract is preserved for /events for one release via a thin redirect middleware that maps old → new params; bookmarks don't 404.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions for m before lock-in
|
||||
|
||||
These are decisions where my recommendation might be challenged:
|
||||
|
||||
**Q1. State model: full URL-canonical, or do we accept localStorage for shape/density preferences?** I recommend hybrid: URL for filter axes, localStorage for shape + density prefs (per-surface). Keeps shareable URLs honest while letting "I always want compact density on /inbox" persist across sessions.
|
||||
|
||||
**Q2. Save-as-view modal vs slide-out vs inline.** I recommend modal — minimal surface, four fields, blocks the page. Alternatives: a slide-out (less interruption, more work) or an inline expansion of the "Speichern" button (cramped on mobile). Modal lines up with existing `<dialog>` usage on /admin.
|
||||
|
||||
**Q3. /events 5-card summary — keep, or fold into the bar?** I recommend keep (above the bar, unchanged). The cards encode urgency at a glance; collapsing them into the bar's `time` chip would lose the "9 / 3 / 2 / 5 / Überfällig 1" density. Clicking a card still updates the bar's time horizon (existing behaviour preserved).
|
||||
|
||||
**Q4. Tabs on /inbox — collapse into the `approval_viewer_role` chip cluster, or keep tabs as visual chrome above the bar?** I recommend collapse — one fewer place for state, the chip cluster is exactly the right control for 3 mutually exclusive options. Counter-argument: tabs are a strong visual hint of "two pages with the same shape". My counter-counter: the bar's chips are the same hint, less mid-air.
|
||||
|
||||
**Q5. URL parameter naming.** I recommend short, namespaced names: `?time=`, `?sources=`, `?project=`, `?personal=`, per-source predicate names (`?d_status=` for deadline.status, `?a_role=` for approval_request.viewer_role, `?pe_kind=` for project_event.event_types). Cargo-friendly to long names like `?deadline_status=` if m prefers — same axis, same wire format.
|
||||
|
||||
**Q6. "Speichern als Sicht" on the dashboard inline bars — show or hide?** I recommend hide. The dashboard composes two tiny bars; saving a sub-bar's spec as a custom view would feel disjoint from the dashboard concept. Power users can craft custom views via /views/new instead.
|
||||
|
||||
**Q7. Migration: do we keep `?type=` redirecting on /events for one release, or hard-cut?** I recommend keep for one release (small middleware in `internal/handlers/events_pages.go`) so existing bookmarks (Sidebar, internal docs, the /events sidebar links at `events.ts:838`) keep working through Phase 3.
|
||||
|
||||
**Q8. /views/{slug} — should the URL overlay tweak persist in localStorage as a "draft" until the user resets or saves?** I recommend no — URL is the only state, and a tweak that disappears on reload matches user expectation. The `?dirty=1` toast is enough. Alternative: a per-view-id `paliad.bar.view-{id}.draft` localStorage key that re-applies on re-visit — more powerful, more surprising.
|
||||
|
||||
**Q9. Sortable column headers — list shape only, or also rule for cards/calendar in a future phase?** I recommend list-shape only for v1. Cards and calendar have their own ordering semantics (group_by + within-group sort); promoting headers would over-complicate.
|
||||
|
||||
**Q10. Bar embedding twice on dashboard — `urlNamespace` worth the complexity, or single namespace and accept that dashboard's two bars share `?time=`?** I recommend `urlNamespace` for dashboard only (e.g. `?agenda_time=` and `?activity_time=`). Costs ~10 LoC, keeps two bars from colliding.
|
||||
|
||||
**Q11. Multi-project select — phase C, or fold into Phase 2?** I recommend phase C. Single-project covers every surface today; multi-project unlocks "all my Düsseldorf cases this week" type queries but no current page asks for it. Save complexity until a user does.
|
||||
|
||||
**Q12. EventTypeMultiSelect today supports `none` ("Ohne Typ") — keep or drop?** I recommend keep. The bar's deadline_event_type axis just wraps `attachEventTypeMultiSelectFilter`, so `none` works as-is. Honestly nothing to design here.
|
||||
|
||||
---
|
||||
|
||||
## 9. Scope boundaries (in + out)
|
||||
|
||||
### In scope
|
||||
- New `<FilterBar>` component + axis registry + URL codec.
|
||||
- One `RenderSpec.list.row_action` field with validator update.
|
||||
- Phase 1: /inbox surface + tests.
|
||||
- Documentation + i18n keys for the bar.
|
||||
- Phase 2..5 named in the migration path with clear gates between them — but each is its own PR and not part of "the inventor design has shipped" definition-of-done.
|
||||
|
||||
### Out of scope (per the brief + my reading)
|
||||
- New entity surfaces. Only the 7 named surfaces.
|
||||
- Backend SQL migrations beyond the one optional `RenderSpec.list.row_action` field. The bar runs through `/api/views/run` which already exists.
|
||||
- /projects redesign — t-paliad-149 stands.
|
||||
- Mobile-list-mode reimagining — separate workstream.
|
||||
- Multi-project selection — phase C, not v1.
|
||||
- Multi-column sort — when a user asks.
|
||||
- Internationalisation beyond DE + EN.
|
||||
|
||||
---
|
||||
|
||||
## 10. Files implementer will touch (Phase 1: /inbox)
|
||||
|
||||
To make the scope concrete:
|
||||
|
||||
**New:**
|
||||
- `frontend/src/components/FilterBar.tsx` — TSX wrapper with the host divs.
|
||||
- `frontend/src/client/filter-bar/index.ts` — `mountFilterBar` entry point.
|
||||
- `frontend/src/client/filter-bar/axes.ts` — per-axis render functions (one per `AxisKey`).
|
||||
- `frontend/src/client/filter-bar/url-codec.ts` — `encode/decode/diffWithBase`.
|
||||
- `frontend/src/client/filter-bar/save-modal.ts` — the "Speichern als Sicht" modal.
|
||||
- `frontend/src/client/filter-bar/types.ts` — `FilterBarOpts`, `AxisKey`.
|
||||
- `frontend/src/client/filter-bar/i18n.ts` — namespace registry helper.
|
||||
|
||||
**Modified (Phase 1):**
|
||||
- `frontend/src/inbox.tsx` — replace tab row with `<div id="filter-bar">` + `<div id="filter-bar-results">`.
|
||||
- `frontend/src/client/inbox.ts` — shrink to `mountFilterBar(host, {baseFilter: inboxSystemView, axes: [...], onResult: renderListShape})`.
|
||||
- `internal/handlers/inbox.go` — add `?approval_role=` redirect from old `?tab=` for one release. (The actual rows continue to come from `/api/views/run` via the bar.)
|
||||
- `internal/services/render_spec.go` — add `RowAction` field + validator + `KnownRowActions = ["navigate", "complete_toggle", "approve", "none"]`.
|
||||
- `frontend/src/client/views/types.ts` — TS mirror of the new `RowAction` field.
|
||||
- `frontend/src/client/views/shape-list.ts` — honour `RowAction` (navigate is the existing default; `approve` mounts approve/reject buttons; `complete_toggle` mounts the checkbox).
|
||||
- `frontend/src/client/i18n.ts` + `i18n-keys.ts` — ~30 new keys under `views.bar.*`.
|
||||
- `frontend/src/styles/global.css` — bar layout + mobile rules. Reuses existing `.agenda-chip`, `.akten-multi-*`, `.frist-summary-card`, `.multi-anchor`/`.multi-panel`, `.events-view-btn` styles.
|
||||
|
||||
**Tests (Phase 1):**
|
||||
- `internal/services/render_spec_test.go` — add cases for `RowAction` validator (8 cases: each enum value + invalid + omitted + …).
|
||||
- `frontend/src/client/filter-bar/url-codec.test.ts` — round-trip encode/decode for every `AxisKey`.
|
||||
- `internal/handlers/inbox_redirect_test.go` — old-tab → new-axis redirect.
|
||||
|
||||
**Phase 2..5 file lists** are not enumerated here — each is a separate PR with its own surface refactor and follows the same shape (replace per-page chrome + URL sync, mount the bar, hand `onResult` to the existing shape components).
|
||||
|
||||
---
|
||||
|
||||
## 11. Recommended implementer
|
||||
|
||||
**Pattern-fluent Sonnet coder** is the right fit. Substrate is well-trodden:
|
||||
- Custom Views client + render shapes already exist (t-paliad-144).
|
||||
- Multi-select listbox-panel already exists (`event-types.ts`).
|
||||
- Chip-row pattern exists on `/agenda`, `/projects`, `/events`.
|
||||
- Save modal pattern exists on `/views/new` (`views-editor.ts`).
|
||||
- URL-sync pattern exists on every system page.
|
||||
|
||||
The first PR (Phase 1: /inbox + bar scaffolding + `RowAction` schema bump) is contained and reviewable in one window. Subsequent phases are smaller — they're "swap in the bar and delete page-local code".
|
||||
|
||||
I am happy to be the coder if m wants minimum context-switch — riemann has the live model of every piece of this design. Equally happy to hand off to a fresh Sonnet coder with this doc as the brief; the doc is intended to be self-contained for that path.
|
||||
|
||||
The head decides.
|
||||
|
||||
---
|
||||
|
||||
## 12. Phasing summary (no estimates, just order)
|
||||
|
||||
1. /inbox migration + `<FilterBar>` scaffolding + `RowAction` schema bump.
|
||||
2. /agenda migration.
|
||||
3. /events migration (proof point — most complex filter today, biggest LoC delta).
|
||||
4. Dashboard inline bars (Agenda + Letzte Aktivität).
|
||||
5. /views/{slug} bar overlay + "Aktualisieren" affordance.
|
||||
|
||||
Each phase is its own PR. Phases must merge in order; m's merge gate at every step.
|
||||
|
||||
---
|
||||
|
||||
## 13. Why this is worth an inventor
|
||||
|
||||
m's last line in the brainstorm: *"worth an inventor?"*. Yes — and the reason is exactly what the design doc surfaces: the substrate already exists, the schema's right, the run endpoints are shipped, and 5 SystemViews are already declared. A coder coming in cold would either (a) not realise the substrate is there and reinvent it, or (b) realise and underestimate how much per-surface chrome can collapse into one bar. The inventor's job here was to read what's there, name the bar primitive, identify /events as the proof point, propose the one schema bump (`RowAction`) that makes /inbox shippable in Phase 1, and resist designing a layout-spec system that's already covered by `RenderSpec`.
|
||||
|
||||
Stop. DESIGN READY FOR REVIEW.
|
||||
577
docs/proposals/orphan-concepts-2026-05-15.md
Normal file
577
docs/proposals/orphan-concepts-2026-05-15.md
Normal file
@@ -0,0 +1,577 @@
|
||||
# Orphan Concept Seed Proposals — Fristen Phase 3 Slice 12 (t-paliad-196)
|
||||
|
||||
**Date:** 2026-05-15
|
||||
**Author:** curie (researcher)
|
||||
**Status:** DRAFT — for m's review, not yet ingested via `/admin/rules`
|
||||
**Branch:** `mai/curie/fristen-phase-3-slice-12`
|
||||
**Source audit:** `docs/audit-fristen-logic-2026-05-13.md` § 3.4 + § 7.9 (pauli)
|
||||
|
||||
---
|
||||
|
||||
## 0. Read-this-first — orphan count discrepancy
|
||||
|
||||
m's task description (and pauli's audit dated 2026-05-13) cited **nine** orphan concepts with `rule_count=0`. Today's live `paliad` DB shows **five**:
|
||||
|
||||
| # | Slug | Party | Category |
|
||||
|---|------|-------|----------|
|
||||
| 1 | `wiedereinsetzung` | both | submission |
|
||||
| 2 | `schriftsatznachreichung` | both | submission |
|
||||
| 3 | `versaeumnisurteil-einspruch` | defendant | submission |
|
||||
| 4 | `weiterbehandlung` | claimant | submission |
|
||||
| 5 | `counterclaim-for-revocation` | defendant | submission |
|
||||
|
||||
Four of the audit's nine were almost certainly seeded between 2026-05-13 and 2026-05-15 by Slice 10 (migration 090, fuzzy backfill) and the Slice-11 admin rule-editor work. `notice-of-defence-intention` is one of them: today's `DE_INF` corpus contains `de_inf.anzeige` (Anzeige der Verteidigungsbereitschaft, ZPO §276.1) linked to its own concept, which removes it from the orphan list.
|
||||
|
||||
**FLAG (count discrepancy):** I drafted proposals for the **5** remaining orphans, not 9. m should confirm whether the other 4 audit-named concepts were intentionally seeded or whether something else is going on before treating this as "done".
|
||||
|
||||
### 0.1 A second, more important framing problem
|
||||
|
||||
The orphan query `deadline_concepts.id NOT IN (SELECT concept_id FROM deadline_rules)` counts only **direct** `concept_id` linkages on `paliad.deadline_rules`. But the schema has two alternate rooting columns: `proceeding_type_id` (Pipeline A) and `trigger_event_id` (Pipeline C). The Pipeline-C migration (Slice 4, m/paliad#…) imported 77 event-rooted rules from `paliad.event_deadlines` but left their `concept_id` **NULL** on the unified `deadline_rules` table — even when the source trigger event had a matching `concept_id` slug already set on `paliad.trigger_events`.
|
||||
|
||||
Concretely, the following rules **already exist** in `paliad.deadline_rules` but lack `concept_id`:
|
||||
|
||||
| Rule name | `trigger_event_id` | Trigger event code | Owning concept (via `trigger_events.concept_id` slug) |
|
||||
|---|---|---|---|
|
||||
| Wiedereinsetzungsantrag (§ 123 PatG) | 200 | `wegfall_hindernisses_de_patg` | `wiedereinsetzung` |
|
||||
| Wiedereinsetzungsantrag (§ 233 ZPO) | 201 | `wegfall_hindernisses_de_zpo` | `wiedereinsetzung` |
|
||||
| Wiedereinsetzungsantrag (Art. 122 EPÜ) | 202 | `wegfall_hindernisses_eu_epc` | `wiedereinsetzung` |
|
||||
| Wiedereinsetzungsantrag (DPMA) | 203 | `wegfall_hindernisses_dpma` | `wiedereinsetzung` |
|
||||
| Einspruch gegen Versäumnisurteil (§ 339 ZPO) | 204 | `zustellung_versaeumnisurteil` | `versaeumnisurteil-einspruch` |
|
||||
| Schriftsatznachreichung (§ 296a ZPO) | 205 | `ende_muendl_verhandlung` | `schriftsatznachreichung` |
|
||||
| Weiterbehandlungsantrag (Art. 121 EPÜ) | 206 | `mitteilung_rechtsverlust_eu` | `weiterbehandlung` |
|
||||
| *(none yet)* | 207 | `wegfall_hindernisses_upc` | `wiedereinsetzung` |
|
||||
|
||||
**Net effect:** four of the five "orphan" concepts already have at least one workable rule — it is just disconnected from the concept by a NULL `concept_id`. The genuine coverage gap is much smaller than "5 concepts × ~5 rules each = 25 rules to draft". Practical Phase-3-Slice-12 work splits into:
|
||||
|
||||
- **Track A (linkage, no legal review needed):** `UPDATE paliad.deadline_rules SET concept_id = … WHERE trigger_event_id IN (200,201,202,203,204,205,206)`. 7 rows, zero new legal substance. See § 6 of this doc.
|
||||
- **Track B (new rule drafts, this doc's main body):** UPC R.320 Wiedereinsetzung (`trigger_event_id=207` truly has no rule yet), proceeding-rooted variants for the four jurisdictions where having a rule under the UPC_INF / DE_INF / EPA_OPP / DPMA_OPP umbrella makes the cascade complete, plus the schema-correct way to resolve `counterclaim-for-revocation` (which is intentionally encoded as flag-gated UPC_INF rules and probably should not get fresh rules at all).
|
||||
|
||||
**FLAG (audit framing):** I recommend the orphan KPI be redefined as "concepts where NO rule references the concept, **directly via `deadline_rules.concept_id` OR transitively via `deadline_rules.trigger_event_id → trigger_events.concept_id`**". Until that happens, the orphan list will keep over-reporting work that has already been done in another column. The Phase 2 design (`docs/design-fristen-phase2-2026-05-15.md` § 3 Step C) anticipates dropping the `paliad.trigger_events` table entirely in Slice 9 and copying `concept_id` onto `deadline_rules` at that point — once that migration runs, the discrepancy resolves itself.
|
||||
|
||||
### 0.2 Convention notes
|
||||
|
||||
- Rule **code** column (`paliad.deadline_rules.code`) uses `<proceeding_short>.<action>` for proceeding-rooted rules (e.g. `inf.sod`, `de_inf.berufung`). For event-rooted rules `code` is NULL today; I follow that pattern.
|
||||
- **Anchor semantics** (audit § 4): `parent_id NULL + duration_value=0` = root anchor / court-set absolute. `parent_id NULL + duration_value>0 + trigger_event_id` = event-rooted, anchored to the trigger event's date. `parent_id NOT NULL` = chained off another rule.
|
||||
- **Priority values** (post-Slice-3): `mandatory` | `recommended` | `optional` | `informational`. Wiedereinsetzung-class rules are conceptually `optional` for the user (they may decide not to file), but the legal-source side is mandatory once invoked. I tag them `optional` with the legal source making the obligation conditional — m to confirm.
|
||||
- **`is_court_set`** is true when the deadline date is set by court order rather than computed from a statutory period. For Schriftsatznachreichung this is the relevant case; for Wiedereinsetzung/Weiterbehandlung it's false (statutory period).
|
||||
- **`legal_source`** uses the existing convention seen on live rules (`UPC.RoP.29.a`, `DE.ZPO.234.1`, `EU.EPC-R.135.1`, `EU.EPÜ.99.1`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Concept: `wiedereinsetzung` (Wiedereinsetzung in den vorigen Stand)
|
||||
|
||||
**Concept ID:** `00b737bf-58a6-4f41-9650-ac3f2e7079e8`
|
||||
**Party:** both · **Category:** submission
|
||||
**Linked event_categories (cascade leaves):**
|
||||
- `cms-eingang.gericht.rechtsverlust-epa` (Mitteilung über Rechtsverlust, EPA)
|
||||
- `frist-verpasst.de-patg` (DE Patentverfahren, PatG §123)
|
||||
- `frist-verpasst.de-zpo` (DE Zivilverfahren, ZPO §233)
|
||||
- `frist-verpasst.dpma` (DPMA, PatG §123)
|
||||
- `frist-verpasst.epa` (EPA, Art. 122 EPÜ)
|
||||
- `frist-verpasst.upc` (UPC, R.320 RoP)
|
||||
|
||||
**Existing trigger-event-rooted rules:** trigger events 200/201/202/203 already have rules in `paliad.deadline_rules` (DE PatG, DE ZPO, EPC, DPMA respectively). Only te 207 (UPC R.320) has no rule yet. See § 6 for the linkage UPDATE that brings the existing four into the concept's rule list.
|
||||
|
||||
**Drafts below:**
|
||||
|
||||
### Rule 1.1 — UPC R.320 Wiedereinsetzungsantrag
|
||||
|
||||
- **Rule code:** `upc.wiedereinsetzung` *(proceeding-rooted) ORalt. NULL code + `trigger_event_id=207` (event-rooted, matches pattern of te 200-206 rules)*
|
||||
- **Proceeding type:** UPC_INF (id=8) — primary. Also relevant for UPC_REV (9), UPC_PI (10), UPC_APP (11), UPC_DAMAGES (17), UPC_DISCOVERY (18), UPC_COST_APPEAL (19), UPC_APP_ORDERS (20). **FLAG:** Wiedereinsetzung applies across the full UPC corpus; m to decide whether to (a) seed one event-rooted rule referencing te 207 — pattern matches the existing four jurisdictions — or (b) seed seven proceeding-rooted clones. Recommend (a): cleaner, mirrors the pattern already set for DE/EPC/DPMA, and Slice 9's table-drop migration in Phase 2 will canonicalise it.
|
||||
- **Name (DE):** Wiedereinsetzungsantrag (R. 320 RoP UPC)
|
||||
- **Name (EN):** Application for re-establishment of rights (UPC R.320 RoP)
|
||||
- **Party:** both (claimant or defendant, whoever missed)
|
||||
- **Anchor:** `trigger_event_id = 207` (`wegfall_hindernisses_upc`)
|
||||
- **Duration:** 2, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional *(filing is at the party's discretion — see § 0.2)*
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `UPC.RoP.320.1`
|
||||
- **Notes:** UPC R.320.1 sets a 2-month window from removal of the cause of non-compliance, capped by an absolute 1-year limit from expiry of the missed period (see Rule 1.2 below). The omitted act must be completed within the same 2-month window (R.320.2). Court fee per R.150(1)(p). UI may want to show the 1-year backstop as a sibling "Achtung" line; that is a renderer decision, not a separate rule.
|
||||
|
||||
### Rule 1.2 — UPC R.320 — 1-Jahres-Ausschlussfrist (informational)
|
||||
|
||||
- **Rule code:** `upc.wiedereinsetzung.cutoff` (or trigger-rooted with a sibling `sequence_order` after Rule 1.1)
|
||||
- **Proceeding type:** same as Rule 1.1
|
||||
- **Name (DE):** Absolute Ausschlussfrist Wiedereinsetzung (1 Jahr)
|
||||
- **Name (EN):** Absolute cut-off for re-establishment (1 year)
|
||||
- **Party:** both
|
||||
- **Anchor:** the **missed** deadline's date — not `wegfall_hindernisses_upc`. **FLAG:** Today's `trigger_events` model can't express "anchor = the missed deadline" because the trigger fires on removal of cause, not on the missed deadline. Either (a) add a new trigger event `frist_versaeumt_upc` and root this rule there, or (b) make this an `informational` UI-only rule rendered by the renderer next to Rule 1.1 with no real anchor. Recommend (b) for now; (a) is a Phase-3 schema follow-up.
|
||||
- **Duration:** 12, months
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `UPC.RoP.320.1` (second half: "but at the latest within one year of the expiry of the unobserved time limit")
|
||||
- **Notes:** Cosmetically important — practitioners forget the cut-off. Keep as informational rendering until the schema supports two-anchor rules.
|
||||
|
||||
### Rule 1.3 — EPC Art. 122 / R.136 Wiedereinsetzungsantrag (EPA)
|
||||
|
||||
- **Rule code:** *(event-rooted; NULL `code`, matches existing pattern for te 200-203)*
|
||||
- **Proceeding type:** NULL (or EPA_OPP=14 / EPA_APP=15 / EP_GRANT=16 if proceeding-rooted)
|
||||
- **Name (DE):** Wiedereinsetzungsantrag (Art. 122 EPÜ)
|
||||
- **Name (EN):** Petition for re-establishment of rights (EPC Art.122)
|
||||
- **Party:** both
|
||||
- **Anchor:** `trigger_event_id = 202` (`wegfall_hindernisses_eu_epc`)
|
||||
- **Duration:** 2, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `EU.EPC-R.136.1`
|
||||
- **Notes:** **DUPLICATE of existing rule** `23c6f445-4ed2-4ade-8ea0-c4ab6b364bb6` — already in `deadline_rules`, just missing `concept_id`. See § 6 linkage UPDATE; do not double-seed.
|
||||
|
||||
### Rule 1.4 — EPC R.136 — 1-Jahres-Ausschlussfrist
|
||||
|
||||
- **Rule code:** as Rule 1.2 pattern
|
||||
- **Name (DE):** Absolute Ausschlussfrist Wiedereinsetzung EPA (1 Jahr)
|
||||
- **Name (EN):** Absolute cut-off for re-establishment, EPC (1 year)
|
||||
- **Party:** both
|
||||
- **Anchor:** missed-deadline date (same FLAG as Rule 1.2 — schema follow-up)
|
||||
- **Duration:** 12, months
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `EU.EPC-R.136.1` (second sentence)
|
||||
- **Notes:** R.136(1) third sentence carves out a special **2-month** cut-off for restoration of priority (Art. 87(1) in conjunction with R.136(1)). m may want a separate rule 1.4b for that priority variant; flagging rather than auto-resolving.
|
||||
|
||||
### Rule 1.5 — DE PatG §123 Wiedereinsetzungsantrag (DPMA + national)
|
||||
|
||||
- **Rule code:** event-rooted, te=200 (PatG) and te=203 (DPMA)
|
||||
- **Name (DE):** Wiedereinsetzungsantrag (§ 123 PatG)
|
||||
- **Name (EN):** Petition for re-establishment of rights (PatG §123)
|
||||
- **Party:** both
|
||||
- **Anchor:** `trigger_event_id = 200` (`wegfall_hindernisses_de_patg`) — for general DE PatG context — AND `trigger_event_id = 203` (`wegfall_hindernisses_dpma`) — for DPMA-specific context.
|
||||
- **Duration:** 2, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.PatG.123.2`
|
||||
- **Notes:** **DUPLICATE of existing rules** `c24d494c-…` (te 200) and `b588fa64-…` (te 203). Linkage only — see § 6.
|
||||
|
||||
### Rule 1.6 — DE PatG §123 — 1-Jahres-Ausschlussfrist
|
||||
|
||||
- **Rule code:** as 1.2/1.4 pattern (informational)
|
||||
- **Name (DE):** Absolute Ausschlussfrist Wiedereinsetzung PatG (1 Jahr)
|
||||
- **Name (EN):** Absolute cut-off for re-establishment, PatG (1 year)
|
||||
- **Party:** both
|
||||
- **Anchor:** missed-deadline date (schema FLAG as 1.2)
|
||||
- **Duration:** 12, months
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.PatG.123.2` (Satz 4)
|
||||
- **Notes:** PatG §123(2) Satz 4: "Innerhalb eines Jahres nach Ablauf der versäumten Frist ist keine Wiedereinsetzung mehr möglich." Same as PatG also for DPMA proceedings.
|
||||
|
||||
### Rule 1.7 — DE ZPO §233 Wiedereinsetzungsantrag (Notfrist, 2 Wochen)
|
||||
|
||||
- **Rule code:** event-rooted, te=201
|
||||
- **Name (DE):** Wiedereinsetzungsantrag — Notfrist (§ 234 Abs. 1 S. 1 ZPO)
|
||||
- **Name (EN):** Petition for re-establishment of rights — Notfrist (ZPO §234(1) sentence 1)
|
||||
- **Party:** both
|
||||
- **Anchor:** `trigger_event_id = 201` (`wegfall_hindernisses_de_zpo`)
|
||||
- **Duration:** 2, weeks
|
||||
- **Timing:** after
|
||||
- **Priority:** optional
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL — but see Rule 1.8 for the 1-month variant.
|
||||
- **Legal source:** `DE.ZPO.234.1`
|
||||
- **Notes:** **DUPLICATE of existing rule** `d40d9be7-…` — linkage only. ZPO §234(1) sentence 1: 2 weeks for Notfristen (Berufungsfrist, Revisionsfrist, Beschwerdefrist, etc.).
|
||||
|
||||
### Rule 1.8 — DE ZPO §234(1)2 Wiedereinsetzungsantrag (Begründungsfrist, 1 Monat)
|
||||
|
||||
- **Rule code:** event-rooted, te=201, sibling to 1.7
|
||||
- **Name (DE):** Wiedereinsetzungsantrag — Begründungsfrist (§ 234 Abs. 1 S. 2 ZPO)
|
||||
- **Name (EN):** Petition for re-establishment — appeal/revision grounds period (ZPO §234(1) sentence 2)
|
||||
- **Party:** both
|
||||
- **Anchor:** `trigger_event_id = 201` (`wegfall_hindernisses_de_zpo`)
|
||||
- **Duration:** 1, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** **FLAG** — needs a flag like `{"flag":"begruendungsfrist"}` or similar to distinguish from Rule 1.7 because today's data model can't differentiate "the missed deadline was a Berufungsbegründungsfrist" without an explicit flag from the caller. m to decide whether to add a flag or leave the rule as "informational alternative" rendered alongside 1.7.
|
||||
- **Legal source:** `DE.ZPO.234.1`
|
||||
- **Notes:** ZPO §234(1) Satz 2: "Die Frist beträgt einen Monat, wenn die Partei verhindert war, die Frist zur Begründung der Berufung, der Revision, der Nichtzulassungsbeschwerde oder der Rechtsbeschwerde oder die Frist des § 234 Abs. 3 einzuhalten."
|
||||
|
||||
### Rule 1.9 — DE ZPO §234(3) — 1-Jahres-Ausschlussfrist
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Absolute Ausschlussfrist Wiedereinsetzung ZPO (1 Jahr)
|
||||
- **Name (EN):** Absolute cut-off for re-establishment, ZPO (1 year)
|
||||
- **Party:** both
|
||||
- **Anchor:** missed-deadline date (schema FLAG as 1.2)
|
||||
- **Duration:** 12, months
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.ZPO.234.3`
|
||||
- **Notes:** "Nach Ablauf eines Jahres, von dem Ende der versäumten Frist an gerechnet, kann die Wiedereinsetzung nicht mehr beantragt … werden."
|
||||
|
||||
**Summary for `wiedereinsetzung`:** four of the five linked event categories (DE PatG, DE ZPO, EPC, DPMA) already have **existing rules** that just need `concept_id` set — see § 6. The genuinely new substance is **Rule 1.1** (UPC R.320, te 207), plus a set of informational 1-year cut-off rules (1.2/1.4/1.6/1.9), plus the optional ZPO §234(1) sentence-2 variant (1.8). Six new rules in total, one duplicate-flagged, four pure linkages. **FLAG:** UPC fee for Wiedereinsetzung (R.150(1)(p)) is not modelled as a rule — should it appear as a sibling informational rule with the fee amount? Today's model doesn't carry money, so probably no, but worth m's call.
|
||||
|
||||
---
|
||||
|
||||
## 2. Concept: `schriftsatznachreichung` (Schriftsatznachreichung, § 296a ZPO)
|
||||
|
||||
**Concept ID:** `b7a3cb3e-ef7e-47a1-8067-be0fe35a4235`
|
||||
**Party:** both · **Category:** submission
|
||||
**Linked event_categories:**
|
||||
- `cms-eingang.gericht.ladung` (Ladung zur mündlichen Verhandlung)
|
||||
- `muendl-verhandlung.gehalten` (Soeben gehalten / heute)
|
||||
- `muendl-verhandlung.geladen` (Geladen — wann findet sie statt?)
|
||||
|
||||
**Existing rules:** te 205 (`ende_muendl_verhandlung`) already has rule `3c36f149-…` (3 weeks). Linkage only — see § 6.
|
||||
|
||||
### Rule 2.1 — DE ZPO §296a Schriftsatznachreichungsfrist
|
||||
|
||||
- **Rule code:** event-rooted, te=205
|
||||
- **Proceeding type:** NULL (event-rooted) — primarily DE_INF/DE_NULL/OLG/BGH context but cross-cutting via the trigger event.
|
||||
- **Name (DE):** Schriftsatznachreichung (§ 296a ZPO)
|
||||
- **Name (EN):** Subsequent written submission (ZPO §296a)
|
||||
- **Party:** both
|
||||
- **Anchor:** `trigger_event_id = 205` (`ende_muendl_verhandlung`)
|
||||
- **Duration:** 3, weeks
|
||||
- **Timing:** after
|
||||
- **Priority:** optional *(only available if court grants Schriftsatznachreichungsfrist; otherwise §296a bars new attack/defence means)*
|
||||
- **is_court_set:** **true** — the deadline date is set by the court order granting the Schriftsatznachreichungsfrist, not by the statute itself. ZPO §296a permits the court to set it; typical practice is 2-3 weeks but the court fixes the exact date.
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.ZPO.296a`
|
||||
- **Notes:** **DUPLICATE of existing rule** — linkage only. **FLAG:** the existing rule sets `is_court_set=false` and a fixed 3-week duration. Strictly, the court sets the date, so `is_court_set=true` is more accurate; the 3-week duration is a typical-case estimate. m to decide whether to update the existing rule or leave the heuristic as-is and document the deviation.
|
||||
|
||||
### Rule 2.2 — Schriftsatznachreichung — Beschränkung auf in der Verhandlung erörterte Punkte (informational)
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Beschränkung der Schriftsatznachreichung (nur Bezug auf Verhandlungspunkte)
|
||||
- **Name (EN):** Schriftsatznachreichung scope limit (only matters raised at the hearing)
|
||||
- **Party:** both
|
||||
- **Anchor:** same as 2.1
|
||||
- **Duration:** 0
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.ZPO.296a`
|
||||
- **Notes:** Reminds the user that a Schriftsatznachreichung is limited to matters raised at the oral hearing — new attack/defence means are barred under §296a. Useful for the cascade card; not a calendar deadline.
|
||||
|
||||
### Rule 2.3 — Schriftsatznachreichung — UPC equivalent? (open question)
|
||||
|
||||
**FLAG:** UPC RoP has no direct §296a analogue. Post-hearing submissions in UPC proceedings are limited and require court leave (general practice; see R.117). I am intentionally **not** drafting a UPC rule under this concept and recommend m confirm the concept stays DE-only. If the cascade exposes the concept under a UPC entry, that is a cascade taxonomy bug, not a rule gap.
|
||||
|
||||
**Summary:** 2 substantive rules (1 duplicate-flagged, 1 informational). Concept is essentially solved by linkage + 1 informational sibling.
|
||||
|
||||
---
|
||||
|
||||
## 3. Concept: `versaeumnisurteil-einspruch` (Einspruch gegen Versäumnisurteil, § 339 ZPO)
|
||||
|
||||
**Concept ID:** `9f809d1d-ea06-4aa5-80d0-6feaa33b464e`
|
||||
**Party:** defendant · **Category:** submission
|
||||
**Linked event_categories:**
|
||||
- `beschluss-entscheidung.versaeumnisurteil` (Versäumnisurteil DE)
|
||||
- `cms-eingang.gericht.endentscheidung.versaeumnisurteil` (Versäumnisurteil DE)
|
||||
|
||||
**Existing rules:** te 204 (`zustellung_versaeumnisurteil`) already has rule `20254f4e-…` (2 weeks). Linkage only — see § 6.
|
||||
|
||||
### Rule 3.1 — DE ZPO §339(1) Einspruchsfrist (Inland-Zustellung, 2 Wochen)
|
||||
|
||||
- **Rule code:** event-rooted, te=204
|
||||
- **Name (DE):** Einspruch gegen Versäumnisurteil (§ 339 Abs. 1 ZPO)
|
||||
- **Name (EN):** Objection to default judgment, domestic service (ZPO §339(1))
|
||||
- **Party:** defendant
|
||||
- **Anchor:** `trigger_event_id = 204` (`zustellung_versaeumnisurteil`)
|
||||
- **Duration:** 2, weeks
|
||||
- **Timing:** after
|
||||
- **Priority:** mandatory *(if defence wants to undo default; otherwise judgment becomes final)*
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL — but see Rule 3.2 for the international-service variant.
|
||||
- **Legal source:** `DE.ZPO.339.1`
|
||||
- **Notes:** **DUPLICATE of existing rule** — linkage only. ZPO §339(1) sentence 1: 2-week Notfrist from Zustellung. §339(1) sentence 2 reserves longer periods for cases under §339(2) and §234(2).
|
||||
|
||||
### Rule 3.2 — DE ZPO §339(2) Einspruchsfrist (Auslands-Zustellung, ≥ 1 Monat)
|
||||
|
||||
- **Rule code:** event-rooted, te=204, sibling
|
||||
- **Name (DE):** Einspruch gegen Versäumnisurteil — Auslandszustellung (§ 339 Abs. 2 ZPO)
|
||||
- **Name (EN):** Objection to default judgment — service abroad (ZPO §339(2))
|
||||
- **Party:** defendant
|
||||
- **Anchor:** `trigger_event_id = 204`
|
||||
- **Duration:** 1, months
|
||||
- **Timing:** after
|
||||
- **Priority:** mandatory
|
||||
- **is_court_set:** **true** — §339(2) sentence 2 says the court sets the period in the order; "at least one month" is the statutory floor.
|
||||
- **condition_expr:** **FLAG** — needs a flag like `{"flag":"auslandszustellung"}` to distinguish from Rule 3.1. m to decide flag naming.
|
||||
- **Legal source:** `DE.ZPO.339.2`
|
||||
- **Notes:** ZPO §339(2): "Bei einer Zustellung im Ausland nach § 183 Abs. 1 Nr. 1 wird die Einspruchsfrist auf mindestens einen Monat festgesetzt."
|
||||
|
||||
### Rule 3.3 — DE ZPO §340 Inhalt der Einspruchsschrift (informational)
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Inhalt der Einspruchsschrift (§ 340 ZPO)
|
||||
- **Name (EN):** Required contents of the objection (ZPO §340)
|
||||
- **Party:** defendant
|
||||
- **Anchor:** same as Rule 3.1
|
||||
- **Duration:** 0
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.ZPO.340`
|
||||
- **Notes:** Reminds the user that the Einspruchsschrift must contain the designation of the judgment, the declaration of objection, and the parties' applications. Not a calendar deadline.
|
||||
|
||||
### Rule 3.4 — Rechtsfolge Einspruch (informational)
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Rechtsfolge des zulässigen Einspruchs (§ 342 ZPO)
|
||||
- **Name (EN):** Effect of admissible objection (ZPO §342)
|
||||
- **Party:** defendant
|
||||
- **Anchor:** same as Rule 3.1
|
||||
- **Duration:** 0
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.ZPO.342`
|
||||
- **Notes:** Tells the user that an admissible Einspruch puts the case back in the state pre-default. Useful as a cascade-card pill; not a deadline.
|
||||
|
||||
**Summary:** 4 rules, 1 duplicate-flagged, 1 needing a condition flag, 2 informational.
|
||||
|
||||
---
|
||||
|
||||
## 4. Concept: `weiterbehandlung` (Weiterbehandlung, Art. 121 EPÜ)
|
||||
|
||||
**Concept ID:** `5a58f14c-3042-48e9-87fd-c94b62d13662`
|
||||
**Party:** claimant · **Category:** submission
|
||||
**Linked event_categories:**
|
||||
- `cms-eingang.gericht.rechtsverlust-epa` (Mitteilung über Rechtsverlust, EPA)
|
||||
- `frist-verpasst.epa` (EPA, Art. 122 EPÜ)
|
||||
|
||||
**Existing rules:** te 206 (`mitteilung_rechtsverlust_eu`) already has rule `f1099cf6-…` (2 months). Linkage only — see § 6.
|
||||
|
||||
### Rule 4.1 — EPC Art. 121 / R.135 Weiterbehandlungsantrag
|
||||
|
||||
- **Rule code:** event-rooted, te=206
|
||||
- **Name (DE):** Weiterbehandlungsantrag (Art. 121 EPÜ)
|
||||
- **Name (EN):** Request for further processing (Art.121 EPC)
|
||||
- **Party:** claimant *(applicant during prosecution)*
|
||||
- **Anchor:** `trigger_event_id = 206` (`mitteilung_rechtsverlust_eu`)
|
||||
- **Duration:** 2, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional *(applicant's choice; preferred over Wiedereinsetzung when available because cheaper and no fault analysis)*
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `EU.EPC-R.135.1`
|
||||
- **Notes:** **DUPLICATE of existing rule** — linkage only. R.135(1): 2 months from notification of loss of rights. Missed act must be completed; Weiterbehandlungsgebühr payable per R.135(1) third sentence.
|
||||
|
||||
### Rule 4.2 — Weiterbehandlung Ausschlüsse (informational)
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Ausschlüsse Weiterbehandlung (R.135(2) EPÜ)
|
||||
- **Name (EN):** Further-processing exclusions (EPC R.135(2))
|
||||
- **Party:** claimant
|
||||
- **Anchor:** same as Rule 4.1
|
||||
- **Duration:** 0
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `EU.EPC-R.135.2`
|
||||
- **Notes:** R.135(2): Weiterbehandlung not available for the priority period (Art. 87(1)), the period under Art. 112a(4), the periods for filing of opposition and appeal (Art. 99(1), 108), and various R.6/R.36(1)(a)/R.51(2)/R.158/R.27(3) periods. Cascade-card pill so the user knows when to fall back to Wiedereinsetzung instead. **FLAG:** could be modeled per excluded period as a fine-grained `condition_expr`-gated set; that is overkill for now — informational siblings are enough.
|
||||
|
||||
### Rule 4.3 — Weiterbehandlungsgebühr (informational)
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Weiterbehandlungsgebühr fällig
|
||||
- **Name (EN):** Further-processing fee due
|
||||
- **Party:** claimant
|
||||
- **Anchor:** same as Rule 4.1
|
||||
- **Duration:** 2, months
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `EU.EPC-R.135.1` (third sentence)
|
||||
- **Notes:** Fee per Art. 2(1) item 12 of the EPA fee schedule. Mirrors the missed-act window — both must be completed in the same 2-month window for the request to be effective.
|
||||
|
||||
**Summary:** 3 rules, 1 duplicate-flagged, 2 informational.
|
||||
|
||||
---
|
||||
|
||||
## 5. Concept: `counterclaim-for-revocation` (Nichtigkeitswiderklage, UPC R.25)
|
||||
|
||||
**Concept ID:** `52134900-2bcf-4810-9de3-0b0681c79dd7`
|
||||
**Party:** defendant · **Category:** submission
|
||||
**Linked event_category:**
|
||||
- `ich-moechte-einreichen.widerklage.nichtigkeit-upc` (Nichtigkeitswiderklage UPC R.25)
|
||||
|
||||
**Existing rules:** UPC R.25 / RoP 25-32 are **already encoded** in `UPC_INF` (proceeding_type_id=8) as flag-gated rules using `condition_expr.flag = "with_ccr"`:
|
||||
|
||||
| Rule code | Name | Duration | condition_expr | concept_slug today |
|
||||
|---|---|---|---|---|
|
||||
| `inf.def_to_ccr` | Erwiderung auf Nichtigkeitswiderklage | 2 months | `{"flag":"with_ccr"}` | `defence-to-counterclaim-for-revocation` |
|
||||
| `inf.reply` (with_ccr variant) | Replik | 2 months | `{"flag":"with_ccr"}` | `reply-to-defence` |
|
||||
| `inf.reply_def_ccr` | Replik auf Erwiderung zur Nichtigkeitswiderklage | 2 months | `{"flag":"with_ccr"}` | (not yet checked) |
|
||||
| `inf.rejoin` (with_ccr) | Duplik | 1 month | `{"flag":"with_ccr"}` | `rejoinder` |
|
||||
| `inf.rejoin_reply_ccr` | Duplik auf Replik | 1 month | `{"flag":"with_ccr"}` | (not yet checked) |
|
||||
| `inf.def_to_amend` | Erwiderung auf Patentänderungsantrag | 2 months | `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` | `defence-to-application-to-amend` |
|
||||
| `inf.app_to_amend` | Antrag auf Patentänderung | 2 months | `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` | **NULL** (orphan column) |
|
||||
| `inf.reply_def_amd` | Replik auf Erwiderung zum Patentänderungsantrag | 1 month | same | `reply-to-defence-to-application-to-amend` (or similar) |
|
||||
| `inf.rejoin_amd` | Duplik auf Replik zum Patentänderungsantrag | 1 month | same | `rejoinder-on-amend` (or similar) |
|
||||
|
||||
**The CCR itself** — the act of filing the Nichtigkeitswiderklage — is part of `inf.sod` (Statement of Defence) when `with_ccr=true`. The 3-month SoD period from R.23 doubles as the CCR-filing period from R.25.
|
||||
|
||||
### Proposal 5.1 — Do **not** seed new rules under this concept.
|
||||
|
||||
The concept models a logical artifact ("Nichtigkeitswiderklage") that is, in the data model, an attribute of the SoD rather than a separate timed event. Seeding new rules under `counterclaim-for-revocation.concept_id` would either:
|
||||
|
||||
- (a) Duplicate the existing `inf.sod` / `inf.def_to_ccr` / etc. rules — wasteful, fragile (two sources of truth for the same legal period).
|
||||
- (b) Add a synthetic "filing CCR" rule with the same 3-month period as `inf.sod` — redundant once `inf.sod`'s `concept_id` is set correctly.
|
||||
|
||||
### Proposal 5.2 — Link existing UPC_INF rules to this concept (linkage only).
|
||||
|
||||
Specifically:
|
||||
|
||||
| Rule | Current `concept_id` link | Proposed action |
|
||||
|---|---|---|
|
||||
| `inf.sod` (UPC_INF) | `statement-of-defence` (presumably) | Leave as-is — SoD's primary concept is "Statement of Defence". |
|
||||
| `inf.app_to_amend` (UPC_INF, with_ccr+with_amend) | NULL | **Link to `counterclaim-for-revocation`** — this is the genuine "CCR-derived deadline" that has no concept today. |
|
||||
|
||||
**FLAG:** Whether the cascade entry `ich-moechte-einreichen.widerklage.nichtigkeit-upc` should resolve to the SoD itself or to a CCR-card-with-derivative-deadlines is a UX question m needs to decide. My read: when a user clicks "I want to file Nichtigkeitswiderklage", they want to see the SoD deadline (because that's when the CCR is due — same period as SoD) plus the consequential deadlines (Defence to CCR, Replik, Duplik, Patent amendment etc.). A cleaner data-model fix is to add a junction `paliad.concept_rules` (many-to-many) so a rule can belong to multiple concepts (e.g. `inf.sod` ∈ {`statement-of-defence`, `counterclaim-for-revocation`}). That's a Phase 3+ schema add and outside Slice 12's scope.
|
||||
|
||||
### Proposal 5.3 — Alternative: event-rooted CCR rule.
|
||||
|
||||
Trigger event 1 (`statement_of_defence_which_includes_a_counterclaim_for_revocation`) exists but lacks `concept_id` text. Setting `paliad.trigger_events.concept_id = 'counterclaim-for-revocation'` on te 1 and seeding 1-3 event-rooted rules that fire from te 1 (Defence to CCR within 2 months, Reply within 2 months, etc.) would give the cascade card concrete deadlines without duplicating the SoD-tree rules. This is the pattern the audit § 3.4 description hints at.
|
||||
|
||||
**Recommendation:** Proposal 5.2 + 5.3 combined. m to confirm. Until decided, I'm **not** drafting fresh rules for this concept — it's a data-model question disguised as a coverage gap.
|
||||
|
||||
---
|
||||
|
||||
## 6. Track A — Linkage-only UPDATEs (no legal review needed)
|
||||
|
||||
The following `paliad.deadline_rules` rows already exist; they only need `concept_id` pointed at the right concept. These are the lowest-risk part of Slice 12 and can be applied via the admin UI as no-op edits (or as a one-off migration if m prefers).
|
||||
|
||||
```sql
|
||||
-- DRAFT — do not run blindly; the admin UI route (PATCH /api/admin/rules/{id}) is the preferred path.
|
||||
|
||||
-- Wiedereinsetzung (DE PatG)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '00b737bf-58a6-4f41-9650-ac3f2e7079e8'
|
||||
WHERE id = 'c24d494c-0da1-4f01-aa74-0f37f99fe1ae';
|
||||
|
||||
-- Wiedereinsetzung (DE ZPO)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '00b737bf-58a6-4f41-9650-ac3f2e7079e8'
|
||||
WHERE id = 'd40d9be7-e1b6-451c-bee2-6eaee2307ec5';
|
||||
|
||||
-- Wiedereinsetzung (EPC)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '00b737bf-58a6-4f41-9650-ac3f2e7079e8'
|
||||
WHERE id = '23c6f445-4ed2-4ade-8ea0-c4ab6b364bb6';
|
||||
|
||||
-- Wiedereinsetzung (DPMA)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '00b737bf-58a6-4f41-9650-ac3f2e7079e8'
|
||||
WHERE id = 'b588fa64-a727-4cfb-a45d-69a835a3b05a';
|
||||
|
||||
-- Versäumnisurteil-Einspruch (ZPO §339)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '9f809d1d-ea06-4aa5-80d0-6feaa33b464e'
|
||||
WHERE id = '20254f4e-d213-4cf6-8f5f-1d9d36eeb6ac';
|
||||
|
||||
-- Schriftsatznachreichung (ZPO §296a)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = 'b7a3cb3e-ef7e-47a1-8067-be0fe35a4235'
|
||||
WHERE id = '3c36f149-3a81-456e-aac1-d4d18bfcb16b';
|
||||
|
||||
-- Weiterbehandlung (EPC Art.121)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '5a58f14c-3042-48e9-87fd-c94b62d13662'
|
||||
WHERE id = 'f1099cf6-4c87-430e-b1c5-488bd44cb143';
|
||||
```
|
||||
|
||||
After these 7 rows update, `counterclaim-for-revocation` is the only remaining concept with `direct rule_count = 0`, and that is by design (see § 5).
|
||||
|
||||
---
|
||||
|
||||
## 7. Track B — Genuinely new rule drafts
|
||||
|
||||
Pure-new (not in DB today), to be added through `/admin/rules`:
|
||||
|
||||
| # | Concept | Rule | Status |
|
||||
|---|---|---|---|
|
||||
| 1.1 | `wiedereinsetzung` | UPC R.320 Wiedereinsetzungsantrag (te 207) | NEW |
|
||||
| 1.2 | `wiedereinsetzung` | UPC R.320 1-Jahres-Ausschlussfrist | NEW, schema FLAG |
|
||||
| 1.4 | `wiedereinsetzung` | EPC R.136 1-Jahres-Ausschlussfrist | NEW, schema FLAG |
|
||||
| 1.6 | `wiedereinsetzung` | DE PatG §123 1-Jahres-Ausschlussfrist | NEW, schema FLAG |
|
||||
| 1.8 | `wiedereinsetzung` | DE ZPO §234(1)2 — 1-Monat Begründungsfrist | NEW, condition_expr FLAG |
|
||||
| 1.9 | `wiedereinsetzung` | DE ZPO §234(3) 1-Jahres-Ausschlussfrist | NEW, schema FLAG |
|
||||
| 2.2 | `schriftsatznachreichung` | §296a-Beschränkung (informational) | NEW |
|
||||
| 3.2 | `versaeumnisurteil-einspruch` | ZPO §339(2) Auslandszustellung 1 Monat | NEW, condition_expr FLAG |
|
||||
| 3.3 | `versaeumnisurteil-einspruch` | ZPO §340 Inhalt der Einspruchsschrift (info) | NEW |
|
||||
| 3.4 | `versaeumnisurteil-einspruch` | ZPO §342 Rechtsfolge (info) | NEW |
|
||||
| 4.2 | `weiterbehandlung` | R.135(2) Ausschlüsse (info) | NEW |
|
||||
| 4.3 | `weiterbehandlung` | Weiterbehandlungsgebühr (info) | NEW |
|
||||
| 5.x | `counterclaim-for-revocation` | (none — see § 5 proposal) | — |
|
||||
|
||||
**Total new rule drafts: 12.** That is well under the "50 rule drafts" estimate in the task brief, because the linkage path covers the bulk of what looked like missing coverage. **FLAG:** if m wants me to draft additional UPC R.320 jurisdiction-specific variants (UPC_REV, UPC_PI, UPC_APP, UPC_DAMAGES, UPC_DISCOVERY) as separate proceeding-rooted rules instead of one shared event-rooted rule (Rule 1.1), that adds ~6-7 more drafts.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions / FLAGs index
|
||||
|
||||
For convenience, all `**FLAG**`-marked items in one place. m's decision is needed on each before /admin/rules ingest of the corresponding rule.
|
||||
|
||||
| ID | Section | Question |
|
||||
|---|---|---|
|
||||
| F1 | § 0 | Count discrepancy: 9 vs 5 — confirm the other 4 audit-named orphans were intentionally resolved, not lost. |
|
||||
| F2 | § 0 | Redefine the orphan KPI to also count `trigger_event_id → trigger_events.concept_id`, so the count reflects actual UX coverage. |
|
||||
| F3 | § 1.1 | UPC R.320: one event-rooted rule (te 207) vs seven proceeding-rooted clones (UPC_INF/UPC_REV/UPC_PI/UPC_APP/UPC_DAMAGES/UPC_DISCOVERY/UPC_APP_ORDERS). |
|
||||
| F4 | § 1.2, 1.4, 1.6, 1.9 | 1-year cut-off rules have no clean anchor in the current schema; informational rendering vs new `frist_versaeumt_*` trigger event. |
|
||||
| F5 | § 1.4 | EPC R.136(1) third sentence: priority-restoration 2-month cut-off — separate rule? |
|
||||
| F6 | § 1.8 | ZPO §234(1) sentence 2 (Begründungsfrist) — flag-gated or informational sibling? |
|
||||
| F7 | § 1.x | UPC Wiedereinsetzungs-Gebühr (R.150(1)(p)) — surface as informational rule or out of scope? |
|
||||
| F8 | § 2.1 | Schriftsatznachreichung existing rule has `is_court_set=false`; strictly it's court-set. Update the row or leave the heuristic in place? |
|
||||
| F9 | § 2.3 | Confirm `schriftsatznachreichung` is DE-only — cascade should not expose it under UPC entries. |
|
||||
| F10 | § 3.2 | ZPO §339(2) Auslandszustellung — flag name for `condition_expr` (e.g. `auslandszustellung`). |
|
||||
| F11 | § 5 | `counterclaim-for-revocation` — link existing UPC_INF rules (proposal 5.2) vs event-rooted CCR rule under te 1 (proposal 5.3) vs both. |
|
||||
| F12 | § 5 | Many-to-many concept↔rule junction (`paliad.concept_rules`) as a Phase 3+ schema add. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Sources cited
|
||||
|
||||
| Citation key | Reference |
|
||||
|---|---|
|
||||
| `UPC.RoP.320.1` | UPC Rules of Procedure, Rule 320(1) — Application for re-establishment of rights, time limits |
|
||||
| `UPC.RoP.320.2` | UPC RoP Rule 320(2) — Completion of omitted act |
|
||||
| `UPC.RoP.150.1.p` | UPC RoP Rule 150(1)(p) — Re-establishment fee |
|
||||
| `UPC.RoP.25` | UPC RoP Rule 25 — Lodging of Counterclaim for Revocation |
|
||||
| `UPC.RoP.23.1` | UPC RoP Rule 23(1) — Statement of Defence period (existing rule reference) |
|
||||
| `EU.EPC-R.136.1` | EPC Implementing Regulations Rule 136(1) |
|
||||
| `EU.EPC-R.136.2` | EPC Implementing Regulations Rule 136(2) — Exclusions |
|
||||
| `EU.EPC-R.135.1` | EPC Implementing Regulations Rule 135(1) — Further processing |
|
||||
| `EU.EPC-R.135.2` | EPC Implementing Regulations Rule 135(2) — Exclusions |
|
||||
| `EU.EPÜ.122` | European Patent Convention Article 122 |
|
||||
| `EU.EPÜ.121` | European Patent Convention Article 121 |
|
||||
| `DE.PatG.123.2` | German Patent Act §123(2) — Wiedereinsetzung |
|
||||
| `DE.ZPO.233` | German ZPO §233 — Wiedereinsetzung in den vorigen Stand |
|
||||
| `DE.ZPO.234.1` | German ZPO §234(1) — Antragsfrist (2 Wochen / 1 Monat) |
|
||||
| `DE.ZPO.234.3` | German ZPO §234(3) — 1-year cut-off |
|
||||
| `DE.ZPO.296a` | German ZPO §296a — Schriftsatznachreichung |
|
||||
| `DE.ZPO.339.1` | German ZPO §339(1) — Einspruchsfrist 2 Wochen |
|
||||
| `DE.ZPO.339.2` | German ZPO §339(2) — Einspruchsfrist Auslandszustellung |
|
||||
| `DE.ZPO.340` | German ZPO §340 — Inhalt der Einspruchsschrift |
|
||||
| `DE.ZPO.342` | German ZPO §342 — Rechtsfolge des zulässigen Einspruchs |
|
||||
|
||||
---
|
||||
|
||||
## 10. What's next (if m approves)
|
||||
|
||||
1. **Track A first** (low risk): apply the 7 linkage UPDATEs from § 6 via `/admin/rules` PATCH. Cascade UX immediately recovers for 4 of 5 concepts.
|
||||
2. **Track B legal-review pass:** m or HLC lawyer signs off on the 12 new drafts in § 7 — adjust durations / phrasings as needed.
|
||||
3. **Ingest Track B** via `/admin/rules` POST, one rule at a time. Each new rule goes into `lifecycle_state='draft'` first; m promotes to `published` after spot-checking via the calculator preview endpoint (Slice 11a).
|
||||
4. **Schema follow-ups** (FLAGs F2, F4, F12) deferred to Phase 3 follow-up tickets — not in Slice 12 scope.
|
||||
|
||||
**Estimated rule count after Slice 12 land:** Track A linkage = 7 connections, Track B new rules = 12 drafts → total `paliad.deadline_rules` row count grows from 249 to **261**; orphan-concept count drops from 5 to **1** (only `counterclaim-for-revocation`, which is by design — see § 5).
|
||||
394
docs/research-determinator-coverage-2026-05-08.md
Normal file
394
docs/research-determinator-coverage-2026-05-08.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# Research — Determinator coverage audit (gaps + smart-navigation framing)
|
||||
|
||||
**Author:** curie (researcher)
|
||||
**Date:** 2026-05-08
|
||||
**Task:** t-paliad-167 (Gitea m/paliad#26)
|
||||
**Mode:** read-only research; produces a gap matrix + design framing, not migrations.
|
||||
|
||||
Builds on `docs/audit-upc-rop-deadlines-2026-05-08.md` (t-paliad-159) which drove from the UPC Rules of Procedure outward. This one drives from **paliad's own corpus** outward: every active rule, every firm-wide event_type, every cascade leaf — and asks "can a Determinator user actually reach this row?"
|
||||
|
||||
m's prompt (verbatim, 2026-05-08 22:24 Determinator dogfooding):
|
||||
|
||||
> We are still missing all kinds of orders in our decision tree. What do we need to do to cover everything? Can we maybe check what "options" we have covered in our tree and which we don't? I want to have a smart way to navigate people through the tree to determine what's next.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope and method
|
||||
|
||||
**Five surfaces, three pathways.**
|
||||
|
||||
paliad currently has three independent ways to land on a deadline:
|
||||
|
||||
- **Pathway A — Fristenrechner (proceeding tree).** User picks a proceeding type (`UPC_INF`, `DE_NULL`, `EPA_OPP`, …) and a trigger date; the engine emits the entire timeline. Source: `paliad.deadline_rules` rows where the parent proceeding has `category='fristenrechner'` (19 active proceeding types).
|
||||
- **Pathway B — Determinator cascade.** User answers "what just happened?" by drilling 1-3 levels through `paliad.event_categories` (6 roots → 27 → 49 → 43 leaves; 103 leaves total). Each leaf maps to one or more `paliad.deadline_concepts` via `paliad.event_category_concepts`. Concepts then resolve to rules (`deadline_rules.concept_id`) and event_types (`deadline_concept_event_types`, mig 072).
|
||||
- **Pathway C — Trigger-event search.** Free-text `paliad.trigger_events` lookup (102 youpc-imported rows). Used by the t-paliad-086 "Was kommt nach…" mode and by autocomplete. Out of audit scope here — no Determinator surface uses it.
|
||||
|
||||
**Reachability rule.** For this audit, "reachable from the Determinator cascade" means: there exists some leaf `L` in `event_categories` such that `event_category_concepts(L → C)` and either:
|
||||
- (rule-side) `deadline_rules.concept_id = C` for the rule under test, or
|
||||
- (event_type-side) `deadline_concept_event_types(C, E)` for the event_type under test.
|
||||
|
||||
Concepts that exist but never appear in `event_category_concepts` are **dead-end concepts** — Pathway A may use them, Pathway B can't.
|
||||
|
||||
**Inventory snapshot (live youpc Supabase, 2026-05-08 22:30):**
|
||||
|
||||
| Surface | Rows | Notes |
|
||||
|---|---|---|
|
||||
| `proceeding_types` (`category='fristenrechner'`) | 19 | UPC×8, DE×5, EPA×2, EP×1, DPMA×3 |
|
||||
| `proceeding_types` (`category='litigation'`, legacy/dormant) | 7 | INF, REV, CCR, AMD, APM, APP, ZPO_CIVIL — see §2.1 |
|
||||
| `deadline_rules` active | 172 | 95 true deadlines (`duration_value > 0`), rest are anchors / court-set |
|
||||
| `deadline_rules` true deadlines, `category='fristenrechner'` only | **76** | The audit denominator |
|
||||
| `event_categories` active | 125 | 6 roots, 103 leaves |
|
||||
| `event_category_concepts` mappings | 153 | 45 distinct concepts in cascade |
|
||||
| `deadline_concepts` active | 57 | 45 in cascade, 12 dead-end |
|
||||
| `event_types` firm-wide active | 44 | 26 reachable, 18 unreachable |
|
||||
| `deadline_concept_event_types` (mig 072) | 32 rows / 25 concepts / 30 event_types | The Regel↔Typ junction |
|
||||
|
||||
**Cascade root inventory (Pathway B entry chips):**
|
||||
|
||||
| Root | Children | Leaves | Purpose |
|
||||
|---|---|---|---|
|
||||
| `cms-eingang` | gericht / gegenseite | 50 | Inbound — paper just landed |
|
||||
| `muendl-verhandlung` | geladen / gehalten / verlegt / zwischenverfahren | 4 | Hearing-pivot |
|
||||
| `beschluss-entscheidung` | (11 leaf decisions per forum) | 11 | Decision-pivot — duplicate of `cms-eingang.gericht.endentscheidung.*` |
|
||||
| `frist-verpasst` | upc / de-patg / de-zpo / epa / dpma | 5 | Wiedereinsetzung family |
|
||||
| `ich-moechte-einreichen` | klage / berufung / widerklage / spätere-schriftsätze / einspruch | 32 | Outbound — file something |
|
||||
| `sonstiges` | — | 1 (dangling, no concept) | Escape hatch |
|
||||
|
||||
**Per-forum cascade depth:** UPC has 38 reachable leaves, DE 35, EPA 11, DPMA 7. The DE corpus is now within 8% of UPC's — the imbalance flagged in earlier audits is largely closed. EPA/DPMA remain underbuilt.
|
||||
|
||||
---
|
||||
|
||||
## 2. Inventory by jurisdiction
|
||||
|
||||
Each section answers the same three questions: (a) which rules exist, (b) are they reachable from the cascade, (c) what's missing relative to a real practitioner's everyday surface area.
|
||||
|
||||
### 2.1 Legacy / dormant proceedings (out of scope but worth flagging)
|
||||
|
||||
The 7 `category='litigation'` proceedings (INF, REV, CCR, APM, AMD, APP, ZPO_CIVIL) carry **40 active rules** between them but:
|
||||
- 0 cascade references (`event_category_concepts.proceeding_type_code` never names them),
|
||||
- 0 concept_id linkage on any of their 18 true deadlines,
|
||||
- not surfaced in the Fristenrechner UI (filtered by `category='fristenrechner'` in `deadline_rule_service.go:740`).
|
||||
|
||||
These rows are zombie taxonomy from migration 008/009 — superseded by the `UPC_*` / `DE_*` / `EPA_*` / `DPMA_*` family in mig 012/042/043/044. **Recommendation:** flag them `is_active=false` in a follow-up cleanup migration; they only confuse audits.
|
||||
|
||||
The audit denominator is therefore **76 true Fristenrechner deadlines across 19 active proceedings**.
|
||||
|
||||
### 2.2 UPC
|
||||
|
||||
Most-mature jurisdiction. 8 proceedings, 40 true deadlines, 39 reachable from cascade.
|
||||
|
||||
| Proceeding | True deadlines | Reachable | Notes |
|
||||
|---|---|---|---|
|
||||
| UPC_INF | 11 | 10 | `inf.app_to_amend` (RoP.030.1, 2mo) has no concept_id — Pathway A only |
|
||||
| UPC_REV | 9 | 9 | Plus 2 duration bugs flagged in t-paliad-159 (R.49.1 3→2mo, R.52 2→1mo) |
|
||||
| UPC_PI | 0 | n/a | All 4 rules are anchors / court-set (no calendar arithmetic) |
|
||||
| UPC_APP | 5 | 5 | 3 rule_code-drift bugs flagged in t-paliad-159 (R.224.1.a, R.224.2.a, R.235.2) |
|
||||
| UPC_DAMAGES | 3 | 3 | |
|
||||
| UPC_DISCOVERY | 3 | 3 | |
|
||||
| UPC_COST_APPEAL | 1 | 1 | Tree-end leaf still missing R.155 chain |
|
||||
| UPC_APP_ORDERS | 4 | 4 | R.224.2.b grounds-on-orders missing entirely (RoP audit gap 6) |
|
||||
|
||||
**Cascade-side gaps that t-paliad-159 surfaced and remain open:**
|
||||
- R.19 Preliminary Objection (no leaf, no rule, no event_type — but `upc_preliminary_objection` event_type exists, archived from cascade)
|
||||
- R.197.3 Saisie review request, R.198/R.213 31d-or-20wd start-of-merits
|
||||
- R.262.2 Confidentiality response (14d) — daily occurrence in HLC infringement, completely absent from both pathways
|
||||
- R.333.2 Review of CMO (15d) — trigger event #16 exists, no rule, no leaf
|
||||
- R.353 Rectification (1mo) — trigger event #41 exists, no rule, no leaf
|
||||
- R.207.6.a / R.229.2 / R.71 Mängelbeseitigung — registry-correction family entirely missing
|
||||
- R.109.1 / R.109.4 / R.109.5 oral-hearing translation prep (only `before`-mode rules in the corpus)
|
||||
|
||||
### 2.3 DE (Zivilgericht + Bundesinstanzen)
|
||||
|
||||
5 proceedings, 22 true deadlines, all 22 reachable from cascade.
|
||||
|
||||
| Proceeding | True deadlines | Reachable | Cascade entry |
|
||||
|---|---|---|---|
|
||||
| DE_INF | 6 | 6 | `cms-eingang.gegenseite.de-inf.*` + `urteil-de-inf-lg` |
|
||||
| DE_NULL | 5 | 5 | `cms-eingang.gegenseite.de-null.*` + `urteil-de-null-bpatg` |
|
||||
| DE_INF_OLG | 3 | 3 | `urteil-de-inf-lg` (Berufung-Begründung) |
|
||||
| DE_INF_BGH | 5 | 5 | `urteil-de-inf-olg` (NZB / NZB-Begründung / Revisionsfrist / Revisionsbegründung) |
|
||||
| DE_NULL_BGH | 3 | 3 | `urteil-de-null-bpatg` (Berufung BGH) |
|
||||
|
||||
**Headline DE gaps (entirely uncovered by both pathways):**
|
||||
- **Hinweisbeschluss** — `cms-eingang.gericht.hinweisbeschluss` leaf exists and links to `response-to-preliminary-opinion` concept, but **no rule row computes a deadline from it**. The concept has 1 rule (`r79-further-stellungnahme`, 2mo) wired to EPA_OPP only. The DE Hinweisbeschluss deadline (4 weeks under §139 ZPO is judge-set; under § 522 ZPO Berufung-Hinweis is judge-set with min 2 weeks) is not in the rule corpus.
|
||||
- **Beweisbeschluss / Beweissicherungsanordnung (DE)** — `cms-eingang.gericht.anordnung` leaf exists but only links to `request-for-discretionary-review` (UPC R.220.3). No DE-side reaction (e.g. Stellungnahme nach Beweisaufnahme, § 411 ZPO 2-week comment on Sachverständigengutachten).
|
||||
- **Streitwertbeschluss** — neither cascade leaf nor rule. Streitwertbeschwerde is § 68 GKG, 6 months → frequent and unrepresented.
|
||||
- **Versäumnisurteil** — leaf `versaeumnisurteil` exists with concept `versaeumnisurteil-einspruch`, but the concept has 0 rules. The 2-week Einspruch deadline (§ 339 Abs. 1 ZPO) is documented in the concept text but doesn't compute. A user lands on the leaf and gets a hint card, no calendar entry.
|
||||
- **ZPO Klage as starting point** — Pathway A has a legacy `ZPO_CIVIL` proceeding (dormant per §2.1) but no live equivalent; Pathway B's `cms-eingang.gegenseite.de-inf.klageschrift` covers the *defendant*'s perspective only. A claimant entering "I just filed a Klageschrift" has no path.
|
||||
- **Schriftsatznachfristsetzung (§ 283 ZPO)** — concept `schriftsatznachreichung` exists in cascade with 0 rules; "court grants me a 3-week response window" produces no calendar entry.
|
||||
|
||||
### 2.4 EPO
|
||||
|
||||
2 active proceedings (EPA_OPP, EPA_APP) plus 1 grant-side outlier (EP_GRANT). 12 true deadlines, 8 reachable from cascade.
|
||||
|
||||
| Proceeding | True deadlines | Reachable | Notes |
|
||||
|---|---|---|---|
|
||||
| EPA_OPP | 4 | 4 | Cascade entry via `cms-eingang.gegenseite.epa-opp.einspruchsschrift` + `entscheidung-epa-opp` |
|
||||
| EPA_APP | 4 | 4 | Cascade entry via `cms-eingang.gegenseite.epa-app` + `entscheidung-epa-boa` |
|
||||
| **EP_GRANT** | **4** | **0** | All 4 unreachable — concepts (`search-report`, `publication`, `request-for-examination`, `approval-and-translation`) have no `event_category_concepts` row |
|
||||
|
||||
**EP_GRANT is the single biggest blanket-gap in the audit.** The 4 most fundamental EPO grant-side deadlines (R.70(1) examination request 6mo, Art. 93 publication, R.71(3) approval+translation 4mo, search-report 6mo) are computable in Pathway A but the cascade has zero entry points for them. A user landing on the Determinator says "EP-Anmeldung erteilt, was nun?" and finds nothing.
|
||||
|
||||
**Headline EPO gaps (both pathways):**
|
||||
- **R.71(3) communication received** — `cms-eingang.gericht.rechtsverlust-epa` covers the *negative* outcome (Rechtsverlust → Weiterbehandlung/Wiedereinsetzung) but the *positive* outcome (Mitteilung nach R.71(3) → 4-month approval+translation) has no leaf. The concept exists (`approval-and-translation`) but no leaf binds it.
|
||||
- **R.94(3) examination-stage Bescheid** — entirely absent. Most-frequent EPO deadline in prosecution practice ("4-month period to respond to examination report"); no rule, no leaf, no event_type.
|
||||
- **EPO opposition reply** — event_type `epo_opposition_reply` exists, archived from cascade (no concept link). Pathway A's EPA_OPP has the rule but no Pathway B path.
|
||||
- **R.116 EPO oral-proceedings final-submissions** — covered (`r116-final-submissions` concept, 2 rules, leaf `muendl-verhandlung.geladen` + `ich-moechte-einreichen.spaetere-schriftsaetze.r116-eingaben`).
|
||||
- **Annual renewal fees (Art. 86 EPC)** — `epo_renewal_fee` event_type exists, archived from cascade. No concept, no rule.
|
||||
|
||||
### 2.5 DPMA
|
||||
|
||||
3 active proceedings (DPMA_OPP, DPMA_BPATG_BESCHWERDE, DPMA_BGH_RB). 6 true deadlines, all 6 reachable from cascade.
|
||||
|
||||
| Proceeding | True deadlines | Reachable | Cascade entry |
|
||||
|---|---|---|---|
|
||||
| DPMA_OPP | 2 | 2 | `cms-eingang.gegenseite.dpma-opp` + `entscheidung-dpma` |
|
||||
| DPMA_BPATG_BESCHWERDE | 2 | 2 | `entscheidung-dpma` (Beschwerde) + `beschluss-bpatg-beschwerde` |
|
||||
| DPMA_BGH_RB | 2 | 2 | `beschluss-bpatg-beschwerde` (Rechtsbeschwerde) |
|
||||
|
||||
**Headline DPMA gaps (both pathways):**
|
||||
- **Beanstandungsbescheid (Prüfungsverfahren)** — DPMA examination-stage objection notice with 4-month default response window (§ 45 PatG). No rule, no leaf, no event_type. Most-frequent DPMA deadline in real practice and entirely unrepresented.
|
||||
- **Aktenversendungsbescheid / Anhörungsbescheid (Einspruchsverfahren)** — § 59 PatG opposition oral-hearing summons; no leaf.
|
||||
- **Anmeldetag-Mitteilung / Recherchenbericht (DPMA)** — `dpma_examination_request` event_type exists with concept link to `request-for-examination`, but the concept is a Pathway-A-only dead-end (not in cascade).
|
||||
- **Patenterteilungsbeschluss** — no leaf for the positive grant decision (the negative-outcome Beschluss-BPatG path covers appeals, not the grant-stage event).
|
||||
|
||||
### 2.6 Cross-cutting (procedural orders that span jurisdictions)
|
||||
|
||||
The categories m specifically called out — "court orders that aren't entry events but procedural orders." Status:
|
||||
|
||||
| Order type | UPC | DE | EPA | DPMA | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| Hinweisbeschluss / vorläufige Würdigung | concept-only | concept-only (no rule) | n/a | n/a | Leaf `cms-eingang.gericht.hinweisbeschluss` exists; the only rule wired to `response-to-preliminary-opinion` is EPA-side R.79. Judge-set period in DE/UPC; the leaf produces no calendar entry. |
|
||||
| Beweisbeschluss / Beweissicherungsanordnung | partial (R.196/R.197) | absent | n/a | n/a | Trigger events #26 / #44 / #65 / #66 exist; only R.197.3 (saisie review 30d) is missing as a rule. § 411 ZPO 2-week Stellungnahme-Frist nowhere. |
|
||||
| Streitwertbeschluss | n/a | absent | n/a | n/a | § 68 GKG 6-month Streitwertbeschwerde — common, unrepresented. |
|
||||
| Versäumnisurteil | n/a | leaf-only (no rule) | n/a | n/a | § 339 ZPO 2-week Einspruch — concept `versaeumnisurteil-einspruch` carries 0 rules. |
|
||||
| Case-Management-Order (R.220.1.c / § 273 ZPO) | partial | absent | n/a | n/a | UPC R.333.2 review-of-CMO 15d missing; trigger event #16 exists. |
|
||||
| Berichtigungsbeschluss / Tatbestandsberichtigung | absent | absent | n/a | n/a | UPC R.353 1mo / § 320 ZPO 2-week — both unrepresented. |
|
||||
| Konfidentialitätsantrag der Gegenseite | absent | n/a | n/a | n/a | UPC R.262.2 14d — high-frequency in HLC infringement work. |
|
||||
| R.71(3) communication | n/a | n/a | absent | n/a | The most-common EPO prosecution deadline. |
|
||||
| Examination-stage Bescheid | n/a | n/a | absent (R.94(3)) | absent (§ 45 PatG) | 4-month response. Single biggest *prosecution* gap. |
|
||||
| Mängelbeseitigung notification | absent (R.71/R.207.6.a/R.229.2) | absent | absent | absent | Cross-jurisdictional gap. Trigger event #71 exists for UPC. |
|
||||
| Translation lodging order | absent (R.109.5) | n/a | n/a | n/a | `before`-mode rules — schema supports, no data. |
|
||||
| Rechtsverlust-Mitteilung | n/a | n/a | leaf-only (covered) | n/a | Only EPA branch wired (`weiterbehandlung` + `wiedereinsetzung`). |
|
||||
|
||||
---
|
||||
|
||||
## 3. Cascade reachability tables
|
||||
|
||||
### 3.1 Rule reachability per proceeding
|
||||
|
||||
| Proceeding | True deadlines | No concept | Reachable | Unreachable (concept exists, not in cascade) |
|
||||
|---|---|---|---|---|
|
||||
| UPC_INF | 11 | 1 (`inf.app_to_amend`) | 10 | 0 |
|
||||
| UPC_REV | 9 | 0 | 9 | 0 |
|
||||
| UPC_APP | 5 | 0 | 5 | 0 |
|
||||
| UPC_DAMAGES | 3 | 0 | 3 | 0 |
|
||||
| UPC_DISCOVERY | 3 | 0 | 3 | 0 |
|
||||
| UPC_COST_APPEAL | 1 | 0 | 1 | 0 |
|
||||
| UPC_APP_ORDERS | 4 | 0 | 4 | 0 |
|
||||
| EP_GRANT | 4 | 0 | 0 | **4** |
|
||||
| DE_INF | 6 | 0 | 6 | 0 |
|
||||
| DE_NULL | 5 | 0 | 5 | 0 |
|
||||
| DE_INF_OLG | 3 | 0 | 3 | 0 |
|
||||
| DE_INF_BGH | 5 | 0 | 5 | 0 |
|
||||
| DE_NULL_BGH | 3 | 0 | 3 | 0 |
|
||||
| EPA_OPP | 4 | 0 | 4 | 0 |
|
||||
| EPA_APP | 4 | 0 | 4 | 0 |
|
||||
| DPMA_OPP | 2 | 0 | 2 | 0 |
|
||||
| DPMA_BPATG_BESCHWERDE | 2 | 0 | 2 | 0 |
|
||||
| DPMA_BGH_RB | 2 | 0 | 2 | 0 |
|
||||
| **Total** | **76** | **1** | **71** | **4** |
|
||||
|
||||
**Reachability rate: 71/76 = 93.4 %.** The 5 unreachable rules concentrate in two clusters:
|
||||
- `UPC_INF.inf.app_to_amend` (RoP.030.1, 2mo) — no concept_id assigned. Recommended fix: link to `defence-to-application-to-amend` or create a new `application-to-amend` concept.
|
||||
- All 4 `EP_GRANT` rules — concepts exist (`search-report`, `publication`, `request-for-examination`, `approval-and-translation`) but none has an `event_category_concepts` row. Recommended fix: add an EP-Grant subtree under either `cms-eingang.gericht` or a new `ich-moechte-einreichen.ep-grant` branch.
|
||||
|
||||
### 3.2 Event_type reachability (firm-wide active types only, n=44)
|
||||
|
||||
**Reachable via cascade (26 of 44):**
|
||||
|
||||
| Slug | Category | Jurisdiction |
|
||||
|---|---|---|
|
||||
| de_klageerwiderung | submission | DE |
|
||||
| dpma_appeal | submission | DPMA |
|
||||
| dpma_opposition | submission | DPMA |
|
||||
| epo_appeal_grounds, epo_appeal_notice, epo_opposition_filing | submission | EPO |
|
||||
| upc_application_for_cost_decision, upc_application_for_damages | submission | UPC |
|
||||
| upc_counterclaim_for_infringement, upc_counterclaim_for_revocation | submission | UPC |
|
||||
| upc_cross_appeal_2242a (×2 concepts) | submission | UPC |
|
||||
| upc_defence_to_amend_patent, upc_defence_to_revocation | submission | UPC |
|
||||
| upc_grounds_of_appeal_2242a (×2 concepts) | submission | UPC |
|
||||
| upc_protective_letter, upc_rejoinder_to_reply, upc_reply_to_defence | submission | UPC |
|
||||
| upc_reply_to_defence_to_amend_patent, upc_reply_to_defence_to_revocation | submission | UPC |
|
||||
| upc_request_to_lay_open_books | submission | UPC |
|
||||
| upc_statement_for_revocation, upc_statement_of_appeal_2201 | submission | UPC |
|
||||
| upc_statement_of_claim, upc_statement_of_defence | submission | UPC |
|
||||
| upc_statement_of_defence_no_ccr, upc_statement_of_defence_with_ccr | submission | UPC |
|
||||
|
||||
**Unreachable (18 of 44):**
|
||||
|
||||
| Slug | Category | Why unreachable |
|
||||
|---|---|---|
|
||||
| upc_decision_of_epo | decision | Concept missing, no junction row |
|
||||
| upc_decision_on_costs | decision | Junction → `cost-decision` concept; that concept is dead-end (not in cascade) |
|
||||
| upc_decision_on_merits | decision | No junction row |
|
||||
| upc_final_decision | decision | No junction row |
|
||||
| upc_oral_hearing | hearing | Junction → `oral-hearing` concept; dead-end |
|
||||
| upc_case_management_order | order | Junction → `order` concept; dead-end |
|
||||
| upc_order_lodge_translations | order | No junction row |
|
||||
| upc_summons_oral_hearing | service | No junction row |
|
||||
| upc_application_to_amend_patent | submission | No junction row (parallel to UPC_INF gap above) |
|
||||
| upc_defence_to_statement_dni, upc_statement_dni | submission | DNI family (RoP audit gap 23) — no rule, no concept, no leaf |
|
||||
| upc_grounds_of_appeal_2242b | submission | RoP audit gap 6 — R.224.2.b orders-track grounds entirely missing |
|
||||
| upc_preliminary_objection | submission | RoP audit gap 5 — R.19 entirely missing |
|
||||
| dpma_examination_request | submission | Junction → `request-for-examination`; dead-end |
|
||||
| epo_renewal_fee, contract_renewal | fee | No junction row, no concept |
|
||||
| epo_opposition_reply | submission | No junction row |
|
||||
| stellungnahme | submission | No junction row, no concept (generic catch-all) |
|
||||
|
||||
**Pattern.** The 18 unreachable types split into three groups:
|
||||
- **Court-side trigger types (8/18)**: decisions, orders, hearings, summons. The cascade is *reaction*-oriented (clicking a leaf yields "what's next") and cannot represent these as endpoints because they are themselves the entry points of reaction trees. Adding them via the `ich-moechte-einreichen` root is structurally wrong; they're not user filings. Adding them via `cms-eingang.gericht` would require an explicit "tag this incoming court event" sub-mode that the Determinator currently doesn't have.
|
||||
- **Genuinely missing UPC content (5/18)**: DNI family, R.19 PO, R.224.2.b orders-track grounds, EP-grant `application_to_amend_patent`. These are real gaps the RoP audit already named.
|
||||
- **Prosecution-side gaps (5/18)**: EPO renewal fees, R.94(3) reply, DPMA examination request, generic Stellungnahme, contract renewal. Both pathways skip prosecution; the platform is litigation-first today.
|
||||
|
||||
### 3.3 Cascade-side dangling (leaves with no concept attached)
|
||||
|
||||
3 leaves carry no concept link:
|
||||
- `cms-eingang.gericht.bescheid-mit-frist` ("Bescheid mit explizit gesetzter Frist") — intentional escape hatch but produces no calendar entry. A user lands here when no specific Bescheid type matches; without a concept, no autofill, no "I'll do the math for you."
|
||||
- `muendl-verhandlung.verlegt` — when an oral hearing is rescheduled, no follow-on deadline (correct: judge re-issues with new date).
|
||||
- `sonstiges` — top-level "Anderes" escape hatch.
|
||||
|
||||
These three leaves are the existing "not in the tree" UX — a user already CAN bottom out, but only with zero downstream support. §4 below proposes how to make those moments useful.
|
||||
|
||||
### 3.4 Concept-side dead-ends (concepts with rules but no cascade entry)
|
||||
|
||||
12 concepts have `is_active=true` and ≥1 rule attached but never appear in `event_category_concepts`:
|
||||
|
||||
| Concept | Rules | Comment |
|
||||
|---|---|---|
|
||||
| `decision` | 14 | Generic decision-anchor — used by every proceeding's `*.decision` row. Not a reaction target. |
|
||||
| `oral-hearing` | 11 | Same as decision — anchor not reaction. |
|
||||
| `publication` | 3 | EP grant publication, A1/B1 dates. |
|
||||
| `order` | 2 | Generic order-anchor. |
|
||||
| `cost-decision` | 1 | R.157 fixation-of-costs. Should arguably be reachable since post-cost-decision reactions exist (`application-for-leave-to-appeal`); the leaf `kostenfestsetzung` already maps to `notice-of-appeal` and `application-for-leave-to-appeal`, so the *reaction* path is covered — `cost-decision` itself just doesn't need to be in the cascade. |
|
||||
| `preliminary-opinion` | 1 | EPA preliminary opinion — used by EPA_OPP. |
|
||||
| `grant` | 1 | EP grant decision. |
|
||||
| `filing` | 1 | EP filing date. |
|
||||
| `search-report` | 1 | EPO search-report 6mo period. |
|
||||
| `request-for-examination` | 1 | EPO R.70(1) 6mo. |
|
||||
| `approval-and-translation` | 1 | EPO R.71(3) 4mo. |
|
||||
| `communication-r71-3` | 1 | Same family as approval-and-translation; intermediate. |
|
||||
|
||||
**Reading.** 8 of these are court-side anchors (decision, order, hearing, publication, grant, filing, search-report, preliminary-opinion) — by design not reactions, so their absence from the cascade is structurally correct. The remaining 4 are all the EP-grant family (request-for-examination, approval-and-translation, communication-r71-3, plus the implicit `publication` for EP_GRANT) — these *should* be reachable and currently aren't. Confirms §3.1's EP_GRANT cluster as the single biggest fixable cluster.
|
||||
|
||||
---
|
||||
|
||||
## 4. Smart-navigation framing — which pattern fits the gap distribution?
|
||||
|
||||
Issue §3 names three candidate patterns:
|
||||
|
||||
- **(P1) Free-text search at every cascade depth.** "Beweisbeschluss" → suggests closest leaves with a "that's not it" fallback.
|
||||
- **(P2) Persistent "Mein Ereignis ist nicht dabei" escape button.** Visible at every level → opens a manual entry form with rule-only / no-rule paths.
|
||||
- **(P3) Breadcrumb-aware "weiter unten suchen".** Flattens deeper levels into the current row's chip set when the user can't pick at the current depth.
|
||||
|
||||
The gap distribution we just enumerated tells us which pattern earns its keep. There are four kinds of "I don't see my event" moments:
|
||||
|
||||
**Type α — Real gap, content missing.** The user wants a real event paliad genuinely doesn't model (Streitwertbeschluss, R.19 PO, DPMA Beanstandungsbescheid, R.71(3), R.94(3), § 411 ZPO Stellungnahme nach Beweisaufnahme). Count: ~18-22 events from §2.6 plus the RoP audit's 25 missing. **What helps:** an escape that captures *what* the user wanted, so we can prioritise the right migration rather than guess. P2 + telemetry.
|
||||
|
||||
**Type β — Reachable but mis-modelled cascade path.** The leaf exists, the user can't find it (different mental label, deeper than expected, wrong root). E.g. R.116 final submissions live under `muendl-verhandlung.geladen` AND `ich-moechte-einreichen.spaetere-schriftsaetze.r116-eingaben`; if the user starts at `cms-eingang` they hit a dead end. Or: Wiedereinsetzung is under `frist-verpasst.*` but a user might look under `ich-moechte-einreichen.spaetere-schriftsaetze`. **What helps:** P1 (search collapses the labelling problem) and P3 (flat-search within current branch when nothing matches).
|
||||
|
||||
**Type γ — Court-side trigger event needs to be tagged, not reacted-to.** The user has a `upc_decision_on_merits` and wants to *file it as an event in their project*, not get a reaction list. The cascade doesn't model this — it always assumes "reaction wanted." Count: ~8 of the 18 unreachable event_types. **What helps:** none of P1/P2/P3 directly — this is a separate "tag, don't react" mode. Out of scope here but worth flagging.
|
||||
|
||||
**Type δ — Dead-end leaf with no concept (the 3 dangling leaves).** User selected `bescheid-mit-frist` and lands on a content-free card. **What helps:** P2's "manual entry with rule-only path" is exactly the escape these leaves need — turn the dangle into a deliberate fall-through.
|
||||
|
||||
### 4.1 Recommendation: **P2 + P1, in that order, with P3 as a stretch.**
|
||||
|
||||
**Why P2 first.** Of the four types, only Type α (real content gaps) is genuinely closed by P2, but Type α is also the *only* type that produces actionable feedback for paliad's roadmap. A persistent "Ich finde mein Ereignis nicht" button at every cascade depth, opening a `<dialog>` with:
|
||||
- a free-text "What event are you trying to file/respond to?" input,
|
||||
- a date input,
|
||||
- "kein Regelwerk verfügbar" rule-only path that creates a deadline with `event_type=null, rule_id=null, manual_due_date=...`,
|
||||
- an opt-in checkbox "Mein Hinweis hilft, paliad zu verbessern" that posts the captured text to a (future) `paliad.coverage_gaps` table,
|
||||
|
||||
…does three things at once: (a) unblocks the user immediately, (b) gives m a backlog that's *exactly* the prioritisation signal this audit can't provide alone (which gaps are real demand vs. theoretical RoP completeness), (c) repurposes the 3 dangling leaves and `sonstiges` from "looks broken" to "deliberate fall-through."
|
||||
|
||||
Implementation cost: one `<dialog>` modal reused at every depth + one new `coverage_gap` event sink + one feedback-style admin view. The button itself can hang off the existing FilterBar primitive (t-paliad-163) or attach to the bottom of every cascade list.
|
||||
|
||||
**Why P1 second.** Type β (mis-modelled paths) is the *quietest* failure mode — the user gives up before clicking anywhere relevant. Search would catch it but the gap data alone doesn't tell us how many such users exist. Layering P1 on top of P2 turns the captured "Mein Ereignis nicht dabei" texts into the very query corpus that powers fuzzy-search ranking. A search input at the top of every cascade level (`<input type="search">` filtering the current set of children + drilling into matching deeper leaves via FTS over `label_de` / `label_en` / `aliases` / linked `concept.aliases`) closes Type β cheaply once the corpus is decent.
|
||||
|
||||
**Why P3 is a stretch.** "Flatten deeper levels into current chip-set" reads cleanly but trades depth for breadth: the cascade currently has 38 reachable UPC leaves under 2-3 levels — flattening to 38 chips at depth 1 produces analysis paralysis. The cascade's depth is a feature, not a bug. P3 is only worth building if telemetry from P2 shows a cluster of users bottoming out at level 2 with the *right* root selected. Defer.
|
||||
|
||||
### 4.2 What this means for current scope
|
||||
|
||||
- **m/paliad#25 (minkowski's row-by-row)** is orthogonal — that fixes individual rule rows. Keep that going.
|
||||
- **Type α gap fill** is a separate workstream driven by the Wave 1-5 RoP-audit sequencing in `audit-upc-rop-deadlines-2026-05-08.md` §6. The smart-navigation work doesn't replace it; it gives the work a feedback loop.
|
||||
- **Type γ (tag-don't-react)** is its own design problem — file as a separate ticket if/when it shows up in P2 telemetry.
|
||||
- **The 5 unreachable rules from §3.1** (4 EP_GRANT + 1 UPC_INF) should be fixed with a 5-row migration regardless of the navigation work. Independent. EP-grant in particular is the single change that lifts cascade reachability from 93.4 % to 100 % of the audited rule corpus.
|
||||
|
||||
### 4.3 Suggested next steps (not implementation, just ordering)
|
||||
|
||||
1. **5-row reachability migration** (no design needed): link `inf.app_to_amend` to `defence-to-application-to-amend` concept; add cascade leaves for the 4 EP_GRANT concepts under a new `ich-moechte-einreichen.ep-erteilung` subtree. Wave-0 alongside the t-paliad-159 duration bug fixes.
|
||||
2. **Inventor pass on P2 + P1** as one design ticket: persistent escape button + free-text search at each level + capture-table schema + admin view. This is where m's "smart navigation" intuition lives — keep P1 and P2 as a pair so the captured texts feed search ranking.
|
||||
3. **Type α gap fill** continues independently per RoP audit waves — capture-table data in (2) refines priorities after a few weeks of real use.
|
||||
4. **Defer P3 + Type γ** until telemetry justifies them.
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary
|
||||
|
||||
**Coverage today (n=76 true Fristenrechner deadlines across 19 active proceedings):**
|
||||
|
||||
| Status | Count | Share |
|
||||
|---|---|---|
|
||||
| Reachable from cascade | 71 | 93 % |
|
||||
| No concept_id | 1 | 1 % |
|
||||
| Concept exists, dead-end | 4 | 5 % |
|
||||
|
||||
**Event_type reachability (n=44 firm-wide active types):**
|
||||
|
||||
| Status | Count | Share |
|
||||
|---|---|---|
|
||||
| Reachable | 26 | 59 % |
|
||||
| Unreachable | 18 | 41 % |
|
||||
|
||||
**Headline gap categories** (entirely uncovered by both pathways, ordered by daily-practice frequency):
|
||||
|
||||
1. EPO R.94(3) examination-stage Bescheid (4mo) — most-frequent EPO prosecution deadline, **completely absent**.
|
||||
2. EPO R.71(3) communication → approval+translation (4mo) — concept exists but no cascade entry.
|
||||
3. DPMA § 45 PatG Beanstandungsbescheid (4mo) — most-frequent DPMA prosecution deadline, completely absent.
|
||||
4. UPC R.262.2 confidentiality response (14d) — high-frequency in HLC infringement.
|
||||
5. DE Hinweisbeschluss reaction — leaf exists, no rule.
|
||||
6. DE Versäumnisurteil-Einspruch (§ 339 ZPO 2 weeks) — leaf exists, no rule.
|
||||
7. DE Streitwertbeschwerde (§ 68 GKG 6mo) — neither leaf nor rule.
|
||||
8. UPC R.19 Preliminary Objection (1mo) — neither pathway.
|
||||
9. UPC R.224.2.b grounds-on-orders-track (15d) — neither pathway.
|
||||
10. UPC R.353 Rectification (1mo) — neither pathway.
|
||||
11. UPC EP-grant family (R.70(1), Art. 93, R.71(3), search-report) — Pathway A only, no cascade entry.
|
||||
12. UPC R.109 oral-hearing translation prep (1mo / 2w / 2w `before`-mode) — schema-supported, no data.
|
||||
|
||||
**Recommended smart-navigation pattern:** P2 (persistent "Ich finde mein Ereignis nicht" escape with capture) + P1 (free-text search per cascade level), in that order. P2 alone unblocks users and produces the feedback loop the rest of the gap-fill roadmap needs; P1 layered on top closes mis-labelling. P3 is over-scoped for current data.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — files consulted
|
||||
|
||||
- `internal/services/deadline_rule_service.go` (proceeding-type filtering, `category='fristenrechner'` gate)
|
||||
- `internal/services/event_category_service.go` (cascade traversal)
|
||||
- `internal/services/fristenrechner.go` (Pathway A composer)
|
||||
- `internal/db/migrations/008_seed_proceeding_types.up.sql` (legacy 7 codes)
|
||||
- `internal/db/migrations/012_fristenrechner_rules.up.sql` (UPC/DE/EPA seed)
|
||||
- `internal/db/migrations/042_de_expansion_b3.up.sql` (DE_INF_OLG / DE_INF_BGH / DE_NULL_BGH)
|
||||
- `internal/db/migrations/043_de_instance_split_proceedings.up.sql`
|
||||
- `internal/db/migrations/044_dpma_proceedings.up.sql`
|
||||
- `internal/db/migrations/045_epa_gap_fill.up.sql`
|
||||
- `internal/db/migrations/048_event_categories.up.sql` (cascade seed)
|
||||
- `internal/db/migrations/049_event_categories_seed.up.sql`
|
||||
- `internal/db/migrations/051_proceeding_display_order.up.sql`
|
||||
- `internal/db/migrations/052_event_categories_rop_audit.up.sql` (cascade-side RoP fixes)
|
||||
- `internal/db/migrations/063_frist_verpasst_upc.up.sql` (R.320 leaf)
|
||||
- `internal/db/migrations/072_deadline_concept_event_types.up.sql` (Regel↔Typ junction)
|
||||
|
||||
## Appendix B — companion audits
|
||||
|
||||
- `docs/audit-upc-rop-deadlines-2026-05-08.md` — RoP-driven UPC audit (t-paliad-159, curie). Half the data for §2.2.
|
||||
- `docs/audit-fristenrechner-completeness-2026-04-30.md` — youpc-vs-paliad (t-paliad-084, curie).
|
||||
- `docs/design-deadline-data-model-2026-05-08.md` — current data-model design.
|
||||
@@ -4,6 +4,7 @@ import { renderIndex } from "./src/index";
|
||||
import { renderLogin } from "./src/login";
|
||||
import { renderKostenrechner } from "./src/kostenrechner";
|
||||
import { renderFristenrechner } from "./src/fristenrechner";
|
||||
import { renderVerfahrensablauf } from "./src/verfahrensablauf";
|
||||
import { renderDownloads } from "./src/downloads";
|
||||
import { renderLinks } from "./src/links";
|
||||
import { renderGlossary } from "./src/glossary";
|
||||
@@ -15,6 +16,7 @@ import { renderCourts } from "./src/courts";
|
||||
import { renderProjects } from "./src/projects";
|
||||
import { renderProjectsNew } from "./src/projects-new";
|
||||
import { renderProjectsDetail } from "./src/projects-detail";
|
||||
import { renderProjectsChart } from "./src/projects-chart";
|
||||
import { renderEvents } from "./src/events";
|
||||
import { renderDeadlinesNew } from "./src/deadlines-new";
|
||||
import { renderDeadlinesDetail } from "./src/deadlines-detail";
|
||||
@@ -40,6 +42,9 @@ import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit"
|
||||
import { renderAdminEventTypes } from "./src/admin-event-types";
|
||||
import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
|
||||
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
|
||||
import { renderAdminRulesList } from "./src/admin-rules-list";
|
||||
import { renderAdminRulesEdit } from "./src/admin-rules-edit";
|
||||
import { renderAdminRulesExport } from "./src/admin-rules-export";
|
||||
import { renderPaliadin } from "./src/paliadin";
|
||||
import { renderAdminPaliadin } from "./src/admin-paliadin";
|
||||
import { renderNotFound } from "./src/notfound";
|
||||
@@ -234,6 +239,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/login.ts"),
|
||||
join(import.meta.dir, "src/client/kostenrechner.ts"),
|
||||
join(import.meta.dir, "src/client/fristenrechner.ts"),
|
||||
join(import.meta.dir, "src/client/verfahrensablauf.ts"),
|
||||
join(import.meta.dir, "src/client/downloads.ts"),
|
||||
join(import.meta.dir, "src/client/links.ts"),
|
||||
join(import.meta.dir, "src/client/glossary.ts"),
|
||||
@@ -245,6 +251,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/projects.ts"),
|
||||
join(import.meta.dir, "src/client/projects-new.ts"),
|
||||
join(import.meta.dir, "src/client/projects-detail.ts"),
|
||||
join(import.meta.dir, "src/client/projects-chart.ts"),
|
||||
join(import.meta.dir, "src/client/events.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-new.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-detail.ts"),
|
||||
@@ -270,6 +277,9 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/admin-event-types.ts"),
|
||||
join(import.meta.dir, "src/client/admin-approval-policies.ts"),
|
||||
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-list.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-edit.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-export.ts"),
|
||||
join(import.meta.dir, "src/client/paliadin.ts"),
|
||||
// t-paliad-161 — inline Paliadin widget. Loaded via the
|
||||
// PaliadinWidget component on every authenticated page, so the
|
||||
@@ -354,6 +364,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
|
||||
await Bun.write(join(DIST, "kostenrechner.html"), renderKostenrechner());
|
||||
await Bun.write(join(DIST, "fristenrechner.html"), renderFristenrechner());
|
||||
await Bun.write(join(DIST, "verfahrensablauf.html"), renderVerfahrensablauf());
|
||||
await Bun.write(join(DIST, "downloads.html"), renderDownloads());
|
||||
await Bun.write(join(DIST, "links.html"), renderLinks());
|
||||
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
|
||||
@@ -365,6 +376,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "projects.html"), renderProjects());
|
||||
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
|
||||
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
|
||||
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
|
||||
// t-paliad-115 — shared EventsPage at the canonical /events URL.
|
||||
// One HTML output; defaultType="all" baked in. Sidebar Fristen /
|
||||
// Termine entries point at /events?type=… and events.ts re-highlights
|
||||
@@ -394,6 +406,9 @@ async function build() {
|
||||
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
|
||||
await Bun.write(join(DIST, "admin-approval-policies.html"), renderAdminApprovalPolicies());
|
||||
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
|
||||
await Bun.write(join(DIST, "admin-rules-list.html"), renderAdminRulesList());
|
||||
await Bun.write(join(DIST, "admin-rules-edit.html"), renderAdminRulesEdit());
|
||||
await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport());
|
||||
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
|
||||
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
|
||||
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
|
||||
|
||||
352
frontend/src/admin-rules-edit.tsx
Normal file
352
frontend/src/admin-rules-edit.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/rules/{id}/edit — Slice 11b (t-paliad-192). Form for the full
|
||||
// 37-column rule row plus a side panel with the preview widget and the
|
||||
// audit-log timeline. Lifecycle action bar at the bottom adapts to the
|
||||
// rule's current state (draft/published/archived). Every write goes
|
||||
// through a reason modal that enforces the ≥10-char rule from Slice 11a
|
||||
// edge case #4.
|
||||
//
|
||||
// The id of the rule is parsed from the URL path on hydration —
|
||||
// frontend never reads it from a server-injected blob, so the static
|
||||
// HTML shell is reusable for every rule. condition_expr ships with a
|
||||
// raw JSON textarea + a simple AND/OR/NOT tree-builder (toggle).
|
||||
export function renderAdminRulesEdit(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.rules.edit.title">Regel bearbeiten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/rules" />
|
||||
<BottomNav currentPath="/admin/rules" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header admin-rules-edit-header">
|
||||
<div>
|
||||
<p className="admin-rules-breadcrumb">
|
||||
<a href="/admin/rules" data-i18n="admin.rules.edit.breadcrumb">← Regeln verwalten</a>
|
||||
</p>
|
||||
<h1 id="rules-edit-heading" data-i18n="admin.rules.edit.heading.loading">Regel laden...</h1>
|
||||
<div className="admin-rules-edit-meta">
|
||||
<span id="rules-edit-lifecycle" className="admin-rules-pill admin-rules-pill-draft" />
|
||||
<span id="rules-edit-id" className="admin-rules-edit-uuid" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="rules-edit-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="admin-rules-edit-grid">
|
||||
<form id="rules-edit-form" className="entity-form admin-rules-edit-form" autocomplete="off">
|
||||
<fieldset className="admin-rules-fieldset">
|
||||
<legend data-i18n="admin.rules.edit.section.identity">Identität</legend>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-name" data-i18n="admin.rules.edit.field.name">Name (DE)</label>
|
||||
<input type="text" id="f-name" className="admin-rules-input" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-name-en" data-i18n="admin.rules.edit.field.name_en">Name (EN)</label>
|
||||
<input type="text" id="f-name-en" className="admin-rules-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-description" data-i18n="admin.rules.edit.field.description">Beschreibung</label>
|
||||
<textarea id="f-description" className="admin-rules-input" rows={2} />
|
||||
</div>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-code" data-i18n="admin.rules.edit.field.code">Code</label>
|
||||
<input type="text" id="f-code" className="admin-rules-input" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-rule-code" data-i18n="admin.rules.edit.field.rule_code">Rule-Code (zit.)</label>
|
||||
<input type="text" id="f-rule-code" className="admin-rules-input" placeholder="z. B. RoP.151" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-legal-source" data-i18n="admin.rules.edit.field.legal_source">Rechtsgrundlage</label>
|
||||
<input type="text" id="f-legal-source" className="admin-rules-input" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="admin-rules-fieldset">
|
||||
<legend data-i18n="admin.rules.edit.section.proceeding">Verfahren & Trigger</legend>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-proceeding" data-i18n="admin.rules.edit.field.proceeding">Verfahrenstyp</label>
|
||||
<select id="f-proceeding" className="admin-rules-select">
|
||||
<option value="" data-i18n="admin.rules.edit.field.proceeding.none">—</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-trigger" data-i18n="admin.rules.edit.field.trigger">Trigger-Ereignis</label>
|
||||
<select id="f-trigger" className="admin-rules-select">
|
||||
<option value="" data-i18n="admin.rules.edit.field.trigger.none">—</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-parent" data-i18n="admin.rules.edit.field.parent">Parent-Regel (UUID)</label>
|
||||
<input type="text" id="f-parent" className="admin-rules-input" placeholder="UUID oder leer" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-concept" data-i18n="admin.rules.edit.field.concept">Konzept (UUID)</label>
|
||||
<input type="text" id="f-concept" className="admin-rules-input" placeholder="UUID oder leer" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-sequence" data-i18n="admin.rules.edit.field.sequence_order">Reihenfolge</label>
|
||||
<input type="number" id="f-sequence" className="admin-rules-input" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="admin-rules-fieldset">
|
||||
<legend data-i18n="admin.rules.edit.section.timing">Berechnung</legend>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-duration" data-i18n="admin.rules.edit.field.duration_value">Dauer</label>
|
||||
<input type="number" id="f-duration" className="admin-rules-input" min="0" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-duration-unit" data-i18n="admin.rules.edit.field.duration_unit">Einheit</label>
|
||||
<select id="f-duration-unit" className="admin-rules-select">
|
||||
<option value="days">days</option>
|
||||
<option value="weeks">weeks</option>
|
||||
<option value="months">months</option>
|
||||
<option value="working_days">working_days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-timing" data-i18n="admin.rules.edit.field.timing">Timing</label>
|
||||
<select id="f-timing" className="admin-rules-select">
|
||||
<option value="">—</option>
|
||||
<option value="after">after</option>
|
||||
<option value="before">before</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-combine-op" data-i18n="admin.rules.edit.field.combine_op">Combine-Op</label>
|
||||
<select id="f-combine-op" className="admin-rules-select">
|
||||
<option value="">—</option>
|
||||
<option value="max">max</option>
|
||||
<option value="min">min</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-alt-duration" data-i18n="admin.rules.edit.field.alt_duration_value">Alt-Dauer</label>
|
||||
<input type="number" id="f-alt-duration" className="admin-rules-input" min="0" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-alt-duration-unit" data-i18n="admin.rules.edit.field.alt_duration_unit">Alt-Einheit</label>
|
||||
<select id="f-alt-duration-unit" className="admin-rules-select">
|
||||
<option value="">—</option>
|
||||
<option value="days">days</option>
|
||||
<option value="weeks">weeks</option>
|
||||
<option value="months">months</option>
|
||||
<option value="working_days">working_days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-alt-rule-code" data-i18n="admin.rules.edit.field.alt_rule_code">Alt-Rule-Code</label>
|
||||
<input type="text" id="f-alt-rule-code" className="admin-rules-input" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-anchor-alt" data-i18n="admin.rules.edit.field.anchor_alt">Alt-Anchor</label>
|
||||
<input type="text" id="f-anchor-alt" className="admin-rules-input" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="admin-rules-fieldset">
|
||||
<legend data-i18n="admin.rules.edit.section.party">Partei & Ereignis</legend>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-primary-party" data-i18n="admin.rules.edit.field.primary_party">Primäre Partei</label>
|
||||
<input type="text" id="f-primary-party" className="admin-rules-input" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-event-type" data-i18n="admin.rules.edit.field.event_type">Event-Typ (frei)</label>
|
||||
<input type="text" id="f-event-type" className="admin-rules-input" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="admin-rules-fieldset">
|
||||
<legend data-i18n="admin.rules.edit.section.display">Anzeige & Notizen</legend>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-notes" data-i18n="admin.rules.edit.field.deadline_notes">Hinweise (DE)</label>
|
||||
<textarea id="f-notes" className="admin-rules-input" rows={2} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-notes-en" data-i18n="admin.rules.edit.field.deadline_notes_en">Hinweise (EN)</label>
|
||||
<textarea id="f-notes-en" className="admin-rules-input" rows={2} />
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="admin-rules-fieldset">
|
||||
<legend data-i18n="admin.rules.edit.section.lifecycle">Priorität & Flags</legend>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-priority" data-i18n="admin.rules.edit.field.priority">Priorität</label>
|
||||
<select id="f-priority" className="admin-rules-select">
|
||||
<option value="mandatory">mandatory</option>
|
||||
<option value="recommended">recommended</option>
|
||||
<option value="optional">optional</option>
|
||||
<option value="informational">informational</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field admin-rules-checkbox-field">
|
||||
<label>
|
||||
<input type="checkbox" id="f-is-court-set" />
|
||||
<span data-i18n="admin.rules.edit.field.is_court_set">Gerichtlich gesetzt</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-field admin-rules-checkbox-field">
|
||||
<label>
|
||||
<input type="checkbox" id="f-is-spawn" />
|
||||
<span data-i18n="admin.rules.edit.field.is_spawn">Spawn</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-rules-edit-row" id="f-spawn-row" style="display:none">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-spawn-label" data-i18n="admin.rules.edit.field.spawn_label">Spawn-Label</label>
|
||||
<input type="text" id="f-spawn-label" className="admin-rules-input" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-spawn-proceeding" data-i18n="admin.rules.edit.field.spawn_proceeding">Spawn-Verfahren</label>
|
||||
<select id="f-spawn-proceeding" className="admin-rules-select">
|
||||
<option value="" data-i18n="admin.rules.edit.field.spawn_proceeding.none">—</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="admin-rules-fieldset">
|
||||
<legend data-i18n="admin.rules.edit.section.condition">Bedingung (condition_expr)</legend>
|
||||
<p className="admin-rules-hint" data-i18n="admin.rules.edit.field.condition_hint">
|
||||
JSON-Grammatik: <code>{"flag":"name"}</code> · <code>{"op":"and|or","args":[...]}</code> · <code>{"op":"not","args":[...]}</code>
|
||||
</p>
|
||||
<div className="form-field">
|
||||
<textarea id="f-condition-expr" className="admin-rules-input admin-rules-code-input" rows={5} placeholder='z. B. {"flag":"with_ccr"}' />
|
||||
<p className="admin-rules-hint" id="f-condition-msg" />
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<aside className="admin-rules-edit-side">
|
||||
{/* Preview widget */}
|
||||
<div className="admin-rules-edit-card">
|
||||
<h3 data-i18n="admin.rules.edit.preview.heading">Preview</h3>
|
||||
<p className="admin-rules-hint" data-i18n="admin.rules.edit.preview.hint">
|
||||
Nur für Drafts. Berechnet die Fristenkette mit dieser Draft-Regel anstelle der publizierten Variante.
|
||||
</p>
|
||||
<div className="form-field">
|
||||
<label htmlFor="preview-trigger-date" data-i18n="admin.rules.edit.preview.trigger_date">Trigger-Datum</label>
|
||||
<input type="date" lang="de" id="preview-trigger-date" className="admin-rules-input" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="preview-flags" data-i18n="admin.rules.edit.preview.flags">Flags (komma-separiert)</label>
|
||||
<input type="text" id="preview-flags" className="admin-rules-input" placeholder="z. B. with_ccr,is_appeal" />
|
||||
</div>
|
||||
<button type="button" id="preview-run" className="btn-secondary" data-i18n="admin.rules.edit.preview.run">
|
||||
Preview berechnen
|
||||
</button>
|
||||
<div id="preview-result" className="admin-rules-preview-result" style="display:none" />
|
||||
</div>
|
||||
|
||||
{/* Audit-log timeline */}
|
||||
<div className="admin-rules-edit-card">
|
||||
<h3 data-i18n="admin.rules.edit.audit.heading">Audit-Log</h3>
|
||||
<ol id="rules-edit-audit" className="admin-rules-audit-list">
|
||||
<li className="admin-rules-loading" data-i18n="admin.rules.edit.audit.loading">Lade...</li>
|
||||
</ol>
|
||||
<button type="button" id="audit-loadmore" className="btn-secondary" style="display:none" data-i18n="admin.rules.edit.audit.loadmore">
|
||||
Weitere laden
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="admin-rules-actionbar">
|
||||
<button type="button" id="action-save-draft" className="btn-primary" style="display:none" data-i18n="admin.rules.edit.action.save_draft">
|
||||
Draft speichern
|
||||
</button>
|
||||
<button type="button" id="action-publish" className="btn-primary" style="display:none" data-i18n="admin.rules.edit.action.publish">
|
||||
Publish
|
||||
</button>
|
||||
<button type="button" id="action-clone" className="btn-secondary" style="display:none" data-i18n="admin.rules.edit.action.clone">
|
||||
Als Draft klonen
|
||||
</button>
|
||||
<button type="button" id="action-archive" className="btn-secondary" style="display:none" data-i18n="admin.rules.edit.action.archive">
|
||||
Archivieren
|
||||
</button>
|
||||
<button type="button" id="action-restore" className="btn-secondary" style="display:none" data-i18n="admin.rules.edit.action.restore">
|
||||
Wiederherstellen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Reason modal — shared for every lifecycle action. Action-specific
|
||||
body text is set by the client at open time. */}
|
||||
<div className="modal-overlay" id="rules-action-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 id="rules-action-modal-title">Aktion bestätigen</h2>
|
||||
<button className="modal-close" id="rules-action-modal-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<p id="rules-action-modal-body" className="invite-modal-body" />
|
||||
<form id="rules-action-modal-form" className="entity-form" autocomplete="off">
|
||||
<div className="form-field">
|
||||
<label htmlFor="rules-action-modal-reason" data-i18n="admin.rules.modal.reason">Grund</label>
|
||||
<textarea
|
||||
id="rules-action-modal-reason"
|
||||
className="admin-rules-input"
|
||||
rows={3}
|
||||
required
|
||||
minlength={10}
|
||||
/>
|
||||
<p className="admin-rules-hint" data-i18n="admin.rules.modal.reason.hint">
|
||||
Mindestens 10 Zeichen.
|
||||
</p>
|
||||
</div>
|
||||
<p className="form-msg" id="rules-action-modal-msg" style="display:none" />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="rules-action-modal-cancel" data-i18n="common.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary" id="rules-action-modal-submit" data-i18n="admin.rules.modal.confirm">
|
||||
Bestätigen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-rules-edit.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
186
frontend/src/admin-rules-list.tsx
Normal file
186
frontend/src/admin-rules-list.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/rules — Slice 11b (t-paliad-192). Filterable rule table + an
|
||||
// Orphans tab that surfaces the Slice 10 fuzzy-match staging rows so an
|
||||
// admin can hand-bind each legacy deadline to one of the candidate
|
||||
// rule_ids. Both surfaces share the same page shell to keep navigation
|
||||
// shallow — the count badge on the Orphans tab is loaded eagerly on
|
||||
// first paint so the editor sees the legal-review backlog every visit.
|
||||
export function renderAdminRulesList(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.rules.list.title">Regeln verwalten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/rules" />
|
||||
<BottomNav currentPath="/admin/rules" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.rules.list.heading">Regeln verwalten</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.rules.list.subtitle">
|
||||
Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-rules-header-actions">
|
||||
<a href="/admin/rules/export" className="btn-secondary" data-i18n="admin.rules.list.export">
|
||||
Migrations exportieren
|
||||
</a>
|
||||
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.rules.list.new">
|
||||
+ Neue Regel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-rules-tabs">
|
||||
<button type="button" className="admin-rules-tab active" id="rules-tab-rules" data-tab="rules" data-i18n="admin.rules.tab.rules">
|
||||
Regeln
|
||||
</button>
|
||||
<button type="button" className="admin-rules-tab" id="rules-tab-orphans" data-tab="orphans">
|
||||
<span data-i18n="admin.rules.tab.orphans">Orphans</span>
|
||||
<span className="admin-rules-tab-badge" id="rules-orphans-badge" style="display:none">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="rules-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
{/* Rules tab */}
|
||||
<div id="rules-pane-rules" className="admin-rules-pane">
|
||||
<div className="admin-rules-filters">
|
||||
<div className="admin-rules-filter">
|
||||
<label htmlFor="rules-filter-proceeding" data-i18n="admin.rules.filter.proceeding">Verfahrenstyp</label>
|
||||
<select id="rules-filter-proceeding" className="admin-rules-select">
|
||||
<option value="" data-i18n="admin.rules.filter.proceeding.any">Alle</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="admin-rules-filter">
|
||||
<label htmlFor="rules-filter-trigger" data-i18n="admin.rules.filter.trigger">Trigger-Ereignis</label>
|
||||
<select id="rules-filter-trigger" className="admin-rules-select">
|
||||
<option value="" data-i18n="admin.rules.filter.trigger.any">Alle</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="admin-rules-filter admin-rules-filter-chips">
|
||||
<span className="admin-rules-filter-label" data-i18n="admin.rules.filter.lifecycle">Lifecycle</span>
|
||||
<div className="admin-rules-chips" id="rules-filter-lifecycle">
|
||||
<button type="button" className="admin-rules-chip active" data-state="" data-i18n="admin.rules.filter.lifecycle.any">Alle</button>
|
||||
<button type="button" className="admin-rules-chip" data-state="draft" data-i18n="admin.rules.lifecycle.draft">Draft</button>
|
||||
<button type="button" className="admin-rules-chip" data-state="published" data-i18n="admin.rules.lifecycle.published">Published</button>
|
||||
<button type="button" className="admin-rules-chip" data-state="archived" data-i18n="admin.rules.lifecycle.archived">Archived</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-rules-filter admin-rules-filter-search">
|
||||
<label htmlFor="rules-filter-search" data-i18n="admin.rules.filter.search">Suche</label>
|
||||
<input
|
||||
type="text"
|
||||
id="rules-filter-search"
|
||||
className="admin-rules-input"
|
||||
placeholder="Name, Code, rule_code..."
|
||||
data-i18n-placeholder="admin.rules.filter.search.placeholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap admin-rules-table-wrap">
|
||||
<table className="entity-table admin-rules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.rules.col.code">Code</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rules-tbody">
|
||||
<tr><td colspan={6} className="admin-rules-loading" data-i18n="admin.rules.loading">Lade...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="rules-empty" style="display:none">
|
||||
<p data-i18n="admin.rules.empty">Keine Regeln für die gewählten Filter.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orphans tab */}
|
||||
<div id="rules-pane-orphans" className="admin-rules-pane" style="display:none">
|
||||
<p className="tool-subtitle" data-i18n="admin.rules.orphans.subtitle">
|
||||
Legacy-Deadlines aus dem fuzzy-match Backfill (Slice 10), die nicht eindeutig einer Regel zugeordnet werden konnten. Bitte die richtige Kandidaten-Regel auswählen.
|
||||
</p>
|
||||
<div id="rules-orphans-list" className="admin-rules-orphans">
|
||||
<p className="admin-rules-loading" data-i18n="admin.rules.orphans.loading">Lade...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Reason modal — reused for "+ Neue Regel" (creates a draft) and for
|
||||
the orphan resolve flow. Both writes go through audit-reason
|
||||
session config server-side, so the modal enforces the 10-char
|
||||
minimum client-side per Slice 11a edge case #4. */}
|
||||
<div className="modal-overlay" id="rules-reason-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 id="rules-reason-title" data-i18n="admin.rules.modal.new.title">Neue Regel anlegen</h2>
|
||||
<button className="modal-close" id="rules-reason-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<p id="rules-reason-body" className="invite-modal-body" data-i18n="admin.rules.modal.new.body">
|
||||
Eine neue Regel wird als Draft angelegt. Bitte einen Grund (mind. 10 Zeichen) angeben — dieser wandert ins Audit-Log und beim Export in die Migration.
|
||||
</p>
|
||||
<form id="rules-reason-form" className="entity-form" autocomplete="off">
|
||||
<div id="rules-reason-extra" />
|
||||
<div className="form-field">
|
||||
<label htmlFor="rules-reason-text" data-i18n="admin.rules.modal.reason">Grund</label>
|
||||
<textarea
|
||||
id="rules-reason-text"
|
||||
className="admin-rules-input"
|
||||
rows={3}
|
||||
required
|
||||
minlength={10}
|
||||
placeholder="z. B. „Neue Regel für RoP.198 nach UPC-Reform 2026..."
|
||||
data-i18n-placeholder="admin.rules.modal.reason.placeholder"
|
||||
/>
|
||||
<p className="admin-rules-hint" data-i18n="admin.rules.modal.reason.hint">
|
||||
Mindestens 10 Zeichen.
|
||||
</p>
|
||||
</div>
|
||||
<p className="form-msg" id="rules-reason-msg" style="display:none" />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="rules-reason-cancel" data-i18n="common.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary" id="rules-reason-submit" data-i18n="admin.rules.modal.confirm">
|
||||
Bestätigen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-rules-list.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -95,6 +95,11 @@ export function renderAdmin(): string {
|
||||
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
|
||||
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.</p>
|
||||
</a>
|
||||
<a href="/admin/rules" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_TABLE }} />
|
||||
<h2 data-i18n="admin.card.rules.title">Regeln verwalten</h2>
|
||||
<p data-i18n="admin.card.rules.desc">Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>
|
||||
|
||||
664
frontend/src/client/admin-rules-edit.ts
Normal file
664
frontend/src/client/admin-rules-edit.ts
Normal file
@@ -0,0 +1,664 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-rules-edit.ts — /admin/rules/{id}/edit. Loads a single rule
|
||||
// row, drives every form field, the preview widget, the audit-log
|
||||
// timeline and the lifecycle action bar. Every write is gated behind
|
||||
// a reason modal — the ≥10-char rule is enforced client-side per
|
||||
// Slice 11a edge case #4.
|
||||
|
||||
interface Rule {
|
||||
id: string;
|
||||
proceeding_type_id?: number | null;
|
||||
parent_id?: string | null;
|
||||
code?: string | null;
|
||||
rule_code?: string | null;
|
||||
name: string;
|
||||
name_en: string;
|
||||
description?: string | null;
|
||||
primary_party?: string | null;
|
||||
event_type?: string | null;
|
||||
duration_value: number;
|
||||
duration_unit: string;
|
||||
timing?: string | null;
|
||||
alt_duration_value?: number | null;
|
||||
alt_duration_unit?: string | null;
|
||||
alt_rule_code?: string | null;
|
||||
anchor_alt?: string | null;
|
||||
combine_op?: string | null;
|
||||
legal_source?: string | null;
|
||||
deadline_notes?: string | null;
|
||||
deadline_notes_en?: string | null;
|
||||
priority: string;
|
||||
is_court_set: boolean;
|
||||
is_spawn: boolean;
|
||||
spawn_label?: string | null;
|
||||
spawn_proceeding_type_id?: number | null;
|
||||
trigger_event_id?: number | null;
|
||||
condition_expr?: unknown;
|
||||
sequence_order: number;
|
||||
concept_id?: string | null;
|
||||
lifecycle_state: string;
|
||||
draft_of?: string | null;
|
||||
published_at?: string | null;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
}
|
||||
|
||||
interface TriggerEvent {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_de: string;
|
||||
}
|
||||
|
||||
interface AuditEntry {
|
||||
id: string;
|
||||
rule_id: string;
|
||||
changed_by?: string | null;
|
||||
changed_by_display_name?: string | null;
|
||||
changed_at: string;
|
||||
action: string;
|
||||
before_json?: unknown;
|
||||
after_json?: unknown;
|
||||
reason: string;
|
||||
migration_exported: boolean;
|
||||
}
|
||||
|
||||
let ruleId = "";
|
||||
let rule: Rule | null = null;
|
||||
let proceedings: ProceedingType[] = [];
|
||||
let triggers: TriggerEvent[] = [];
|
||||
let auditEntries: AuditEntry[] = [];
|
||||
let auditOffset = 0;
|
||||
const AUDIT_PAGE = 20;
|
||||
let auditHasMore = false;
|
||||
let previewDebounce: number | undefined;
|
||||
|
||||
function esc(s: string | null | undefined): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s ?? "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
return d.toLocaleString(locale, {
|
||||
year: "numeric", month: "2-digit", day: "2-digit",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function parseRuleIDFromPath(): string {
|
||||
// /admin/rules/{uuid}/edit
|
||||
const m = /^\/admin\/rules\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
|
||||
return m ? decodeURIComponent(m[1]) : "";
|
||||
}
|
||||
|
||||
function showFeedback(msg: string, isError: boolean) {
|
||||
const el = document.getElementById("rules-edit-feedback") as HTMLElement | null;
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
|
||||
el.style.display = "block";
|
||||
if (!isError) {
|
||||
setTimeout(() => { el.style.display = "none"; }, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function lifecycleLabel(state: string): string {
|
||||
return tDyn(`admin.rules.lifecycle.${state}`) || state;
|
||||
}
|
||||
|
||||
function lifecycleClass(state: string): string {
|
||||
switch (state) {
|
||||
case "draft": return "admin-rules-pill admin-rules-pill-draft";
|
||||
case "published": return "admin-rules-pill admin-rules-pill-published";
|
||||
case "archived": return "admin-rules-pill admin-rules-pill-archived";
|
||||
default: return "admin-rules-pill";
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Loaders.
|
||||
// --------------------------------------------------------------------
|
||||
async function loadProceedings(): Promise<void> {
|
||||
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
|
||||
if (!resp.ok) return;
|
||||
proceedings = (await resp.json()) as ProceedingType[];
|
||||
fillProceedingSelect("f-proceeding", proceedings);
|
||||
fillProceedingSelect("f-spawn-proceeding", proceedings);
|
||||
}
|
||||
|
||||
async function loadTriggers(): Promise<void> {
|
||||
const resp = await fetch("/api/tools/trigger-events");
|
||||
if (!resp.ok) return;
|
||||
triggers = (await resp.json()) as TriggerEvent[];
|
||||
const sel = document.getElementById("f-trigger") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const placeholder = sel.querySelector('option[value=""]');
|
||||
sel.innerHTML = "";
|
||||
if (placeholder) sel.appendChild(placeholder);
|
||||
for (const te of triggers) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(te.id);
|
||||
opt.textContent = `${te.code} · ${getLang() === "en" ? te.name : te.name_de}`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
function fillProceedingSelect(selectId: string, list: ProceedingType[]) {
|
||||
const sel = document.getElementById(selectId) as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const placeholder = sel.querySelector('option[value=""]');
|
||||
sel.innerHTML = "";
|
||||
if (placeholder) sel.appendChild(placeholder);
|
||||
for (const pt of list) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(pt.id);
|
||||
opt.textContent = `${pt.code} · ${getLang() === "en" ? pt.name_en : pt.name_de}`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRule(): Promise<void> {
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`);
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 404) {
|
||||
showFeedback(t("admin.rules.edit.error.not_found") || "Regel nicht gefunden.", true);
|
||||
} else {
|
||||
showFeedback(t("admin.rules.edit.error.load") || "Konnte Regel nicht laden.", true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
rule = await resp.json() as Rule;
|
||||
populateForm();
|
||||
updateLifecycleUI();
|
||||
}
|
||||
|
||||
async function loadAudit(reset: boolean = true): Promise<void> {
|
||||
if (reset) {
|
||||
auditEntries = [];
|
||||
auditOffset = 0;
|
||||
}
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
|
||||
if (!resp.ok) return;
|
||||
const body = await resp.json();
|
||||
const rows = Array.isArray(body) ? body as AuditEntry[] : [];
|
||||
auditEntries.push(...rows);
|
||||
auditOffset += rows.length;
|
||||
auditHasMore = rows.length === AUDIT_PAGE;
|
||||
renderAudit();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Form binding.
|
||||
// --------------------------------------------------------------------
|
||||
function setInput(id: string, val: unknown) {
|
||||
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null;
|
||||
if (!el) return;
|
||||
if (val == null) {
|
||||
el.value = "";
|
||||
return;
|
||||
}
|
||||
el.value = String(val);
|
||||
}
|
||||
|
||||
function setCheckbox(id: string, val: boolean) {
|
||||
const el = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (!el) return;
|
||||
el.checked = !!val;
|
||||
}
|
||||
|
||||
function getInput(id: string): string {
|
||||
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null;
|
||||
return el ? el.value.trim() : "";
|
||||
}
|
||||
|
||||
function getCheckbox(id: string): boolean {
|
||||
const el = document.getElementById(id) as HTMLInputElement | null;
|
||||
return el ? el.checked : false;
|
||||
}
|
||||
|
||||
function getOptionalInt(id: string): number | null {
|
||||
const v = getInput(id);
|
||||
if (!v) return null;
|
||||
const n = parseInt(v, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function getOptionalString(id: string): string | null {
|
||||
const v = getInput(id);
|
||||
return v ? v : null;
|
||||
}
|
||||
|
||||
function populateForm() {
|
||||
if (!rule) return;
|
||||
const heading = document.getElementById("rules-edit-heading") as HTMLElement;
|
||||
const idEl = document.getElementById("rules-edit-id") as HTMLElement;
|
||||
const lifecycleEl = document.getElementById("rules-edit-lifecycle") as HTMLElement;
|
||||
heading.textContent = (getLang() === "en" ? rule.name_en : rule.name) || rule.name;
|
||||
idEl.textContent = rule.id;
|
||||
lifecycleEl.className = lifecycleClass(rule.lifecycle_state);
|
||||
lifecycleEl.textContent = lifecycleLabel(rule.lifecycle_state);
|
||||
|
||||
setInput("f-name", rule.name);
|
||||
setInput("f-name-en", rule.name_en);
|
||||
setInput("f-description", rule.description ?? "");
|
||||
setInput("f-code", rule.code ?? "");
|
||||
setInput("f-rule-code", rule.rule_code ?? "");
|
||||
setInput("f-legal-source", rule.legal_source ?? "");
|
||||
setInput("f-proceeding", rule.proceeding_type_id ?? "");
|
||||
setInput("f-trigger", rule.trigger_event_id ?? "");
|
||||
setInput("f-parent", rule.parent_id ?? "");
|
||||
setInput("f-concept", rule.concept_id ?? "");
|
||||
setInput("f-sequence", rule.sequence_order);
|
||||
setInput("f-duration", rule.duration_value);
|
||||
setInput("f-duration-unit", rule.duration_unit);
|
||||
setInput("f-timing", rule.timing ?? "");
|
||||
setInput("f-combine-op", rule.combine_op ?? "");
|
||||
setInput("f-alt-duration", rule.alt_duration_value ?? "");
|
||||
setInput("f-alt-duration-unit", rule.alt_duration_unit ?? "");
|
||||
setInput("f-alt-rule-code", rule.alt_rule_code ?? "");
|
||||
setInput("f-anchor-alt", rule.anchor_alt ?? "");
|
||||
setInput("f-primary-party", rule.primary_party ?? "");
|
||||
setInput("f-event-type", rule.event_type ?? "");
|
||||
setInput("f-notes", rule.deadline_notes ?? "");
|
||||
setInput("f-notes-en", rule.deadline_notes_en ?? "");
|
||||
setInput("f-priority", rule.priority);
|
||||
setCheckbox("f-is-court-set", rule.is_court_set);
|
||||
setCheckbox("f-is-spawn", rule.is_spawn);
|
||||
setInput("f-spawn-label", rule.spawn_label ?? "");
|
||||
setInput("f-spawn-proceeding", rule.spawn_proceeding_type_id ?? "");
|
||||
toggleSpawnRow();
|
||||
setInput("f-condition-expr", rule.condition_expr ? JSON.stringify(rule.condition_expr, null, 2) : "");
|
||||
}
|
||||
|
||||
function toggleSpawnRow() {
|
||||
const row = document.getElementById("f-spawn-row") as HTMLElement | null;
|
||||
if (!row) return;
|
||||
row.style.display = getCheckbox("f-is-spawn") ? "" : "none";
|
||||
}
|
||||
|
||||
function updateLifecycleUI() {
|
||||
const draftOnly = (id: string, show: boolean) => {
|
||||
const el = document.getElementById(id) as HTMLElement | null;
|
||||
if (el) el.style.display = show ? "" : "none";
|
||||
};
|
||||
if (!rule) return;
|
||||
const isDraft = rule.lifecycle_state === "draft";
|
||||
const isPublished = rule.lifecycle_state === "published";
|
||||
const isArchived = rule.lifecycle_state === "archived";
|
||||
|
||||
draftOnly("action-save-draft", isDraft);
|
||||
draftOnly("action-publish", isDraft);
|
||||
draftOnly("action-clone", isPublished || isArchived);
|
||||
draftOnly("action-archive", isDraft || isPublished);
|
||||
draftOnly("action-restore", isArchived);
|
||||
|
||||
// Lock form fields when not editable (i.e. not draft). Published /
|
||||
// archived rules show the form read-only so editors can confirm
|
||||
// they're about to clone the right row.
|
||||
const readOnly = !isDraft;
|
||||
document.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(
|
||||
"#rules-edit-form input, #rules-edit-form select, #rules-edit-form textarea",
|
||||
).forEach((el) => {
|
||||
el.disabled = readOnly;
|
||||
});
|
||||
}
|
||||
|
||||
function renderAudit() {
|
||||
const list = document.getElementById("rules-edit-audit") as HTMLElement | null;
|
||||
const more = document.getElementById("audit-loadmore") as HTMLElement | null;
|
||||
if (!list) return;
|
||||
if (auditEntries.length === 0) {
|
||||
list.innerHTML = `<li class="admin-rules-audit-empty">${esc(t("admin.rules.edit.audit.empty") || "Keine Audit-Einträge.")}</li>`;
|
||||
} else {
|
||||
list.innerHTML = auditEntries.map((e) => {
|
||||
const actor = e.changed_by_display_name || (e.changed_by ? e.changed_by.slice(0, 8) : (t("admin.rules.edit.audit.actor.system") || "System"));
|
||||
const actionLabel = tDyn(`admin.rules.edit.audit.action.${e.action}`) || e.action;
|
||||
const exported = e.migration_exported
|
||||
? `<span class="admin-rules-audit-badge">${esc(t("admin.rules.edit.audit.exported") || "exported")}</span>`
|
||||
: "";
|
||||
return `
|
||||
<li class="admin-rules-audit-entry admin-rules-audit-action-${esc(e.action)}">
|
||||
<div class="admin-rules-audit-head">
|
||||
<span class="admin-rules-audit-action">${esc(actionLabel)}</span>
|
||||
<span class="admin-rules-audit-time">${esc(fmtDateTime(e.changed_at))}</span>
|
||||
${exported}
|
||||
</div>
|
||||
<div class="admin-rules-audit-actor">${esc(actor)}</div>
|
||||
${e.reason ? `<div class="admin-rules-audit-reason">${esc(e.reason)}</div>` : ""}
|
||||
</li>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
if (more) more.style.display = auditHasMore ? "" : "none";
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Validation helpers.
|
||||
// --------------------------------------------------------------------
|
||||
function validateConditionExpr(): { ok: boolean; value: unknown | undefined; msg: string } {
|
||||
const raw = getInput("f-condition-expr");
|
||||
const msgEl = document.getElementById("f-condition-msg") as HTMLElement | null;
|
||||
if (!raw) {
|
||||
if (msgEl) {
|
||||
msgEl.textContent = "";
|
||||
msgEl.className = "admin-rules-hint";
|
||||
}
|
||||
return { ok: true, value: undefined, msg: "" };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (msgEl) {
|
||||
msgEl.textContent = "✓ " + (t("admin.rules.edit.field.condition.valid") || "JSON gültig.");
|
||||
msgEl.className = "admin-rules-hint admin-rules-hint-ok";
|
||||
}
|
||||
return { ok: true, value: parsed, msg: "" };
|
||||
} catch (err) {
|
||||
const m = err instanceof Error ? err.message : String(err);
|
||||
if (msgEl) {
|
||||
msgEl.textContent = "⚠ " + m;
|
||||
msgEl.className = "admin-rules-hint admin-rules-hint-error";
|
||||
}
|
||||
return { ok: false, value: undefined, msg: m };
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Action modal (reason + lifecycle handler).
|
||||
// --------------------------------------------------------------------
|
||||
type Action = "save-draft" | "publish" | "clone" | "archive" | "restore";
|
||||
|
||||
let pendingAction: Action | null = null;
|
||||
|
||||
function openActionModal(action: Action) {
|
||||
pendingAction = action;
|
||||
const modal = document.getElementById("rules-action-modal") as HTMLElement;
|
||||
const title = document.getElementById("rules-action-modal-title") as HTMLElement;
|
||||
const body = document.getElementById("rules-action-modal-body") as HTMLElement;
|
||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||
const reasonInput = document.getElementById("rules-action-modal-reason") as HTMLTextAreaElement;
|
||||
msg.style.display = "none";
|
||||
reasonInput.value = "";
|
||||
switch (action) {
|
||||
case "save-draft":
|
||||
title.textContent = t("admin.rules.edit.modal.save_draft.title") || "Draft speichern";
|
||||
body.textContent = t("admin.rules.edit.modal.save_draft.body") || "Bitte einen Grund für die Änderung angeben (mind. 10 Zeichen). Wird ins Audit-Log geschrieben.";
|
||||
break;
|
||||
case "publish":
|
||||
title.textContent = t("admin.rules.edit.modal.publish.title") || "Publish";
|
||||
body.textContent = t("admin.rules.edit.modal.publish.body") || "Diese Draft-Regel wird live geschaltet. Bestehende publizierte Variante wird archiviert.";
|
||||
break;
|
||||
case "clone":
|
||||
title.textContent = t("admin.rules.edit.modal.clone.title") || "Als Draft klonen";
|
||||
body.textContent = t("admin.rules.edit.modal.clone.body") || "Eine neue Draft-Kopie dieser Regel wird angelegt. Sie werden auf die neue Draft-Seite weitergeleitet.";
|
||||
break;
|
||||
case "archive":
|
||||
title.textContent = t("admin.rules.edit.modal.archive.title") || "Archivieren";
|
||||
body.textContent = t("admin.rules.edit.modal.archive.body") || "Regel wird archiviert. Calculator nutzt sie nicht mehr.";
|
||||
break;
|
||||
case "restore":
|
||||
title.textContent = t("admin.rules.edit.modal.restore.title") || "Wiederherstellen";
|
||||
body.textContent = t("admin.rules.edit.modal.restore.body") || "Regel wird wiederhergestellt (archived → published).";
|
||||
break;
|
||||
}
|
||||
modal.style.display = "flex";
|
||||
reasonInput.focus();
|
||||
}
|
||||
|
||||
function closeActionModal() {
|
||||
(document.getElementById("rules-action-modal") as HTMLElement).style.display = "none";
|
||||
pendingAction = null;
|
||||
}
|
||||
|
||||
async function submitActionModal(ev: Event) {
|
||||
ev.preventDefault();
|
||||
if (!pendingAction || !rule) return;
|
||||
const reasonInput = document.getElementById("rules-action-modal-reason") as HTMLTextAreaElement;
|
||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||
const submit = document.getElementById("rules-action-modal-submit") as HTMLButtonElement;
|
||||
const reason = reasonInput.value.trim();
|
||||
if (reason.length < 10) {
|
||||
msg.textContent = t("admin.rules.modal.reason.too_short") || "Grund muss mindestens 10 Zeichen enthalten.";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
return;
|
||||
}
|
||||
submit.disabled = true;
|
||||
try {
|
||||
if (pendingAction === "save-draft") {
|
||||
await doSaveDraft(reason);
|
||||
} else if (pendingAction === "publish") {
|
||||
await doLifecycle("publish", reason);
|
||||
} else if (pendingAction === "clone") {
|
||||
await doClone(reason);
|
||||
} else if (pendingAction === "archive") {
|
||||
await doLifecycle("archive", reason);
|
||||
} else if (pendingAction === "restore") {
|
||||
await doLifecycle("restore", reason);
|
||||
}
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPatchPayload(): Record<string, unknown> {
|
||||
const validation = validateConditionExpr();
|
||||
if (!validation.ok) throw new Error(validation.msg);
|
||||
const payload: Record<string, unknown> = {
|
||||
name: getInput("f-name"),
|
||||
name_en: getInput("f-name-en"),
|
||||
description: getInput("f-description"),
|
||||
primary_party: getInput("f-primary-party"),
|
||||
event_type: getInput("f-event-type"),
|
||||
duration_value: getOptionalInt("f-duration") ?? 0,
|
||||
duration_unit: getInput("f-duration-unit"),
|
||||
timing: getOptionalString("f-timing"),
|
||||
alt_duration_value: getOptionalInt("f-alt-duration"),
|
||||
alt_duration_unit: getOptionalString("f-alt-duration-unit"),
|
||||
alt_rule_code: getOptionalString("f-alt-rule-code"),
|
||||
anchor_alt: getOptionalString("f-anchor-alt"),
|
||||
combine_op: getOptionalString("f-combine-op"),
|
||||
rule_code: getOptionalString("f-rule-code"),
|
||||
legal_source: getOptionalString("f-legal-source"),
|
||||
deadline_notes: getInput("f-notes"),
|
||||
deadline_notes_en: getInput("f-notes-en"),
|
||||
priority: getInput("f-priority"),
|
||||
is_court_set: getCheckbox("f-is-court-set"),
|
||||
is_spawn: getCheckbox("f-is-spawn"),
|
||||
spawn_label: getOptionalString("f-spawn-label"),
|
||||
spawn_proceeding_type_id: getOptionalInt("f-spawn-proceeding"),
|
||||
trigger_event_id: getOptionalInt("f-trigger"),
|
||||
sequence_order: getOptionalInt("f-sequence") ?? 0,
|
||||
};
|
||||
if (validation.value !== undefined) {
|
||||
payload.condition_expr = validation.value;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function doSaveDraft(reason: string) {
|
||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = buildPatchPayload();
|
||||
} catch (e) {
|
||||
msg.textContent = e instanceof Error ? e.message : String(e);
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
return;
|
||||
}
|
||||
payload.reason = reason;
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
msg.textContent = body.error || (t("admin.rules.edit.action.save_draft.error") || "Speichern fehlgeschlagen.");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
return;
|
||||
}
|
||||
rule = await resp.json() as Rule;
|
||||
closeActionModal();
|
||||
populateForm();
|
||||
updateLifecycleUI();
|
||||
await loadAudit(true);
|
||||
showFeedback(t("admin.rules.edit.action.save_draft.ok") || "Draft gespeichert.", false);
|
||||
}
|
||||
|
||||
async function doLifecycle(op: "publish" | "archive" | "restore", reason: string) {
|
||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/${op}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
msg.textContent = body.error || (tDyn(`admin.rules.edit.action.${op}.error`) || "Aktion fehlgeschlagen.");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
return;
|
||||
}
|
||||
rule = await resp.json() as Rule;
|
||||
closeActionModal();
|
||||
populateForm();
|
||||
updateLifecycleUI();
|
||||
await loadAudit(true);
|
||||
showFeedback(tDyn(`admin.rules.edit.action.${op}.ok`) || (t("admin.rules.edit.action.ok") || "Erledigt."), false);
|
||||
}
|
||||
|
||||
async function doClone(reason: string) {
|
||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/clone-as-draft`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
msg.textContent = body.error || (t("admin.rules.edit.action.clone.error") || "Klonen fehlgeschlagen.");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
return;
|
||||
}
|
||||
const newRule = await resp.json() as Rule;
|
||||
window.location.href = `/admin/rules/${encodeURIComponent(newRule.id)}/edit`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Preview.
|
||||
// --------------------------------------------------------------------
|
||||
async function runPreview() {
|
||||
const out = document.getElementById("preview-result") as HTMLElement;
|
||||
if (!rule) return;
|
||||
if (rule.lifecycle_state !== "draft") {
|
||||
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(t("admin.rules.edit.preview.only_drafts") || "Preview ist nur für Drafts verfügbar.")}</p>`;
|
||||
out.style.display = "";
|
||||
return;
|
||||
}
|
||||
const triggerDate = getInput("preview-trigger-date");
|
||||
if (!triggerDate) {
|
||||
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(t("admin.rules.edit.preview.trigger_required") || "Bitte Trigger-Datum angeben.")}</p>`;
|
||||
out.style.display = "";
|
||||
return;
|
||||
}
|
||||
const flagsRaw = getInput("preview-flags");
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("trigger_date", triggerDate);
|
||||
if (flagsRaw) qs.set("flags", flagsRaw);
|
||||
out.innerHTML = `<p class="admin-rules-loading">${esc(t("admin.rules.edit.preview.running") || "Berechne...")}</p>`;
|
||||
out.style.display = "";
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(body.error || (t("admin.rules.edit.preview.error") || "Preview fehlgeschlagen."))}</p>`;
|
||||
return;
|
||||
}
|
||||
const body = await resp.json();
|
||||
renderPreview(body);
|
||||
}
|
||||
|
||||
function renderPreview(resp: unknown) {
|
||||
const out = document.getElementById("preview-result") as HTMLElement;
|
||||
type Result = { deadlines?: Array<{ name?: string; titleDE?: string; due_date?: string; dueDate?: string; ruleCode?: string; rule_code?: string }>; deadline?: Array<unknown> };
|
||||
const r = resp as Result;
|
||||
const list = (r && (r.deadlines || r.deadline)) as Array<Record<string, unknown>> | undefined;
|
||||
if (!list || list.length === 0) {
|
||||
out.innerHTML = `<p class="admin-rules-hint">${esc(t("admin.rules.edit.preview.empty") || "Keine Deadlines.")}</p>`;
|
||||
return;
|
||||
}
|
||||
out.innerHTML = `<ul class="admin-rules-preview-list">${list.map((d) => {
|
||||
const name = String(d.name || d.titleDE || d.title || "");
|
||||
const date = String(d.due_date || d.dueDate || "");
|
||||
const code = String(d.rule_code || d.ruleCode || "");
|
||||
return `<li>
|
||||
${code ? `<code>${esc(code)}</code>` : ""}
|
||||
<span class="admin-rules-preview-name">${esc(name)}</span>
|
||||
<span class="admin-rules-preview-date">${esc(date)}</span>
|
||||
</li>`;
|
||||
}).join("")}</ul>`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Init.
|
||||
// --------------------------------------------------------------------
|
||||
async function init() {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
ruleId = parseRuleIDFromPath();
|
||||
if (!ruleId) {
|
||||
showFeedback(t("admin.rules.edit.error.bad_id") || "Ungültige Regel-ID in der URL.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
(document.getElementById("rules-action-modal-close") as HTMLElement).addEventListener("click", closeActionModal);
|
||||
(document.getElementById("rules-action-modal-cancel") as HTMLElement).addEventListener("click", closeActionModal);
|
||||
(document.getElementById("rules-action-modal-form") as HTMLFormElement).addEventListener("submit", submitActionModal);
|
||||
|
||||
(document.getElementById("action-save-draft") as HTMLElement).addEventListener("click", () => openActionModal("save-draft"));
|
||||
(document.getElementById("action-publish") as HTMLElement).addEventListener("click", () => openActionModal("publish"));
|
||||
(document.getElementById("action-clone") as HTMLElement).addEventListener("click", () => openActionModal("clone"));
|
||||
(document.getElementById("action-archive") as HTMLElement).addEventListener("click", () => openActionModal("archive"));
|
||||
(document.getElementById("action-restore") as HTMLElement).addEventListener("click", () => openActionModal("restore"));
|
||||
|
||||
(document.getElementById("f-is-spawn") as HTMLInputElement).addEventListener("change", toggleSpawnRow);
|
||||
(document.getElementById("f-condition-expr") as HTMLTextAreaElement).addEventListener("input", () => {
|
||||
validateConditionExpr();
|
||||
});
|
||||
|
||||
(document.getElementById("preview-run") as HTMLElement).addEventListener("click", () => {
|
||||
window.clearTimeout(previewDebounce);
|
||||
previewDebounce = window.setTimeout(runPreview, 100);
|
||||
});
|
||||
(document.getElementById("audit-loadmore") as HTMLElement).addEventListener("click", () => loadAudit(false));
|
||||
|
||||
await Promise.all([loadProceedings(), loadTriggers()]);
|
||||
await loadRule();
|
||||
await loadAudit(true);
|
||||
|
||||
onLangChange(() => {
|
||||
if (rule) {
|
||||
populateForm();
|
||||
updateLifecycleUI();
|
||||
}
|
||||
renderAudit();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
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);
|
||||
520
frontend/src/client/admin-rules-list.ts
Normal file
520
frontend/src/client/admin-rules-list.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-rules-list.ts — /admin/rules. Drives the rule table (filterable
|
||||
// by proceeding type, trigger event, lifecycle state, free-text query)
|
||||
// plus the Orphans tab (Slice 10 backfill staging rows). Row click on
|
||||
// a rule routes to /admin/rules/{id}/edit; orphan cards have their own
|
||||
// "Pick" affordance with an inline reason prompt that posts to
|
||||
// /admin/api/orphans/{id}/resolve.
|
||||
|
||||
interface Rule {
|
||||
id: string;
|
||||
proceeding_type_id?: number | null;
|
||||
code?: string | null;
|
||||
rule_code?: string | null;
|
||||
name: string;
|
||||
name_en: string;
|
||||
priority: string;
|
||||
lifecycle_state: string;
|
||||
updated_at: string;
|
||||
trigger_event_id?: number | null;
|
||||
duration_value: number;
|
||||
duration_unit: string;
|
||||
}
|
||||
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface TriggerEvent {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_de: string;
|
||||
}
|
||||
|
||||
interface OrphanCandidate {
|
||||
id: string;
|
||||
rule_code?: string | null;
|
||||
name: string;
|
||||
name_en: string;
|
||||
}
|
||||
|
||||
interface Orphan {
|
||||
id: string;
|
||||
deadline_id: string;
|
||||
title: string;
|
||||
project_id?: string | null;
|
||||
project_title?: string | null;
|
||||
proceeding_code?: string | null;
|
||||
reason: string;
|
||||
candidate_count: number;
|
||||
candidate_ids: string[];
|
||||
candidates: OrphanCandidate[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
let rules: Rule[] = [];
|
||||
let orphans: Orphan[] = [];
|
||||
let proceedings: ProceedingType[] = [];
|
||||
let triggerEvents: TriggerEvent[] = [];
|
||||
|
||||
let activeProceeding = "";
|
||||
let activeTrigger = "";
|
||||
let activeLifecycle = "";
|
||||
let activeQuery = "";
|
||||
let searchDebounce: number | undefined;
|
||||
|
||||
function esc(s: string | null | undefined): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s ?? "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
return d.toLocaleString(locale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function showFeedback(msg: string, isError: boolean) {
|
||||
const el = document.getElementById("rules-feedback") as HTMLElement | null;
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
|
||||
el.style.display = "block";
|
||||
if (!isError) {
|
||||
setTimeout(() => { el.style.display = "none"; }, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function lifecycleLabel(state: string): string {
|
||||
return tDyn(`admin.rules.lifecycle.${state}`) || state;
|
||||
}
|
||||
|
||||
function lifecycleClass(state: string): string {
|
||||
switch (state) {
|
||||
case "draft": return "admin-rules-pill admin-rules-pill-draft";
|
||||
case "published": return "admin-rules-pill admin-rules-pill-published";
|
||||
case "archived": return "admin-rules-pill admin-rules-pill-archived";
|
||||
default: return "admin-rules-pill";
|
||||
}
|
||||
}
|
||||
|
||||
function priorityLabel(p: string): string {
|
||||
return tDyn(`admin.rules.priority.${p}`) || p;
|
||||
}
|
||||
|
||||
function proceedingLabel(id: number | null | undefined): string {
|
||||
if (id == null) return "—";
|
||||
const pt = proceedings.find((p) => p.id === id);
|
||||
if (!pt) return `#${id}`;
|
||||
const name = getLang() === "en" ? pt.name_en : pt.name_de;
|
||||
return `${pt.code} · ${name}`;
|
||||
}
|
||||
|
||||
function buildFilterURL(): string {
|
||||
const qs = new URLSearchParams();
|
||||
if (activeProceeding) qs.set("proceeding_type_id", activeProceeding);
|
||||
if (activeTrigger) qs.set("trigger_event_id", activeTrigger);
|
||||
if (activeLifecycle) qs.set("lifecycle_state", activeLifecycle);
|
||||
if (activeQuery) qs.set("q", activeQuery);
|
||||
qs.set("limit", "500");
|
||||
return "/admin/api/rules?" + qs.toString();
|
||||
}
|
||||
|
||||
async function loadProceedings(): Promise<void> {
|
||||
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
|
||||
if (!resp.ok) return;
|
||||
proceedings = (await resp.json()) as ProceedingType[];
|
||||
const sel = document.getElementById("rules-filter-proceeding") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
// Preserve the "Alle" placeholder option then append every proceeding.
|
||||
// The placeholder is the one with empty value already in the markup.
|
||||
const placeholder = sel.querySelector('option[value=""]');
|
||||
sel.innerHTML = "";
|
||||
if (placeholder) sel.appendChild(placeholder);
|
||||
for (const pt of proceedings) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(pt.id);
|
||||
opt.textContent = `${pt.code} · ${getLang() === "en" ? pt.name_en : pt.name_de}`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTriggerEvents(): Promise<void> {
|
||||
const resp = await fetch("/api/tools/trigger-events");
|
||||
if (!resp.ok) return;
|
||||
triggerEvents = (await resp.json()) as TriggerEvent[];
|
||||
const sel = document.getElementById("rules-filter-trigger") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const placeholder = sel.querySelector('option[value=""]');
|
||||
sel.innerHTML = "";
|
||||
if (placeholder) sel.appendChild(placeholder);
|
||||
for (const te of triggerEvents) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(te.id);
|
||||
opt.textContent = `${te.code} · ${getLang() === "en" ? te.name : te.name_de}`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRules(): Promise<void> {
|
||||
const resp = await fetch(buildFilterURL());
|
||||
if (!resp.ok) {
|
||||
showFeedback(t("admin.rules.error.load") || "Konnte Regeln nicht laden.", true);
|
||||
rules = [];
|
||||
return;
|
||||
}
|
||||
const body = await resp.json();
|
||||
rules = Array.isArray(body) ? body as Rule[] : [];
|
||||
}
|
||||
|
||||
async function loadOrphans(): Promise<void> {
|
||||
const resp = await fetch("/admin/api/orphans");
|
||||
if (!resp.ok) {
|
||||
orphans = [];
|
||||
return;
|
||||
}
|
||||
const body = await resp.json();
|
||||
orphans = Array.isArray(body) ? body as Orphan[] : [];
|
||||
updateOrphansBadge();
|
||||
}
|
||||
|
||||
function updateOrphansBadge() {
|
||||
const badge = document.getElementById("rules-orphans-badge") as HTMLElement | null;
|
||||
if (!badge) return;
|
||||
if (orphans.length === 0) {
|
||||
badge.style.display = "none";
|
||||
} else {
|
||||
badge.style.display = "";
|
||||
badge.textContent = String(orphans.length);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRulesTable() {
|
||||
const tbody = document.getElementById("rules-tbody") as HTMLElement | null;
|
||||
const empty = document.getElementById("rules-empty") as HTMLElement | null;
|
||||
if (!tbody || !empty) return;
|
||||
|
||||
if (rules.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
const name = (r: Rule) => (getLang() === "en" ? r.name_en : r.name) || r.name;
|
||||
tbody.innerHTML = rules.map((r) => `
|
||||
<tr data-row-id="${esc(r.id)}" class="admin-rules-row">
|
||||
<td class="admin-rules-col-code"><code>${esc(r.rule_code || r.code || "")}</code></td>
|
||||
<td>${esc(name(r))}</td>
|
||||
<td>${esc(proceedingLabel(r.proceeding_type_id ?? null))}</td>
|
||||
<td><span class="admin-rules-priority admin-rules-priority-${esc(r.priority)}">${esc(priorityLabel(r.priority))}</span></td>
|
||||
<td><span class="${lifecycleClass(r.lifecycle_state)}">${esc(lifecycleLabel(r.lifecycle_state))}</span></td>
|
||||
<td class="admin-rules-col-modified">${esc(fmtDateTime(r.updated_at))}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
tbody.querySelectorAll<HTMLElement>(".admin-rules-row").forEach((row) => {
|
||||
row.addEventListener("click", (ev) => {
|
||||
const target = ev.target as HTMLElement | null;
|
||||
if (target && (target.closest("a") || target.closest("button"))) return;
|
||||
const id = row.dataset.rowId;
|
||||
if (!id) return;
|
||||
window.location.href = `/admin/rules/${encodeURIComponent(id)}/edit`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderOrphans() {
|
||||
const list = document.getElementById("rules-orphans-list") as HTMLElement | null;
|
||||
if (!list) return;
|
||||
if (orphans.length === 0) {
|
||||
list.innerHTML = `<p class="entity-empty" data-i18n="admin.rules.orphans.empty">${esc(t("admin.rules.orphans.empty") || "Keine offenen Orphans. ✔")}</p>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = orphans.map((o) => renderOrphanCard(o)).join("");
|
||||
list.querySelectorAll<HTMLButtonElement>(".admin-rules-orphan-pick").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const orphanId = btn.dataset.orphanId!;
|
||||
const ruleId = btn.dataset.ruleId!;
|
||||
onPickOrphanCandidate(orphanId, ruleId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderOrphanCard(o: Orphan): string {
|
||||
const reasonLabel = tDyn(`admin.rules.orphans.reason.${o.reason}`) || o.reason;
|
||||
const meta = [
|
||||
o.project_title ? `<span class="admin-rules-orphan-meta">${esc(t("admin.rules.orphans.field.project") || "Projekt")}: ${esc(o.project_title)}</span>` : "",
|
||||
o.proceeding_code ? `<span class="admin-rules-orphan-meta">${esc(t("admin.rules.orphans.field.proceeding") || "Verfahren")}: <code>${esc(o.proceeding_code)}</code></span>` : "",
|
||||
`<span class="admin-rules-orphan-meta">${esc(t("admin.rules.orphans.field.reason") || "Grund")}: ${esc(reasonLabel)}</span>`,
|
||||
].filter(Boolean).join(" · ");
|
||||
|
||||
let candidatesHTML = "";
|
||||
if (o.candidates.length === 0) {
|
||||
candidatesHTML = `<p class="admin-rules-orphan-empty">${esc(t("admin.rules.orphans.no_candidates") || "Keine Kandidaten gefunden. Bitte Regel manuell anlegen.")}</p>`;
|
||||
} else {
|
||||
candidatesHTML = `<div class="admin-rules-orphan-candidates">
|
||||
${o.candidates.map((c) => {
|
||||
const cname = getLang() === "en" ? c.name_en : c.name;
|
||||
return `<button type="button" class="admin-rules-orphan-pick"
|
||||
data-orphan-id="${esc(o.id)}" data-rule-id="${esc(c.id)}">
|
||||
<code>${esc(c.rule_code || "")}</code>
|
||||
<span class="admin-rules-orphan-pick-name">${esc(cname)}</span>
|
||||
</button>`;
|
||||
}).join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="admin-rules-orphan-card" data-orphan-id="${esc(o.id)}">
|
||||
<div class="admin-rules-orphan-header">
|
||||
<div class="admin-rules-orphan-title">${esc(o.title)}</div>
|
||||
<div class="admin-rules-orphan-metas">${meta}</div>
|
||||
</div>
|
||||
${candidatesHTML}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Reason modal — shared between "+ Neue Regel" and orphan resolve.
|
||||
// --------------------------------------------------------------------
|
||||
type ModalContext =
|
||||
| { kind: "new-rule" }
|
||||
| { kind: "orphan-resolve"; orphanId: string; ruleId: string };
|
||||
|
||||
let modalCtx: ModalContext | null = null;
|
||||
|
||||
function openReasonModal(ctx: ModalContext) {
|
||||
modalCtx = ctx;
|
||||
const modal = document.getElementById("rules-reason-modal") as HTMLElement;
|
||||
const title = document.getElementById("rules-reason-title") as HTMLElement;
|
||||
const body = document.getElementById("rules-reason-body") as HTMLElement;
|
||||
const extra = document.getElementById("rules-reason-extra") as HTMLElement;
|
||||
const msg = document.getElementById("rules-reason-msg") as HTMLElement;
|
||||
const reasonInput = document.getElementById("rules-reason-text") as HTMLTextAreaElement;
|
||||
msg.style.display = "none";
|
||||
reasonInput.value = "";
|
||||
extra.innerHTML = "";
|
||||
|
||||
if (ctx.kind === "new-rule") {
|
||||
title.textContent = t("admin.rules.modal.new.title") || "Neue Regel anlegen";
|
||||
body.textContent = t("admin.rules.modal.new.body") || "Eine neue Regel wird als Draft angelegt. Bitte einen Grund angeben.";
|
||||
extra.innerHTML = `
|
||||
<div class="form-field">
|
||||
<label for="rules-new-name" data-i18n="admin.rules.modal.field.name">Name (DE)</label>
|
||||
<input type="text" id="rules-new-name" class="admin-rules-input" required minlength="2" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="rules-new-name-en" data-i18n="admin.rules.modal.field.name_en">Name (EN)</label>
|
||||
<input type="text" id="rules-new-name-en" class="admin-rules-input" required minlength="2" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="rules-new-duration" data-i18n="admin.rules.modal.field.duration">Dauer</label>
|
||||
<div class="admin-rules-duration-row">
|
||||
<input type="number" id="rules-new-duration" class="admin-rules-input" min="0" value="0" required />
|
||||
<select id="rules-new-unit" class="admin-rules-select">
|
||||
<option value="days">days</option>
|
||||
<option value="weeks">weeks</option>
|
||||
<option value="months">months</option>
|
||||
<option value="working_days">working_days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
title.textContent = t("admin.rules.modal.resolve.title") || "Orphan zuordnen";
|
||||
body.textContent = t("admin.rules.modal.resolve.body") || "Bitte einen Grund (mind. 10 Zeichen) angeben.";
|
||||
}
|
||||
modal.style.display = "flex";
|
||||
reasonInput.focus();
|
||||
}
|
||||
|
||||
function closeReasonModal() {
|
||||
const modal = document.getElementById("rules-reason-modal") as HTMLElement;
|
||||
modal.style.display = "none";
|
||||
modalCtx = null;
|
||||
}
|
||||
|
||||
async function submitReasonModal(ev: Event) {
|
||||
ev.preventDefault();
|
||||
if (!modalCtx) return;
|
||||
const reasonInput = document.getElementById("rules-reason-text") as HTMLTextAreaElement;
|
||||
const msg = document.getElementById("rules-reason-msg") as HTMLElement;
|
||||
const submit = document.getElementById("rules-reason-submit") as HTMLButtonElement;
|
||||
const reason = reasonInput.value.trim();
|
||||
if (reason.length < 10) {
|
||||
msg.textContent = t("admin.rules.modal.reason.too_short") || "Grund muss mindestens 10 Zeichen enthalten.";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
return;
|
||||
}
|
||||
submit.disabled = true;
|
||||
try {
|
||||
if (modalCtx.kind === "new-rule") {
|
||||
const name = (document.getElementById("rules-new-name") as HTMLInputElement).value.trim();
|
||||
const nameEn = (document.getElementById("rules-new-name-en") as HTMLInputElement).value.trim();
|
||||
const duration = parseInt((document.getElementById("rules-new-duration") as HTMLInputElement).value, 10);
|
||||
const unit = (document.getElementById("rules-new-unit") as HTMLSelectElement).value;
|
||||
if (!name || !nameEn) {
|
||||
msg.textContent = t("admin.rules.modal.error.name_required") || "Bitte Name und Name (EN) angeben.";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
submit.disabled = false;
|
||||
return;
|
||||
}
|
||||
const resp = await fetch("/admin/api/rules", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
name_en: nameEn,
|
||||
duration_value: Number.isFinite(duration) ? duration : 0,
|
||||
duration_unit: unit,
|
||||
priority: "mandatory",
|
||||
is_court_set: false,
|
||||
is_spawn: false,
|
||||
sequence_order: 0,
|
||||
reason,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
msg.textContent = body.error || t("admin.rules.modal.error.create") || "Anlegen fehlgeschlagen.";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
submit.disabled = false;
|
||||
return;
|
||||
}
|
||||
const created = await resp.json();
|
||||
window.location.href = `/admin/rules/${encodeURIComponent(created.id)}/edit`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalCtx.kind === "orphan-resolve") {
|
||||
const resp = await fetch(`/admin/api/orphans/${encodeURIComponent(modalCtx.orphanId)}/resolve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rule_id: modalCtx.ruleId, reason }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
msg.textContent = body.error || t("admin.rules.modal.error.resolve") || "Zuordnung fehlgeschlagen.";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
msg.style.display = "block";
|
||||
submit.disabled = false;
|
||||
return;
|
||||
}
|
||||
closeReasonModal();
|
||||
showFeedback(t("admin.rules.orphans.resolved") || "Orphan zugeordnet.", false);
|
||||
await loadOrphans();
|
||||
renderOrphans();
|
||||
}
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onPickOrphanCandidate(orphanId: string, ruleId: string) {
|
||||
openReasonModal({ kind: "orphan-resolve", orphanId, ruleId });
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Tabs + filter wiring.
|
||||
// --------------------------------------------------------------------
|
||||
function setActiveTab(name: "rules" | "orphans") {
|
||||
const paneRules = document.getElementById("rules-pane-rules") as HTMLElement;
|
||||
const paneOrphans = document.getElementById("rules-pane-orphans") as HTMLElement;
|
||||
const tabRules = document.getElementById("rules-tab-rules") as HTMLElement;
|
||||
const tabOrphans = document.getElementById("rules-tab-orphans") as HTMLElement;
|
||||
if (name === "rules") {
|
||||
paneRules.style.display = "";
|
||||
paneOrphans.style.display = "none";
|
||||
tabRules.classList.add("active");
|
||||
tabOrphans.classList.remove("active");
|
||||
} else {
|
||||
paneRules.style.display = "none";
|
||||
paneOrphans.style.display = "";
|
||||
tabRules.classList.remove("active");
|
||||
tabOrphans.classList.add("active");
|
||||
renderOrphans();
|
||||
}
|
||||
}
|
||||
|
||||
function wireFilters() {
|
||||
const proc = document.getElementById("rules-filter-proceeding") as HTMLSelectElement;
|
||||
const trig = document.getElementById("rules-filter-trigger") as HTMLSelectElement;
|
||||
const search = document.getElementById("rules-filter-search") as HTMLInputElement;
|
||||
proc.addEventListener("change", async () => {
|
||||
activeProceeding = proc.value;
|
||||
await loadRules();
|
||||
renderRulesTable();
|
||||
});
|
||||
trig.addEventListener("change", async () => {
|
||||
activeTrigger = trig.value;
|
||||
await loadRules();
|
||||
renderRulesTable();
|
||||
});
|
||||
search.addEventListener("input", () => {
|
||||
window.clearTimeout(searchDebounce);
|
||||
searchDebounce = window.setTimeout(async () => {
|
||||
activeQuery = search.value.trim();
|
||||
await loadRules();
|
||||
renderRulesTable();
|
||||
}, 220);
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("#rules-filter-lifecycle .admin-rules-chip").forEach((chip) => {
|
||||
chip.addEventListener("click", async () => {
|
||||
document.querySelectorAll(".admin-rules-chip").forEach((c) => c.classList.remove("active"));
|
||||
chip.classList.add("active");
|
||||
activeLifecycle = chip.dataset.state || "";
|
||||
await loadRules();
|
||||
renderRulesTable();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wireTabs() {
|
||||
(document.getElementById("rules-tab-rules") as HTMLElement).addEventListener("click", () => setActiveTab("rules"));
|
||||
(document.getElementById("rules-tab-orphans") as HTMLElement).addEventListener("click", () => setActiveTab("orphans"));
|
||||
}
|
||||
|
||||
function wireModal() {
|
||||
(document.getElementById("rules-new-btn") as HTMLElement).addEventListener("click", () => openReasonModal({ kind: "new-rule" }));
|
||||
(document.getElementById("rules-reason-cancel") as HTMLElement).addEventListener("click", closeReasonModal);
|
||||
(document.getElementById("rules-reason-close") as HTMLElement).addEventListener("click", closeReasonModal);
|
||||
(document.getElementById("rules-reason-form") as HTMLFormElement).addEventListener("submit", submitReasonModal);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
wireFilters();
|
||||
wireTabs();
|
||||
wireModal();
|
||||
await Promise.all([loadProceedings(), loadTriggerEvents()]);
|
||||
await Promise.all([loadRules(), loadOrphans()]);
|
||||
renderRulesTable();
|
||||
// Re-render proceeding labels when language changes
|
||||
onLangChange(() => {
|
||||
renderRulesTable();
|
||||
renderOrphans();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
@@ -1,10 +1,23 @@
|
||||
import { initI18n, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { attachEventTypePicker, type PickerHandle } from "./event-types";
|
||||
import {
|
||||
attachEventTypePicker,
|
||||
eventTypeLabel,
|
||||
fetchEventTypes,
|
||||
type EventType,
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let currentUserAdmin = false;
|
||||
let eventTypesByID = new Map<string, EventType>();
|
||||
// expandedOverride flips to true when the user clicks "Anderen Typ
|
||||
// wählen" on the collapsed inline summary. Sticky for the rest of the
|
||||
// form session — cleared only when the user reverts the rule to "Keine
|
||||
// Regel". When true, the picker stays visible regardless of whether
|
||||
// the chip matches the rule's canonical default.
|
||||
let expandedOverride = false;
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -19,8 +32,22 @@ interface DeadlineRule {
|
||||
name: string;
|
||||
name_en: string;
|
||||
rule_code?: string;
|
||||
// t-paliad-165 — canonical event_type for this rule's concept,
|
||||
// hydrated server-side from paliad.deadline_concept_event_types.
|
||||
// Drives auto-fill of the Typ chip when the user picks this rule.
|
||||
concept_default_event_type_id?: string | null;
|
||||
}
|
||||
|
||||
// Rules indexed by id so the Regel-change handler can look up the
|
||||
// concept's canonical event_type without re-fetching.
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
|
||||
// Last event_type the rule auto-filled. Tracked so we can tell whether
|
||||
// the picker still reflects the rule's suggestion (replace silently on
|
||||
// new rule pick) or whether the user has manually edited (leave alone,
|
||||
// surface the mismatch warning instead).
|
||||
let lastAutoFilledEventTypeID: string | null = null;
|
||||
|
||||
let preselectedProjectID = "";
|
||||
|
||||
function esc(s: string): string {
|
||||
@@ -71,6 +98,7 @@ async function loadRules() {
|
||||
const resp = await fetch("/api/deadline-rules");
|
||||
if (!resp.ok) return;
|
||||
const rules: DeadlineRule[] = await resp.json();
|
||||
rulesByID = new Map(rules.map((r) => [r.id, r]));
|
||||
const opts: string[] = [
|
||||
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
|
||||
];
|
||||
@@ -85,6 +113,93 @@ async function loadRules() {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
|
||||
// picker. The two modes are mutually exclusive:
|
||||
//
|
||||
// collapsed: rule selected + canonical event_type known + picker
|
||||
// contains exactly [default] + user hasn't clicked "Anderen Typ
|
||||
// wählen". Hides the chip cluster, surfaces a single inline
|
||||
// summary "Klageerwiderung (vorgegeben durch Regel)" + an
|
||||
// override link.
|
||||
//
|
||||
// expanded: every other case — no rule, no default for the rule,
|
||||
// picker has been edited, or expandedOverride is sticky after the
|
||||
// user clicked the override link. Picker visible; mismatch warning
|
||||
// surfaces yellow when the rule expected a different event_type.
|
||||
function refreshRuleView(): void {
|
||||
const collapsed = document.getElementById("deadline-event-type-collapsed");
|
||||
const collapsedLabel = document.getElementById("deadline-event-type-collapsed-label");
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
const warn = document.getElementById("deadline-event-type-rule-mismatch");
|
||||
if (!collapsed || !collapsedLabel || !pickerHost || !warn) return;
|
||||
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
|
||||
const pickerMatchesDefault =
|
||||
expected !== null && picked.length === 1 && picked[0] === expected;
|
||||
const wantsCollapsed =
|
||||
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault;
|
||||
|
||||
if (wantsCollapsed) {
|
||||
const et = eventTypesByID.get(expected!);
|
||||
collapsedLabel.textContent = et ? eventTypeLabel(et) : "";
|
||||
collapsed.style.display = "";
|
||||
pickerHost.style.display = "none";
|
||||
warn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
collapsed.style.display = "none";
|
||||
pickerHost.style.display = "";
|
||||
// Mismatch warning: rule expected an event_type AND the picker
|
||||
// doesn't contain it. (When the picker is empty + no override, no
|
||||
// warning — user is free to leave it blank.)
|
||||
if (expected && picked.length > 0 && !picked.includes(expected)) {
|
||||
warn.style.display = "";
|
||||
} else {
|
||||
warn.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// applyRuleAutoFill replaces the picker silently when it still reflects
|
||||
// the previous rule's suggestion (or is empty); leaves a manually-edited
|
||||
// picker alone. Called whenever the Regel select changes.
|
||||
function applyRuleAutoFill(): void {
|
||||
if (!eventTypePicker) return;
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
const current = eventTypePicker.getIDs();
|
||||
|
||||
// Reset the override on transition to "Keine Regel" — fresh form
|
||||
// session. Otherwise expandedOverride stays sticky.
|
||||
if (ruleID === "") {
|
||||
expandedOverride = false;
|
||||
}
|
||||
|
||||
const pickerStillReflectsLastSuggestion =
|
||||
lastAutoFilledEventTypeID !== null &&
|
||||
current.length === 1 &&
|
||||
current[0] === lastAutoFilledEventTypeID;
|
||||
const pickerIsEmpty = current.length === 0;
|
||||
|
||||
if (expected) {
|
||||
if (pickerIsEmpty || pickerStillReflectsLastSuggestion) {
|
||||
eventTypePicker.setIDs([expected]);
|
||||
lastAutoFilledEventTypeID = expected;
|
||||
}
|
||||
} else if (pickerStillReflectsLastSuggestion) {
|
||||
// New rule has no canonical event_type — clear the stale auto-fill
|
||||
// so the picker doesn't carry a chip from the old rule.
|
||||
eventTypePicker.setIDs([]);
|
||||
lastAutoFilledEventTypeID = null;
|
||||
}
|
||||
refreshRuleView();
|
||||
}
|
||||
|
||||
function initBackLinks() {
|
||||
if (preselectedProjectID) {
|
||||
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
|
||||
@@ -233,8 +348,36 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
if (pickerHost) {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
currentUserAdmin,
|
||||
onChange: () => refreshRuleView(),
|
||||
});
|
||||
}
|
||||
// t-paliad-165 follow-up — preload event_types so the collapsed
|
||||
// summary can render the type's label inline without an extra round
|
||||
// trip when the user picks a Regel.
|
||||
fetchEventTypes()
|
||||
.then((types) => {
|
||||
eventTypesByID = new Map(types.map((et) => [et.id, et]));
|
||||
refreshRuleView();
|
||||
})
|
||||
.catch(() => {/* non-fatal — collapsed view falls back to empty label */});
|
||||
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
|
||||
// concept's canonical event_type, when the picker hasn't been
|
||||
// manually edited away from the previous rule's suggestion.
|
||||
document.getElementById("deadline-rule")?.addEventListener("change", () => {
|
||||
applyRuleAutoFill();
|
||||
});
|
||||
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
|
||||
// visible even when the chip still matches the rule's default.
|
||||
document.getElementById("deadline-event-type-override-btn")?.addEventListener("click", () => {
|
||||
expandedOverride = true;
|
||||
refreshRuleView();
|
||||
// Move focus into the picker's search box so the user can type
|
||||
// immediately without an extra click.
|
||||
const search = document.querySelector<HTMLInputElement>(
|
||||
"#deadline-event-types .event-type-search",
|
||||
);
|
||||
search?.focus();
|
||||
});
|
||||
// Wire approval-hint refresh: on first render + on project change.
|
||||
void refreshApprovalHint();
|
||||
document.getElementById("deadline-project")?.addEventListener("change", () => {
|
||||
|
||||
512
frontend/src/client/filter-bar/axes.ts
Normal file
512
frontend/src/client/filter-bar/axes.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
// Per-axis renderers for the FilterBar — t-paliad-163.
|
||||
//
|
||||
// Each axis is a small, self-contained render function that takes the
|
||||
// current BarState slice and a callback. The bar's mountFilterBar
|
||||
// composes them in the order declared on the surface.
|
||||
//
|
||||
// Reuses existing CSS classes wherever possible:
|
||||
// - .agenda-chip / .agenda-chip-active (chip cluster pattern)
|
||||
// - .filter-group (label + control wrapping)
|
||||
// - .akten-multi-trigger / .multi-anchor / .multi-panel
|
||||
//
|
||||
// New classes are scoped under .filter-bar-* so they don't bleed.
|
||||
|
||||
import { t, tDyn, type I18nKey } from "../i18n";
|
||||
import type { BarState, AxisKey } from "./types";
|
||||
|
||||
export interface AxisCtx {
|
||||
// Read the current value for this axis.
|
||||
get<K extends keyof BarState>(key: K): BarState[K];
|
||||
// Patch one or more axis values + trigger re-run.
|
||||
patch(delta: Partial<BarState>): void;
|
||||
}
|
||||
|
||||
// RenderAxisOpts — per-surface tuning the bar threads through to axis
|
||||
// renderers. Currently only time-axis chip presets; future axes can grow
|
||||
// here without changing every call site.
|
||||
export interface RenderAxisOpts {
|
||||
timePresets?: NonNullable<BarState["time"]>["horizon"][];
|
||||
}
|
||||
|
||||
// renderAxis returns the HTML element for a single axis. The bar's
|
||||
// mountFilterBar appends the result to its internal toolbar. Returns
|
||||
// null when the axis is ignored (e.g. surface didn't declare it).
|
||||
export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts): HTMLElement | null {
|
||||
switch (axis) {
|
||||
case "time": return renderTimeAxis(ctx, opts?.timePresets);
|
||||
case "project": return null; // populated lazily — see attachProjectAxis below
|
||||
case "personal_only": return renderPersonalOnlyAxis(ctx);
|
||||
case "approval_viewer_role": return renderApprovalRoleAxis(ctx);
|
||||
case "approval_status": return renderApprovalStatusAxis(ctx);
|
||||
case "approval_entity_type": return renderApprovalEntityTypeAxis(ctx);
|
||||
case "deadline_status": return renderDeadlineStatusAxis(ctx);
|
||||
case "appointment_type": return renderAppointmentTypeAxis(ctx);
|
||||
case "project_event_kind": return renderProjectEventKindAxis(ctx);
|
||||
case "timeline_status": return renderTimelineStatusAxis(ctx);
|
||||
case "timeline_track": return renderTimelineTrackAxis(ctx);
|
||||
case "shape": return renderShapeAxis(ctx);
|
||||
case "density": return renderDensityAxis(ctx);
|
||||
case "sort": return renderSortAxis(ctx);
|
||||
|
||||
// Per-source predicates that need their own widgets and a roundtrip
|
||||
// through fetched option lists. Phase 2+ will fill these in by
|
||||
// wiring the existing event-types component.
|
||||
case "deadline_event_type":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// time — chip cluster (presets + Anpassen)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type TimeHorizonValue = NonNullable<BarState["time"]>["horizon"];
|
||||
|
||||
const TIME_PRESET_LABELS: Record<TimeHorizonValue, I18nKey> = {
|
||||
next_7d: "views.bar.time.next_7d",
|
||||
next_30d: "views.bar.time.next_30d",
|
||||
next_90d: "views.bar.time.next_90d",
|
||||
past_7d: "views.bar.time.past_7d",
|
||||
past_30d: "views.bar.time.past_30d",
|
||||
past_90d: "views.bar.time.past_90d",
|
||||
any: "views.bar.time.any",
|
||||
all: "views.bar.time.all",
|
||||
custom: "views.bar.time.custom",
|
||||
};
|
||||
|
||||
const DEFAULT_TIME_PRESETS: TimeHorizonValue[] = [
|
||||
"next_7d", "next_30d", "next_90d", "past_30d", "any",
|
||||
];
|
||||
|
||||
function renderTimeAxis(ctx: AxisCtx, presetOverride?: TimeHorizonValue[]): HTMLElement {
|
||||
const wrap = group("views.bar.label.time");
|
||||
const row = chipRow();
|
||||
const presets = presetOverride && presetOverride.length ? presetOverride : DEFAULT_TIME_PRESETS;
|
||||
// "any" / "all" are both unbounded — clearing state is the cleanest
|
||||
// representation, so each maps to "no overlay" rather than a stored
|
||||
// horizon. The chip's active state then keys off "no time set".
|
||||
const current = ctx.get("time")?.horizon ?? "any";
|
||||
for (const preset of presets) {
|
||||
if (preset === "custom") continue; // custom rendered separately below
|
||||
const isUnbounded = preset === "any" || preset === "all";
|
||||
const isActive = isUnbounded
|
||||
? !ctx.get("time")
|
||||
: preset === current;
|
||||
const chip = chipBtn(t(TIME_PRESET_LABELS[preset]), isActive);
|
||||
chip.addEventListener("click", () => {
|
||||
if (isUnbounded) {
|
||||
ctx.patch({ time: undefined });
|
||||
} else {
|
||||
ctx.patch({ time: { horizon: preset } });
|
||||
}
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
// Custom range — placeholder chip; opens a small popover with two
|
||||
// <input type="date"> in Phase 2. For Phase 1 we render the chip
|
||||
// disabled with a tooltip so the affordance is discoverable.
|
||||
const customChip = chipBtn(t("views.bar.time.custom"), current === "custom");
|
||||
customChip.classList.add("filter-bar-chip-pending");
|
||||
customChip.title = t("views.bar.time.custom.coming_soon");
|
||||
customChip.disabled = true;
|
||||
row.appendChild(customChip);
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// personal_only — single chip (binary)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function renderPersonalOnlyAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.personal");
|
||||
const chip = chipBtn(t("views.bar.personal.on"), !!ctx.get("personal_only"));
|
||||
chip.addEventListener("click", () => {
|
||||
ctx.patch({ personal_only: !ctx.get("personal_only") });
|
||||
});
|
||||
wrap.appendChild(chip);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// approval_viewer_role — chip cluster (3 mutually exclusive)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPROVAL_ROLES: Array<{ value: NonNullable<BarState["approval_viewer_role"]>; key: I18nKey }> = [
|
||||
{ value: "approver_eligible", key: "views.bar.approval_role.approver_eligible" },
|
||||
{ value: "self_requested", key: "views.bar.approval_role.self_requested" },
|
||||
{ value: "any_visible", key: "views.bar.approval_role.any_visible" },
|
||||
];
|
||||
|
||||
function renderApprovalRoleAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.approval_role");
|
||||
const row = chipRow();
|
||||
// Default to "any_visible" so the surface lands on a populated view
|
||||
// for every user. The InboxSystemView's base spec also defaults here;
|
||||
// these two defaults must stay in sync — otherwise the chip and the
|
||||
// server narrow disagree on the empty URL.
|
||||
const current = ctx.get("approval_viewer_role") ?? "any_visible";
|
||||
for (const role of APPROVAL_ROLES) {
|
||||
const chip = chipBtn(t(role.key), role.value === current);
|
||||
chip.addEventListener("click", () => {
|
||||
ctx.patch({ approval_viewer_role: role.value });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// approval_status — chip cluster (multi-select)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPROVAL_STATUSES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "pending", key: "views.bar.approval_status.pending" },
|
||||
{ value: "approved", key: "views.bar.approval_status.approved" },
|
||||
{ value: "rejected", key: "views.bar.approval_status.rejected" },
|
||||
{ value: "revoked", key: "views.bar.approval_status.revoked" },
|
||||
];
|
||||
|
||||
function renderApprovalStatusAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.approval_status");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("approval_status")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ approval_status: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("approval_status") ?? []);
|
||||
for (const status of APPROVAL_STATUSES) {
|
||||
const chip = chipBtn(t(status.key), current.has(status.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(status.value)) current.delete(status.value);
|
||||
else current.add(status.value);
|
||||
ctx.patch({ approval_status: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// approval_entity_type — chip pair (multi-select; deadline / appointment)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPROVAL_ENTITY_TYPES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "deadline", key: "views.bar.approval_entity.deadline" },
|
||||
{ value: "appointment", key: "views.bar.approval_entity.appointment" },
|
||||
];
|
||||
|
||||
function renderApprovalEntityTypeAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.approval_entity");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("approval_entity_type")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ approval_entity_type: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("approval_entity_type") ?? []);
|
||||
for (const ent of APPROVAL_ENTITY_TYPES) {
|
||||
const chip = chipBtn(t(ent.key), current.has(ent.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(ent.value)) current.delete(ent.value);
|
||||
else current.add(ent.value);
|
||||
ctx.patch({ approval_entity_type: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// deadline_status — chip cluster (multi-select)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const DEADLINE_STATUSES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "pending", key: "views.bar.deadline_status.pending" },
|
||||
{ value: "completed", key: "views.bar.deadline_status.completed" },
|
||||
];
|
||||
|
||||
function renderDeadlineStatusAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.deadline_status");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("deadline_status")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ deadline_status: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("deadline_status") ?? []);
|
||||
for (const s of DEADLINE_STATUSES) {
|
||||
const chip = chipBtn(t(s.key), current.has(s.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(s.value)) current.delete(s.value);
|
||||
else current.add(s.value);
|
||||
ctx.patch({ deadline_status: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// appointment_type — chip cluster (multi-select)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPOINTMENT_TYPES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "hearing", key: "views.bar.appointment_type.hearing" },
|
||||
{ value: "meeting", key: "views.bar.appointment_type.meeting" },
|
||||
{ value: "consultation", key: "views.bar.appointment_type.consultation" },
|
||||
{ value: "deadline_hearing", key: "views.bar.appointment_type.deadline_hearing" },
|
||||
];
|
||||
|
||||
function renderAppointmentTypeAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.appointment_type");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("appointment_type")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ appointment_type: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("appointment_type") ?? []);
|
||||
for (const ty of APPOINTMENT_TYPES) {
|
||||
const chip = chipBtn(t(ty.key), current.has(ty.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(ty.value)) current.delete(ty.value);
|
||||
else current.add(ty.value);
|
||||
ctx.patch({ appointment_type: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// project_event_kind — chip cluster (multi-select)
|
||||
//
|
||||
// Mirrors KnownProjectEventKinds in internal/services/filter_spec.go.
|
||||
// Labels reuse the existing `event.title.<kind>` translation table so
|
||||
// the chip text matches the Verlauf row title for the same event type.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const PROJECT_EVENT_KINDS: string[] = [
|
||||
"project_created",
|
||||
"project_archived",
|
||||
"project_reparented",
|
||||
"project_type_changed",
|
||||
"status_changed",
|
||||
"deadline_created",
|
||||
"deadline_completed",
|
||||
"deadline_reopened",
|
||||
"appointment_created",
|
||||
"appointment_updated",
|
||||
"appointment_deleted",
|
||||
"approval_decided",
|
||||
"member_role_changed",
|
||||
];
|
||||
|
||||
function renderProjectEventKindAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.project_event_kind");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("project_event_kind")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ project_event_kind: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("project_event_kind") ?? []);
|
||||
for (const kind of PROJECT_EVENT_KINDS) {
|
||||
const label = tDyn(`event.title.${kind}`);
|
||||
const chip = chipBtn(label, current.has(kind));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(kind)) current.delete(kind);
|
||||
else current.add(kind);
|
||||
ctx.patch({ project_event_kind: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// timeline_status — chip cluster (multi-select)
|
||||
//
|
||||
// SmartTimeline (t-paliad-173) status vocabulary spans actuals +
|
||||
// projections. Default: all. Macro chip pair "Zukunft anzeigen" /
|
||||
// "Nur vergangenes" toggles the [predicted, court_set] subset on
|
||||
// or off in one click.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const TIMELINE_STATUSES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "done", key: "views.bar.timeline_status.done" },
|
||||
{ value: "open", key: "views.bar.timeline_status.open" },
|
||||
{ value: "overdue", key: "views.bar.timeline_status.overdue" },
|
||||
{ value: "predicted", key: "views.bar.timeline_status.predicted" },
|
||||
{ value: "predicted_overdue", key: "views.bar.timeline_status.predicted_overdue" },
|
||||
{ value: "court_set", key: "views.bar.timeline_status.court_set" },
|
||||
{ value: "off_script", key: "views.bar.timeline_status.off_script" },
|
||||
];
|
||||
|
||||
function renderTimelineStatusAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.timeline_status");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("timeline_status")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ timeline_status: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("timeline_status") ?? []);
|
||||
for (const s of TIMELINE_STATUSES) {
|
||||
const chip = chipBtn(t(s.key), current.has(s.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(s.value)) current.delete(s.value);
|
||||
else current.add(s.value);
|
||||
ctx.patch({ timeline_status: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
// Macro chips. "Zukunft anzeigen" = include predicted+court_set; "Nur
|
||||
// vergangenes" = strip them. Implemented in terms of timeline_status.
|
||||
const future = chipBtn(t("views.bar.timeline_status.macro.future"), false);
|
||||
future.classList.add("filter-bar-chip-macro");
|
||||
future.addEventListener("click", () => {
|
||||
const next = new Set(["done", "open", "overdue", "predicted", "court_set", "predicted_overdue", "off_script"]);
|
||||
ctx.patch({ timeline_status: [...next] });
|
||||
});
|
||||
row.appendChild(future);
|
||||
const past = chipBtn(t("views.bar.timeline_status.macro.past"), false);
|
||||
past.classList.add("filter-bar-chip-macro");
|
||||
past.addEventListener("click", () => {
|
||||
ctx.patch({ timeline_status: ["done", "overdue", "off_script"] });
|
||||
});
|
||||
row.appendChild(past);
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// timeline_track — chip cluster (multi-select)
|
||||
//
|
||||
// Slice 2 only renders parent + off_script; counterclaim and child:<id>
|
||||
// values land with Slice 3's CCR sub-project FK migration. The renderer
|
||||
// stays ready for those values — chip rendering is dynamic on the
|
||||
// state set, not hard-coded to the catalogue below.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const TIMELINE_TRACKS: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "parent", key: "views.bar.timeline_track.parent" },
|
||||
{ value: "counterclaim", key: "views.bar.timeline_track.counterclaim" },
|
||||
{ value: "off_script", key: "views.bar.timeline_track.off_script" },
|
||||
];
|
||||
|
||||
function renderTimelineTrackAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.timeline_track");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("timeline_track")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ timeline_track: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("timeline_track") ?? []);
|
||||
for (const tr of TIMELINE_TRACKS) {
|
||||
const chip = chipBtn(t(tr.key), current.has(tr.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(tr.value)) current.delete(tr.value);
|
||||
else current.add(tr.value);
|
||||
ctx.patch({ timeline_track: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// shape — segmented control (list / cards / calendar)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const SHAPES: Array<{ value: NonNullable<BarState["shape"]>; key: I18nKey }> = [
|
||||
{ value: "list", key: "views.bar.shape.list" },
|
||||
{ value: "cards", key: "views.bar.shape.cards" },
|
||||
{ value: "calendar", key: "views.bar.shape.calendar" },
|
||||
];
|
||||
|
||||
function renderShapeAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.shape");
|
||||
const row = chipRow();
|
||||
row.classList.add("filter-bar-segment");
|
||||
const current = ctx.get("shape");
|
||||
for (const sh of SHAPES) {
|
||||
const chip = chipBtn(t(sh.key), sh.value === current);
|
||||
chip.addEventListener("click", () => ctx.patch({ shape: sh.value }));
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// density — segmented pair (comfortable / compact)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const DENSITIES: Array<{ value: NonNullable<BarState["density"]>; key: I18nKey }> = [
|
||||
{ value: "comfortable", key: "views.bar.density.comfortable" },
|
||||
{ value: "compact", key: "views.bar.density.compact" },
|
||||
];
|
||||
|
||||
function renderDensityAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.density");
|
||||
const row = chipRow();
|
||||
row.classList.add("filter-bar-segment");
|
||||
const current = ctx.get("density") ?? "comfortable";
|
||||
for (const d of DENSITIES) {
|
||||
const chip = chipBtn(t(d.key), d.value === current);
|
||||
chip.addEventListener("click", () => ctx.patch({ density: d.value }));
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// sort — small <select>
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const SORTS: Array<{ value: NonNullable<BarState["sort"]>; key: I18nKey }> = [
|
||||
{ value: "date_asc", key: "views.bar.sort.date_asc" },
|
||||
{ value: "date_desc", key: "views.bar.sort.date_desc" },
|
||||
];
|
||||
|
||||
function renderSortAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.sort");
|
||||
const sel = document.createElement("select");
|
||||
sel.className = "entity-select filter-bar-select";
|
||||
for (const s of SORTS) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = s.value;
|
||||
opt.textContent = t(s.key);
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.value = ctx.get("sort") ?? "date_asc";
|
||||
sel.addEventListener("change", () => ctx.patch({ sort: sel.value as NonNullable<BarState["sort"]> }));
|
||||
wrap.appendChild(sel);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// shared helpers — group + chip + row
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function group(labelKey: I18nKey): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "filter-group filter-bar-group";
|
||||
const label = document.createElement("span");
|
||||
label.className = "filter-bar-label";
|
||||
label.textContent = t(labelKey);
|
||||
wrap.appendChild(label);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function chipRow(): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "filter-bar-chip-row";
|
||||
return row;
|
||||
}
|
||||
|
||||
function chipBtn(text: string, active: boolean): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "agenda-chip filter-bar-chip" + (active ? " agenda-chip-active" : "");
|
||||
btn.textContent = text;
|
||||
return btn;
|
||||
}
|
||||
349
frontend/src/client/filter-bar/index.ts
Normal file
349
frontend/src/client/filter-bar/index.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
// FilterBar — the universal filter + view-mode primitive
|
||||
// (t-paliad-163). One client component every list-shaped paliad surface
|
||||
// mounts.
|
||||
//
|
||||
// Lifecycle:
|
||||
// 1. Caller hands in baseFilter + baseRender + axes + onResult.
|
||||
// 2. We parse URL params (within urlNamespace) and localStorage prefs,
|
||||
// overlay them on the base spec to compute the effective spec.
|
||||
// 3. We render the toolbar (one chip cluster / popover / select per
|
||||
// axis, plus trailing actions).
|
||||
// 4. We POST /api/views/{slug}/run with the effective spec as override
|
||||
// and hand the result + effective spec to onResult. The surface's
|
||||
// shape host renders.
|
||||
// 5. Every axis interaction patches BarState, re-encodes the URL,
|
||||
// re-runs the spec.
|
||||
//
|
||||
// The bar is a closed loop — surfaces don't see FilterSpec/RenderSpec
|
||||
// directly, just BarState diffs and the final ViewRunResult. That keeps
|
||||
// the substrate's validation invariants in one place (the bar).
|
||||
|
||||
import { onLangChange, t } from "../i18n";
|
||||
import type { FilterSpec, RenderSpec, ViewRunResult } from "../views/types";
|
||||
import {
|
||||
parseBar,
|
||||
encodeBar,
|
||||
} from "./url-codec";
|
||||
import { renderAxis, type AxisCtx, type RenderAxisOpts } from "./axes";
|
||||
import { openSaveModal } from "./save-modal";
|
||||
import type { BarState, MountOpts, BarHandle, EffectiveSpec, AxisKey } from "./types";
|
||||
|
||||
export type { MountOpts, BarHandle, AxisKey } from "./types";
|
||||
|
||||
const PREFS_PREFIX = "paliad.bar.";
|
||||
|
||||
interface PrefsBlob {
|
||||
shape?: string;
|
||||
density?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
|
||||
if (!!opts.customRunner === !!opts.systemViewSlug) {
|
||||
throw new Error(
|
||||
"mountFilterBar: exactly one of customRunner or systemViewSlug must be provided",
|
||||
);
|
||||
}
|
||||
let state: BarState = {};
|
||||
const ns = opts.urlNamespace;
|
||||
|
||||
// Hydrate state: URL > localStorage prefs > base.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
state = parseBar(urlParams, ns);
|
||||
hydratePrefs(state, opts.surfaceKey);
|
||||
|
||||
// Toolbar shell.
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "filter-bar";
|
||||
host.appendChild(toolbar);
|
||||
|
||||
// Trailing actions: Save as view + Reset (when not suppressed).
|
||||
const showSave = opts.showSaveAsView !== false;
|
||||
|
||||
// Run + render orchestration.
|
||||
let runVersion = 0;
|
||||
let lastEffective: EffectiveSpec | null = null;
|
||||
|
||||
const runAndRender = async () => {
|
||||
const effective = computeEffective(opts.baseFilter, opts.baseRender, state);
|
||||
lastEffective = effective;
|
||||
const myVersion = ++runVersion;
|
||||
try {
|
||||
let result: ViewRunResult;
|
||||
if (opts.customRunner) {
|
||||
// Hand the runner a frozen snapshot of the bar state so it can
|
||||
// read axes the EffectiveSpec doesn't round-trip (SmartTimeline
|
||||
// timeline_status / timeline_track on the Verlauf surface).
|
||||
result = await opts.customRunner(effective, Object.freeze({ ...state }));
|
||||
} else {
|
||||
const slug = opts.systemViewSlug as string; // ctor guard guarantees this
|
||||
const r = await fetch(`/api/views/${encodeURIComponent(slug)}/run`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filter: effective.filter }),
|
||||
});
|
||||
if (myVersion !== runVersion) return; // a newer click superseded us
|
||||
if (!r.ok) {
|
||||
opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
|
||||
return;
|
||||
}
|
||||
result = (await r.json()) as ViewRunResult;
|
||||
}
|
||||
if (myVersion !== runVersion) return;
|
||||
opts.onResult(result, effective);
|
||||
} catch (_e) {
|
||||
if (myVersion !== runVersion) return;
|
||||
opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
|
||||
}
|
||||
};
|
||||
|
||||
// Axis context — all axis renderers patch state through here.
|
||||
const ctx: AxisCtx = {
|
||||
get<K extends keyof BarState>(key: K) { return state[key]; },
|
||||
patch(delta) {
|
||||
state = { ...state, ...delta };
|
||||
// Coerce empties so URL stays clean.
|
||||
for (const k of Object.keys(delta) as (keyof BarState)[]) {
|
||||
const v = state[k];
|
||||
if (Array.isArray(v) && v.length === 0) delete state[k];
|
||||
if (v === undefined || v === null || v === false) delete state[k];
|
||||
}
|
||||
// personal_only false should also be deleted (handled above as
|
||||
// falsy, but explicit for clarity).
|
||||
if (state.personal_only === false) delete state.personal_only;
|
||||
syncURL();
|
||||
syncPrefs();
|
||||
renderToolbar();
|
||||
void runAndRender();
|
||||
},
|
||||
};
|
||||
|
||||
const axisRenderOpts: RenderAxisOpts = {
|
||||
timePresets: opts.timePresets,
|
||||
};
|
||||
|
||||
// First paint.
|
||||
const renderToolbar = () => {
|
||||
toolbar.innerHTML = "";
|
||||
for (const axis of opts.axes) {
|
||||
const el = renderAxis(axis as AxisKey, ctx, axisRenderOpts);
|
||||
if (el) toolbar.appendChild(el);
|
||||
}
|
||||
if (showSave) {
|
||||
const trailing = document.createElement("div");
|
||||
trailing.className = "filter-bar-trailing";
|
||||
|
||||
const resetBtn = document.createElement("button");
|
||||
resetBtn.type = "button";
|
||||
resetBtn.className = "btn-secondary btn-small filter-bar-reset";
|
||||
resetBtn.textContent = t("views.bar.action.reset");
|
||||
resetBtn.disabled = !isDirty(state);
|
||||
resetBtn.addEventListener("click", () => handle.reset());
|
||||
trailing.appendChild(resetBtn);
|
||||
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.type = "button";
|
||||
saveBtn.className = "btn-primary btn-small filter-bar-save";
|
||||
saveBtn.textContent = t("views.bar.action.save_as_view");
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
if (!lastEffective) return;
|
||||
const result = await openSaveModal(lastEffective.filter, lastEffective.render);
|
||||
if (result) {
|
||||
window.location.href = `/views/${encodeURIComponent(result.view.slug)}`;
|
||||
}
|
||||
});
|
||||
trailing.appendChild(saveBtn);
|
||||
|
||||
toolbar.appendChild(trailing);
|
||||
}
|
||||
};
|
||||
|
||||
const syncURL = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
encodeBar(state, params, ns);
|
||||
const qs = params.toString();
|
||||
const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
|
||||
history.replaceState(null, "", url);
|
||||
};
|
||||
|
||||
const syncPrefs = () => {
|
||||
const blob: PrefsBlob = {};
|
||||
if (state.shape) blob.shape = state.shape;
|
||||
if (state.density) blob.density = state.density;
|
||||
if (state.sort) blob.sort = state.sort;
|
||||
try {
|
||||
if (Object.keys(blob).length === 0) {
|
||||
localStorage.removeItem(PREFS_PREFIX + opts.surfaceKey);
|
||||
} else {
|
||||
localStorage.setItem(PREFS_PREFIX + opts.surfaceKey, JSON.stringify(blob));
|
||||
}
|
||||
} catch { /* private mode / quota — ignore */ }
|
||||
};
|
||||
|
||||
// Re-render labels on language change without losing state. The
|
||||
// existing onLangChange API is register-only (no off-handler). We
|
||||
// gate via a `destroyed` flag so a torn-down bar's callback no-ops.
|
||||
let destroyed = false;
|
||||
onLangChange(() => {
|
||||
if (destroyed) return;
|
||||
renderToolbar();
|
||||
});
|
||||
|
||||
const handle: BarHandle = {
|
||||
reset() {
|
||||
state = {};
|
||||
syncURL();
|
||||
syncPrefs();
|
||||
renderToolbar();
|
||||
void runAndRender();
|
||||
},
|
||||
async refresh() {
|
||||
await runAndRender();
|
||||
},
|
||||
getEffective() {
|
||||
if (lastEffective) return lastEffective;
|
||||
return computeEffective(opts.baseFilter, opts.baseRender, state);
|
||||
},
|
||||
getState() {
|
||||
// Hand back a frozen snapshot so callers can't smuggle mutations
|
||||
// back into the bar's owned state — the bar is the single writer.
|
||||
return Object.freeze({ ...state });
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
toolbar.remove();
|
||||
},
|
||||
};
|
||||
|
||||
renderToolbar();
|
||||
void runAndRender();
|
||||
return handle;
|
||||
}
|
||||
|
||||
// hydratePrefs reads the saved `paliad.bar.<surfaceKey>` blob and fills
|
||||
// in render axes the URL didn't already pin. URL wins over prefs.
|
||||
function hydratePrefs(state: BarState, surfaceKey: string): void {
|
||||
let blob: PrefsBlob;
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFS_PREFIX + surfaceKey);
|
||||
if (!raw) return;
|
||||
blob = JSON.parse(raw) as PrefsBlob;
|
||||
} catch { return; }
|
||||
if (!state.shape && (blob.shape === "list" || blob.shape === "cards" || blob.shape === "calendar")) {
|
||||
state.shape = blob.shape;
|
||||
}
|
||||
if (!state.density && (blob.density === "comfortable" || blob.density === "compact")) {
|
||||
state.density = blob.density;
|
||||
}
|
||||
if (!state.sort && (blob.sort === "date_asc" || blob.sort === "date_desc")) {
|
||||
state.sort = blob.sort;
|
||||
}
|
||||
}
|
||||
|
||||
// computeEffective overlays the BarState onto the base FilterSpec +
|
||||
// RenderSpec to produce the spec that gets POSTed to the substrate.
|
||||
//
|
||||
// Server-side validator (FilterSpec.Validate) is the final gate; we
|
||||
// produce shapes the validator will accept, but defer to it for the
|
||||
// hard rejection case (e.g. PersonalOnly + ScopeExplicit).
|
||||
export function computeEffective(
|
||||
base: FilterSpec,
|
||||
baseRender: RenderSpec,
|
||||
state: BarState,
|
||||
): EffectiveSpec {
|
||||
// Deep-clone to avoid mutating the caller's base. JSON round-trip is
|
||||
// fine here — every field on FilterSpec is a primitive / array /
|
||||
// object literal (no class instances, no Date, no functions).
|
||||
const filter = JSON.parse(JSON.stringify(base)) as FilterSpec;
|
||||
const render = JSON.parse(JSON.stringify(baseRender)) as RenderSpec;
|
||||
|
||||
if (state.time) {
|
||||
filter.time = {
|
||||
...filter.time,
|
||||
horizon: state.time.horizon,
|
||||
from: state.time.horizon === "custom" ? state.time.from : undefined,
|
||||
to: state.time.horizon === "custom" ? state.time.to : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (state.project) {
|
||||
if (state.project.mode === "personal") {
|
||||
filter.scope = {
|
||||
...filter.scope,
|
||||
personal_only: true,
|
||||
// When personal_only takes over, leave projects on the base
|
||||
// mode (typically all_visible). Validator rejects ScopeExplicit
|
||||
// + personal_only so we don't overwrite the mode here.
|
||||
};
|
||||
} else if (state.project.id) {
|
||||
filter.scope = {
|
||||
...filter.scope,
|
||||
projects: { mode: "explicit", ids: [state.project.id] },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (state.personal_only) {
|
||||
filter.scope = { ...filter.scope, personal_only: true };
|
||||
}
|
||||
|
||||
// Per-source predicates. Build the predicates map idempotently;
|
||||
// never inject a predicate for a source the spec doesn't list.
|
||||
const sources = new Set(filter.sources);
|
||||
filter.predicates = filter.predicates ?? {};
|
||||
|
||||
if (sources.has("deadline") && (state.deadline_status || state.deadline_event_type)) {
|
||||
const cur = filter.predicates.deadline ?? {};
|
||||
const next = { ...cur };
|
||||
if (state.deadline_status) next.status = state.deadline_status;
|
||||
if (state.deadline_event_type) {
|
||||
next.event_types = state.deadline_event_type.ids;
|
||||
next.include_untyped = state.deadline_event_type.include_untyped;
|
||||
}
|
||||
filter.predicates.deadline = next;
|
||||
}
|
||||
if (sources.has("appointment") && state.appointment_type) {
|
||||
const cur = filter.predicates.appointment ?? {};
|
||||
filter.predicates.appointment = { ...cur, appointment_types: state.appointment_type };
|
||||
}
|
||||
if (sources.has("approval_request") && (state.approval_viewer_role || state.approval_status || state.approval_entity_type)) {
|
||||
const cur = filter.predicates.approval_request ?? {};
|
||||
const next = { ...cur };
|
||||
if (state.approval_viewer_role) next.viewer_role = state.approval_viewer_role;
|
||||
if (state.approval_status) next.status = state.approval_status;
|
||||
if (state.approval_entity_type) next.entity_types = state.approval_entity_type;
|
||||
filter.predicates.approval_request = next;
|
||||
}
|
||||
if (sources.has("project_event") && state.project_event_kind) {
|
||||
const cur = filter.predicates.project_event ?? {};
|
||||
filter.predicates.project_event = { ...cur, event_types: state.project_event_kind };
|
||||
}
|
||||
|
||||
// Render overlays.
|
||||
if (state.shape) render.shape = state.shape;
|
||||
if (state.sort) {
|
||||
if (render.shape === "list" || (state.shape === "list" && !render.list)) {
|
||||
render.list = { ...(render.list ?? {}), sort: state.sort };
|
||||
}
|
||||
if (render.shape === "cards" || state.shape === "cards") {
|
||||
render.cards = { ...(render.cards ?? {}), sort: state.sort };
|
||||
}
|
||||
}
|
||||
if (state.density && (render.shape === "list" || state.shape === "list")) {
|
||||
render.list = { ...(render.list ?? {}), density: state.density };
|
||||
}
|
||||
|
||||
return { filter, render };
|
||||
}
|
||||
|
||||
// isDirty — used to enable the Reset button only when there's something
|
||||
// to reset to.
|
||||
function isDirty(state: BarState): boolean {
|
||||
for (const k of Object.keys(state) as (keyof BarState)[]) {
|
||||
const v = state[k];
|
||||
if (v === undefined || v === null || v === false) continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
146
frontend/src/client/filter-bar/save-modal.ts
Normal file
146
frontend/src/client/filter-bar/save-modal.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// Save-as-view modal for the FilterBar. Mirrors the create form on
|
||||
// /views/new (frontend/src/client/views-editor.ts:168) but as a modal
|
||||
// so the user can save the bar's current effective spec without
|
||||
// leaving the page they're filtering on.
|
||||
//
|
||||
// On success, the new view appears in the "Meine Sichten" sidebar
|
||||
// group on next render (the sidebar polls /api/user-views on init).
|
||||
|
||||
import { t } from "../i18n";
|
||||
import type { FilterSpec, RenderSpec, UserView } from "../views/types";
|
||||
|
||||
export interface SaveModalResult {
|
||||
view: UserView;
|
||||
}
|
||||
|
||||
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
||||
|
||||
export function openSaveModal(filter: FilterSpec, render: RenderSpec): Promise<SaveModalResult | null> {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = "filter-bar-save-modal";
|
||||
|
||||
dialog.innerHTML = `
|
||||
<form method="dialog" class="filter-bar-save-form">
|
||||
<h2>${t("views.bar.save.heading")}</h2>
|
||||
<label class="filter-bar-save-field">
|
||||
<span>${t("views.bar.save.field.name")}</span>
|
||||
<input type="text" name="name" required maxlength="100" autocomplete="off" />
|
||||
</label>
|
||||
<label class="filter-bar-save-field">
|
||||
<span>${t("views.bar.save.field.slug")}</span>
|
||||
<input type="text" name="slug" required maxlength="63" autocomplete="off" pattern="[a-z0-9][a-z0-9-]*" />
|
||||
<small>${t("views.bar.save.field.slug_hint")}</small>
|
||||
</label>
|
||||
<label class="filter-bar-save-checkbox">
|
||||
<input type="checkbox" name="show_count" />
|
||||
<span>${t("views.bar.save.field.show_count")}</span>
|
||||
</label>
|
||||
<p class="filter-bar-save-error" hidden></p>
|
||||
<div class="filter-bar-save-actions">
|
||||
<button type="button" class="btn-secondary" data-action="cancel">${t("views.bar.save.cancel")}</button>
|
||||
<button type="submit" class="btn-primary">${t("views.bar.save.confirm")}</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
const form = dialog.querySelector<HTMLFormElement>(".filter-bar-save-form")!;
|
||||
const errorEl = dialog.querySelector<HTMLParagraphElement>(".filter-bar-save-error")!;
|
||||
const nameInput = form.elements.namedItem("name") as HTMLInputElement;
|
||||
const slugInput = form.elements.namedItem("slug") as HTMLInputElement;
|
||||
const showCount = form.elements.namedItem("show_count") as HTMLInputElement;
|
||||
const cancelBtn = dialog.querySelector<HTMLButtonElement>('[data-action="cancel"]')!;
|
||||
|
||||
// Auto-derive slug from name as the user types — but only until
|
||||
// they touch the slug field manually.
|
||||
let slugDirty = false;
|
||||
nameInput.addEventListener("input", () => {
|
||||
if (!slugDirty) slugInput.value = derivedSlug(nameInput.value);
|
||||
});
|
||||
slugInput.addEventListener("input", () => { slugDirty = true; });
|
||||
|
||||
const cleanup = () => {
|
||||
dialog.close();
|
||||
dialog.remove();
|
||||
};
|
||||
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
errorEl.hidden = true;
|
||||
errorEl.textContent = "";
|
||||
|
||||
const name = nameInput.value.trim();
|
||||
const slug = slugInput.value.trim();
|
||||
if (!name) {
|
||||
showError(errorEl, t("views.bar.save.error.name_required"));
|
||||
return;
|
||||
}
|
||||
if (!SLUG_REGEX.test(slug)) {
|
||||
showError(errorEl, t("views.bar.save.error.slug_format"));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
slug,
|
||||
filter_spec: filter,
|
||||
render_spec: render,
|
||||
show_count: showCount.checked,
|
||||
};
|
||||
try {
|
||||
const r = await fetch("/api/user-views", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (r.status === 409) {
|
||||
showError(errorEl, t("views.bar.save.error.slug_taken"));
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({} as { error?: string }));
|
||||
showError(errorEl, body.error || `${r.status}: ${r.statusText}`);
|
||||
return;
|
||||
}
|
||||
const view = (await r.json()) as UserView;
|
||||
cleanup();
|
||||
resolve({ view });
|
||||
} catch (_e) {
|
||||
showError(errorEl, t("views.bar.save.error.network"));
|
||||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener("cancel", () => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
dialog.showModal();
|
||||
nameInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function showError(el: HTMLElement, msg: string): void {
|
||||
el.textContent = msg;
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function derivedSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[äÄ]/g, "ae")
|
||||
.replace(/[öÖ]/g, "oe")
|
||||
.replace(/[üÜ]/g, "ue")
|
||||
.replace(/[ß]/g, "ss")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 63);
|
||||
}
|
||||
161
frontend/src/client/filter-bar/types.ts
Normal file
161
frontend/src/client/filter-bar/types.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
// FilterBar types — t-paliad-163. Mirrors the Go FilterSpec/RenderSpec
|
||||
// shapes from internal/services/{filter_spec,render_spec}.go via
|
||||
// client/views/types.ts. The FilterBar is the universal frontend
|
||||
// primitive that consumes a base FilterSpec + RenderSpec, declares
|
||||
// which axes the surface supports, and emits diffs back through
|
||||
// onResult after running the spec via /api/views/run.
|
||||
|
||||
import type { FilterSpec, RenderSpec, RenderShape, ViewRunResult, ListRowAction } from "../views/types";
|
||||
|
||||
// AxisKey — every filter dimension the bar can render. Declared per
|
||||
// surface in mountFilterBar's `axes` array. See design §3.1 for the
|
||||
// universal-vs-per-surface split.
|
||||
export type AxisKey =
|
||||
| "time"
|
||||
| "project"
|
||||
| "personal_only"
|
||||
| "deadline_status"
|
||||
| "deadline_event_type"
|
||||
| "appointment_type"
|
||||
| "approval_viewer_role"
|
||||
| "approval_status"
|
||||
| "approval_entity_type"
|
||||
| "project_event_kind"
|
||||
| "timeline_status"
|
||||
| "timeline_track"
|
||||
| "shape"
|
||||
| "sort"
|
||||
| "density";
|
||||
|
||||
// Effective spec — the result of overlaying URL + localStorage prefs
|
||||
// on top of the base spec. Handed back to onResult so the surface can
|
||||
// dispatch into the matching shape renderer with the right config.
|
||||
export interface EffectiveSpec {
|
||||
filter: FilterSpec;
|
||||
render: RenderSpec;
|
||||
}
|
||||
|
||||
// Per-axis state — what the URL codec round-trips. Each axis's value
|
||||
// type is bounded to the FilterSpec/RenderSpec subset it touches.
|
||||
export interface BarState {
|
||||
// Universal
|
||||
time?: TimeOverlay;
|
||||
project?: ProjectOverlay;
|
||||
personal_only?: boolean;
|
||||
|
||||
// Per-source
|
||||
deadline_status?: string[];
|
||||
deadline_event_type?: { ids: string[]; include_untyped: boolean };
|
||||
appointment_type?: string[];
|
||||
approval_viewer_role?: "approver_eligible" | "self_requested" | "any_visible";
|
||||
approval_status?: string[];
|
||||
approval_entity_type?: string[];
|
||||
project_event_kind?: string[];
|
||||
// SmartTimeline axes (t-paliad-173). timeline_status spans actuals +
|
||||
// projections; timeline_track is parent / counterclaim / off_script
|
||||
// and grows once Slice 3 lands the CCR sub-project FK (child:<id>
|
||||
// values dynamically populated then).
|
||||
timeline_status?: string[];
|
||||
timeline_track?: string[];
|
||||
|
||||
// Render
|
||||
shape?: RenderShape;
|
||||
sort?: "date_asc" | "date_desc";
|
||||
density?: "comfortable" | "compact";
|
||||
}
|
||||
|
||||
export interface TimeOverlay {
|
||||
horizon: "next_7d" | "next_30d" | "next_90d" | "past_7d" | "past_30d" | "past_90d" | "any" | "all" | "custom";
|
||||
from?: string; // ISO 8601 — only when horizon === "custom"
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export interface ProjectOverlay {
|
||||
// The bar's project chip is single-select today; Phase C upgrades
|
||||
// to multi-select. "personal" is a sentinel — the legacy /events
|
||||
// contract reserved this name, we keep it so old bookmarks still
|
||||
// resolve to the right state.
|
||||
mode: "single" | "personal";
|
||||
id?: string;
|
||||
}
|
||||
|
||||
// MountOpts — the public API.
|
||||
export interface MountOpts {
|
||||
// Base spec. Usually a SystemView's FilterSpec+RenderSpec, fetched
|
||||
// from /api/views/system on the surface and passed in here. For
|
||||
// /views/{slug}, the saved user-view's spec.
|
||||
baseFilter: FilterSpec;
|
||||
baseRender: RenderSpec;
|
||||
|
||||
// Which axes the surface exposes. Order is preserved in the rendered
|
||||
// chrome — surfaces use this to control left-to-right grouping.
|
||||
axes: AxisKey[];
|
||||
|
||||
// URL parameter namespace. When set, every URL key is prefixed
|
||||
// (`?<ns>_time=`, `?<ns>_project=`, …). Used when two bars share a
|
||||
// page (dashboard inline lists). Defaults to no prefix.
|
||||
urlNamespace?: string;
|
||||
|
||||
// Surface key for localStorage prefs (density, default shape).
|
||||
// Required so two surfaces don't share preferences.
|
||||
surfaceKey: string;
|
||||
|
||||
// Whether to render "Speichern als Sicht" + "Zurücksetzen"
|
||||
// trailing actions. Defaults to true. Set false on the dashboard
|
||||
// inline bars (per design Q6).
|
||||
showSaveAsView?: boolean;
|
||||
|
||||
// Slug of the surface's underlying system view (or saved user view).
|
||||
// POSTed to /api/views/{slug}/run with the override body. Required
|
||||
// unless `customRunner` is supplied — see below. When the bar runs
|
||||
// through this endpoint it is the substrate's canonical entry.
|
||||
systemViewSlug?: string;
|
||||
|
||||
// Custom runner. When set, the bar bypasses the substrate POST and
|
||||
// hands the effective spec + raw BarState to this function instead.
|
||||
// Used by surfaces that need axes the EffectiveSpec doesn't round-trip
|
||||
// (e.g. SmartTimeline's timeline_status / timeline_track, t-paliad-176).
|
||||
// The state argument is a frozen snapshot — same shape getState()
|
||||
// returns on the handle, but available on the very first run before
|
||||
// the handle has been assigned. Must be either this OR systemViewSlug
|
||||
// — the bar throws if both / neither are provided.
|
||||
customRunner?: (effective: EffectiveSpec, state: Readonly<BarState>) => Promise<ViewRunResult>;
|
||||
|
||||
// Per-surface override of the time-axis chip presets. Order is
|
||||
// preserved. Default presets are forward-looking (next_*+past_30d+any)
|
||||
// — backward-looking surfaces (Verlauf, audit) pass past_*+all here.
|
||||
timePresets?: NonNullable<BarState["time"]>["horizon"][];
|
||||
|
||||
// When true, the bar exposes an "Aktualisieren" affordance that
|
||||
// PATCHes /api/user-views/{userViewId} with the effective spec.
|
||||
// Set on /views/{slug} where the user is viewing a saved view.
|
||||
userViewId?: string;
|
||||
|
||||
// Called every time the spec changes (mount, URL change, axis
|
||||
// interaction). The surface dispatches to the matching shape
|
||||
// renderer with the rows from /api/views/{slug}/run.
|
||||
onResult(result: ViewRunResult, effective: EffectiveSpec): void;
|
||||
|
||||
// Optional — surface-specific row-action override. Phase 1: /inbox
|
||||
// pins this to "approve"; /events Phase 3 pins to "complete_toggle".
|
||||
// Future: sourced from the spec's render.list.row_action when set.
|
||||
rowAction?: ListRowAction;
|
||||
}
|
||||
|
||||
// Bar handle — what mountFilterBar returns. Pages can call .reset()
|
||||
// from page-level controls (e.g. an empty-state "Filter zurücksetzen"
|
||||
// button), or .destroy() if the page tears down.
|
||||
export interface BarHandle {
|
||||
reset(): void;
|
||||
refresh(): Promise<void>;
|
||||
destroy(): void;
|
||||
// Read-only effective spec at this moment (post URL + localStorage
|
||||
// overlay). Pages use this to construct deep-link URLs etc.
|
||||
getEffective(): EffectiveSpec;
|
||||
// Read-only raw BarState. Surfaces with axes the EffectiveSpec doesn't
|
||||
// round-trip (timeline_status / timeline_track on the SmartTimeline
|
||||
// surface — the substrate FilterSpec has no per-source predicate for
|
||||
// those) read state directly to drive client-side filtering. Returns
|
||||
// a frozen snapshot; callers must not mutate.
|
||||
getState(): Readonly<BarState>;
|
||||
}
|
||||
102
frontend/src/client/filter-bar/url-codec.test.ts
Normal file
102
frontend/src/client/filter-bar/url-codec.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// Unit tests for the FilterBar URL codec. Round-trip discipline:
|
||||
// every BarState shape parseBar produces must encode back to the same
|
||||
// URL params, and vice versa. Run with `bun test`.
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { parseBar, encodeBar } from "./url-codec";
|
||||
import type { BarState } from "./types";
|
||||
|
||||
function roundTrip(state: BarState, ns?: string): BarState {
|
||||
const params = new URLSearchParams();
|
||||
encodeBar(state, params, ns);
|
||||
return parseBar(params, ns);
|
||||
}
|
||||
|
||||
describe("filter-bar/url-codec", () => {
|
||||
test("empty state round-trips to empty", () => {
|
||||
expect(roundTrip({})).toEqual({});
|
||||
});
|
||||
|
||||
test("time horizon round-trips", () => {
|
||||
for (const h of ["next_7d", "next_30d", "next_90d", "past_30d", "past_90d", "any", "all"] as const) {
|
||||
expect(roundTrip({ time: { horizon: h } })).toEqual({ time: { horizon: h } });
|
||||
}
|
||||
});
|
||||
|
||||
test("custom time horizon round-trips with from + to", () => {
|
||||
const state: BarState = { time: { horizon: "custom", from: "2026-01-01", to: "2026-12-31" } };
|
||||
expect(roundTrip(state)).toEqual(state);
|
||||
});
|
||||
|
||||
test("project sentinel + uuid round-trip", () => {
|
||||
expect(roundTrip({ project: { mode: "personal" } })).toEqual({ project: { mode: "personal" } });
|
||||
expect(roundTrip({ project: { mode: "single", id: "11111111-1111-1111-1111-111111111111" } }))
|
||||
.toEqual({ project: { mode: "single", id: "11111111-1111-1111-1111-111111111111" } });
|
||||
});
|
||||
|
||||
test("personal_only flag round-trips", () => {
|
||||
expect(roundTrip({ personal_only: true })).toEqual({ personal_only: true });
|
||||
expect(roundTrip({})).toEqual({});
|
||||
});
|
||||
|
||||
test("deadline_event_type honours legacy 'none' sentinel", () => {
|
||||
const state: BarState = { deadline_event_type: { ids: ["a", "b"], include_untyped: true } };
|
||||
expect(roundTrip(state)).toEqual(state);
|
||||
const state2: BarState = { deadline_event_type: { ids: [], include_untyped: true } };
|
||||
expect(roundTrip(state2)).toEqual(state2);
|
||||
const state3: BarState = { deadline_event_type: { ids: ["a"], include_untyped: false } };
|
||||
expect(roundTrip(state3)).toEqual(state3);
|
||||
});
|
||||
|
||||
test("approval_request triple round-trips together", () => {
|
||||
const state: BarState = {
|
||||
approval_viewer_role: "approver_eligible",
|
||||
approval_status: ["pending", "approved"],
|
||||
approval_entity_type: ["deadline"],
|
||||
};
|
||||
expect(roundTrip(state)).toEqual(state);
|
||||
});
|
||||
|
||||
test("namespace prefix isolates two bars on the same page", () => {
|
||||
const a: BarState = { time: { horizon: "next_7d" } };
|
||||
const b: BarState = { time: { horizon: "next_30d" } };
|
||||
const params = new URLSearchParams();
|
||||
encodeBar(a, params, "agenda");
|
||||
encodeBar(b, params, "activity");
|
||||
expect(parseBar(params, "agenda")).toEqual(a);
|
||||
expect(parseBar(params, "activity")).toEqual(b);
|
||||
// Without namespace neither bar's keys are visible.
|
||||
expect(parseBar(params)).toEqual({});
|
||||
});
|
||||
|
||||
test("render axes round-trip", () => {
|
||||
const state: BarState = { shape: "cards", sort: "date_desc", density: "compact" };
|
||||
expect(roundTrip(state)).toEqual(state);
|
||||
});
|
||||
|
||||
test("encode is idempotent — re-encoding same state replaces, doesn't accumulate", () => {
|
||||
const state: BarState = { time: { horizon: "next_7d" }, deadline_status: ["pending"] };
|
||||
const params = new URLSearchParams();
|
||||
encodeBar(state, params);
|
||||
encodeBar(state, params);
|
||||
expect(params.get("d_status")).toBe("pending");
|
||||
// Only one entry per key.
|
||||
expect(params.getAll("d_status")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("encode replaces stale keys when state shrinks", () => {
|
||||
const params = new URLSearchParams();
|
||||
encodeBar({ deadline_status: ["pending"], approval_viewer_role: "self_requested" }, params);
|
||||
encodeBar({ deadline_status: ["completed"] }, params);
|
||||
expect(params.get("d_status")).toBe("completed");
|
||||
expect(params.has("a_role")).toBe(false);
|
||||
});
|
||||
|
||||
test("parse drops unknown enum values silently (forward-compat)", () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("a_role", "future_role_we_dont_know_yet");
|
||||
params.set("shape", "kanban");
|
||||
params.set("density", "huge");
|
||||
expect(parseBar(params)).toEqual({});
|
||||
});
|
||||
});
|
||||
198
frontend/src/client/filter-bar/url-codec.ts
Normal file
198
frontend/src/client/filter-bar/url-codec.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// FilterBar URL codec — t-paliad-163. Encodes BarState ↔ URL
|
||||
// parameters with optional namespace prefix (?<ns>_<key>=).
|
||||
//
|
||||
// The bar treats the URL as canonical for everything that affects
|
||||
// which rows you see. Round-trip discipline: anything written by
|
||||
// encodeBar must parse back identically via parseBar so deep-links
|
||||
// and refresh both yield the same effective spec.
|
||||
//
|
||||
// Empty / default values are NOT written — the URL stays clean for
|
||||
// users who don't tweak. The page's base spec is the implicit baseline.
|
||||
|
||||
import type { BarState, TimeOverlay, ProjectOverlay } from "./types";
|
||||
|
||||
const PERSONAL_PROJECT_SENTINEL = "personal";
|
||||
|
||||
// parseBar reads URL params into a BarState. Unknown values are
|
||||
// dropped silently (forward-compat with future axes).
|
||||
export function parseBar(params: URLSearchParams, ns?: string): BarState {
|
||||
const k = (key: string) => (ns ? `${ns}_${key}` : key);
|
||||
const out: BarState = {};
|
||||
|
||||
// time
|
||||
const time = params.get(k("time"));
|
||||
if (time) {
|
||||
const horizon = parseHorizon(time);
|
||||
if (horizon) {
|
||||
const overlay: TimeOverlay = { horizon };
|
||||
if (horizon === "custom") {
|
||||
const from = params.get(k("from"));
|
||||
const to = params.get(k("to"));
|
||||
if (from) overlay.from = from;
|
||||
if (to) overlay.to = to;
|
||||
}
|
||||
out.time = overlay;
|
||||
}
|
||||
}
|
||||
|
||||
// project
|
||||
const project = params.get(k("project"));
|
||||
if (project) {
|
||||
if (project === PERSONAL_PROJECT_SENTINEL) {
|
||||
out.project = { mode: "personal" };
|
||||
} else {
|
||||
out.project = { mode: "single", id: project };
|
||||
}
|
||||
}
|
||||
|
||||
// personal_only
|
||||
if (params.get(k("personal")) === "1") {
|
||||
out.personal_only = true;
|
||||
}
|
||||
|
||||
// deadline.status
|
||||
const dStatus = params.get(k("d_status"));
|
||||
if (dStatus) out.deadline_status = parseCSV(dStatus);
|
||||
|
||||
// deadline.event_types — preserves the legacy /events contract
|
||||
// where "none" inside the CSV means include_untyped=true.
|
||||
const dEvent = params.get(k("d_event_type"));
|
||||
if (dEvent) {
|
||||
const tokens = parseCSV(dEvent);
|
||||
const ids: string[] = [];
|
||||
let untyped = false;
|
||||
for (const tok of tokens) {
|
||||
if (tok === "none") untyped = true;
|
||||
else ids.push(tok);
|
||||
}
|
||||
out.deadline_event_type = { ids, include_untyped: untyped };
|
||||
}
|
||||
|
||||
// appointment.types
|
||||
const appType = params.get(k("app_type"));
|
||||
if (appType) out.appointment_type = parseCSV(appType);
|
||||
|
||||
// approval_request.viewer_role
|
||||
const aRole = params.get(k("a_role"));
|
||||
if (aRole === "approver_eligible" || aRole === "self_requested" || aRole === "any_visible") {
|
||||
out.approval_viewer_role = aRole;
|
||||
}
|
||||
|
||||
// approval_request.status
|
||||
const aStatus = params.get(k("a_status"));
|
||||
if (aStatus) out.approval_status = parseCSV(aStatus);
|
||||
|
||||
// approval_request.entity_types
|
||||
const aEntity = params.get(k("a_entity_type"));
|
||||
if (aEntity) out.approval_entity_type = parseCSV(aEntity);
|
||||
|
||||
// project_event.event_types
|
||||
const peKind = params.get(k("pe_kind"));
|
||||
if (peKind) out.project_event_kind = parseCSV(peKind);
|
||||
|
||||
// SmartTimeline (t-paliad-173) — status + track axes.
|
||||
const tlStatus = params.get(k("tl_status"));
|
||||
if (tlStatus) out.timeline_status = parseCSV(tlStatus);
|
||||
const tlTrack = params.get(k("tl_track"));
|
||||
if (tlTrack) out.timeline_track = parseCSV(tlTrack);
|
||||
|
||||
// render.shape
|
||||
const shape = params.get(k("shape"));
|
||||
if (shape === "list" || shape === "cards" || shape === "calendar") out.shape = shape;
|
||||
|
||||
// render.list.sort / render.cards.sort — the bar treats sort as one axis
|
||||
const sort = params.get(k("sort"));
|
||||
if (sort === "date_asc" || sort === "date_desc") out.sort = sort;
|
||||
|
||||
// render.list.density
|
||||
const density = params.get(k("density"));
|
||||
if (density === "comfortable" || density === "compact") out.density = density;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// encodeBar writes BarState back into URL params, mutating the
|
||||
// passed-in URLSearchParams. Empty / undefined values are omitted.
|
||||
// The caller controls how the result is applied (history.replaceState
|
||||
// with the page pathname unchanged).
|
||||
export function encodeBar(state: BarState, params: URLSearchParams, ns?: string): void {
|
||||
const k = (key: string) => (ns ? `${ns}_${key}` : key);
|
||||
|
||||
// Clear every key the bar owns first, then re-write the non-empty ones.
|
||||
for (const key of [
|
||||
"time", "from", "to", "project", "personal",
|
||||
"d_status", "d_event_type",
|
||||
"app_type",
|
||||
"a_role", "a_status", "a_entity_type",
|
||||
"pe_kind",
|
||||
"tl_status", "tl_track",
|
||||
"shape", "sort", "density",
|
||||
]) {
|
||||
params.delete(k(key));
|
||||
}
|
||||
|
||||
if (state.time) {
|
||||
params.set(k("time"), state.time.horizon);
|
||||
if (state.time.horizon === "custom") {
|
||||
if (state.time.from) params.set(k("from"), state.time.from);
|
||||
if (state.time.to) params.set(k("to"), state.time.to);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.project) {
|
||||
if (state.project.mode === "personal") {
|
||||
params.set(k("project"), PERSONAL_PROJECT_SENTINEL);
|
||||
} else if (state.project.id) {
|
||||
params.set(k("project"), state.project.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.personal_only) params.set(k("personal"), "1");
|
||||
|
||||
if (state.deadline_status?.length) params.set(k("d_status"), state.deadline_status.join(","));
|
||||
|
||||
if (state.deadline_event_type) {
|
||||
const parts = [...state.deadline_event_type.ids];
|
||||
if (state.deadline_event_type.include_untyped) parts.push("none");
|
||||
if (parts.length) params.set(k("d_event_type"), parts.join(","));
|
||||
}
|
||||
|
||||
if (state.appointment_type?.length) params.set(k("app_type"), state.appointment_type.join(","));
|
||||
if (state.approval_viewer_role) params.set(k("a_role"), state.approval_viewer_role);
|
||||
if (state.approval_status?.length) params.set(k("a_status"), state.approval_status.join(","));
|
||||
if (state.approval_entity_type?.length) params.set(k("a_entity_type"), state.approval_entity_type.join(","));
|
||||
if (state.project_event_kind?.length) params.set(k("pe_kind"), state.project_event_kind.join(","));
|
||||
if (state.timeline_status?.length) params.set(k("tl_status"), state.timeline_status.join(","));
|
||||
if (state.timeline_track?.length) params.set(k("tl_track"), state.timeline_track.join(","));
|
||||
|
||||
if (state.shape) params.set(k("shape"), state.shape);
|
||||
if (state.sort) params.set(k("sort"), state.sort);
|
||||
if (state.density) params.set(k("density"), state.density);
|
||||
}
|
||||
|
||||
function parseHorizon(s: string): TimeOverlay["horizon"] | null {
|
||||
switch (s) {
|
||||
case "next_7d":
|
||||
case "next_30d":
|
||||
case "next_90d":
|
||||
case "past_7d":
|
||||
case "past_30d":
|
||||
case "past_90d":
|
||||
case "any":
|
||||
case "all":
|
||||
case "custom":
|
||||
return s;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCSV(s: string): string[] {
|
||||
return s.split(",").map((x) => x.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export { PERSONAL_PROJECT_SENTINEL };
|
||||
|
||||
// Re-exported so consumers don't need to import ProjectOverlay just
|
||||
// to construct one in tests.
|
||||
export type { ProjectOverlay };
|
||||
@@ -1,67 +1,28 @@
|
||||
// Fristenrechner client-side logic
|
||||
// 3-step wizard: select proceeding -> enter date -> view timeline
|
||||
//
|
||||
// Rendering primitives (renderTimelineBody / renderColumnsBody /
|
||||
// deadlineCardHtml / formatDate / partyBadge / court picker) live in
|
||||
// `./views/verfahrensablauf-core` and are shared with the
|
||||
// /tools/verfahrensablauf page (t-paliad-179 Slice 1). This module owns
|
||||
// the Step1/2/3a wizard, Pathway A/B, Akte save flow, anchor-override
|
||||
// click-to-edit — none of which Verfahrensablauf wants.
|
||||
|
||||
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
interface AdjustmentHoliday {
|
||||
Date: string;
|
||||
Name: string;
|
||||
IsVacation: boolean;
|
||||
IsClosure: boolean;
|
||||
}
|
||||
|
||||
interface AdjustmentReason {
|
||||
kind: "weekend" | "public_holiday" | "vacation";
|
||||
holidays?: AdjustmentHoliday[];
|
||||
vacation_name?: string;
|
||||
vacation_start?: string;
|
||||
vacation_end?: string;
|
||||
original_weekday?: string;
|
||||
}
|
||||
|
||||
interface CalculatedDeadline {
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
party: string;
|
||||
isMandatory: boolean;
|
||||
ruleRef: string;
|
||||
legalSource?: string;
|
||||
notes?: string;
|
||||
notesEN?: string;
|
||||
dueDate: string;
|
||||
originalDate: string;
|
||||
wasAdjusted: boolean;
|
||||
adjustmentReason?: AdjustmentReason;
|
||||
isRootEvent: boolean;
|
||||
isCourtSet: boolean;
|
||||
// True when isCourtSet is "unbestimmt" — the rule chains off a
|
||||
// court-determined parent (e.g. RoP.151 = 1 Monat ab
|
||||
// Hauptentscheidung) rather than being itself court-set. The UI
|
||||
// renders "unbestimmt" instead of "wird vom Gericht bestimmt".
|
||||
isCourtSetIndirect?: boolean;
|
||||
// True when the deadline is conditional on a user act (filing a
|
||||
// cost-decision request, choosing to appeal, etc.). Pre-unchecked
|
||||
// in the save modal so the user must opt in.
|
||||
isOptional?: boolean;
|
||||
isOverridden?: boolean;
|
||||
}
|
||||
|
||||
interface DeadlineResponse {
|
||||
proceedingType: string;
|
||||
proceedingName: string;
|
||||
triggerDate: string;
|
||||
deadlines: CalculatedDeadline[];
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
claimant: "party-claimant",
|
||||
defendant: "party-defendant",
|
||||
court: "party-court",
|
||||
both: "party-both",
|
||||
};
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
type DeadlineResponse,
|
||||
calculateDeadlines,
|
||||
escAttr,
|
||||
escHtml,
|
||||
formatDate,
|
||||
populateCourtPicker as populateCourtPickerCore,
|
||||
priorityRendering,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
@@ -106,92 +67,29 @@ onLangChange(() => {
|
||||
}
|
||||
});
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return "\u2014";
|
||||
const d = new Date(dateStr + "T00:00:00");
|
||||
if (getLang() === "en") {
|
||||
// ISO date (YYYY-MM-DD) \u2014 unambiguous for both US and intl readers, since
|
||||
// en-GB renders dd/mm/yyyy which US users misread as mm/dd/yyyy.
|
||||
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${weekday}, ${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
return d.toLocaleDateString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
// formatDate / partyBadge / formatDateSpan / localizeVacationName /
|
||||
// localizeWeekday / renderAdjustmentReason / formatAdjustedNote moved to
|
||||
// ./views/verfahrensablauf-core so /tools/verfahrensablauf can share them.
|
||||
// (t-paliad-179 Slice 1)
|
||||
|
||||
function partyBadge(party: string): string {
|
||||
const cls = PARTY_CLASS[party] || "party-both";
|
||||
return `<span class="party-badge ${cls}">${tDyn("deadlines.party." + party)}</span>`;
|
||||
}
|
||||
|
||||
// Short date span like "27.7.–28.8." (DE) or "27 Jul – 28 Aug" (EN). Used in
|
||||
// the vacation adjustment label, where the explicit weekday + year would
|
||||
// just be noise — the surrounding sentence carries the full year via the
|
||||
// dueDate / originalDate that the note brackets.
|
||||
function formatDateSpan(startISO: string, endISO: string): string {
|
||||
const start = new Date(startISO + "T00:00:00");
|
||||
const end = new Date(endISO + "T00:00:00");
|
||||
if (getLang() === "en") {
|
||||
const fmt = (d: Date) => d.toLocaleDateString("en-US", { day: "numeric", month: "short" });
|
||||
return `${fmt(start)} – ${fmt(end)}`;
|
||||
}
|
||||
const fmt = (d: Date) => `${d.getDate()}.${d.getMonth() + 1}.`;
|
||||
return `${fmt(start)}–${fmt(end)}`;
|
||||
}
|
||||
|
||||
// Vacation names come straight from paliad.holidays (e.g. "UPC judicial
|
||||
// vacation"). The Fristenrechner doesn't translate them: they're proper
|
||||
// names of court-set closures, not generic strings, and rotating them via
|
||||
// i18n.ts duplicates state that should live in the DB. Rename in the seed
|
||||
// if the wording needs to change.
|
||||
function localizeVacationName(name: string): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
function localizeWeekday(en: string): string {
|
||||
if (en === "Saturday") return t("deadlines.adjusted.weekend.saturday");
|
||||
if (en === "Sunday") return t("deadlines.adjusted.weekend.sunday");
|
||||
return en;
|
||||
}
|
||||
|
||||
// Backend-shaped reason → human-readable phrase ("UPC-Gerichtsferien
|
||||
// (27.7.–28.8.)" / "Karfreitag holiday" / "Wochenende"). See t-paliad-119.
|
||||
function renderAdjustmentReason(r: AdjustmentReason): string {
|
||||
if (r.kind === "vacation" && r.vacation_name && r.vacation_start && r.vacation_end) {
|
||||
const span = formatDateSpan(r.vacation_start, r.vacation_end);
|
||||
return tDyn("deadlines.adjusted.vacation")
|
||||
.replace("{name}", localizeVacationName(r.vacation_name))
|
||||
.replace("{span}", span);
|
||||
}
|
||||
if (r.kind === "public_holiday" && r.holidays && r.holidays.length > 0) {
|
||||
return tDyn("deadlines.adjusted.holiday").replace("{name}", r.holidays[0].Name);
|
||||
}
|
||||
if (r.kind === "weekend" && r.original_weekday) {
|
||||
return localizeWeekday(r.original_weekday);
|
||||
}
|
||||
return t("deadlines.adjusted.weekend");
|
||||
}
|
||||
|
||||
// "Verschoben wegen X: A → B" (DE) / "Shifted (X): A → B" (EN). Falls back
|
||||
// to the legacy "Wochenende/Feiertag" string when the backend hasn't sent a
|
||||
// structured reason — keeps older API responses readable.
|
||||
function formatAdjustedNote(dl: CalculatedDeadline): string {
|
||||
const arrow = `${formatDate(dl.originalDate)} → ${formatDate(dl.dueDate)}`;
|
||||
const reason = dl.adjustmentReason
|
||||
? renderAdjustmentReason(dl.adjustmentReason)
|
||||
: t("deadlines.adjusted.reason");
|
||||
if (getLang() === "en") {
|
||||
return `${t("deadlines.adjusted")} (${reason}): ${arrow}`;
|
||||
}
|
||||
return `${t("deadlines.adjusted")} wegen ${reason}: ${arrow}`;
|
||||
}
|
||||
|
||||
let selectedType = "";
|
||||
|
||||
@@ -247,35 +145,19 @@ async function calculate() {
|
||||
? courtPicker.value
|
||||
: "";
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/tools/fristenrechner", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
priorityDate: priorityDate || undefined,
|
||||
flags: flags.length > 0 ? flags : undefined,
|
||||
anchorOverrides: Object.keys(overrides).length > 0 ? overrides : undefined,
|
||||
courtId: courtId || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (seq !== procCalcSeq) return;
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
console.error("API error:", err);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: DeadlineResponse = await resp.json();
|
||||
if (seq !== procCalcSeq) return;
|
||||
lastResponse = data;
|
||||
renderProcedureResults(data);
|
||||
showStep(3);
|
||||
} catch (e) {
|
||||
console.error("Fetch error:", e);
|
||||
}
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
priorityDate,
|
||||
flags,
|
||||
anchorOverrides: overrides,
|
||||
courtId,
|
||||
});
|
||||
if (seq !== procCalcSeq) return;
|
||||
if (!data) return;
|
||||
lastResponse = data;
|
||||
renderProcedureResults(data);
|
||||
showStep(3);
|
||||
}
|
||||
|
||||
interface ProjectOption {
|
||||
@@ -288,16 +170,12 @@ interface ProjectOption {
|
||||
// (Slice 3b) can scope the cascade by the project's jurisdiction
|
||||
// without an extra fetch.
|
||||
proceeding_type_id?: number | null;
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
// our_side carries which side the firm represents on this project
|
||||
// (t-paliad-164). When a user selects an Akte, the perspective chip
|
||||
// pre-locks to this value; a small hint above the strip flags the
|
||||
// pre-selection and the user can still click another chip to
|
||||
// override. NULL/undefined leaves the chip unset (free-pick).
|
||||
our_side?: "claimant" | "defendant" | "court" | "both" | null;
|
||||
}
|
||||
|
||||
async function fetchProjects(): Promise<ProjectOption[]> {
|
||||
@@ -392,16 +270,32 @@ async function openSaveModal() {
|
||||
// any party=court row) have no calculable date — disable + pre-uncheck
|
||||
// so users don't save the trigger-date placeholder as a real deadline.
|
||||
const isCourtDetermined = dl.isCourtSet || dl.party === "court";
|
||||
// Phase 3 Slice 8 (t-paliad-189) wire-shape swap: priority drives
|
||||
// the save-modal pre-check + the "no save action" notice-card
|
||||
// render. priorityRendering falls back to the legacy
|
||||
// (isMandatory, isOptional) pair semantic for pre-Slice-8
|
||||
// responses; new responses carry `priority` directly.
|
||||
const pr = priorityRendering(dl);
|
||||
if (pr.hideSave) {
|
||||
// informational rules render as notice cards — no checkbox, no
|
||||
// save button, distinct visual tier. The 18 F/F filing rules
|
||||
// (Berufungserwiderung, Replik, Duplik, R.19, R.116 EPÜ, etc.)
|
||||
// currently fall here once they're flipped to 'informational' by
|
||||
// editorial review; today they're 'recommended' so this branch
|
||||
// remains exercised only by future rule edits.
|
||||
return `<li class="frist-save-row frist-save-row--notice">
|
||||
<span class="frist-save-notice-label">${escHtml(t("deadlines.priority.informational.notice_label"))}</span>
|
||||
<span class="frist-save-title">${escHtml(dlName)}</span>
|
||||
<span class="frist-save-meta">${escHtml(t("deadlines.priority.informational"))}</span>
|
||||
</li>`;
|
||||
}
|
||||
const disabled = isCourtDetermined || !dl.dueDate;
|
||||
// Optional rules (RoP.151 cost-decision request etc.) start
|
||||
// unchecked; the user opts in. Disabled court-determined rows
|
||||
// already pre-uncheck via `disabled`. m's 2026-05-08 batch Item 2.
|
||||
const checked = !disabled && !dl.isOptional;
|
||||
const checked = !disabled && pr.preChecked;
|
||||
// Same direct-vs-indirect split as the timeline date cell —
|
||||
// chained court-set rules read as "unbestimmt" rather than
|
||||
// "wird vom Gericht bestimmt".
|
||||
const courtLabelKey = dl.isCourtSetIndirect ? "deadlines.court.indirect" : "deadlines.court.set";
|
||||
const optionalBadge = dl.isOptional && !isCourtDetermined
|
||||
const optionalBadge = dl.priority === "optional" && !isCourtDetermined
|
||||
? `<span class="frist-save-optional">${escHtml(t("deadlines.optional.badge"))}</span>`
|
||||
: "";
|
||||
const meta = isCourtDetermined
|
||||
@@ -494,8 +388,8 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
</div>`;
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data)
|
||||
: renderTimelineBody(data);
|
||||
? renderColumnsBody(data, { editable: true })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true });
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
printBtn.style.display = "block";
|
||||
@@ -566,186 +460,8 @@ function openInlineDateEditor(span: HTMLElement) {
|
||||
if (editor.value) editor.select();
|
||||
}
|
||||
|
||||
function deadlineCardHtml(dl: CalculatedDeadline, opts: { showParty: boolean }): string {
|
||||
// Click-to-edit on dated rows + court-set placeholders: lets the user
|
||||
// override the calculated date (e.g. court extended the deadline) or
|
||||
// fill in a court-set decision date once known. Downstream rules
|
||||
// re-anchor on the override via anchorOverrides → /api/tools/fristenrechner.
|
||||
// Root-event rows (the trigger anchor itself) are NOT editable — the
|
||||
// trigger date input is the canonical place to change that.
|
||||
const editable = !dl.isRootEvent && dl.code !== "";
|
||||
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
|
||||
const editAttrs = editable
|
||||
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
|
||||
: "";
|
||||
// "wird vom Gericht bestimmt" only fits direct court-set rules
|
||||
// (Urteil / Beschluss / Anordnung). Indirect rules (chained off a
|
||||
// court-set parent, e.g. RoP.151) render "unbestimmt" instead — the
|
||||
// date isn't directly determined by the court, it's derived from
|
||||
// the parent's date that the court will set. m's 2026-05-08 call.
|
||||
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>`;
|
||||
|
||||
const mandatoryBadge = dl.isMandatory
|
||||
? ""
|
||||
: '<span class="optional-badge">optional</span>';
|
||||
|
||||
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
|
||||
|
||||
const adjustedNote = dl.wasAdjusted
|
||||
? `<div class="timeline-adjusted">\u26a0 ${formatAdjustedNote(dl)}</div>`
|
||||
: "";
|
||||
|
||||
const ruleRef = dl.ruleRef
|
||||
? `<span class="timeline-rule">${dl.ruleRef}</span>`
|
||||
: "";
|
||||
|
||||
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
const notes = noteText
|
||||
? `<div class="timeline-notes">${noteText}</div>`
|
||||
: "";
|
||||
|
||||
const meta = (opts.showParty || ruleRef)
|
||||
? `<div class="timeline-meta">
|
||||
${opts.showParty ? partyBadge(dl.party) : ""}
|
||||
${ruleRef}
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
return `<div class="timeline-item-header">
|
||||
<span class="timeline-name">
|
||||
${dlName}
|
||||
${mandatoryBadge}
|
||||
</span>
|
||||
${dateStr}
|
||||
</div>
|
||||
${meta}
|
||||
${adjustedNote}
|
||||
${notes}`;
|
||||
}
|
||||
|
||||
function renderTimelineBody(data: DeadlineResponse): string {
|
||||
let html = '<div class="timeline">';
|
||||
for (const dl of data.deadlines) {
|
||||
html += `
|
||||
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
|
||||
<div class="timeline-dot-col">
|
||||
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
|
||||
<div class="timeline-line"></div>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
${deadlineCardHtml(dl, { showParty: true })}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
}
|
||||
|
||||
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
|
||||
// (defendant). Each grid row corresponds to a distinct dueDate, so events on
|
||||
// the same day line up across columns. Deadlines with party=both render in
|
||||
// BOTH the Proactive and Reactive cells of their row with a "beide Seiten"
|
||||
// caption so the duplication is legible as intentional. Undated events
|
||||
// (Urteil, Beschluss, court-set placeholders) trail the dated rows; each
|
||||
// gets its own row in the backend's sequence_order so e.g. Urteil precedes
|
||||
// Berufungseinlegung visually instead of stacking in one bucket.
|
||||
function renderColumnsBody(data: DeadlineResponse): string {
|
||||
type Cell = CalculatedDeadline[];
|
||||
type Row = { proactive: Cell; court: Cell; reactive: Cell };
|
||||
|
||||
const UNSCHEDULED_PREFIX = "__unscheduled__";
|
||||
const rowsMap = new Map<string, Row>();
|
||||
const ensureRow = (key: string): Row => {
|
||||
let r = rowsMap.get(key);
|
||||
if (!r) {
|
||||
r = { proactive: [], court: [], reactive: [] };
|
||||
rowsMap.set(key, r);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
data.deadlines.forEach((dl, idx) => {
|
||||
// Dated rows share a row by date; undated rows each get their own row,
|
||||
// keyed by index so the backend's sequence_order is preserved in the
|
||||
// dateless tail.
|
||||
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
|
||||
const row = ensureRow(key);
|
||||
switch (dl.party) {
|
||||
case "claimant":
|
||||
row.proactive.push(dl);
|
||||
break;
|
||||
case "defendant":
|
||||
row.reactive.push(dl);
|
||||
break;
|
||||
case "court":
|
||||
row.court.push(dl);
|
||||
break;
|
||||
case "both":
|
||||
// Mirrored: same card lands in Proactive AND Reactive at this date.
|
||||
row.proactive.push(dl);
|
||||
row.reactive.push(dl);
|
||||
break;
|
||||
default:
|
||||
// Unknown party: keep visible by parking in the Court column.
|
||||
row.court.push(dl);
|
||||
}
|
||||
});
|
||||
|
||||
// Dated keys (YYYY-MM-DD) sort chronologically by lexicographic compare.
|
||||
// Unscheduled keys carry the sequence-order index in their padded suffix
|
||||
// so they likewise sort by source order. Concatenate so the dateless tail
|
||||
// sits below the dated rows.
|
||||
const datedKeys: string[] = [];
|
||||
const unscheduledKeys: string[] = [];
|
||||
for (const k of rowsMap.keys()) {
|
||||
if (k.startsWith(UNSCHEDULED_PREFIX)) unscheduledKeys.push(k);
|
||||
else datedKeys.push(k);
|
||||
}
|
||||
datedKeys.sort();
|
||||
unscheduledKeys.sort();
|
||||
const keys = [...datedKeys, ...unscheduledKeys];
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
|
||||
}
|
||||
const cards = items
|
||||
.map((dl) => {
|
||||
const mirrorTag = dl.party === "both"
|
||||
? `<div class="fr-col-mirror">\u2194 ${escHtml(t("deadlines.party.both.label"))}</div>`
|
||||
: "";
|
||||
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
|
||||
${deadlineCardHtml(dl, { showParty: false })}
|
||||
${mirrorTag}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="fr-col-cell">${cards}</div>`;
|
||||
};
|
||||
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
|
||||
|
||||
for (const key of keys) {
|
||||
const row = rowsMap.get(key)!;
|
||||
html += renderCell(row.proactive);
|
||||
html += renderCell(row.court);
|
||||
html += renderCell(row.reactive);
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
}
|
||||
// deadlineCardHtml / renderTimelineBody / renderColumnsBody moved to
|
||||
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
|
||||
|
||||
function reset() {
|
||||
selectedType = "";
|
||||
@@ -806,7 +522,7 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
if (revCciRow) revCciRow.style.display = selectedType === "UPC_REV" ? "" : "none";
|
||||
|
||||
syncInfAmendEnabled();
|
||||
populateCourtPicker(selectedType);
|
||||
populateCourtPickerCore("court-picker-row", "court-picker", selectedType);
|
||||
|
||||
// Hide the four group blocks; show the compact summary in their place.
|
||||
setProceedingPickerCollapsed(true, name);
|
||||
@@ -815,99 +531,9 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
scheduleProcCalc(0);
|
||||
}
|
||||
|
||||
// Court picker — t-paliad-122. Visible only for proceeding types that can
|
||||
// land in multiple courts with different holiday calendars (today: every
|
||||
// UPC-flavoured proceeding type, since UPC LDs span DE/FR/IT/NL/BE/FI/PT/
|
||||
// AT/SI/DK + Stockholm RD + 3 CD seats). For DE-only proceedings (DE_NULL,
|
||||
// DE_NULL_BGH, DE_INF_BGH, DPMA_*, EPA_*, EP_GRANT) the court is fixed by
|
||||
// the proceeding type — no picker, server resolves the default.
|
||||
//
|
||||
// The picker calls /api/tools/courts?courtType=UPC-LD on first need and
|
||||
// caches the response per-type. Defaulting to upc-ld-muenchen matches HLC's
|
||||
// most common venue and keeps current behaviour for users who don't choose.
|
||||
interface CourtRow {
|
||||
id: string;
|
||||
code: string;
|
||||
nameDE: string;
|
||||
nameEN: string;
|
||||
country: string;
|
||||
regime?: string;
|
||||
courtType: string;
|
||||
}
|
||||
|
||||
const courtCache = new Map<string, CourtRow[]>();
|
||||
|
||||
function courtTypesFor(proceedingType: string): string[] {
|
||||
// Map proceeding code to compatible court types. UPC proceedings → UPC-LD
|
||||
// (most common); appeals → UPC-CoA; central-division revocations → UPC-CD.
|
||||
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
|
||||
return ["UPC-CoA"];
|
||||
}
|
||||
if (proceedingType === "UPC_REV") {
|
||||
return ["UPC-CD", "UPC-LD"]; // CD is the default revocation forum, LD when joined with infringement
|
||||
}
|
||||
if (proceedingType.startsWith("UPC_")) {
|
||||
return ["UPC-LD"];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function defaultCourtFor(proceedingType: string): string {
|
||||
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
|
||||
return "upc-coa-luxembourg";
|
||||
}
|
||||
if (proceedingType === "UPC_REV") {
|
||||
return "upc-cd-paris";
|
||||
}
|
||||
return "upc-ld-muenchen";
|
||||
}
|
||||
|
||||
async function fetchCourts(courtType: string): Promise<CourtRow[]> {
|
||||
if (courtCache.has(courtType)) return courtCache.get(courtType)!;
|
||||
try {
|
||||
const resp = await fetch(`/api/tools/courts?courtType=${encodeURIComponent(courtType)}`);
|
||||
if (!resp.ok) return [];
|
||||
const rows = (await resp.json()) as CourtRow[];
|
||||
courtCache.set(courtType, rows);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function populateCourtPicker(proceedingType: string): Promise<void> {
|
||||
const row = document.getElementById("court-picker-row");
|
||||
const select = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
if (!row || !select) return;
|
||||
|
||||
const types = courtTypesFor(proceedingType);
|
||||
if (types.length === 0) {
|
||||
row.style.display = "none";
|
||||
select.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Load all compatible court types and concatenate (CD before LD for REV).
|
||||
const lists = await Promise.all(types.map(t => fetchCourts(t)));
|
||||
const courts = lists.flat();
|
||||
if (courts.length <= 1) {
|
||||
// Single compatible court — no point asking the user. Server's
|
||||
// jurisdiction default lands the same place.
|
||||
row.style.display = "none";
|
||||
select.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const lang = getLang();
|
||||
const defaultID = defaultCourtFor(proceedingType);
|
||||
select.innerHTML = courts.map(c => {
|
||||
const name = lang === "en" ? c.nameEN : c.nameDE;
|
||||
return `<option value="${escAttr(c.id)}"${c.id === defaultID ? " selected" : ""}>${escHtml(name)}</option>`;
|
||||
}).join("");
|
||||
row.style.display = "";
|
||||
}
|
||||
|
||||
// inf-amend-flag is only meaningful when ccr-flag is on (R.30 application
|
||||
// Court-picker primitives (CourtRow / courtCache / courtTypesFor /
|
||||
// defaultCourtFor / fetchCourts / populateCourtPicker) moved to
|
||||
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
|
||||
// is filed within the Defence to CCR). When ccr-flag flips off, also
|
||||
// untick inf-amend-flag so the calc payload stays coherent.
|
||||
function syncInfAmendEnabled() {
|
||||
@@ -2484,8 +2110,12 @@ function writeStep1ContextToURL(ctx: Step1Context, replace = false) {
|
||||
|
||||
// isAdhocMode is read by the save-to-project CTA — ad-hoc has no
|
||||
// project to save against, so the CTA disables and renders a hint.
|
||||
// t-paliad-168: also true when no Step 1 context is set at all (the
|
||||
// "Verfahrensablauf einsehen" / sidebar deep-link browse path opens
|
||||
// Pathway A without an Akte). In both cases the user has no project
|
||||
// to save against; the CTA renders disabled with the same hint.
|
||||
function isAdhocMode(): boolean {
|
||||
return currentStep1Context.kind === "adhoc";
|
||||
return currentStep1Context.kind === "adhoc" || currentStep1Context.kind === "none";
|
||||
}
|
||||
|
||||
function adhocSummaryLabel(forum: AdhocForum): string {
|
||||
@@ -2530,6 +2160,11 @@ function selectProject(project: ProjectOption) {
|
||||
writeStep1ContextToURL(currentStep1Context);
|
||||
renderStep1Summary();
|
||||
showStep2Card();
|
||||
// t-paliad-164: project.our_side predefines the perspective chip.
|
||||
// Only fires when the user hasn't already locked a perspective via
|
||||
// ?role= in the URL — the URL pick wins because it represents an
|
||||
// explicit choice (chip click or shared link).
|
||||
applyOurSidePredefine(project, /* replaceURL */ false);
|
||||
// Slice 3b: project's proceeding type narrows the B1 cascade if the
|
||||
// user reaches it via Step 2 → Etwas ist passiert. Refresh here so
|
||||
// a cascade already on screen (rare but possible via popstate) picks
|
||||
@@ -2551,6 +2186,12 @@ function clearStep1Context() {
|
||||
renderStep1Summary();
|
||||
hideStep2Card();
|
||||
triggerCascadeRefresh();
|
||||
// t-paliad-164: hint dies with the project context. We deliberately
|
||||
// leave the perspective chip itself alone — the user may want to
|
||||
// keep their pick when returning to Step 1; we only clear the
|
||||
// "vorgegeben durch Akte" annotation since there's no Akte anymore.
|
||||
const hint = document.getElementById("fristen-perspective-hint");
|
||||
if (hint) hint.hidden = true;
|
||||
}
|
||||
|
||||
function renderStep1Summary() {
|
||||
@@ -2626,6 +2267,10 @@ function initPathwayFork() {
|
||||
if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
|
||||
currentStep1Context.project = cachedAkten.find((p) => p.id === currentStep1Context.projectId);
|
||||
renderStep1Summary();
|
||||
// t-paliad-164: deep-link / refresh path. project loaded async, so
|
||||
// the predefine has to wait for cachedAkten. replace=true keeps
|
||||
// the URL clean — the user didn't navigate, they just refreshed.
|
||||
applyOurSidePredefine(currentStep1Context.project, /* replaceURL */ true);
|
||||
}
|
||||
renderAkteList("");
|
||||
// Cascade may already be on screen if the user landed with
|
||||
@@ -2657,6 +2302,11 @@ function initPathwayFork() {
|
||||
const next: Perspective = isClear ? null : ((chip.dataset.perspective as Perspective) ?? null);
|
||||
writePerspectiveToURL(next);
|
||||
applyPerspective(next);
|
||||
// t-paliad-164: any chip click is an explicit override; hide the
|
||||
// "vorgegeben durch Akte" hint so the bar reads as "user choice"
|
||||
// from here on.
|
||||
const hint = document.getElementById("fristen-perspective-hint");
|
||||
if (hint) hint.hidden = true;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2679,6 +2329,9 @@ function initPathwayFork() {
|
||||
document.getElementById("fristen-step2-happened")?.addEventListener("click", () => {
|
||||
navigateToPathway("b", "tree");
|
||||
});
|
||||
// t-paliad-179 Slice 1: the "Verfahrensablauf einsehen" Step 2 card
|
||||
// has been retired — the abstract-browse intent lives on its own
|
||||
// route at /tools/verfahrensablauf now. No third-card handler here.
|
||||
|
||||
// Step 3a cards — File / Draft / Enter. File drops into the existing
|
||||
// Pathway A wizard; Enter routes to the manual-create form;
|
||||
@@ -2744,6 +2397,17 @@ function initPathwayFork() {
|
||||
renderStep1Summary();
|
||||
if (currentStep1Context.kind !== "none") showStep2Card(); else hideStep2Card();
|
||||
applyPerspective(readPerspectiveFromURL());
|
||||
// t-paliad-164: restore the hint visibility from URL+project state.
|
||||
// The hint shows when the active URL perspective matches what the
|
||||
// current project's our_side would have predefined — i.e. the
|
||||
// "predefined-and-not-yet-overridden" state. Approximation: hint
|
||||
// visible iff project.our_side maps to currentPerspective.
|
||||
const hint = document.getElementById("fristen-perspective-hint");
|
||||
if (hint) {
|
||||
const proj = currentStep1Context.kind === "project" ? currentStep1Context.project : undefined;
|
||||
const expected = ourSideToPerspective(proj?.our_side);
|
||||
hint.hidden = !(proj && proj.our_side && expected === currentPerspective);
|
||||
}
|
||||
const path = readPathwayFromURL();
|
||||
const mode = readBModeFromURL();
|
||||
showPathway(path, mode);
|
||||
@@ -3382,6 +3046,45 @@ function applyPerspective(p: Perspective) {
|
||||
triggerCascadeRefresh();
|
||||
}
|
||||
|
||||
// ourSideToPerspective maps the project-level "Wir vertreten" enum
|
||||
// onto the chip-strip Perspective. 'court' / 'both' map to null
|
||||
// (chip cleared) — court actions are neutral to the user's side and
|
||||
// "both" is explicit no-filter intent.
|
||||
function ourSideToPerspective(os: string | null | undefined): Perspective {
|
||||
if (os === "claimant") return "claimant";
|
||||
if (os === "defendant") return "defendant";
|
||||
return null;
|
||||
}
|
||||
|
||||
// applyOurSidePredefine locks the perspective chip from
|
||||
// project.our_side when the user hasn't already explicitly picked
|
||||
// one. The URL is the "explicit pick" signal: if ?role= is present
|
||||
// at call time, the user (or a shared link) chose it and we don't
|
||||
// overwrite. When we do predefine, we write the same value to the
|
||||
// URL so back/forward + refresh round-trip cleanly, and we show the
|
||||
// "vorgegeben durch Akte" hint so the user knows where the
|
||||
// pre-selection came from. Clicking a chip clears the hint.
|
||||
//
|
||||
// `replaceURL=true` is for the deep-link / refresh path; `false` for
|
||||
// in-page project selection so back-button restores the empty state.
|
||||
function applyOurSidePredefine(project: ProjectOption | undefined, replaceURL: boolean) {
|
||||
const hint = document.getElementById("fristen-perspective-hint");
|
||||
if (!project || !project.our_side) {
|
||||
if (hint) hint.hidden = true;
|
||||
return;
|
||||
}
|
||||
// URL wins — user has an explicit pick. Don't clobber it; also no
|
||||
// hint, since the active perspective didn't come from the project.
|
||||
if (readPerspectiveFromURL() !== null) {
|
||||
if (hint) hint.hidden = true;
|
||||
return;
|
||||
}
|
||||
const next = ourSideToPerspective(project.our_side);
|
||||
writePerspectiveToURL(next, replaceURL);
|
||||
applyPerspective(next);
|
||||
if (hint) hint.hidden = false;
|
||||
}
|
||||
|
||||
// perspectiveAllowsParty returns true when a node tagged with `party`
|
||||
// should be visible under the current perspective. Neutral nodes
|
||||
// (party undefined / empty) always pass. "both" matches every
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,122 +1,176 @@
|
||||
import { initI18n, t, getLang, type I18nKey } from "./i18n";
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { mountFilterBar, type BarHandle } from "./filter-bar";
|
||||
import type { AxisKey } from "./filter-bar";
|
||||
import type { FilterSpec, RenderSpec, SystemView, ViewRunResult } from "./views/types";
|
||||
import { renderListShape } from "./views/shape-list";
|
||||
|
||||
// /inbox client. Two tabs (pending-mine / mine), action buttons (approve /
|
||||
// reject / revoke), and a small inline diff for update / complete / delete
|
||||
// lifecycle events.
|
||||
// /inbox client — t-paliad-163 universal-filter migration.
|
||||
//
|
||||
// State is URL-driven via ?tab= so back/forward buttons work and the bell
|
||||
// badge can deep-link to either tab. The badge in the sidebar (id
|
||||
// sidebar-inbox-badge) is updated by the shared global polling loop in
|
||||
// sidebar.ts; this module just keeps the page content in sync.
|
||||
// The bar owns every axis the old tab UI exposed plus more:
|
||||
// - approval_viewer_role: "Zur Genehmigung" / "Eigene Anfragen" /
|
||||
// "Alle sichtbaren" (collapses the legacy two-tab UI per Q4 lock-in)
|
||||
// - approval_status: chip cluster (default: pending)
|
||||
// - approval_entity_type: chip pair (Frist / Termin)
|
||||
// - time: chip cluster (Any default)
|
||||
// - density: comfortable / compact
|
||||
// - sort: date asc / desc
|
||||
//
|
||||
// Row rendering: shape-list.ts with row_action="approve" stamps the
|
||||
// inbox markup (entity title, diff, approve/reject/revoke buttons).
|
||||
// We wire action click handlers in onResult and refresh through the
|
||||
// bar handle.
|
||||
|
||||
type Lifecycle = "create" | "update" | "complete" | "delete";
|
||||
type RequestStatus = "pending" | "approved" | "rejected" | "revoked" | "superseded";
|
||||
type DecisionKind = "peer" | "admin_override";
|
||||
const INBOX_AXES: AxisKey[] = [
|
||||
"time",
|
||||
"approval_viewer_role",
|
||||
"approval_status",
|
||||
"approval_entity_type",
|
||||
"density",
|
||||
"sort",
|
||||
];
|
||||
|
||||
interface ApprovalRequestView {
|
||||
id: string;
|
||||
project_id: string;
|
||||
project_title: string;
|
||||
entity_type: "deadline" | "appointment";
|
||||
entity_id: string;
|
||||
entity_title?: string;
|
||||
lifecycle_event: Lifecycle;
|
||||
pre_image?: Record<string, unknown> | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
required_role: string;
|
||||
status: RequestStatus;
|
||||
requested_at: string;
|
||||
requested_by: string;
|
||||
requester_name: string;
|
||||
decided_at?: string;
|
||||
decided_by?: string;
|
||||
decider_name?: string;
|
||||
decision_kind?: DecisionKind;
|
||||
decision_note?: string;
|
||||
// t-paliad-161: 'user' (direct create) or 'agent' (Paliadin-drafted).
|
||||
// 'agent' rows render with a sparkle ✨ next to the requester's name.
|
||||
requester_kind?: "user" | "agent";
|
||||
agent_turn_id?: string;
|
||||
}
|
||||
|
||||
type Tab = "pending-mine" | "mine";
|
||||
|
||||
let currentTab: Tab = "pending-mine";
|
||||
|
||||
initI18n();
|
||||
initSidebar();
|
||||
let bar: BarHandle | null = null;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const url = new URL(window.location.href);
|
||||
const t = url.searchParams.get("tab");
|
||||
if (t === "mine") currentTab = "mine";
|
||||
bindTabs();
|
||||
refresh();
|
||||
initI18n();
|
||||
initSidebar();
|
||||
applyLegacyTabRedirect();
|
||||
void hydrate();
|
||||
});
|
||||
|
||||
function bindTabs() {
|
||||
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tab = (btn.dataset.tab as Tab) || "pending-mine";
|
||||
if (tab === currentTab) return;
|
||||
currentTab = tab;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tab);
|
||||
history.replaceState({}, "", url.toString());
|
||||
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((b) => {
|
||||
b.classList.toggle("active", b.dataset.tab === tab);
|
||||
});
|
||||
refresh();
|
||||
});
|
||||
// ?tab=pending-mine | mine -> ?a_role=approver_eligible | self_requested.
|
||||
// Done client-side because /inbox serves a static dist file (no Go
|
||||
// router involvement). Bookmarks from the sidebar bell + outbound
|
||||
// emails keep landing on the right sub-view through the bar.
|
||||
function applyLegacyTabRedirect(): void {
|
||||
const url = new URL(window.location.href);
|
||||
const tab = url.searchParams.get("tab");
|
||||
if (!tab) return;
|
||||
url.searchParams.delete("tab");
|
||||
if (tab === "mine") {
|
||||
url.searchParams.set("a_role", "self_requested");
|
||||
} else if (tab === "pending-mine") {
|
||||
url.searchParams.set("a_role", "approver_eligible");
|
||||
}
|
||||
history.replaceState(null, "", url.toString());
|
||||
}
|
||||
|
||||
async function hydrate(): Promise<void> {
|
||||
const host = document.getElementById("inbox-filter-bar");
|
||||
const loading = document.getElementById("inbox-loading");
|
||||
const results = document.getElementById("inbox-results");
|
||||
const empty = document.getElementById("inbox-empty");
|
||||
if (!host || !loading || !results || !empty) return;
|
||||
|
||||
const sys = await fetchInboxSystemView();
|
||||
if (!sys) {
|
||||
loading.style.display = "none";
|
||||
empty.style.display = "";
|
||||
empty.textContent = t("approvals.error.internal");
|
||||
return;
|
||||
}
|
||||
|
||||
bar = mountFilterBar(host, {
|
||||
baseFilter: sys.Filter,
|
||||
baseRender: sys.Render,
|
||||
axes: INBOX_AXES,
|
||||
surfaceKey: "inbox",
|
||||
systemViewSlug: sys.Slug,
|
||||
onResult: (result, effective) => paint(result, effective.render, results, empty, loading),
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const loading = document.getElementById("inbox-loading") as HTMLElement | null;
|
||||
const empty = document.getElementById("inbox-empty") as HTMLElement | null;
|
||||
const list = document.getElementById("inbox-list") as HTMLUListElement | null;
|
||||
if (!loading || !empty || !list) return;
|
||||
loading.style.display = "";
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = "";
|
||||
const path = currentTab === "pending-mine" ? "/api/inbox/pending-mine" : "/api/inbox/mine";
|
||||
let rows: ApprovalRequestView[] = [];
|
||||
async function fetchInboxSystemView(): Promise<SystemView | null> {
|
||||
try {
|
||||
const r = await fetch(path, { credentials: "include" });
|
||||
if (r.ok) {
|
||||
// Defensive: a Go `nil` slice serialises as JSON `null`, not `[]`.
|
||||
// Coerce so `rows.length` never throws (t-paliad-160 §D regression
|
||||
// hardening). Server-side handler also forces `[]`, but keep the
|
||||
// client guard for older / cached deploys.
|
||||
const body = (await r.json()) as ApprovalRequestView[] | null;
|
||||
rows = body ?? [];
|
||||
}
|
||||
const r = await fetch("/api/views/system", { credentials: "include" });
|
||||
if (!r.ok) return null;
|
||||
const list = (await r.json()) as SystemView[];
|
||||
return list.find((v) => v.Slug === "inbox") ?? null;
|
||||
} catch (_e) {
|
||||
// Network errors fall through to empty render.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function paint(
|
||||
result: ViewRunResult,
|
||||
render: RenderSpec,
|
||||
results: HTMLElement,
|
||||
empty: HTMLElement,
|
||||
loading: HTMLElement,
|
||||
): void {
|
||||
loading.style.display = "none";
|
||||
if (rows.length === 0) {
|
||||
empty.textContent = t(
|
||||
currentTab === "pending-mine"
|
||||
? "approvals.empty.pending_mine"
|
||||
: "approvals.empty.mine"
|
||||
);
|
||||
|
||||
if (!result.rows || result.rows.length === 0) {
|
||||
results.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
empty.textContent = t("approvals.empty.pending_mine");
|
||||
void maybeShowAdminNudge();
|
||||
return;
|
||||
}
|
||||
hideAdminNudge();
|
||||
for (const row of rows) list.appendChild(renderRow(row));
|
||||
empty.style.display = "none";
|
||||
|
||||
// shape-list.ts honours render.list.row_action — InboxSystemView's
|
||||
// RenderSpec sets row_action="approve" so we get the inbox markup.
|
||||
renderListShape(results, result.rows, render);
|
||||
|
||||
// Wire action handlers on the freshly stamped DOM. The action
|
||||
// POSTs land on the same endpoints the legacy /inbox used; on
|
||||
// success we trigger a bar refresh so the new state propagates.
|
||||
wireApprovalActions(results);
|
||||
}
|
||||
|
||||
function wireApprovalActions(host: HTMLElement): void {
|
||||
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
|
||||
const action = btn.dataset.action as "approve" | "reject" | "revoke" | undefined;
|
||||
const li = btn.closest<HTMLLIElement>(".views-approval-row");
|
||||
const id = li?.dataset.requestId;
|
||||
if (!action || !id) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
let note = "";
|
||||
if (action === "reject") {
|
||||
note = window.prompt(t("approvals.note.placeholder")) || "";
|
||||
}
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/approval-requests/${id}/${action}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({} as { error?: string }));
|
||||
alert(mapApprovalError(body.error || "internal"));
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
await bar?.refresh();
|
||||
await refreshInboxBadge();
|
||||
} catch (_e) {
|
||||
alert("Network error");
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mapApprovalError(key: string): string {
|
||||
switch (key) {
|
||||
case "self_approval_blocked": return t("approvals.error.self_approval");
|
||||
case "no_qualified_approver": return t("approvals.error.no_qualified_approver");
|
||||
case "concurrent_pending": return t("approvals.error.concurrent_pending");
|
||||
case "not_authorized": return t("approvals.error.not_authorized");
|
||||
case "request_not_pending": return t("approvals.error.request_not_pending");
|
||||
default: return key;
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-154 — show the admin-only "configure policies" nudge when:
|
||||
// - the current user is global_admin
|
||||
// - the inbox is empty
|
||||
// - no approval_policies row exists firm-wide (matrix is dormant)
|
||||
//
|
||||
// All three checks are AND-ed. Anonymous users + non-admins + active-policy
|
||||
// admins all skip the nudge.
|
||||
// - current user is global_admin
|
||||
// - inbox empty
|
||||
// - no approval_policies row exists firm-wide
|
||||
async function maybeShowAdminNudge(): Promise<void> {
|
||||
const nudge = document.getElementById("inbox-admin-nudge");
|
||||
if (!nudge) return;
|
||||
@@ -132,9 +186,7 @@ async function maybeShowAdminNudge(): Promise<void> {
|
||||
if (data.any) return;
|
||||
|
||||
nudge.style.display = "";
|
||||
} catch (_e) {
|
||||
// Network failure → keep nudge hidden.
|
||||
}
|
||||
} catch (_e) { /* keep hidden */ }
|
||||
}
|
||||
|
||||
function hideAdminNudge(): void {
|
||||
@@ -142,175 +194,7 @@ function hideAdminNudge(): void {
|
||||
if (nudge) nudge.style.display = "none";
|
||||
}
|
||||
|
||||
function renderRow(row: ApprovalRequestView): HTMLLIElement {
|
||||
const li = document.createElement("li");
|
||||
li.className = "inbox-row";
|
||||
|
||||
// Header: project / entity / lifecycle / required-role
|
||||
const head = document.createElement("div");
|
||||
head.className = "inbox-row-head";
|
||||
const title = document.createElement("div");
|
||||
title.className = "inbox-row-title";
|
||||
const entityLabel = t(("approvals.entity." + row.entity_type) as I18nKey);
|
||||
const lifecycleLabel = t(("approvals.lifecycle." + row.lifecycle_event) as I18nKey);
|
||||
const entityTitle = row.entity_title || "—";
|
||||
title.textContent = `${entityLabel}: ${entityTitle} — ${lifecycleLabel}`;
|
||||
head.appendChild(title);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "inbox-row-meta";
|
||||
const reqByLabel = t("approvals.requested_by");
|
||||
const roleLabel = t(("approvals.required_role." + row.required_role) as I18nKey);
|
||||
// t-paliad-161 ✨: when the request was drafted by Paliadin, surface
|
||||
// that next to the requester's name. Reads as "von Anna ✨ Paliadin".
|
||||
const requesterTag = row.requester_kind === "agent"
|
||||
? `${row.requester_name} ✨ ${t("approvals.agent.byline")}`
|
||||
: row.requester_name;
|
||||
meta.textContent = `${row.project_title} · ${reqByLabel} ${requesterTag} · ${roleLabel}+ · ${formatRelativeTime(row.requested_at)}`;
|
||||
head.appendChild(meta);
|
||||
li.appendChild(head);
|
||||
|
||||
// Diff for update / complete (date-bearing fields)
|
||||
const diff = renderDiff(row);
|
||||
if (diff) li.appendChild(diff);
|
||||
|
||||
// Decision note if any
|
||||
if (row.decision_note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "inbox-row-note";
|
||||
note.textContent = row.decision_note;
|
||||
li.appendChild(note);
|
||||
}
|
||||
|
||||
// Action row
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "inbox-row-actions";
|
||||
|
||||
if (row.status === "pending" && currentTab === "pending-mine") {
|
||||
actions.appendChild(actionButton("approve", row.id, () => doDecision(row.id, "approve")));
|
||||
actions.appendChild(actionButton("reject", row.id, () => doDecision(row.id, "reject")));
|
||||
} else if (row.status === "pending" && currentTab === "mine") {
|
||||
actions.appendChild(actionButton("revoke", row.id, () => doDecision(row.id, "revoke")));
|
||||
} else {
|
||||
// historic — show status pill
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "approval-pill approval-pill--historic";
|
||||
pill.textContent = t(("approvals.status." + row.status) as I18nKey);
|
||||
if (row.decider_name && row.status !== "revoked") {
|
||||
const decided = document.createElement("span");
|
||||
decided.className = "inbox-row-decided";
|
||||
decided.textContent = ` · ${t("approvals.decided_by")} ${row.decider_name}`;
|
||||
pill.appendChild(decided);
|
||||
}
|
||||
actions.appendChild(pill);
|
||||
}
|
||||
li.appendChild(actions);
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderDiff(row: ApprovalRequestView): HTMLElement | null {
|
||||
const before = (row.pre_image || {}) as Record<string, unknown>;
|
||||
const after = (row.payload || {}) as Record<string, unknown>;
|
||||
const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)]));
|
||||
if (keys.length === 0) return null;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "inbox-row-diff";
|
||||
for (const k of keys) {
|
||||
const line = document.createElement("div");
|
||||
line.className = "inbox-row-diff-line";
|
||||
const label = document.createElement("span");
|
||||
label.className = "inbox-row-diff-key";
|
||||
label.textContent = k;
|
||||
line.appendChild(label);
|
||||
const span = document.createElement("span");
|
||||
span.className = "inbox-row-diff-values";
|
||||
const fmt = (v: unknown) =>
|
||||
v === null || v === undefined ? "—" : String(v);
|
||||
if (k in before && k in after) {
|
||||
span.textContent = `${fmt(before[k])} → ${fmt(after[k])}`;
|
||||
} else if (k in before) {
|
||||
span.textContent = `${t("approvals.diff.before")}: ${fmt(before[k])}`;
|
||||
} else {
|
||||
span.textContent = `${t("approvals.diff.after")}: ${fmt(after[k])}`;
|
||||
}
|
||||
line.appendChild(span);
|
||||
wrap.appendChild(line);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function actionButton(action: "approve" | "reject" | "revoke", _requestID: string, onClick: () => void): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `btn btn-${action === "approve" ? "primary" : action === "reject" ? "danger" : "secondary"} inbox-row-action`;
|
||||
btn.textContent = t(("approvals.action." + action) as I18nKey);
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
async function doDecision(requestID: string, action: "approve" | "reject" | "revoke") {
|
||||
let note = "";
|
||||
if (action === "reject") {
|
||||
note = window.prompt(t("approvals.note.placeholder")) || "";
|
||||
}
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetch(`/api/approval-requests/${requestID}/${action}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
} catch (_e) {
|
||||
alert("Network error");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
const errKey = (body && body.error) || "internal";
|
||||
const msg = mapApprovalError(errKey);
|
||||
alert(msg);
|
||||
return;
|
||||
}
|
||||
refresh();
|
||||
// Update sidebar bell count.
|
||||
refreshInboxBadge();
|
||||
}
|
||||
|
||||
function mapApprovalError(key: string): string {
|
||||
switch (key) {
|
||||
case "self_approval_blocked":
|
||||
return t("approvals.error.self_approval");
|
||||
case "no_qualified_approver":
|
||||
return t("approvals.error.no_qualified_approver");
|
||||
case "concurrent_pending":
|
||||
return t("approvals.error.concurrent_pending");
|
||||
case "not_authorized":
|
||||
return t("approvals.error.not_authorized");
|
||||
case "request_not_pending":
|
||||
return t("approvals.error.request_not_pending");
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const t0 = Date.parse(iso);
|
||||
if (isNaN(t0)) return iso;
|
||||
const diffMs = Date.now() - t0;
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return getLang() === "de" ? `vor ${sec}s` : `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return getLang() === "de" ? `vor ${min}m` : `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return getLang() === "de" ? `vor ${hr}h` : `${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return getLang() === "de" ? `vor ${day}d` : `${day}d ago`;
|
||||
}
|
||||
|
||||
// Update the sidebar inbox badge (shared with sidebar.ts polling).
|
||||
async function refreshInboxBadge() {
|
||||
async function refreshInboxBadge(): Promise<void> {
|
||||
const badge = document.getElementById("sidebar-inbox-badge");
|
||||
if (!badge) return;
|
||||
try {
|
||||
@@ -323,7 +207,5 @@ async function refreshInboxBadge() {
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
}
|
||||
} catch (_e) {
|
||||
/* noop */
|
||||
}
|
||||
} catch (_e) { /* noop */ }
|
||||
}
|
||||
|
||||
84
frontend/src/client/paliadin-late-poll.ts
Normal file
84
frontend/src/client/paliadin-late-poll.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Late-response polling. The Go backend's pollForResponse window is
|
||||
// 60 s; if Claude writes the response file after that (because the
|
||||
// tmux pane was busy mid-turn when the message arrived), the SSE
|
||||
// stream has already closed with an `error` event. The Janitor
|
||||
// (services.LocalPaliadinService.runJanitor) then patches the
|
||||
// paliadin_turns row when the file lands.
|
||||
//
|
||||
// This module is the FE half of that loop: after the bubble shows an
|
||||
// error, the caller registers the turn here. We poll
|
||||
// `/api/paliadin/turns/{id}` every 3 s for up to 10 minutes; once the
|
||||
// row has a non-empty response, we hand it back so the caller can
|
||||
// swap the bubble content in place.
|
||||
|
||||
export interface LateTurn {
|
||||
turn_id: string;
|
||||
response: string | null;
|
||||
error_code: string | null;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
used_tools: string[];
|
||||
rows_seen: number[];
|
||||
chip_count: number;
|
||||
classifier_tag: string | null;
|
||||
}
|
||||
|
||||
export interface LatePollOptions {
|
||||
turnId: string;
|
||||
intervalMs?: number; // default 3000
|
||||
maxDurationMs?: number; // default 600000 (10 min)
|
||||
onLateResponse: (turn: LateTurn) => void;
|
||||
onGiveUp?: () => void;
|
||||
}
|
||||
|
||||
export interface LatePollHandle {
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
const interval = opts.intervalMs ?? 3000;
|
||||
const maxDuration = opts.maxDurationMs ?? 10 * 60 * 1000;
|
||||
const startedAt = Date.now();
|
||||
|
||||
let cancelled = false;
|
||||
let timer: number | undefined;
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
if (Date.now() - startedAt > maxDuration) {
|
||||
opts.onGiveUp?.();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/paliadin/turns/${opts.turnId}`, {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (r.ok) {
|
||||
const turn = (await r.json()) as LateTurn;
|
||||
if (turn.response && turn.response.length > 0) {
|
||||
opts.onLateResponse(turn);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 404: row gone (very unlikely) — give up.
|
||||
if (r.status === 404) {
|
||||
opts.onGiveUp?.();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Transient network error; retry on next tick.
|
||||
}
|
||||
timer = window.setTimeout(tick, interval);
|
||||
};
|
||||
|
||||
// First poll deliberately runs after one interval so we don't race
|
||||
// the 60 s timeout on the very first tick.
|
||||
timer = window.setTimeout(tick, interval);
|
||||
|
||||
return {
|
||||
cancel: () => {
|
||||
cancelled = true;
|
||||
if (timer != null) window.clearTimeout(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
134
frontend/src/client/paliadin-render.ts
Normal file
134
frontend/src/client/paliadin-render.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// Shared Paliadin response renderer — used by both the standalone
|
||||
// /paliadin page (client/paliadin.ts) and the inline drawer widget
|
||||
// (client/paliadin-widget.ts). Extracted from paliadin.ts so the
|
||||
// widget renders the same markdown + chips as the dedicated page
|
||||
// without re-implementing the pipeline.
|
||||
|
||||
const CHIP_RE = /\[(?:#([a-z]+)-OPEN:([A-Za-z0-9\-_]+)|chip:([a-z]+):([^\]]+))\]/g;
|
||||
const MD_LINK_RE = /\[([^\]\n]+)\]\(((?:https?:\/\/|\/)[^\s)]+)\)/g;
|
||||
const BARE_URL_RE = /(^|[^"=>])(https?:\/\/[^\s<>"']+)/g;
|
||||
|
||||
function chipURL(kind: string, id: string): string {
|
||||
switch (kind) {
|
||||
case "deadline":
|
||||
case "frist":
|
||||
return "/deadlines/" + id;
|
||||
case "projekt":
|
||||
case "project":
|
||||
return "/projects/" + id;
|
||||
case "termin":
|
||||
case "appointment":
|
||||
return "/appointments/" + id;
|
||||
default:
|
||||
return "#";
|
||||
}
|
||||
}
|
||||
|
||||
function chipLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "deadline":
|
||||
case "frist":
|
||||
return "Frist öffnen";
|
||||
case "projekt":
|
||||
case "project":
|
||||
return "Akte ansehen";
|
||||
case "termin":
|
||||
case "appointment":
|
||||
return "Termin öffnen";
|
||||
default:
|
||||
return "öffnen";
|
||||
}
|
||||
}
|
||||
|
||||
function renderBlocks(escapedHtml: string): string {
|
||||
const out: string[] = [];
|
||||
let listItems: string[] = [];
|
||||
let paraLines: string[] = [];
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length === 0) return;
|
||||
out.push(`<ul class="paliadin-list">${listItems.map((li) => `<li>${li}</li>`).join("")}</ul>`);
|
||||
listItems = [];
|
||||
};
|
||||
const flushPara = () => {
|
||||
if (paraLines.length === 0) return;
|
||||
out.push(`<p>${paraLines.join("<br>")}</p>`);
|
||||
paraLines = [];
|
||||
};
|
||||
|
||||
for (const rawLine of escapedHtml.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (line === "") {
|
||||
flushList();
|
||||
flushPara();
|
||||
continue;
|
||||
}
|
||||
let m: RegExpMatchArray | null;
|
||||
if ((m = line.match(/^###\s+(.+)$/))) {
|
||||
flushList();
|
||||
flushPara();
|
||||
out.push(`<h3>${m[1]}</h3>`);
|
||||
} else if ((m = line.match(/^##\s+(.+)$/))) {
|
||||
flushList();
|
||||
flushPara();
|
||||
out.push(`<h2>${m[1]}</h2>`);
|
||||
} else if ((m = line.match(/^[-*]\s+(.+)$/))) {
|
||||
flushPara();
|
||||
listItems.push(m[1]);
|
||||
} else if (line.match(/^---+$/)) {
|
||||
flushList();
|
||||
flushPara();
|
||||
out.push(`<hr>`);
|
||||
} else {
|
||||
flushList();
|
||||
paraLines.push(line);
|
||||
}
|
||||
}
|
||||
flushList();
|
||||
flushPara();
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
export function renderResponseHTML(raw: string): string {
|
||||
let html = raw
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
const chipHTML: string[] = [];
|
||||
html = html.replace(CHIP_RE, (_match, kind, id, chipKind, chipArg) => {
|
||||
let rendered = "";
|
||||
if (kind && id) {
|
||||
const url = chipURL(kind, id);
|
||||
const label = chipLabel(kind);
|
||||
rendered = `<a class="paliadin-chip" href="${url}">${label}</a>`;
|
||||
} else if (chipKind === "nav") {
|
||||
rendered = `<a class="paliadin-chip" href="${chipArg}">öffnen</a>`;
|
||||
} else if (chipKind === "filter") {
|
||||
rendered = `<a class="paliadin-chip" href="/inbox?${chipArg}">Filter anwenden</a>`;
|
||||
}
|
||||
if (!rendered) return "";
|
||||
chipHTML.push(rendered);
|
||||
return `CHIP${chipHTML.length - 1}`;
|
||||
});
|
||||
|
||||
html = renderBlocks(html);
|
||||
|
||||
html = html.replace(MD_LINK_RE, (_m, text, url) => {
|
||||
const ext = url.startsWith("http");
|
||||
const attrs = ext ? ` target="_blank" rel="noopener noreferrer"` : "";
|
||||
return `<a href="${url}" class="paliadin-link"${attrs}>${text}</a>`;
|
||||
});
|
||||
|
||||
html = html.replace(BARE_URL_RE, (_m, prefix, url) => {
|
||||
return `${prefix}<a href="${url}" class="paliadin-link" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||||
});
|
||||
|
||||
html = html.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
|
||||
html = html.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
|
||||
|
||||
html = html.replace(/CHIP(\d+)/g, (_m, idx) => chipHTML[Number(idx)] || "");
|
||||
|
||||
return html;
|
||||
}
|
||||
@@ -26,6 +26,8 @@
|
||||
import { initI18n, getLang, t } from "./i18n";
|
||||
import { computePaliadinContext, shouldSendContext, routeNameFor } from "./paliadin-context";
|
||||
import { startersFor, type Starter } from "./paliadin-starters";
|
||||
import { renderResponseHTML } from "./paliadin-render";
|
||||
import { pollForLateResponse, type LateTurn, type LatePollHandle } from "./paliadin-late-poll";
|
||||
|
||||
interface MeResponse {
|
||||
id: string;
|
||||
@@ -45,14 +47,26 @@ interface TurnResponse {
|
||||
sse_url: string;
|
||||
}
|
||||
|
||||
const SESSION_KEY = "paliadin:widget:session";
|
||||
const HISTORY_PREFIX = "paliadin:widget:history:";
|
||||
// Shared session key — the inline drawer and the standalone /paliadin
|
||||
// page must use the same browser-session id so both surfaces show the
|
||||
// same conversation. Migration on first run: if a legacy
|
||||
// `paliadin:widget:session` exists but the shared `paliadin:session`
|
||||
// does not, copy across so the user doesn't lose drawer state on the
|
||||
// rollover.
|
||||
const SESSION_KEY = "paliadin:session";
|
||||
const LEGACY_WIDGET_SESSION_KEY = "paliadin:widget:session";
|
||||
// History bucket — render-cache only; DB is source of truth (server
|
||||
// hydrates via /api/paliadin/history on every mount). The cache is keyed
|
||||
// by session id so a session reset gives a clean slate.
|
||||
const HISTORY_PREFIX = "paliadin:history:";
|
||||
|
||||
let sessionId: string;
|
||||
let history: HistoryEntry[] = [];
|
||||
let drawerOpen = false;
|
||||
let activeStream: EventSource | null = null;
|
||||
let pending = false;
|
||||
// Late-response pollers per turn_id (see paliadin-late-poll.ts).
|
||||
const lateWidgetPolls = new Map<string, LatePollHandle>();
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const trigger = document.getElementById("paliadin-widget-trigger");
|
||||
@@ -70,9 +84,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
function bootSession(): void {
|
||||
let s = localStorage.getItem(SESSION_KEY);
|
||||
if (!s) {
|
||||
s = crypto.randomUUID();
|
||||
// One-time migration: previous widget builds wrote
|
||||
// `paliadin:widget:session` instead of the shared key. Carry over
|
||||
// the existing id so the user keeps their conversation thread.
|
||||
const legacy = localStorage.getItem(LEGACY_WIDGET_SESSION_KEY);
|
||||
s = legacy || crypto.randomUUID();
|
||||
localStorage.setItem(SESSION_KEY, s);
|
||||
}
|
||||
// Drop the legacy key now that we've migrated; harmless if it's
|
||||
// already absent.
|
||||
localStorage.removeItem(LEGACY_WIDGET_SESSION_KEY);
|
||||
sessionId = s;
|
||||
loadHistory();
|
||||
}
|
||||
@@ -119,6 +140,10 @@ async function revealIfOwner(): Promise<void> {
|
||||
showTrigger();
|
||||
renderStarters();
|
||||
rehydrateHistory();
|
||||
// Refresh from DB in the background so cross-surface activity (a
|
||||
// turn typed on the standalone /paliadin page) shows up here without
|
||||
// a manual reload.
|
||||
void hydrateFromServer();
|
||||
}
|
||||
|
||||
function isPaliadinOwner(me: MeResponse): boolean {
|
||||
@@ -195,6 +220,10 @@ function openDrawer(): void {
|
||||
|
||||
refreshContextChip();
|
||||
renderStarters();
|
||||
// Pull the canonical conversation from the DB on every open so a
|
||||
// turn the user typed on /paliadin (or another tab) since the last
|
||||
// open is reflected here.
|
||||
void hydrateFromServer();
|
||||
setTimeout(() => {
|
||||
document.getElementById("paliadin-widget-input")?.focus();
|
||||
}, 60);
|
||||
@@ -369,9 +398,13 @@ async function sendTurn(): Promise<void> {
|
||||
cleanupStream();
|
||||
});
|
||||
es.addEventListener("error", () => {
|
||||
setBubbleText(placeholder, t("paliadin.error.connection_lost"));
|
||||
const errText = t("paliadin.error.connection_lost");
|
||||
setBubbleText(placeholder, errText + " " + t("paliadin.late.waiting"));
|
||||
placeholder.classList.add("paliadin-widget-bubble--error");
|
||||
placeholder.classList.add("paliadin-widget-bubble--late-pending");
|
||||
placeholder.dataset.streaming = "false";
|
||||
placeholder.dataset.turnId = turnRes.turn_id;
|
||||
startWidgetLatePoll(turnRes.turn_id, placeholder);
|
||||
cleanupStream();
|
||||
});
|
||||
es.addEventListener("ping", () => {
|
||||
@@ -386,6 +419,42 @@ function cleanupStream(): void {
|
||||
setSendDisabled(false);
|
||||
}
|
||||
|
||||
function startWidgetLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
lateWidgetPolls.get(turnId)?.cancel();
|
||||
const handle = pollForLateResponse({
|
||||
turnId,
|
||||
onLateResponse: (turn) => {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLateResponse(bubble, turn);
|
||||
},
|
||||
onGiveUp: () => {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
},
|
||||
});
|
||||
lateWidgetPolls.set(turnId, handle);
|
||||
}
|
||||
|
||||
function applyWidgetLateResponse(bubble: HTMLElement, turn: LateTurn): void {
|
||||
if (!turn.response) return;
|
||||
bubble.classList.remove(
|
||||
"paliadin-widget-bubble--error",
|
||||
"paliadin-widget-bubble--late-pending",
|
||||
);
|
||||
bubble.classList.add("paliadin-widget-bubble--late");
|
||||
setBubbleText(bubble, turn.response);
|
||||
// Append a small "(verspätet)" tag so the late arrival is visible.
|
||||
const tag = document.createElement("span");
|
||||
tag.className = "paliadin-widget-bubble-late-tag";
|
||||
tag.textContent = " · " + t("paliadin.late.marker");
|
||||
bubble.appendChild(tag);
|
||||
history.push({
|
||||
role: "assistant",
|
||||
text: turn.response,
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
function setSendDisabled(disabled: boolean): void {
|
||||
const btn = document.getElementById("paliadin-widget-send-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = disabled;
|
||||
@@ -404,7 +473,14 @@ function appendBubble(role: "user" | "assistant", text: string): HTMLElement {
|
||||
wrap.className = `paliadin-widget-bubble paliadin-widget-bubble--${role}`;
|
||||
const body = document.createElement("div");
|
||||
body.className = "paliadin-widget-bubble-text";
|
||||
body.textContent = text;
|
||||
// Assistant bubbles get the same markdown + chip pipeline as the
|
||||
// standalone /paliadin page (client/paliadin-render.ts). User bubbles
|
||||
// stay plain text — no need to interpret the user's typed markup.
|
||||
if (role === "assistant") {
|
||||
body.innerHTML = renderResponseHTML(text);
|
||||
} else {
|
||||
body.textContent = text;
|
||||
}
|
||||
wrap.appendChild(body);
|
||||
messages?.appendChild(wrap);
|
||||
if (messages) messages.scrollTop = messages.scrollHeight;
|
||||
@@ -413,7 +489,14 @@ function appendBubble(role: "user" | "assistant", text: string): HTMLElement {
|
||||
|
||||
function setBubbleText(bubble: HTMLElement, text: string): void {
|
||||
const body = bubble.querySelector(".paliadin-widget-bubble-text");
|
||||
if (body) body.textContent = text;
|
||||
if (body) {
|
||||
const isAssistant = bubble.classList.contains("paliadin-widget-bubble--assistant");
|
||||
if (isAssistant) {
|
||||
(body as HTMLElement).innerHTML = renderResponseHTML(text);
|
||||
} else {
|
||||
body.textContent = text;
|
||||
}
|
||||
}
|
||||
const messages = document.getElementById("paliadin-widget-messages");
|
||||
if (messages) messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
@@ -424,6 +507,67 @@ function rehydrateHistory(): void {
|
||||
history.forEach((h) => appendBubble(h.role, h.text));
|
||||
}
|
||||
|
||||
// PaliadinTurnRow mirrors the JSON shape /api/paliadin/history returns
|
||||
// (services.PaliadinTurn). Fields we don't render yet (used_tools etc.)
|
||||
// are typed as unknown to keep the contract loose.
|
||||
interface PaliadinTurnRow {
|
||||
turn_id: string;
|
||||
session_id: string;
|
||||
started_at: string;
|
||||
user_message: string;
|
||||
response?: string | null;
|
||||
error_code?: string | null;
|
||||
}
|
||||
|
||||
// Hydrate from the DB on every mount. Crash-resistant: a typed turn
|
||||
// always lands in paliad.paliadin_turns, so even if the user closes
|
||||
// the tab mid-flight or the device dies, the next mount picks it up.
|
||||
//
|
||||
// Reconciliation: DB > localStorage. If the DB returns rows, we trust
|
||||
// them entirely and overwrite the cache. If the DB call fails or
|
||||
// returns empty, we keep whatever's in localStorage (offline cushion).
|
||||
async function hydrateFromServer(): Promise<void> {
|
||||
let rows: PaliadinTurnRow[] = [];
|
||||
try {
|
||||
const r = await fetch(
|
||||
"/api/paliadin/history?session=" + encodeURIComponent(sessionId) + "&limit=50",
|
||||
{ credentials: "same-origin" },
|
||||
);
|
||||
if (!r.ok) return;
|
||||
const body = (await r.json()) as PaliadinTurnRow[] | null;
|
||||
rows = Array.isArray(body) ? body : [];
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!rows.length) return;
|
||||
|
||||
// Project DB rows into the {role, text, ts} shape the cache + render
|
||||
// path expect. Each turn becomes two entries (user prompt then
|
||||
// assistant response). Skip turns with no response (in-flight, or
|
||||
// errored without a recovery) so the bubble doesn't show
|
||||
// half-rendered placeholders on reload.
|
||||
const reconstructed: HistoryEntry[] = [];
|
||||
for (const row of rows) {
|
||||
reconstructed.push({ role: "user", text: row.user_message, ts: row.started_at });
|
||||
if (typeof row.response === "string" && row.response.length > 0) {
|
||||
reconstructed.push({ role: "assistant", text: row.response, ts: row.started_at });
|
||||
}
|
||||
}
|
||||
history = reconstructed;
|
||||
saveHistory();
|
||||
|
||||
// Re-render: clear the message list + replay the canonical history.
|
||||
const messages = document.getElementById("paliadin-widget-messages");
|
||||
const empty = document.getElementById("paliadin-widget-empty");
|
||||
if (messages) {
|
||||
// Strip every prior bubble but keep the empty-state placeholder so
|
||||
// it can be hidden by hideEmpty() if we end up rendering anything.
|
||||
messages.querySelectorAll(".paliadin-widget-bubble").forEach((n) => n.remove());
|
||||
if (empty) empty.style.display = "none";
|
||||
history.forEach((h) => appendBubble(h.role, h.text));
|
||||
}
|
||||
}
|
||||
|
||||
async function resetSession(): Promise<void> {
|
||||
if (!confirm(t("paliadin.widget.reset.confirm"))) return;
|
||||
history = [];
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { initI18n, getLang, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { renderResponseHTML } from "./paliadin-render";
|
||||
import { pollForLateResponse, type LateTurn, type LatePollHandle } from "./paliadin-late-poll";
|
||||
|
||||
// Paliadin chat panel client (t-paliad-146 PoC).
|
||||
//
|
||||
@@ -32,6 +34,10 @@ let sessionId: string;
|
||||
let history: HistoryEntry[] = [];
|
||||
let currentEventSource: EventSource | null = null;
|
||||
let currentTurnId: string | null = null;
|
||||
// Late-response polls keyed by turn_id. Each entry runs until the
|
||||
// response arrives or the 10-min cap expires. Stays alive across
|
||||
// turns — m can keep chatting while we wait for the slow one.
|
||||
const latePolls = new Map<string, LatePollHandle>();
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
@@ -41,6 +47,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
wireStarters();
|
||||
wireReset();
|
||||
renderHistory();
|
||||
// Pull the canonical conversation from the DB so a turn typed in the
|
||||
// inline drawer (which shares this session id) shows up here on
|
||||
// mount. DB > localStorage when both have data.
|
||||
void hydrateFromServer();
|
||||
});
|
||||
|
||||
function bootSession(): void {
|
||||
@@ -199,10 +209,21 @@ async function sendTurn(text: string): Promise<void> {
|
||||
});
|
||||
|
||||
es.addEventListener("error", (ev) => {
|
||||
const errText = friendlyErrorMessage((ev as MessageEvent).data);
|
||||
// Annotate the error bubble with a "warten auf späte Antwort" hint
|
||||
// so m knows the turn isn't dead; if Claude finishes after the
|
||||
// 60 s window the Janitor (services.LocalPaliadinService.runJanitor)
|
||||
// patches the row and pollForLateResponse swaps in the real reply.
|
||||
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
|
||||
friendlyErrorMessage((ev as MessageEvent).data);
|
||||
errText + " " + t("paliadin.late.waiting");
|
||||
placeholder.classList.add("paliadin-bubble--error");
|
||||
placeholder.classList.add("paliadin-bubble--late-pending");
|
||||
placeholder.dataset.streaming = "false";
|
||||
placeholder.dataset.errorText = errText;
|
||||
if (currentTurnId) {
|
||||
placeholder.dataset.turnId = currentTurnId;
|
||||
startLatePoll(currentTurnId, placeholder);
|
||||
}
|
||||
cleanupTurn();
|
||||
});
|
||||
|
||||
@@ -339,167 +360,127 @@ function finishBubble(bubble: HTMLElement, data: any): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Marker → button render. Mirrors §4.4 of the design.
|
||||
const CHIP_RE = /\[(?:#([a-z]+)-OPEN:([A-Za-z0-9\-_]+)|chip:([a-z]+):([^\]]+))\]/g;
|
||||
const MD_LINK_RE = /\[([^\]\n]+)\]\(((?:https?:\/\/|\/)[^\s)]+)\)/g;
|
||||
const BARE_URL_RE = /(^|[^"=>])(https?:\/\/[^\s<>"']+)/g;
|
||||
|
||||
function renderResponseHTML(raw: string): string {
|
||||
// First escape any HTML in the raw text (simple textContent → innerHTML
|
||||
// would have been fine but we then need to inject anchors, so the
|
||||
// manual escape is unavoidable).
|
||||
let html = raw
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
// Stage 1: extract chip markers as placeholder sentinels so subsequent
|
||||
// link-rendering passes don't try to re-parse the chip URLs as bare
|
||||
// URLs and double-anchor them.
|
||||
const chipHTML: string[] = [];
|
||||
html = html.replace(CHIP_RE, (_match, kind, id, chipKind, chipArg) => {
|
||||
let rendered = "";
|
||||
if (kind && id) {
|
||||
const url = chipURL(kind, id);
|
||||
const label = chipLabel(kind);
|
||||
rendered = `<a class="paliadin-chip" href="${url}">${label}</a>`;
|
||||
} else if (chipKind === "nav") {
|
||||
rendered = `<a class="paliadin-chip" href="${chipArg}">öffnen</a>`;
|
||||
} else if (chipKind === "filter") {
|
||||
rendered = `<a class="paliadin-chip" href="/inbox?${chipArg}">Filter anwenden</a>`;
|
||||
}
|
||||
if (!rendered) return "";
|
||||
chipHTML.push(rendered);
|
||||
return `CHIP${chipHTML.length - 1}`;
|
||||
// startLatePoll registers the Janitor-patched row poller for one
|
||||
// errored turn. When the row gains a response we swap the bubble's
|
||||
// content + drop the error class + retroactively replace the history
|
||||
// entry (which was never written for the failed turn — append now so
|
||||
// reload renders the late reply).
|
||||
function startLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
// Avoid duplicate pollers for the same turn (e.g. SSE error fires
|
||||
// twice in some browsers when the connection drops).
|
||||
latePolls.get(turnId)?.cancel();
|
||||
const handle = pollForLateResponse({
|
||||
turnId,
|
||||
onLateResponse: (turn) => {
|
||||
latePolls.delete(turnId);
|
||||
applyLateResponse(bubble, turn);
|
||||
},
|
||||
onGiveUp: () => {
|
||||
latePolls.delete(turnId);
|
||||
},
|
||||
});
|
||||
|
||||
// Stage 2: Block-level Markdown — headings (## / ###), unordered lists
|
||||
// (- item), and paragraphs separated by blank lines. Done before the
|
||||
// inline passes so the inline regexes only ever run inside a block.
|
||||
// Chip SOH placeholders are inert text at this point and pass through
|
||||
// untouched.
|
||||
html = renderBlocks(html);
|
||||
|
||||
// Stage 3: Markdown links [text](url). Internal /paths stay same-tab;
|
||||
// external http(s) URLs open in a new tab.
|
||||
html = html.replace(MD_LINK_RE, (_m, text, url) => {
|
||||
const ext = url.startsWith("http");
|
||||
const attrs = ext ? ` target="_blank" rel="noopener noreferrer"` : "";
|
||||
return `<a href="${url}" class="paliadin-link"${attrs}>${text}</a>`;
|
||||
});
|
||||
|
||||
// Stage 4: auto-link bare URLs. The leading-character class on the
|
||||
// regex avoids matching URLs already inside an href attribute (preceded
|
||||
// by `="`) and the prefix capture is preserved verbatim so we don't
|
||||
// drop punctuation.
|
||||
html = html.replace(BARE_URL_RE, (_m, prefix, url) => {
|
||||
return `${prefix}<a href="${url}" class="paliadin-link" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||||
});
|
||||
|
||||
// Stage 5: inline emphasis. Bold first so the italic regex doesn't
|
||||
// misparse `**bold**` as nested `*italic*`. Both bounded to single
|
||||
// lines via [^*\n] to avoid runaway matches across paragraphs.
|
||||
html = html.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
|
||||
html = html.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
|
||||
|
||||
// Stage 4: substitute chip placeholders back. Done last so chip URLs
|
||||
// never go through the link-rendering passes.
|
||||
html = html.replace(/CHIP(\d+)/g, (_m, idx) => chipHTML[Number(idx)] || "");
|
||||
|
||||
return html;
|
||||
latePolls.set(turnId, handle);
|
||||
}
|
||||
|
||||
// renderBlocks parses the escaped html into block-level Markdown:
|
||||
// `## H` → <h2>, `### H` → <h3>, `- item` lines → <ul><li>, blank-line
|
||||
// separated runs → <p> with intra-paragraph newlines as <br>. Anything
|
||||
// not matched falls through verbatim, so the function is a strict
|
||||
// superset of the prior behaviour for plain-text responses.
|
||||
function renderBlocks(escapedHtml: string): string {
|
||||
const out: string[] = [];
|
||||
let listItems: string[] = [];
|
||||
let paraLines: string[] = [];
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length === 0) return;
|
||||
out.push(`<ul class="paliadin-list">${listItems.map((li) => `<li>${li}</li>`).join("")}</ul>`);
|
||||
listItems = [];
|
||||
};
|
||||
const flushPara = () => {
|
||||
if (paraLines.length === 0) return;
|
||||
out.push(`<p>${paraLines.join("<br>")}</p>`);
|
||||
paraLines = [];
|
||||
};
|
||||
|
||||
for (const rawLine of escapedHtml.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (line === "") {
|
||||
flushList();
|
||||
flushPara();
|
||||
continue;
|
||||
}
|
||||
let m: RegExpMatchArray | null;
|
||||
if ((m = line.match(/^###\s+(.+)$/))) {
|
||||
flushList();
|
||||
flushPara();
|
||||
out.push(`<h3>${m[1]}</h3>`);
|
||||
} else if ((m = line.match(/^##\s+(.+)$/))) {
|
||||
flushList();
|
||||
flushPara();
|
||||
out.push(`<h2>${m[1]}</h2>`);
|
||||
} else if ((m = line.match(/^[-*]\s+(.+)$/))) {
|
||||
flushPara();
|
||||
listItems.push(m[1]);
|
||||
} else if (line.match(/^---+$/)) {
|
||||
flushList();
|
||||
flushPara();
|
||||
out.push(`<hr>`);
|
||||
} else {
|
||||
flushList();
|
||||
paraLines.push(line);
|
||||
}
|
||||
}
|
||||
flushList();
|
||||
flushPara();
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function chipURL(kind: string, id: string): string {
|
||||
switch (kind) {
|
||||
case "deadline":
|
||||
case "frist":
|
||||
return "/deadlines/" + id;
|
||||
case "projekt":
|
||||
case "project":
|
||||
return "/projects/" + id;
|
||||
case "termin":
|
||||
case "appointment":
|
||||
return "/appointments/" + id;
|
||||
default:
|
||||
return "#";
|
||||
}
|
||||
}
|
||||
|
||||
function chipLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "deadline":
|
||||
case "frist":
|
||||
return "Frist öffnen";
|
||||
case "projekt":
|
||||
case "project":
|
||||
return "Akte ansehen";
|
||||
case "termin":
|
||||
case "appointment":
|
||||
return "Termin öffnen";
|
||||
default:
|
||||
return "öffnen";
|
||||
function applyLateResponse(bubble: HTMLElement, turn: LateTurn): void {
|
||||
if (!turn.response) return;
|
||||
bubble.classList.remove("paliadin-bubble--error", "paliadin-bubble--late-pending");
|
||||
bubble.classList.add("paliadin-bubble--late");
|
||||
bubble.dataset.fullText = turn.response;
|
||||
bubble.dataset.streaming = "false";
|
||||
finishBubble(bubble, {
|
||||
used_tools: turn.used_tools,
|
||||
rows_seen: turn.rows_seen,
|
||||
classifier_tag: turn.classifier_tag,
|
||||
duration_ms: turn.duration_ms,
|
||||
chip_count: turn.chip_count,
|
||||
});
|
||||
// Inject a small "(verspätet)" marker into the meta row so it's
|
||||
// visible at a glance that this bubble was patched after the fact.
|
||||
const metaEl = bubble.querySelector(".paliadin-bubble-meta") as HTMLElement | null;
|
||||
if (metaEl) {
|
||||
const lateTag = document.createElement("span");
|
||||
lateTag.className = "paliadin-bubble-late-tag";
|
||||
lateTag.textContent = " · " + t("paliadin.late.marker");
|
||||
metaEl.appendChild(lateTag);
|
||||
metaEl.style.display = "";
|
||||
}
|
||||
// Persist so a reload shows the late response in place of the error.
|
||||
history.push({
|
||||
role: "assistant",
|
||||
text: turn.response,
|
||||
meta: {
|
||||
used_tools: turn.used_tools,
|
||||
rows_seen: turn.rows_seen,
|
||||
classifier_tag: turn.classifier_tag ?? undefined,
|
||||
duration_ms: turn.duration_ms ?? undefined,
|
||||
chip_count: turn.chip_count,
|
||||
},
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
function saveHistory(): void {
|
||||
localStorage.setItem(HISTORY_PREFIX + sessionId, JSON.stringify(history));
|
||||
}
|
||||
|
||||
// PaliadinTurnRow mirrors the JSON returned by /api/paliadin/history
|
||||
// (services.PaliadinTurn). Fields we don't render yet are skipped.
|
||||
interface PaliadinTurnRow {
|
||||
turn_id: string;
|
||||
session_id: string;
|
||||
started_at: string;
|
||||
user_message: string;
|
||||
response?: string | null;
|
||||
used_tools?: string[] | null;
|
||||
rows_seen?: number[] | null;
|
||||
classifier_tag?: string | null;
|
||||
duration_ms?: number | null;
|
||||
chip_count?: number | null;
|
||||
}
|
||||
|
||||
// Hydrate from /api/paliadin/history, replacing the localStorage cache
|
||||
// when the DB returns rows. Fail-quiet on network / auth errors —
|
||||
// localStorage is a perfectly good offline fallback.
|
||||
async function hydrateFromServer(): Promise<void> {
|
||||
let rows: PaliadinTurnRow[] = [];
|
||||
try {
|
||||
const r = await fetch(
|
||||
"/api/paliadin/history?session=" + encodeURIComponent(sessionId) + "&limit=50",
|
||||
{ credentials: "same-origin" },
|
||||
);
|
||||
if (!r.ok) return;
|
||||
const body = (await r.json()) as PaliadinTurnRow[] | null;
|
||||
rows = Array.isArray(body) ? body : [];
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!rows.length) return;
|
||||
const reconstructed: HistoryEntry[] = [];
|
||||
for (const row of rows) {
|
||||
reconstructed.push({ role: "user", text: row.user_message, ts: row.started_at });
|
||||
if (typeof row.response === "string" && row.response.length > 0) {
|
||||
reconstructed.push({
|
||||
role: "assistant",
|
||||
text: row.response,
|
||||
ts: row.started_at,
|
||||
meta: {
|
||||
used_tools: row.used_tools ?? undefined,
|
||||
rows_seen: row.rows_seen ?? undefined,
|
||||
classifier_tag: row.classifier_tag ?? undefined,
|
||||
duration_ms: row.duration_ms ?? undefined,
|
||||
chip_count: row.chip_count ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
history = reconstructed;
|
||||
saveHistory();
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
function renderHistory(): void {
|
||||
const stream = document.getElementById("paliadin-stream");
|
||||
if (!stream) return;
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface ProjectFormState {
|
||||
grantDate: string;
|
||||
court: string;
|
||||
caseNumber: string;
|
||||
ourSide: string;
|
||||
}
|
||||
|
||||
let parentCandidates: ProjectMini[] = [];
|
||||
@@ -178,6 +179,17 @@ export function readPayload(
|
||||
stringField("project-case-number", "case_number");
|
||||
}
|
||||
|
||||
// our_side is type-agnostic — every project type can carry "Wir
|
||||
// vertreten" because the Determinator picks it up regardless of
|
||||
// type. The select uses "" for the unset option; the service maps
|
||||
// empty string to NULL via nullableOurSide.
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) {
|
||||
const v = osSel.value.trim();
|
||||
if (v) payload.our_side = v;
|
||||
else if (!opts.omitEmpty) payload.our_side = "";
|
||||
}
|
||||
|
||||
const desc = ($("project-description") as HTMLTextAreaElement).value.trim();
|
||||
if (desc) payload.description = desc;
|
||||
else if (!opts.omitEmpty) payload.description = "";
|
||||
@@ -214,6 +226,8 @@ export function prefillForm(p: Record<string, unknown>) {
|
||||
get("project-grant-date").value = isoToDate(p.grant_date as string | null | undefined);
|
||||
get("project-court").value = String(p.court ?? "");
|
||||
get("project-case-number").value = String(p.case_number ?? "");
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) osSel.value = String(p.our_side ?? "");
|
||||
getTA("project-description").value = String(p.description ?? "");
|
||||
getSel("project-status").value = String(p.status ?? "active");
|
||||
}
|
||||
|
||||
489
frontend/src/client/projects-chart.ts
Normal file
489
frontend/src/client/projects-chart.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
ALL_DENSITIES,
|
||||
ALL_PALETTES,
|
||||
ALL_RANGE_PRESETS,
|
||||
mount,
|
||||
type ChartHandle,
|
||||
type Density,
|
||||
type Palette,
|
||||
type RangePreset,
|
||||
} from "./views/shape-timeline-chart";
|
||||
import {
|
||||
exportCSV,
|
||||
exportJSON,
|
||||
exportPNG,
|
||||
exportPrint,
|
||||
exportSVG,
|
||||
type ExportContext,
|
||||
} from "./views/chart-export";
|
||||
|
||||
// t-paliad-177 Slice 1 — boot client for the standalone Project Timeline
|
||||
// / Chart page. Reads the project id from the URL path, loads the
|
||||
// project metadata (for title + breadcrumb), mounts the SVG renderer
|
||||
// inside #projects-chart-host. Slice 1 keeps the controls inert; Slice 3
|
||||
// wires density / palette / zoom against this same surface.
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string;
|
||||
client_matter?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const PROJECT_ID_RE = /^\/projects\/([0-9a-fA-F-]{36})\/chart\/?$/;
|
||||
|
||||
function projectIdFromPath(): string | null {
|
||||
const match = PROJECT_ID_RE.exec(window.location.pathname);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
const PALETTE_SET: ReadonlySet<string> = new Set(ALL_PALETTES);
|
||||
|
||||
/** Reads ?palette=... from the URL; returns the default when missing /
|
||||
* unknown so a hostile or stale URL can't break the chart. */
|
||||
function paletteFromURL(): Palette {
|
||||
const raw = new URLSearchParams(window.location.search).get("palette");
|
||||
if (raw && PALETTE_SET.has(raw)) return raw as Palette;
|
||||
return "default";
|
||||
}
|
||||
|
||||
/** Mirrors paletteFromURL but for writing — pushes a new history entry
|
||||
* so the URL stays bookmarkable / shareable per design §8.2. */
|
||||
function writePaletteToURL(palette: Palette): void {
|
||||
writeParamToURL("palette", palette, "default");
|
||||
}
|
||||
|
||||
const DENSITY_SET: ReadonlySet<string> = new Set(ALL_DENSITIES);
|
||||
|
||||
function densityFromURL(): Density {
|
||||
const raw = new URLSearchParams(window.location.search).get("density");
|
||||
if (raw && DENSITY_SET.has(raw)) return raw as Density;
|
||||
return "standard";
|
||||
}
|
||||
|
||||
function writeDensityToURL(density: Density): void {
|
||||
writeParamToURL("density", density, "standard");
|
||||
}
|
||||
|
||||
const RANGE_SET: ReadonlySet<string> = new Set(ALL_RANGE_PRESETS);
|
||||
|
||||
interface RangeState {
|
||||
preset: RangePreset;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function rangeFromURL(): RangeState {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get("range");
|
||||
const preset: RangePreset = raw && RANGE_SET.has(raw) ? (raw as RangePreset) : "1y";
|
||||
if (preset === "custom") {
|
||||
const from = params.get("from") || "";
|
||||
const to = params.get("to") || "";
|
||||
return {
|
||||
preset,
|
||||
from: ISO_DATE_RE.test(from) ? from : undefined,
|
||||
to: ISO_DATE_RE.test(to) ? to : undefined,
|
||||
};
|
||||
}
|
||||
return { preset };
|
||||
}
|
||||
|
||||
function writeRangeToURL(state: RangeState): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (state.preset === "1y") {
|
||||
params.delete("range");
|
||||
} else {
|
||||
params.set("range", state.preset);
|
||||
}
|
||||
if (state.preset === "custom") {
|
||||
if (state.from) params.set("from", state.from);
|
||||
else params.delete("from");
|
||||
if (state.to) params.set("to", state.to);
|
||||
else params.delete("to");
|
||||
} else {
|
||||
params.delete("from");
|
||||
params.delete("to");
|
||||
}
|
||||
const qs = params.toString();
|
||||
const next = window.location.pathname + (qs ? "?" + qs : "");
|
||||
window.history.replaceState(null, "", next);
|
||||
}
|
||||
|
||||
/** Read ?lanes=id1,id2 from the URL. Empty / missing → null (show all).
|
||||
* Defence: ids that look hostile (commas embedded, oversized) are dropped
|
||||
* on render via the renderer's allow-set intersection. */
|
||||
function lanesFromURL(): string[] | null {
|
||||
const raw = new URLSearchParams(window.location.search).get("lanes");
|
||||
if (!raw) return null;
|
||||
const ids = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0 && s.length < 200);
|
||||
return ids.length === 0 ? null : ids;
|
||||
}
|
||||
|
||||
function writeLanesToURL(lanes: string[] | null): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!lanes || lanes.length === 0) {
|
||||
params.delete("lanes");
|
||||
} else {
|
||||
params.set("lanes", lanes.join(","));
|
||||
}
|
||||
const qs = params.toString();
|
||||
const next = window.location.pathname + (qs ? "?" + qs : "");
|
||||
window.history.replaceState(null, "", next);
|
||||
}
|
||||
|
||||
/** Shared URL writer — omits the param when it equals its default, so the
|
||||
* canonical URL stays short and dedupable. */
|
||||
function writeParamToURL(name: string, value: string, defaultValue: string): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (value === defaultValue) {
|
||||
params.delete(name);
|
||||
} else {
|
||||
params.set(name, value);
|
||||
}
|
||||
const qs = params.toString();
|
||||
const next = window.location.pathname + (qs ? "?" + qs : "");
|
||||
window.history.replaceState(null, "", next);
|
||||
}
|
||||
|
||||
async function loadProject(id: string): Promise<Project | null> {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`);
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as Project;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatMeta(p: Project): string {
|
||||
const parts: string[] = [];
|
||||
if (p.reference) parts.push(p.reference);
|
||||
if (p.client_matter) parts.push(p.client_matter);
|
||||
return parts.join(" • ");
|
||||
}
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
const loadingEl = document.getElementById("projects-chart-loading");
|
||||
const notfoundEl = document.getElementById("projects-chart-notfound");
|
||||
const bodyEl = document.getElementById("projects-chart-body");
|
||||
const titleEl = document.getElementById("projects-chart-title");
|
||||
const metaEl = document.getElementById("projects-chart-meta");
|
||||
const backLink = document.getElementById("projects-chart-back-link") as HTMLAnchorElement | null;
|
||||
const host = document.getElementById("projects-chart-host");
|
||||
const undatedHint = document.getElementById("projects-chart-undated");
|
||||
|
||||
const id = projectIdFromPath();
|
||||
if (!id || !host || !bodyEl || !loadingEl || !notfoundEl) {
|
||||
if (loadingEl) loadingEl.style.display = "none";
|
||||
if (notfoundEl) notfoundEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
const project = await loadProject(id);
|
||||
if (!project) {
|
||||
loadingEl.style.display = "none";
|
||||
notfoundEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
// Wire back-link to the Verlauf tab specifically — projects-detail.ts
|
||||
// reads the /history sub-path on init and switches to that tab. Going
|
||||
// back to the bare /projects/{id} also lands on Verlauf today, but the
|
||||
// /history form is explicit + survives a future default-tab change.
|
||||
if (backLink) backLink.href = `/projects/${encodeURIComponent(id)}/history`;
|
||||
|
||||
if (titleEl) titleEl.textContent = project.title || t("projects.chart.title");
|
||||
if (metaEl) metaEl.textContent = formatMeta(project);
|
||||
|
||||
loadingEl.style.display = "none";
|
||||
bodyEl.style.display = "";
|
||||
|
||||
const initialPalette = paletteFromURL();
|
||||
const initialDensity = densityFromURL();
|
||||
const initialRange = rangeFromURL();
|
||||
const initialLanes = lanesFromURL();
|
||||
let handle: ChartHandle | null = null;
|
||||
// Module-scope mirrors so the chip click handlers (rendered later)
|
||||
// can reach the live state without threading it through callbacks.
|
||||
moduleVisibleLanes = initialLanes;
|
||||
try {
|
||||
handle = mount(host, {
|
||||
projectId: id,
|
||||
palette: initialPalette,
|
||||
density: initialDensity,
|
||||
rangePreset: initialRange.preset,
|
||||
rangeFrom: initialRange.from,
|
||||
rangeTo: initialRange.to,
|
||||
visibleLanes: initialLanes,
|
||||
onDataLoaded: ({ lanes }) => {
|
||||
renderLaneFilter(lanes);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("chart mount failed", err);
|
||||
host.textContent = t("projects.chart.error.mount");
|
||||
return;
|
||||
}
|
||||
moduleHandleRef = handle;
|
||||
|
||||
// Wire the palette picker. Reflect the URL-decoded initial value, then
|
||||
// re-write the URL + flip the data-palette attribute on every change.
|
||||
const paletteSel = document.getElementById("projects-chart-palette") as HTMLSelectElement | null;
|
||||
if (paletteSel) {
|
||||
paletteSel.value = initialPalette;
|
||||
paletteSel.addEventListener("change", () => {
|
||||
const next = paletteSel.value;
|
||||
if (!PALETTE_SET.has(next)) return;
|
||||
const p = next as Palette;
|
||||
handle!.setPalette(p);
|
||||
writePaletteToURL(p);
|
||||
});
|
||||
}
|
||||
|
||||
// Density picker — same URL-state pattern. Density triggers a repaint
|
||||
// (lane height + mark radius change), palette is a pure CSS swap.
|
||||
const densitySel = document.getElementById("projects-chart-density") as HTMLSelectElement | null;
|
||||
if (densitySel) {
|
||||
densitySel.value = initialDensity;
|
||||
densitySel.addEventListener("change", () => {
|
||||
const next = densitySel.value;
|
||||
if (!DENSITY_SET.has(next)) return;
|
||||
const d = next as Density;
|
||||
handle!.setDensity(d);
|
||||
writeDensityToURL(d);
|
||||
});
|
||||
}
|
||||
|
||||
// Range chips — 4-option select plus a custom date-pair that shows
|
||||
// only when preset === "custom". Per design §8.2 + faraday-Q8 default.
|
||||
const rangeSel = document.getElementById("projects-chart-range") as HTMLSelectElement | null;
|
||||
const rangeCustomWrap = document.getElementById("projects-chart-range-custom");
|
||||
const rangeFromInput = document.getElementById("projects-chart-range-from") as HTMLInputElement | null;
|
||||
const rangeToInput = document.getElementById("projects-chart-range-to") as HTMLInputElement | null;
|
||||
if (rangeSel && rangeCustomWrap && rangeFromInput && rangeToInput) {
|
||||
rangeSel.value = initialRange.preset;
|
||||
if (initialRange.preset === "custom") {
|
||||
rangeCustomWrap.style.display = "";
|
||||
if (initialRange.from) rangeFromInput.value = initialRange.from;
|
||||
if (initialRange.to) rangeToInput.value = initialRange.to;
|
||||
}
|
||||
const applyRange = () => {
|
||||
const preset = rangeSel.value;
|
||||
if (!RANGE_SET.has(preset)) return;
|
||||
const p = preset as RangePreset;
|
||||
rangeCustomWrap.style.display = p === "custom" ? "" : "none";
|
||||
const from = rangeFromInput.value || undefined;
|
||||
const to = rangeToInput.value || undefined;
|
||||
handle!.setRange(p, from, to);
|
||||
writeRangeToURL({ preset: p, from, to });
|
||||
};
|
||||
rangeSel.addEventListener("change", applyRange);
|
||||
rangeFromInput.addEventListener("change", applyRange);
|
||||
rangeToInput.addEventListener("change", applyRange);
|
||||
}
|
||||
|
||||
// Export menu. Each button maps to one chart-export function; the
|
||||
// handle exposes the live SVG + last-fetched data needed to compose
|
||||
// an ExportContext. Errors land in the host's message area so the
|
||||
// user gets feedback instead of a silent failure.
|
||||
function ctxNow(): ExportContext {
|
||||
const data = handle!.getData();
|
||||
return {
|
||||
projectId: id,
|
||||
projectTitle: project.title || t("projects.chart.title"),
|
||||
svgEl: handle!.getSVGElement(),
|
||||
events: data.events,
|
||||
lanes: data.lanes,
|
||||
};
|
||||
}
|
||||
function runExport(fn: (ctx: ExportContext) => void | Promise<void>): void {
|
||||
void Promise.resolve()
|
||||
.then(() => fn(ctxNow()))
|
||||
.catch((err) => {
|
||||
console.error("export failed", err);
|
||||
if (host) {
|
||||
host.setAttribute("data-export-error", "1");
|
||||
}
|
||||
});
|
||||
}
|
||||
wirePermalinkCopy("projects-chart-copylink");
|
||||
|
||||
wireExport("projects-chart-export-svg", () => runExport(exportSVG));
|
||||
wireExport("projects-chart-export-png", () => runExport(exportPNG));
|
||||
wireExport("projects-chart-export-csv", () => runExport(exportCSV));
|
||||
wireExport("projects-chart-export-json", () => runExport(exportJSON));
|
||||
wireExport("projects-chart-export-print", () => exportPrint());
|
||||
// iCal goes server-side so it reuses the existing caldav_ical formatter
|
||||
// (faraday-Q6 / m's pick: deadlines + appointments only — no projected).
|
||||
wireExport("projects-chart-export-ics", () => {
|
||||
window.location.href = `/api/projects/${encodeURIComponent(id)}/timeline.ics`;
|
||||
});
|
||||
|
||||
// After the first paint, surface the undated hint when the renderer
|
||||
// reports clipped/undated rows. Re-checked on resize-debounced repaint.
|
||||
const checkUndated = () => {
|
||||
if (!undatedHint || !handle) return;
|
||||
const layout = handle.getLayout();
|
||||
if (!layout) return;
|
||||
if (layout.undatedCount > 0) {
|
||||
undatedHint.style.display = "";
|
||||
undatedHint.textContent = `${layout.undatedCount} Ereignis(se) ohne Datum (links angeheftet).`;
|
||||
} else {
|
||||
undatedHint.style.display = "none";
|
||||
}
|
||||
};
|
||||
// Poll once after the initial fetch settles. mount() kicks the fetch
|
||||
// synchronously; layout becomes available after the network round-trip.
|
||||
setTimeout(checkUndated, 400);
|
||||
setTimeout(checkUndated, 1500);
|
||||
}
|
||||
|
||||
/** Render the lane-filter chip group once the renderer has lanes from
|
||||
* the server. One toggle button per lane; clicking flips inclusion in
|
||||
* the visible-lane allow-set. Hidden when there's only one lane (or
|
||||
* none) — the filter is pointless on a single-track render. */
|
||||
function renderLaneFilter(lanes: ReadonlyArray<{ id: string; label: string }>): void {
|
||||
const container = document.getElementById("projects-chart-lanes-filter");
|
||||
if (!container) return;
|
||||
// Hide and bail when the filter wouldn't add value.
|
||||
if (lanes.length < 2) {
|
||||
container.innerHTML = "";
|
||||
container.style.display = "none";
|
||||
return;
|
||||
}
|
||||
container.style.display = "";
|
||||
container.innerHTML = "";
|
||||
const titleEl = document.createElement("span");
|
||||
titleEl.className = "smart-timeline-chart-lanes-label";
|
||||
titleEl.textContent = "Spuren:";
|
||||
container.appendChild(titleEl);
|
||||
for (const lane of lanes) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "smart-timeline-chart-lane-chip";
|
||||
const isVisible = laneIsVisible(lane.id);
|
||||
btn.setAttribute("aria-pressed", isVisible ? "true" : "false");
|
||||
btn.dataset.laneId = lane.id;
|
||||
btn.textContent = lane.label || lane.id;
|
||||
btn.addEventListener("click", () => {
|
||||
toggleLane(lane.id, lanes);
|
||||
// Reflect new state immediately on this button + siblings.
|
||||
for (const sibling of container.querySelectorAll<HTMLButtonElement>("button[data-lane-id]")) {
|
||||
const sid = sibling.dataset.laneId || "";
|
||||
sibling.setAttribute("aria-pressed", laneIsVisible(sid) ? "true" : "false");
|
||||
}
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
/** Permalink copy. The URL already aggregates every chip's state via the
|
||||
* individual writeParamToURL writers (palette + density + range + lanes),
|
||||
* so window.location.href IS the canonical shareable link. We copy it
|
||||
* to the clipboard and flash a "kopiert" confirmation on the button. */
|
||||
function wirePermalinkCopy(buttonId: string): void {
|
||||
const btn = document.getElementById(buttonId) as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
const originalLabel = btn.textContent || "";
|
||||
let resetTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
btn.addEventListener("click", async () => {
|
||||
const url = window.location.href;
|
||||
const ok = await copyToClipboard(url);
|
||||
if (resetTimer) clearTimeout(resetTimer);
|
||||
btn.textContent = ok ? "✓ Kopiert" : "⚠ Konnte nicht kopieren";
|
||||
btn.classList.add(ok ? "is-success" : "is-error");
|
||||
resetTimer = setTimeout(() => {
|
||||
btn.textContent = originalLabel;
|
||||
btn.classList.remove("is-success", "is-error");
|
||||
}, 1800);
|
||||
});
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// Prefer the async Clipboard API. Falls back to the legacy exec hack
|
||||
// for browsers / contexts where it's unavailable (some iframes, file://).
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function wireExport(buttonId: string, handler: () => void): void {
|
||||
const btn = document.getElementById(buttonId) as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
handler();
|
||||
// Close the <details> dropdown so the user sees the chart-area
|
||||
// update (download notification, print preview, etc).
|
||||
const details = btn.closest("details");
|
||||
if (details) details.removeAttribute("open");
|
||||
});
|
||||
}
|
||||
|
||||
// Lane-filter mutable state lives at module scope so renderLaneFilter
|
||||
// closures over the same set as toggleLane / laneIsVisible. We can't
|
||||
// access boot()'s `visibleLanes` from here cleanly, so we mirror it.
|
||||
let moduleVisibleLanes: string[] | null = null;
|
||||
let moduleHandleRef: ChartHandle | null = null;
|
||||
|
||||
function laneIsVisible(id: string): boolean {
|
||||
if (moduleVisibleLanes === null) return true;
|
||||
return moduleVisibleLanes.includes(id);
|
||||
}
|
||||
|
||||
function toggleLane(id: string, allLanes: ReadonlyArray<{ id: string }>): void {
|
||||
if (moduleVisibleLanes === null) {
|
||||
// Currently "show all" — turning a chip off means everyone except this one.
|
||||
moduleVisibleLanes = allLanes.map((l) => l.id).filter((l) => l !== id);
|
||||
} else if (moduleVisibleLanes.includes(id)) {
|
||||
moduleVisibleLanes = moduleVisibleLanes.filter((l) => l !== id);
|
||||
} else {
|
||||
moduleVisibleLanes = [...moduleVisibleLanes, id];
|
||||
}
|
||||
// If user toggled every lane back on, collapse to null (show all).
|
||||
if (moduleVisibleLanes.length === allLanes.length) {
|
||||
moduleVisibleLanes = null;
|
||||
}
|
||||
// If user toggled every lane off, snap back to null too — an empty
|
||||
// chart is never useful, treat as "you didn't mean that, show all".
|
||||
if (moduleVisibleLanes !== null && moduleVisibleLanes.length === 0) {
|
||||
moduleVisibleLanes = null;
|
||||
}
|
||||
if (moduleHandleRef) {
|
||||
moduleHandleRef.setVisibleLanes(moduleVisibleLanes);
|
||||
}
|
||||
writeLanesToURL(moduleVisibleLanes);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
void boot();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -73,6 +73,7 @@ export function initSidebar() {
|
||||
initInboxBadge();
|
||||
initAdminGroup();
|
||||
initPaliadinLinks();
|
||||
initProjectContextChartLink();
|
||||
initUserViewsGroup();
|
||||
initThemeToggle();
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
@@ -443,6 +444,11 @@ function initUserViewsGroup(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// fixVerfahrensablaufActive removed (t-paliad-179 Slice 1). The two
|
||||
// sidebar entries now map 1:1 to distinct URLs (/tools/fristenrechner
|
||||
// vs /tools/verfahrensablauf), so the SSR navItem helper picks the
|
||||
// correct active class by pathname alone.
|
||||
|
||||
function renderUserViewItem(view: UserViewLite, currentPath: string): HTMLElement {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/views/${encodeURIComponent(view.slug)}`;
|
||||
@@ -544,6 +550,31 @@ function initPaliadinLinks(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// initProjectContextChartLink (t-paliad-177 Slice 3) reveals an "Als Chart
|
||||
// anzeigen" entry in the sidebar when the user is browsing a project
|
||||
// detail page. Hidden everywhere else, hidden on the chart page itself
|
||||
// (the chart is the destination, not the source).
|
||||
//
|
||||
// Self-contained on URL parsing — no per-page handshake needed. Pages
|
||||
// don't have to know about the sidebar slot; this function walks the
|
||||
// pathname and renders the link if it matches.
|
||||
//
|
||||
// Layout intent: chip sits directly under the "Übersicht" group so it's
|
||||
// visible on every project sub-tab (Verlauf / Team / Parteien / …).
|
||||
function initProjectContextChartLink(): void {
|
||||
const link = document.getElementById("sidebar-project-chart-link") as HTMLAnchorElement | null;
|
||||
if (!link) return;
|
||||
const match = /^\/projects\/([0-9a-fA-F-]{36})(\/.*)?$/.exec(window.location.pathname);
|
||||
if (!match) return;
|
||||
const id = match[1];
|
||||
const rest = match[2] || "";
|
||||
// Hide on the chart page itself — a reciprocal "Zurück zum Verlauf"
|
||||
// affordance lives on the chart page header (separate slice).
|
||||
if (rest === "/chart" || rest === "/chart/") return;
|
||||
link.href = `/projects/${encodeURIComponent(id)}/chart`;
|
||||
link.style.display = "";
|
||||
}
|
||||
|
||||
// initAdminGroup reveals the Admin section in the sidebar when the caller's
|
||||
// /api/me lookup confirms global_role='global_admin'. The markup is in the
|
||||
// DOM with display:none for everyone — flipping it on after the fetch lands
|
||||
|
||||
190
frontend/src/client/verfahrensablauf.ts
Normal file
190
frontend/src/client/verfahrensablauf.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
// /tools/verfahrensablauf client (t-paliad-179 Slice 1)
|
||||
//
|
||||
// Abstract-browse surface: pick a proceeding, pick a trigger date,
|
||||
// see the typical timeline. No Akte, no save-to-project, no anchor
|
||||
// override editing, no Pathway B cascade. Variant chips + lane view
|
||||
// (Slice 3) and compare (Slice 4) layer on top of this in later
|
||||
// slices. Court picker + view toggle + calc fetch + renderers all
|
||||
// come from ./views/verfahrensablauf-core, which fristenrechner.ts
|
||||
// shares.
|
||||
|
||||
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
type DeadlineResponse,
|
||||
calculateDeadlines,
|
||||
formatDate,
|
||||
populateCourtPicker,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
// 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";
|
||||
}
|
||||
}
|
||||
|
||||
async function doCalc() {
|
||||
const seq = ++calcSeq;
|
||||
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
|
||||
const triggerDate = dateInput?.value || "";
|
||||
if (!triggerDate || !selectedType) return;
|
||||
|
||||
const courtPickerRow = document.getElementById("court-picker-row");
|
||||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
const courtId = courtPickerRow && courtPickerRow.style.display !== "none" && courtPicker?.value
|
||||
? courtPicker.value
|
||||
: "";
|
||||
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
courtId,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
if (!data) return;
|
||||
lastResponse = data;
|
||||
renderResults(data);
|
||||
showStep(3);
|
||||
}
|
||||
|
||||
function renderResults(data: DeadlineResponse) {
|
||||
const container = document.getElementById("timeline-container");
|
||||
if (!container) return;
|
||||
const printBtn = document.getElementById("fristen-print-btn");
|
||||
const toggle = document.getElementById("fristen-view-toggle");
|
||||
|
||||
const procName = tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
|
||||
const headerHtml = `<div class="timeline-header">
|
||||
<strong>${procName}</strong>
|
||||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||||
</div>`;
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data)
|
||||
: renderTimelineBody(data);
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
if (printBtn) printBtn.style.display = "block";
|
||||
if (toggle) toggle.style.display = "";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function selectProceeding(btn: HTMLButtonElement) {
|
||||
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
selectedType = btn.dataset.code || "";
|
||||
|
||||
const name = btn.querySelector("strong")?.textContent || "";
|
||||
const triggerEventEl = document.getElementById("trigger-event");
|
||||
if (triggerEventEl) triggerEventEl.textContent = name;
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
||||
|
||||
setProceedingPickerCollapsed(true, name);
|
||||
|
||||
showStep(2);
|
||||
scheduleCalc(0);
|
||||
}
|
||||
|
||||
function initViewToggle() {
|
||||
const toggle = document.getElementById("fristen-view-toggle");
|
||||
if (!toggle) return;
|
||||
|
||||
const initial = new URLSearchParams(window.location.search).get("view");
|
||||
if (initial === "timeline") procedureView = "timeline";
|
||||
|
||||
toggle.querySelectorAll<HTMLInputElement>("input[name=fristen-view]").forEach((input) => {
|
||||
input.checked = input.value === procedureView;
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
procedureView = input.value === "columns" ? "columns" : "timeline";
|
||||
const url = new URL(window.location.href);
|
||||
if (procedureView === "columns") {
|
||||
url.searchParams.delete("view");
|
||||
} else {
|
||||
url.searchParams.set("view", procedureView);
|
||||
}
|
||||
history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
|
||||
toggle.style.display = "none";
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => selectProceeding(btn));
|
||||
});
|
||||
|
||||
document.getElementById("proceeding-summary-reselect")?.addEventListener("click", () => {
|
||||
setProceedingPickerCollapsed(false);
|
||||
});
|
||||
|
||||
document.getElementById("calculate-btn")?.addEventListener("click", () => scheduleCalc(0));
|
||||
|
||||
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
|
||||
if (dateInput) {
|
||||
dateInput.addEventListener("change", () => scheduleCalc());
|
||||
dateInput.addEventListener("input", () => scheduleCalc());
|
||||
dateInput.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
|
||||
|
||||
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
|
||||
|
||||
initViewToggle();
|
||||
|
||||
onLangChange(() => {
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
const activeBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
|
||||
if (activeBtn) {
|
||||
const name = activeBtn.querySelector("strong")?.textContent || "";
|
||||
const triggerEventEl = document.getElementById("trigger-event");
|
||||
if (triggerEventEl) triggerEventEl.textContent = name;
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape } fro
|
||||
import { renderListShape } from "./views/shape-list";
|
||||
import { renderCardsShape } from "./views/shape-cards";
|
||||
import { renderCalendarShape } from "./views/shape-calendar";
|
||||
import { renderTimelineShape } from "./views/shape-timeline-cv";
|
||||
import type { ChartHandle } from "./views/shape-timeline-chart";
|
||||
|
||||
// /views and /views/{slug} client. Loads the saved or system view, runs
|
||||
// it via /api/views/{slug}/run, and dispatches to the matching render-
|
||||
@@ -143,7 +145,7 @@ async function runAndRender(meta: ViewMeta): Promise<void> {
|
||||
}
|
||||
|
||||
function setActiveShape(shape: RenderShape): void {
|
||||
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar"]) {
|
||||
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar", "views-shape-timeline"]) {
|
||||
const el = document.getElementById(host);
|
||||
if (el) el.hidden = !host.endsWith("-" + shape);
|
||||
}
|
||||
@@ -152,9 +154,17 @@ function setActiveShape(shape: RenderShape): void {
|
||||
});
|
||||
}
|
||||
|
||||
let timelineHandle: ChartHandle | null = null;
|
||||
|
||||
function renderShape(shape: RenderShape, render: RenderSpec, rows: ViewRunResult["rows"]): void {
|
||||
const host = document.getElementById(`views-shape-${shape}`);
|
||||
if (!host) return;
|
||||
// Switching away from timeline → dispose the prior chart handle so we
|
||||
// don't leak resize listeners / SVG nodes between shape flips.
|
||||
if (shape !== "timeline" && timelineHandle) {
|
||||
timelineHandle.dispose();
|
||||
timelineHandle = null;
|
||||
}
|
||||
switch (shape) {
|
||||
case "list":
|
||||
renderListShape(host, rows, render);
|
||||
@@ -165,6 +175,47 @@ function renderShape(shape: RenderShape, render: RenderSpec, rows: ViewRunResult
|
||||
case "calendar":
|
||||
renderCalendarShape(host, rows, render);
|
||||
break;
|
||||
case "timeline": {
|
||||
// Tear down any previous chart inside this host before re-mounting
|
||||
// (the CV adapter clears chart-host innerHTML on its own, but we
|
||||
// need to dispose the prior handle's resize/click listeners too).
|
||||
if (timelineHandle) {
|
||||
timelineHandle.dispose();
|
||||
timelineHandle = null;
|
||||
}
|
||||
const chartHost = document.getElementById("views-timeline-chart-host");
|
||||
if (chartHost) {
|
||||
timelineHandle = renderTimelineShape(chartHost, rows, render);
|
||||
}
|
||||
maybeShowTimelineCaveat();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** First-open caveat banner. sessionStorage flag means the user sees it
|
||||
* once per browser session — dismissive but not annoying. Design §13.4
|
||||
* documents the limitation; this is the user-facing surface. */
|
||||
function maybeShowTimelineCaveat(): void {
|
||||
const FLAG = "paliad-views-timeline-caveat-dismissed";
|
||||
const banner = document.getElementById("views-timeline-caveat");
|
||||
const closeBtn = document.getElementById("views-timeline-caveat-close");
|
||||
if (!banner) return;
|
||||
if (sessionStorage.getItem(FLAG) === "1") {
|
||||
banner.hidden = true;
|
||||
return;
|
||||
}
|
||||
banner.hidden = false;
|
||||
if (closeBtn && !closeBtn.dataset.bound) {
|
||||
closeBtn.addEventListener("click", () => {
|
||||
banner.hidden = true;
|
||||
try {
|
||||
sessionStorage.setItem(FLAG, "1");
|
||||
} catch {
|
||||
/* sessionStorage may be unavailable in strict modes — silently noop */
|
||||
}
|
||||
});
|
||||
closeBtn.dataset.bound = "1";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
274
frontend/src/client/views/chart-export.ts
Normal file
274
frontend/src/client/views/chart-export.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
|
||||
|
||||
// chart-export (t-paliad-177 Slice 2) — client-side export helpers for
|
||||
// the Project Timeline / Chart page.
|
||||
//
|
||||
// Five formats land in Slice 2 (per design §7.1, m's pick on faraday-Q4
|
||||
// to rule out server-side PDF via chromedp):
|
||||
//
|
||||
// SVG — XMLSerializer of the live SVG element
|
||||
// PNG — SVG → <img> → <canvas> at 2× HiDPI, toBlob("image/png")
|
||||
// PDF — window.print() with @media print stylesheet (browser handles
|
||||
// the PDF engine; no chromedp dep on Dokploy)
|
||||
// CSV — flat tabular dump of TimelineEvent[] (UTF-8 BOM for Excel-DE)
|
||||
// JSON — wire envelope verbatim + export-metadata header
|
||||
//
|
||||
// iCal lands in a follow-up commit (C5) and goes via a server-side
|
||||
// endpoint that reuses internal/services/caldav_ical.go (faraday-Q6).
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §7.
|
||||
|
||||
export interface ExportContext {
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
svgEl: SVGSVGElement;
|
||||
events: ReadonlyArray<TimelineEvent>;
|
||||
lanes: ReadonlyArray<LaneInfo>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public surface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function exportSVG(ctx: ExportContext): Promise<void> {
|
||||
const svgString = serialiseSVG(ctx.svgEl);
|
||||
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
|
||||
triggerDownload(blob, filename(ctx, "svg"));
|
||||
}
|
||||
|
||||
export async function exportPNG(ctx: ExportContext): Promise<void> {
|
||||
const svgString = serialiseSVG(ctx.svgEl);
|
||||
const blob = await rasterise(svgString, ctx.svgEl);
|
||||
if (!blob) {
|
||||
throw new Error("PNG raster failed");
|
||||
}
|
||||
triggerDownload(blob, filename(ctx, "png"));
|
||||
}
|
||||
|
||||
export function exportCSV(ctx: ExportContext): void {
|
||||
const rows: string[][] = [csvHeader()];
|
||||
for (const event of ctx.events) {
|
||||
rows.push(csvRow(event, ctx));
|
||||
}
|
||||
// UTF-8 BOM keeps Excel-DE from mis-detecting ANSI; ISO-8601 dates
|
||||
// round-trip correctly into German Excel as text.
|
||||
const text = "" + rows.map(csvLine).join("\r\n") + "\r\n";
|
||||
const blob = new Blob([text], { type: "text/csv;charset=utf-8" });
|
||||
triggerDownload(blob, filename(ctx, "csv"));
|
||||
}
|
||||
|
||||
export function exportJSON(ctx: ExportContext): void {
|
||||
const envelope = {
|
||||
project_id: ctx.projectId,
|
||||
project_title: ctx.projectTitle,
|
||||
exported_at: new Date().toISOString(),
|
||||
events: ctx.events,
|
||||
lanes: ctx.lanes,
|
||||
};
|
||||
const text = JSON.stringify(envelope, null, 2) + "\n";
|
||||
const blob = new Blob([text], { type: "application/json;charset=utf-8" });
|
||||
triggerDownload(blob, filename(ctx, "json"));
|
||||
}
|
||||
|
||||
export function exportPrint(): void {
|
||||
// The @media print stylesheet in global.css does the layout work;
|
||||
// we just invoke the browser's print dialog. User picks "Save as PDF"
|
||||
// (Chrome/Edge), "Drucken in Datei" (Firefox), etc.
|
||||
window.print();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG / PNG plumbing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function serialiseSVG(svgEl: SVGSVGElement): string {
|
||||
// Clone so we can inline computed styles without polluting the live DOM.
|
||||
// For a true cross-environment-portable SVG, we'd compute every used
|
||||
// CSS-var into a literal value. v1 keeps it light: the receiver inherits
|
||||
// colours via document context when opened standalone, and the rendered
|
||||
// bars still work because palette tokens fall through to the .smart-
|
||||
// timeline-chart root selector via inline class. Add a fallback width /
|
||||
// height attribute so headless viewers don't render 0×0.
|
||||
const clone = svgEl.cloneNode(true) as SVGSVGElement;
|
||||
if (!clone.getAttribute("width") && svgEl.getAttribute("width")) {
|
||||
clone.setAttribute("width", svgEl.getAttribute("width") || "1000");
|
||||
}
|
||||
if (!clone.getAttribute("height") && svgEl.getAttribute("height")) {
|
||||
clone.setAttribute("height", svgEl.getAttribute("height") || "400");
|
||||
}
|
||||
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
||||
|
||||
// Inline the chart's computed palette tokens so the standalone SVG
|
||||
// paints the same way when opened in an image viewer (which has no
|
||||
// document.css). Read every --chart-* property off the live element.
|
||||
const computed = window.getComputedStyle(svgEl);
|
||||
const styleLines: string[] = [];
|
||||
for (const prop of [
|
||||
"--chart-mark-deadline",
|
||||
"--chart-mark-appointment",
|
||||
"--chart-mark-milestone",
|
||||
"--chart-mark-projected",
|
||||
"--chart-mark-overdue",
|
||||
"--chart-mark-done",
|
||||
"--chart-today-rule",
|
||||
"--chart-grid-line",
|
||||
"--chart-lane-label",
|
||||
"--chart-tick-label",
|
||||
"--chart-bg",
|
||||
]) {
|
||||
const val = computed.getPropertyValue(prop).trim();
|
||||
if (val) styleLines.push(`${prop}: ${val};`);
|
||||
}
|
||||
if (styleLines.length > 0) {
|
||||
const existing = clone.getAttribute("style") || "";
|
||||
clone.setAttribute("style", existing + styleLines.join(" "));
|
||||
}
|
||||
|
||||
return new XMLSerializer().serializeToString(clone);
|
||||
}
|
||||
|
||||
async function rasterise(svgString: string, svgEl: SVGSVGElement): Promise<Blob | null> {
|
||||
const widthAttr = svgEl.getAttribute("width") || "1000";
|
||||
const heightAttr = svgEl.getAttribute("height") || "400";
|
||||
const width = Number(widthAttr) || 1000;
|
||||
const height = Number(heightAttr) || 400;
|
||||
// 2× device pixel ratio for HiDPI exports (design §7.1 "PNG, 2× HiDPI").
|
||||
const scale = 2;
|
||||
|
||||
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
try {
|
||||
const img = await loadImage(url);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.round(width * scale);
|
||||
canvas.height = Math.round(height * scale);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
return await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob((b) => resolve(b), "image/png");
|
||||
});
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error("Image load failed"));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSV plumbing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CSV_COLUMNS = [
|
||||
"project_id",
|
||||
"project_title",
|
||||
"kind",
|
||||
"status",
|
||||
"track",
|
||||
"lane_id",
|
||||
"date",
|
||||
"title",
|
||||
"description",
|
||||
"rule_code",
|
||||
"depends_on_rule_code",
|
||||
"depends_on_date",
|
||||
"depends_on_rule_name",
|
||||
"sub_project_id",
|
||||
"sub_project_title",
|
||||
"bubble_up",
|
||||
"deadline_id",
|
||||
"appointment_id",
|
||||
"project_event_id",
|
||||
"project_event_type",
|
||||
] as const;
|
||||
|
||||
function csvHeader(): string[] {
|
||||
return [...CSV_COLUMNS];
|
||||
}
|
||||
|
||||
function csvRow(event: TimelineEvent, ctx: ExportContext): string[] {
|
||||
return [
|
||||
ctx.projectId,
|
||||
ctx.projectTitle,
|
||||
event.kind,
|
||||
event.status,
|
||||
event.track,
|
||||
event.lane_id ?? "",
|
||||
isoOnly(event.date),
|
||||
event.title,
|
||||
event.description ?? "",
|
||||
event.rule_code ?? "",
|
||||
event.depends_on_rule_code ?? "",
|
||||
isoOnly(event.depends_on_date),
|
||||
event.depends_on_rule_name ?? "",
|
||||
event.sub_project_id ?? "",
|
||||
event.sub_project_title ?? "",
|
||||
event.bubble_up ? "true" : "false",
|
||||
event.deadline_id ?? "",
|
||||
event.appointment_id ?? "",
|
||||
event.project_event_id ?? "",
|
||||
event.project_event_type ?? "",
|
||||
];
|
||||
}
|
||||
|
||||
function csvLine(fields: string[]): string {
|
||||
return fields.map(csvEscape).join(",");
|
||||
}
|
||||
|
||||
/** RFC 4180 quoting: double quotes inside the field are doubled; wrap
|
||||
* the whole field in quotes if it contains comma / quote / newline. */
|
||||
function csvEscape(value: string): string {
|
||||
if (/[,"\r\n]/.test(value)) {
|
||||
return '"' + value.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isoOnly(date: string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
return date.slice(0, 10);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download trigger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function triggerDownload(blob: Blob, name: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
// Some browsers (Safari < 14) ignore the download attribute unless
|
||||
// the link is in the document tree. Inserting + removing is cheap.
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Give the browser a tick to start the download before we revoke.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function filename(ctx: ExportContext, ext: string): string {
|
||||
// Keep filenames diff-friendly + filesystem-safe. Replace anything that
|
||||
// isn't ASCII alnum/dot/hyphen with "_". Truncate the title to 60 chars.
|
||||
const safeTitle = (ctx.projectTitle || "timeline")
|
||||
.normalize("NFKD")
|
||||
.replace(/[^\x20-\x7e]/g, "")
|
||||
.replace(/[^A-Za-z0-9.-]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_|_$/g, "")
|
||||
.slice(0, 60) || "timeline";
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
return `paliad-${safeTitle}-${dateStr}.${ext}`;
|
||||
}
|
||||
@@ -1,17 +1,25 @@
|
||||
import { t, type I18nKey } from "../i18n";
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
import { t, tDyn, getLang, type I18nKey } from "../i18n";
|
||||
import type { ListRowAction, RenderSpec, ViewRow } from "./types";
|
||||
import { formatDate, formatRelative, parseDateOnly } from "./format";
|
||||
|
||||
// shape-list: renders ViewRows as a table (density=comfortable) or a
|
||||
// compact one-line stream (density=compact). The "activity feed" look
|
||||
// is just density=compact + actor/time columns — see Q4 lock-in
|
||||
// 2026-05-07 (3 shapes; no separate "activity").
|
||||
//
|
||||
// Row interaction is controlled by render.list.row_action
|
||||
// (t-paliad-163 schema bump). Default "navigate" keeps every existing
|
||||
// caller's contract — clicking a row goes to the per-kind detail
|
||||
// page. "approve" produces the approval-list layout for /inbox.
|
||||
// "complete_toggle" is wired in Phase 3 (/events). "none" suppresses
|
||||
// any row interaction (audit views).
|
||||
|
||||
export function renderListShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
|
||||
host.innerHTML = "";
|
||||
const list = render.list ?? {};
|
||||
const density = list.density ?? "comfortable";
|
||||
const sort = list.sort ?? "date_asc";
|
||||
const rowAction: ListRowAction = list.row_action ?? "navigate";
|
||||
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const aT = sortKey(a.event_date);
|
||||
@@ -19,6 +27,11 @@ export function renderListShape(host: HTMLElement, rows: ViewRow[], render: Rend
|
||||
return sort === "date_asc" ? aT - bT : bT - aT;
|
||||
});
|
||||
|
||||
if (rowAction === "approve") {
|
||||
host.appendChild(renderApprovalList(sorted));
|
||||
return;
|
||||
}
|
||||
|
||||
if (density === "compact") {
|
||||
host.appendChild(renderCompact(sorted));
|
||||
} else {
|
||||
@@ -162,3 +175,166 @@ function sortKey(iso: string): number {
|
||||
if (dateOnly) return dateOnly.getTime();
|
||||
return Date.parse(iso);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// row_action = "approve" — approval inbox layout
|
||||
//
|
||||
// Stamps the markup the /inbox surface needs (data attrs + classes);
|
||||
// the surface (client/inbox.ts) wires the action handlers in onResult.
|
||||
// This keeps shape-list independent of any specific surface's wiring.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface ApprovalDetail {
|
||||
status?: string;
|
||||
lifecycle_event?: string;
|
||||
entity_type?: string;
|
||||
entity_title?: string;
|
||||
pre_image?: Record<string, unknown> | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
required_role?: string;
|
||||
requester_name?: string;
|
||||
requester_kind?: "user" | "agent";
|
||||
decider_name?: string;
|
||||
decision_note?: string;
|
||||
}
|
||||
|
||||
function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "inbox-list views-approval-list";
|
||||
for (const row of rows) {
|
||||
const detail = (row.detail || {}) as ApprovalDetail;
|
||||
const li = document.createElement("li");
|
||||
li.className = "inbox-row views-approval-row";
|
||||
li.dataset.requestId = row.id;
|
||||
li.dataset.status = detail.status ?? "";
|
||||
|
||||
// Header: entity / lifecycle
|
||||
const head = document.createElement("div");
|
||||
head.className = "inbox-row-head";
|
||||
const title = document.createElement("div");
|
||||
title.className = "inbox-row-title";
|
||||
const entityLabel = detail.entity_type ? t(("approvals.entity." + detail.entity_type) as I18nKey) : "";
|
||||
const lifecycleLabel = detail.lifecycle_event ? t(("approvals.lifecycle." + detail.lifecycle_event) as I18nKey) : "";
|
||||
const entityTitle = detail.entity_title || row.title || "—";
|
||||
title.textContent = `${entityLabel}: ${entityTitle} — ${lifecycleLabel}`;
|
||||
head.appendChild(title);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "inbox-row-meta";
|
||||
const reqByLabel = t("approvals.requested_by");
|
||||
const roleLabel = detail.required_role
|
||||
? t(("approvals.required_role." + detail.required_role) as I18nKey)
|
||||
: "";
|
||||
const requester = detail.requester_name || row.actor_name || "";
|
||||
const requesterTag = detail.requester_kind === "agent"
|
||||
? `${requester} ✨ ${t("approvals.agent.byline")}`
|
||||
: requester;
|
||||
const projectTitle = row.project_title ?? "";
|
||||
const parts = [
|
||||
projectTitle,
|
||||
`${reqByLabel} ${requesterTag}`,
|
||||
];
|
||||
if (roleLabel) parts.push(`${roleLabel}+`);
|
||||
parts.push(formatRelativeTime(row.event_date));
|
||||
meta.textContent = parts.filter(Boolean).join(" · ");
|
||||
head.appendChild(meta);
|
||||
li.appendChild(head);
|
||||
|
||||
// Diff for update / complete
|
||||
const diff = renderDiff(detail);
|
||||
if (diff) li.appendChild(diff);
|
||||
|
||||
if (detail.decision_note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "inbox-row-note";
|
||||
note.textContent = detail.decision_note;
|
||||
li.appendChild(note);
|
||||
}
|
||||
|
||||
// Action row — surface attaches handlers via data-attrs.
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "inbox-row-actions";
|
||||
|
||||
if (detail.status === "pending") {
|
||||
// The bar's approval_viewer_role distinguishes which actions are
|
||||
// appropriate. The surface inspects the active role and decides
|
||||
// which buttons to keep — but for default rendering we stamp all
|
||||
// three with role-class hints and let the surface filter.
|
||||
actions.appendChild(actionBtn("approve"));
|
||||
actions.appendChild(actionBtn("reject"));
|
||||
actions.appendChild(actionBtn("revoke"));
|
||||
} else if (detail.status) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "approval-pill approval-pill--historic";
|
||||
pill.textContent = t(("approvals.status." + detail.status) as I18nKey);
|
||||
if (detail.decider_name && detail.status !== "revoked") {
|
||||
const decided = document.createElement("span");
|
||||
decided.className = "inbox-row-decided";
|
||||
decided.textContent = ` · ${t("approvals.decided_by")} ${detail.decider_name}`;
|
||||
pill.appendChild(decided);
|
||||
}
|
||||
actions.appendChild(pill);
|
||||
}
|
||||
li.appendChild(actions);
|
||||
|
||||
ul.appendChild(li);
|
||||
}
|
||||
return ul;
|
||||
}
|
||||
|
||||
function renderDiff(detail: ApprovalDetail): HTMLElement | null {
|
||||
const before = (detail.pre_image || {}) as Record<string, unknown>;
|
||||
const after = (detail.payload || {}) as Record<string, unknown>;
|
||||
const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)]));
|
||||
if (keys.length === 0) return null;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "inbox-row-diff";
|
||||
for (const k of keys) {
|
||||
const line = document.createElement("div");
|
||||
line.className = "inbox-row-diff-line";
|
||||
const label = document.createElement("span");
|
||||
label.className = "inbox-row-diff-key";
|
||||
label.textContent = k;
|
||||
line.appendChild(label);
|
||||
const span = document.createElement("span");
|
||||
span.className = "inbox-row-diff-values";
|
||||
const fmt = (v: unknown) => v === null || v === undefined ? "—" : String(v);
|
||||
if (k in before && k in after) {
|
||||
span.textContent = `${fmt(before[k])} → ${fmt(after[k])}`;
|
||||
} else if (k in before) {
|
||||
span.textContent = `${t("approvals.diff.before")}: ${fmt(before[k])}`;
|
||||
} else {
|
||||
span.textContent = `${t("approvals.diff.after")}: ${fmt(after[k])}`;
|
||||
}
|
||||
line.appendChild(span);
|
||||
wrap.appendChild(line);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function actionBtn(action: "approve" | "reject" | "revoke"): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.dataset.action = action;
|
||||
const cls = action === "approve" ? "btn-primary" : action === "reject" ? "btn-danger" : "btn-secondary";
|
||||
btn.className = `btn ${cls} inbox-row-action views-approval-action`;
|
||||
btn.textContent = t(("approvals.action." + action) as I18nKey);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const t0 = Date.parse(iso);
|
||||
if (isNaN(t0)) return iso;
|
||||
const diffMs = Date.now() - t0;
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return getLang() === "de" ? `vor ${sec}s` : `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return getLang() === "de" ? `vor ${min}m` : `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return getLang() === "de" ? `vor ${hr}h` : `${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return getLang() === "de" ? `vor ${day}d` : `${day}d ago`;
|
||||
}
|
||||
|
||||
// Suppress unused warning for tDyn — kept available for future axes.
|
||||
void tDyn;
|
||||
|
||||
254
frontend/src/client/views/shape-timeline-chart.test.ts
Normal file
254
frontend/src/client/views/shape-timeline-chart.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { layout, type ChartViewport } from "./shape-timeline-chart";
|
||||
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
|
||||
|
||||
// t-paliad-177 Slice 1 — table-driven tests for the pure `layout()`
|
||||
// function. `layout` translates a TimelineEvent[] + LaneInfo[] + viewport
|
||||
// into deterministic SVG-ready geometry. Tests pin the math so subtle
|
||||
// drift (off-by-one days, axis tick density, lane stacking) surfaces fast.
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §15.
|
||||
|
||||
const vp = (overrides: Partial<ChartViewport> = {}): ChartViewport => ({
|
||||
width: 1000,
|
||||
height: 400,
|
||||
laneLabelWidth: 200,
|
||||
dateAxisHeight: 40,
|
||||
todayISO: "2026-06-15",
|
||||
rangeFrom: "2026-01-01",
|
||||
rangeTo: "2026-12-31",
|
||||
density: "standard",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const ev = (overrides: Partial<TimelineEvent> = {}): TimelineEvent => ({
|
||||
kind: "deadline",
|
||||
status: "open",
|
||||
track: "parent",
|
||||
date: "2026-06-15",
|
||||
title: "Test event",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("layout — base geometry", () => {
|
||||
test("chart canvas sits to the right of lane labels and below date axis", () => {
|
||||
const out = layout([], [], vp());
|
||||
expect(out.chartLeft).toBe(200);
|
||||
expect(out.chartTop).toBe(40);
|
||||
expect(out.chartWidth).toBe(800);
|
||||
expect(out.chartHeight).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("pxPerDay = chartWidth / total_days", () => {
|
||||
// 2026 is 365 days; range Jan 1..Dec 31 is 364 day-deltas + 1 = 365 days.
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
|
||||
expect(out.pxPerDay).toBeCloseTo(800 / 364, 5);
|
||||
});
|
||||
|
||||
test("invalid range (to before from) falls back to a 1-day span", () => {
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-06-01", rangeTo: "2026-05-01" }));
|
||||
// Sanity: pxPerDay finite, no division-by-zero.
|
||||
expect(Number.isFinite(out.pxPerDay)).toBe(true);
|
||||
expect(out.pxPerDay).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — today rule", () => {
|
||||
test("today inside range produces a non-null todayX in the chart canvas", () => {
|
||||
const out = layout([], [], vp({ todayISO: "2026-06-15" }));
|
||||
expect(out.todayX).not.toBeNull();
|
||||
expect(out.todayX!).toBeGreaterThan(out.chartLeft);
|
||||
expect(out.todayX!).toBeLessThan(out.chartLeft + out.chartWidth);
|
||||
});
|
||||
|
||||
test("today before range.from → todayX is null", () => {
|
||||
const out = layout([], [], vp({ todayISO: "2025-12-15" }));
|
||||
expect(out.todayX).toBeNull();
|
||||
});
|
||||
|
||||
test("today after range.to → todayX is null", () => {
|
||||
const out = layout([], [], vp({ todayISO: "2027-01-15" }));
|
||||
expect(out.todayX).toBeNull();
|
||||
});
|
||||
|
||||
test("today equals range.from → todayX sits at chartLeft", () => {
|
||||
const out = layout([], [], vp({ todayISO: "2026-01-01" }));
|
||||
expect(out.todayX).toBeCloseTo(out.chartLeft, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — lane stacking", () => {
|
||||
test("empty lanes synthesises a single 'self' lane", () => {
|
||||
const out = layout([], [], vp());
|
||||
expect(out.laneRows).toHaveLength(1);
|
||||
expect(out.laneRows[0].id).toBe("self");
|
||||
});
|
||||
|
||||
test("multiple lanes stack vertically in input order", () => {
|
||||
const lanes: LaneInfo[] = [
|
||||
{ id: "self", label: "Hauptverfahren" },
|
||||
{ id: "counterclaim:abc", label: "Widerklage" },
|
||||
{ id: "parent_context:xyz", label: "Parent" },
|
||||
];
|
||||
const out = layout([], lanes, vp());
|
||||
expect(out.laneRows).toHaveLength(3);
|
||||
expect(out.laneRows[0].y).toBe(out.chartTop);
|
||||
expect(out.laneRows[1].y).toBeGreaterThan(out.laneRows[0].y);
|
||||
expect(out.laneRows[2].y).toBeGreaterThan(out.laneRows[1].y);
|
||||
// All same height.
|
||||
expect(out.laneRows[0].height).toBe(out.laneRows[1].height);
|
||||
expect(out.laneRows[1].height).toBe(out.laneRows[2].height);
|
||||
});
|
||||
|
||||
test("density compact gives smaller lane height than spacious", () => {
|
||||
const compact = layout([], [], vp({ density: "compact" }));
|
||||
const spacious = layout([], [], vp({ density: "spacious" }));
|
||||
expect(compact.laneRows[0].height).toBeLessThan(spacious.laneRows[0].height);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — marks", () => {
|
||||
test("single deadline maps to one mark in the self lane", () => {
|
||||
const events: TimelineEvent[] = [ev({ date: "2026-06-15" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks).toHaveLength(1);
|
||||
expect(out.marks[0].eventIndex).toBe(0);
|
||||
expect(out.marks[0].laneId).toBe("self");
|
||||
expect(out.marks[0].undated).toBe(false);
|
||||
});
|
||||
|
||||
test("event's x position matches its date offset from range.from", () => {
|
||||
// June 15 is day 165 of 2026 (0-indexed from Jan 1).
|
||||
const events: TimelineEvent[] = [ev({ date: "2026-06-15" })];
|
||||
const out = layout(events, [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
|
||||
const expectedX = out.chartLeft + 165 * out.pxPerDay;
|
||||
expect(out.marks[0].x).toBeCloseTo(expectedX, 1);
|
||||
});
|
||||
|
||||
test("event bucketed by lane_id matches the corresponding lane row", () => {
|
||||
const lanes: LaneInfo[] = [
|
||||
{ id: "self", label: "Self" },
|
||||
{ id: "ccr", label: "CCR" },
|
||||
];
|
||||
const events: TimelineEvent[] = [
|
||||
ev({ date: "2026-06-15", lane_id: "ccr" }),
|
||||
];
|
||||
const out = layout(events, lanes, vp());
|
||||
const ccrRow = out.laneRows.find((r) => r.id === "ccr")!;
|
||||
expect(out.marks[0].laneId).toBe("ccr");
|
||||
expect(out.marks[0].y).toBeCloseTo(ccrRow.y + ccrRow.height / 2, 1);
|
||||
});
|
||||
|
||||
test("unknown lane_id falls back to the first lane (defensive)", () => {
|
||||
const lanes: LaneInfo[] = [{ id: "self", label: "Self" }];
|
||||
const events: TimelineEvent[] = [
|
||||
ev({ date: "2026-06-15", lane_id: "deleted-lane-id" }),
|
||||
];
|
||||
const out = layout(events, lanes, vp());
|
||||
expect(out.marks[0].laneId).toBe("self");
|
||||
});
|
||||
|
||||
test("events outside range are clipped (not emitted)", () => {
|
||||
const events: TimelineEvent[] = [
|
||||
ev({ date: "2025-01-01", title: "before" }),
|
||||
ev({ date: "2026-06-15", title: "inside" }),
|
||||
ev({ date: "2027-12-31", title: "after" }),
|
||||
];
|
||||
const out = layout(events, [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
|
||||
expect(out.marks).toHaveLength(1);
|
||||
expect(out.marks[0].eventIndex).toBe(1);
|
||||
});
|
||||
|
||||
test("undated events go to the undated zone with undated=true", () => {
|
||||
const events: TimelineEvent[] = [ev({ date: null, title: "court-set" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks).toHaveLength(1);
|
||||
expect(out.marks[0].undated).toBe(true);
|
||||
// Undated marks sit in the lane label gutter (x < chartLeft).
|
||||
expect(out.marks[0].x).toBeLessThan(out.chartLeft);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — mark shapes by kind+status", () => {
|
||||
test("deadline.done → dot, deadline.open → dot, deadline.overdue → dot", () => {
|
||||
const events: TimelineEvent[] = [
|
||||
ev({ kind: "deadline", status: "done" }),
|
||||
ev({ kind: "deadline", status: "open" }),
|
||||
ev({ kind: "deadline", status: "overdue" }),
|
||||
];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks.map((m) => m.shape)).toEqual(["dot", "dot", "dot"]);
|
||||
});
|
||||
|
||||
test("milestone → diamond", () => {
|
||||
const events: TimelineEvent[] = [ev({ kind: "milestone", status: "done" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks[0].shape).toBe("diamond");
|
||||
});
|
||||
|
||||
test("appointment → dot (Slice 1 keeps it simple; bar variant deferred)", () => {
|
||||
const events: TimelineEvent[] = [ev({ kind: "appointment", status: "open" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks[0].shape).toBe("dot");
|
||||
});
|
||||
|
||||
test("projected.predicted → hatched-dot", () => {
|
||||
const events: TimelineEvent[] = [ev({ kind: "projected", status: "predicted" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks[0].shape).toBe("hatched-dot");
|
||||
});
|
||||
|
||||
test("projected.court_set → dashed-dot", () => {
|
||||
const events: TimelineEvent[] = [ev({ kind: "projected", status: "court_set" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks[0].shape).toBe("dashed-dot");
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — axis ticks", () => {
|
||||
test("short range (<90d) emits month ticks", () => {
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-02-28" }));
|
||||
const kinds = new Set(out.axisTicks.map((t) => t.kind));
|
||||
expect(kinds.has("month")).toBe(true);
|
||||
});
|
||||
|
||||
test("medium range (90-730d) emits quarter ticks", () => {
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
|
||||
const kinds = new Set(out.axisTicks.map((t) => t.kind));
|
||||
expect(kinds.has("quarter")).toBe(true);
|
||||
});
|
||||
|
||||
test("long range (>730d) emits year ticks", () => {
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2029-12-31" }));
|
||||
const kinds = new Set(out.axisTicks.map((t) => t.kind));
|
||||
expect(kinds.has("year")).toBe(true);
|
||||
});
|
||||
|
||||
test("year-boundary ticks are flagged", () => {
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2027-12-31" }));
|
||||
const yearBoundaries = out.axisTicks.filter((t) => t.isYearBoundary);
|
||||
expect(yearBoundaries.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("all ticks fall inside the chart canvas horizontally", () => {
|
||||
const out = layout([], [], vp());
|
||||
for (const tick of out.axisTicks) {
|
||||
expect(tick.x).toBeGreaterThanOrEqual(out.chartLeft - 0.5);
|
||||
expect(tick.x).toBeLessThanOrEqual(out.chartLeft + out.chartWidth + 0.5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — undated counting", () => {
|
||||
test("undated marks tallied separately from inside-range count", () => {
|
||||
const events: TimelineEvent[] = [
|
||||
ev({ date: "2026-06-15" }),
|
||||
ev({ date: null }),
|
||||
ev({ date: null }),
|
||||
ev({ date: "2025-01-01" }), // out of range
|
||||
];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.undatedCount).toBe(2);
|
||||
expect(out.marks).toHaveLength(3); // 1 dated + 2 undated, the out-of-range one is clipped
|
||||
});
|
||||
});
|
||||
974
frontend/src/client/views/shape-timeline-chart.ts
Normal file
974
frontend/src/client/views/shape-timeline-chart.ts
Normal file
@@ -0,0 +1,974 @@
|
||||
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
|
||||
|
||||
// shape-timeline-chart (t-paliad-177 Slice 1) — horizontal SVG Gantt
|
||||
// renderer for the standalone Project Timeline / Chart page.
|
||||
//
|
||||
// Split into two concerns:
|
||||
//
|
||||
// layout(events, lanes, viewport): ChartLayout
|
||||
// pure function — translates the wire shape into deterministic
|
||||
// SVG-ready geometry (axis ticks, lane row y/height, mark x/y/shape,
|
||||
// today-rule x). No DOM access. Table-driven tests pin this in
|
||||
// shape-timeline-chart.test.ts.
|
||||
//
|
||||
// paint(layout, root): void (Slice 1, next commit)
|
||||
// DOM-mutates an SVGSVGElement. Reads layout, never recomputes
|
||||
// positions. Idempotent — calling twice with the same layout
|
||||
// produces the same DOM.
|
||||
//
|
||||
// mount(host, opts): ChartHandle (Slice 1, next commit)
|
||||
// End-to-end: fetches /api/projects/{id}/timeline, computes layout,
|
||||
// paints, returns a handle with .refresh() / .dispose().
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §3.2 + §12.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Density = "compact" | "standard" | "spacious";
|
||||
|
||||
export interface ChartViewport {
|
||||
width: number;
|
||||
height: number;
|
||||
/** Reserved on the left for lane labels (and the undated zone). */
|
||||
laneLabelWidth: number;
|
||||
/** Reserved on top for the date axis. */
|
||||
dateAxisHeight: number;
|
||||
/** Today's date as ISO YYYY-MM-DD. Used to position the today rule. */
|
||||
todayISO: string;
|
||||
/** Inclusive ISO YYYY-MM-DD start of the chart's date range. */
|
||||
rangeFrom: string;
|
||||
/** Inclusive ISO YYYY-MM-DD end of the chart's date range. */
|
||||
rangeTo: string;
|
||||
density: Density;
|
||||
}
|
||||
|
||||
export interface AxisTick {
|
||||
x: number;
|
||||
label: string;
|
||||
kind: "year" | "quarter" | "month";
|
||||
isYearBoundary: boolean;
|
||||
}
|
||||
|
||||
export interface LaneRow {
|
||||
id: string;
|
||||
label: string;
|
||||
y: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type MarkShape =
|
||||
| "dot"
|
||||
| "diamond"
|
||||
| "hatched-dot"
|
||||
| "dashed-dot";
|
||||
|
||||
export interface Mark {
|
||||
/** Index into the original events array — paint() reuses this for tooltips + deep-links. */
|
||||
eventIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Radius for dot / hatched-dot / dashed-dot, half-diagonal for diamond. */
|
||||
radius: number;
|
||||
shape: MarkShape;
|
||||
kind: TimelineEvent["kind"];
|
||||
status: TimelineEvent["status"];
|
||||
laneId: string;
|
||||
undated: boolean;
|
||||
}
|
||||
|
||||
export interface ChartLayout {
|
||||
viewport: ChartViewport;
|
||||
pxPerDay: number;
|
||||
chartLeft: number;
|
||||
chartTop: number;
|
||||
chartWidth: number;
|
||||
chartHeight: number;
|
||||
axisTicks: AxisTick[];
|
||||
laneRows: LaneRow[];
|
||||
marks: Mark[];
|
||||
/** Pixel x of the today rule, or null when today is outside the range. */
|
||||
todayX: number | null;
|
||||
undatedCount: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Density tokens — single source of truth, used by layout() and CSS swap.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LANE_HEIGHT: Record<Density, number> = {
|
||||
compact: 24,
|
||||
standard: 40,
|
||||
spacious: 64,
|
||||
};
|
||||
|
||||
const MARK_RADIUS: Record<Density, number> = {
|
||||
compact: 5,
|
||||
standard: 7,
|
||||
spacious: 10,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Date helpers — UTC throughout to avoid DST drift in day-math.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
function parseISODay(iso: string): number | null {
|
||||
// Accept "YYYY-MM-DD" and "YYYY-MM-DDTHH:MM:SSZ" (substrate marshals
|
||||
// deadline.due_date as the UTC-midnight form — see format.ts).
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
||||
if (!m) return null;
|
||||
const y = Number(m[1]);
|
||||
const mo = Number(m[2]);
|
||||
const d = Number(m[3]);
|
||||
if (
|
||||
!Number.isFinite(y) || !Number.isFinite(mo) || !Number.isFinite(d) ||
|
||||
mo < 1 || mo > 12 || d < 1 || d > 31
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Date.UTC(y, mo - 1, d);
|
||||
}
|
||||
|
||||
function dayDelta(fromMs: number, toMs: number): number {
|
||||
return Math.round((toMs - fromMs) / DAY_MS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mark shape resolution — single mapping table, mirrors §6.2 of the design.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function markShape(kind: TimelineEvent["kind"], status: TimelineEvent["status"]): MarkShape {
|
||||
if (kind === "milestone") return "diamond";
|
||||
if (kind === "projected") {
|
||||
if (status === "court_set") return "dashed-dot";
|
||||
return "hatched-dot"; // predicted, predicted_overdue, off_script
|
||||
}
|
||||
// deadline + appointment + everything else → plain dot. Status drives
|
||||
// colour saturation (see CSS palette tokens), not shape.
|
||||
return "dot";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Axis tick generation — granularity by total span.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateTicks(
|
||||
fromMs: number,
|
||||
toMs: number,
|
||||
chartLeft: number,
|
||||
pxPerDay: number,
|
||||
): AxisTick[] {
|
||||
const totalDays = dayDelta(fromMs, toMs);
|
||||
const ticks: AxisTick[] = [];
|
||||
|
||||
// Walk from the first day-of-month >= fromMs forward.
|
||||
const start = new Date(fromMs);
|
||||
const yStart = start.getUTCFullYear();
|
||||
const mStart = start.getUTCMonth();
|
||||
|
||||
// Density rules:
|
||||
// <90d → month ticks (every month-start)
|
||||
// 90-730 → quarter ticks (Jan, Apr, Jul, Oct)
|
||||
// >730 → year ticks (Jan only)
|
||||
let kind: AxisTick["kind"];
|
||||
let monthStep: number;
|
||||
if (totalDays < 90) {
|
||||
kind = "month";
|
||||
monthStep = 1;
|
||||
} else if (totalDays <= 730) {
|
||||
kind = "quarter";
|
||||
monthStep = 3;
|
||||
} else {
|
||||
kind = "year";
|
||||
monthStep = 12;
|
||||
}
|
||||
|
||||
// For quarter/year ticks, snap the starting month to the next aligned
|
||||
// boundary so the labels are calendar-aligned (Jan/Apr/Jul/Oct, not
|
||||
// Feb/May/Aug/Nov).
|
||||
let mCursor = mStart;
|
||||
let yCursor = yStart;
|
||||
if (kind === "quarter") {
|
||||
const offset = mCursor % 3;
|
||||
if (offset !== 0) mCursor += 3 - offset;
|
||||
} else if (kind === "year") {
|
||||
if (mCursor !== 0) {
|
||||
mCursor = 0;
|
||||
yCursor += 1;
|
||||
}
|
||||
}
|
||||
// If the first day of fromMs is not month-1, advance by one month so we
|
||||
// don't double-print the partial month at the very start.
|
||||
if (kind === "month" && start.getUTCDate() !== 1) {
|
||||
mCursor += 1;
|
||||
}
|
||||
while (mCursor >= 12) {
|
||||
mCursor -= 12;
|
||||
yCursor += 1;
|
||||
}
|
||||
|
||||
// Emit ticks until past toMs.
|
||||
while (true) {
|
||||
const tickMs = Date.UTC(yCursor, mCursor, 1);
|
||||
if (tickMs > toMs) break;
|
||||
const days = dayDelta(fromMs, tickMs);
|
||||
const x = chartLeft + days * pxPerDay;
|
||||
const label = formatTickLabel(yCursor, mCursor, kind);
|
||||
ticks.push({
|
||||
x,
|
||||
label,
|
||||
kind,
|
||||
isYearBoundary: mCursor === 0,
|
||||
});
|
||||
mCursor += monthStep;
|
||||
while (mCursor >= 12) {
|
||||
mCursor -= 12;
|
||||
yCursor += 1;
|
||||
}
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
const MONTH_DE = [
|
||||
"Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
|
||||
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez",
|
||||
];
|
||||
|
||||
function formatTickLabel(year: number, month: number, kind: AxisTick["kind"]): string {
|
||||
if (kind === "year") return String(year);
|
||||
if (kind === "quarter") {
|
||||
const q = Math.floor(month / 3) + 1;
|
||||
return `Q${q} ${year}`;
|
||||
}
|
||||
return MONTH_DE[month];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function layout(
|
||||
events: ReadonlyArray<TimelineEvent>,
|
||||
lanes: ReadonlyArray<LaneInfo>,
|
||||
viewport: ChartViewport,
|
||||
): ChartLayout {
|
||||
// -- Canvas geometry --------------------------------------------------
|
||||
const chartLeft = viewport.laneLabelWidth;
|
||||
const chartTop = viewport.dateAxisHeight;
|
||||
const chartWidth = Math.max(0, viewport.width - chartLeft);
|
||||
// chartHeight is derived from the number of lane rows so the SVG grows
|
||||
// / shrinks vertically with the data, not the supplied viewport.height
|
||||
// (which the caller uses as a hint — actual height comes back in
|
||||
// viewport.height after the paint pass).
|
||||
const laneCount = Math.max(1, lanes.length);
|
||||
const laneHeight = LANE_HEIGHT[viewport.density];
|
||||
const chartHeight = laneCount * laneHeight;
|
||||
|
||||
// -- Date math --------------------------------------------------------
|
||||
const fromMs = parseISODay(viewport.rangeFrom);
|
||||
const toMsRaw = parseISODay(viewport.rangeTo);
|
||||
if (fromMs === null || toMsRaw === null) {
|
||||
// Degenerate input — return an empty layout rather than NaN-paint.
|
||||
return {
|
||||
viewport,
|
||||
pxPerDay: 0,
|
||||
chartLeft,
|
||||
chartTop,
|
||||
chartWidth,
|
||||
chartHeight,
|
||||
axisTicks: [],
|
||||
laneRows: synthLaneRows(lanes, chartTop, laneHeight),
|
||||
marks: [],
|
||||
todayX: null,
|
||||
undatedCount: 0,
|
||||
};
|
||||
}
|
||||
// Guard against to < from. Clamp the inverted case to a 1-day span so
|
||||
// pxPerDay stays positive and finite.
|
||||
const toMs = toMsRaw <= fromMs ? fromMs + DAY_MS : toMsRaw;
|
||||
const totalDays = Math.max(1, dayDelta(fromMs, toMs));
|
||||
const pxPerDay = chartWidth / totalDays;
|
||||
|
||||
// -- Today rule -------------------------------------------------------
|
||||
const todayMs = parseISODay(viewport.todayISO);
|
||||
let todayX: number | null = null;
|
||||
if (todayMs !== null && todayMs >= fromMs && todayMs <= toMs) {
|
||||
todayX = chartLeft + dayDelta(fromMs, todayMs) * pxPerDay;
|
||||
}
|
||||
|
||||
// -- Lane rows --------------------------------------------------------
|
||||
const laneRows = synthLaneRows(lanes, chartTop, laneHeight);
|
||||
const laneIndex = new Map<string, LaneRow>();
|
||||
for (const row of laneRows) laneIndex.set(row.id, row);
|
||||
const fallbackLane = laneRows[0];
|
||||
|
||||
// -- Marks ------------------------------------------------------------
|
||||
const marks: Mark[] = [];
|
||||
let undatedCount = 0;
|
||||
const radius = MARK_RADIUS[viewport.density];
|
||||
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i];
|
||||
const laneRow = (event.lane_id && laneIndex.get(event.lane_id)) || fallbackLane;
|
||||
|
||||
if (!event.date) {
|
||||
// Undated rows live in a gutter to the left of the chart canvas.
|
||||
// We pile them up vertically inside the lane label area so they
|
||||
// remain hover-/click-targets, but they don't compete with the
|
||||
// date-axis-positioned marks for screen space.
|
||||
undatedCount++;
|
||||
const undatedX = chartLeft - viewport.laneLabelWidth * 0.25;
|
||||
marks.push({
|
||||
eventIndex: i,
|
||||
x: undatedX,
|
||||
y: laneRow.y + laneRow.height / 2,
|
||||
radius,
|
||||
shape: markShape(event.kind, event.status),
|
||||
kind: event.kind,
|
||||
status: event.status,
|
||||
laneId: laneRow.id,
|
||||
undated: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const ms = parseISODay(event.date);
|
||||
if (ms === null) continue; // unparseable date, drop defensively
|
||||
if (ms < fromMs || ms > toMs) continue; // outside range — clipped
|
||||
|
||||
const x = chartLeft + dayDelta(fromMs, ms) * pxPerDay;
|
||||
const y = laneRow.y + laneRow.height / 2;
|
||||
marks.push({
|
||||
eventIndex: i,
|
||||
x,
|
||||
y,
|
||||
radius,
|
||||
shape: markShape(event.kind, event.status),
|
||||
kind: event.kind,
|
||||
status: event.status,
|
||||
laneId: laneRow.id,
|
||||
undated: false,
|
||||
});
|
||||
}
|
||||
|
||||
// -- Axis ticks -------------------------------------------------------
|
||||
const axisTicks = generateTicks(fromMs, toMs, chartLeft, pxPerDay);
|
||||
|
||||
return {
|
||||
viewport,
|
||||
pxPerDay,
|
||||
chartLeft,
|
||||
chartTop,
|
||||
chartWidth,
|
||||
chartHeight,
|
||||
axisTicks,
|
||||
laneRows,
|
||||
marks,
|
||||
todayX,
|
||||
undatedCount,
|
||||
};
|
||||
}
|
||||
|
||||
function synthLaneRows(
|
||||
lanes: ReadonlyArray<LaneInfo>,
|
||||
chartTop: number,
|
||||
laneHeight: number,
|
||||
): LaneRow[] {
|
||||
if (lanes.length === 0) {
|
||||
return [{ id: "self", label: "", y: chartTop, height: laneHeight }];
|
||||
}
|
||||
return lanes.map((lane, idx) => ({
|
||||
id: lane.id,
|
||||
label: lane.label,
|
||||
y: chartTop + idx * laneHeight,
|
||||
height: laneHeight,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: paint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
function svg(name: string, attrs: Record<string, string | number> = {}): SVGElement {
|
||||
const el = document.createElementNS(SVG_NS, name);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
el.setAttribute(k, String(v));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* paint mutates an existing SVGSVGElement to reflect a ChartLayout.
|
||||
* Idempotent: clears prior children before painting, so calling twice
|
||||
* with the same layout produces the same DOM.
|
||||
*
|
||||
* Events are *not* wired here — mount() attaches the delegated listeners
|
||||
* after paint() returns. paint() stays pure-render so it stays cheap to
|
||||
* call from a resize / palette swap.
|
||||
*/
|
||||
export function paint(
|
||||
chart: ChartLayout,
|
||||
root: SVGSVGElement,
|
||||
events: ReadonlyArray<TimelineEvent>,
|
||||
): void {
|
||||
// Clear prior contents.
|
||||
while (root.firstChild) root.removeChild(root.firstChild);
|
||||
|
||||
const totalHeight = chart.chartTop + chart.chartHeight + 24; // 24px bottom pad for axis labels
|
||||
root.setAttribute("viewBox", `0 0 ${chart.viewport.width} ${totalHeight}`);
|
||||
root.setAttribute("preserveAspectRatio", "xMinYMin meet");
|
||||
root.setAttribute("role", "img");
|
||||
root.setAttribute("aria-label", "Project Timeline / Chart");
|
||||
|
||||
// <defs> — hatched pattern for projected marks.
|
||||
const defs = svg("defs");
|
||||
const pattern = svg("pattern", {
|
||||
id: "chart-hatch",
|
||||
patternUnits: "userSpaceOnUse",
|
||||
width: 4,
|
||||
height: 4,
|
||||
});
|
||||
pattern.appendChild(svg("path", {
|
||||
d: "M0,4 L4,0",
|
||||
stroke: "currentColor",
|
||||
"stroke-width": 1,
|
||||
fill: "none",
|
||||
}));
|
||||
defs.appendChild(pattern);
|
||||
root.appendChild(defs);
|
||||
|
||||
// Layer order: grid → lane separators → today rule → marks → labels.
|
||||
const gGrid = svg("g", { class: "chart-grid" });
|
||||
root.appendChild(gGrid);
|
||||
|
||||
// Date axis ticks — vertical guidelines + labels at top.
|
||||
for (const tick of chart.axisTicks) {
|
||||
gGrid.appendChild(svg("line", {
|
||||
class: tick.isYearBoundary
|
||||
? "chart-tick chart-tick--year"
|
||||
: "chart-tick",
|
||||
x1: tick.x,
|
||||
y1: chart.chartTop,
|
||||
x2: tick.x,
|
||||
y2: chart.chartTop + chart.chartHeight,
|
||||
}));
|
||||
const label = svg("text", {
|
||||
class: "chart-tick-label",
|
||||
x: tick.x + 4,
|
||||
y: chart.chartTop - 8,
|
||||
});
|
||||
label.textContent = tick.label;
|
||||
gGrid.appendChild(label);
|
||||
}
|
||||
|
||||
// Lane separators — horizontal lines between rows + labels in the gutter.
|
||||
for (let i = 0; i < chart.laneRows.length; i++) {
|
||||
const row = chart.laneRows[i];
|
||||
if (i > 0) {
|
||||
gGrid.appendChild(svg("line", {
|
||||
class: "chart-lane-separator",
|
||||
x1: 0,
|
||||
y1: row.y,
|
||||
x2: chart.viewport.width,
|
||||
y2: row.y,
|
||||
}));
|
||||
}
|
||||
if (row.label) {
|
||||
const labelEl = svg("text", {
|
||||
class: "chart-lane-label",
|
||||
x: 8,
|
||||
y: row.y + row.height / 2 + 4,
|
||||
});
|
||||
labelEl.textContent = row.label;
|
||||
gGrid.appendChild(labelEl);
|
||||
}
|
||||
}
|
||||
|
||||
// Today rule — vertical lime line + "Heute" label.
|
||||
if (chart.todayX !== null) {
|
||||
gGrid.appendChild(svg("line", {
|
||||
class: "chart-today-rule",
|
||||
x1: chart.todayX,
|
||||
y1: chart.chartTop - 4,
|
||||
x2: chart.todayX,
|
||||
y2: chart.chartTop + chart.chartHeight + 4,
|
||||
}));
|
||||
const todayLabel = svg("text", {
|
||||
class: "chart-today-label",
|
||||
x: chart.todayX + 4,
|
||||
y: chart.chartTop + chart.chartHeight + 18,
|
||||
});
|
||||
todayLabel.textContent = "Heute";
|
||||
gGrid.appendChild(todayLabel);
|
||||
}
|
||||
|
||||
// Marks.
|
||||
const gMarks = svg("g", { class: "chart-marks" });
|
||||
root.appendChild(gMarks);
|
||||
|
||||
for (const mark of chart.marks) {
|
||||
const event = events[mark.eventIndex];
|
||||
const markEl = paintMark(mark, event);
|
||||
gMarks.appendChild(markEl);
|
||||
}
|
||||
}
|
||||
|
||||
function paintMark(mark: Mark, event: TimelineEvent): SVGElement {
|
||||
// Wrap every mark in a <g> with data-* attributes so mount() can do
|
||||
// event-delegation off the top-level <svg> without per-mark listeners.
|
||||
const g = svg("g", {
|
||||
class: markClassName(mark),
|
||||
"data-event-index": mark.eventIndex,
|
||||
"data-kind": mark.kind,
|
||||
"data-status": mark.status,
|
||||
"data-lane": mark.laneId,
|
||||
"data-undated": mark.undated ? "1" : "0",
|
||||
"data-deadline-id": event.deadline_id || "",
|
||||
"data-appointment-id": event.appointment_id || "",
|
||||
"data-project-event-id": event.project_event_id || "",
|
||||
role: "img",
|
||||
tabindex: 0,
|
||||
});
|
||||
|
||||
// ARIA label so screen-readers can read each mark (§13).
|
||||
const title = svg("title");
|
||||
title.textContent = markAriaLabel(mark, event);
|
||||
g.appendChild(title);
|
||||
|
||||
// Generous invisible hit-target so dots are easy to click without
|
||||
// hunting (12px hit halo around a 7px standard radius).
|
||||
g.appendChild(svg("circle", {
|
||||
class: "chart-mark-hit",
|
||||
cx: mark.x,
|
||||
cy: mark.y,
|
||||
r: mark.radius + 6,
|
||||
fill: "transparent",
|
||||
}));
|
||||
|
||||
switch (mark.shape) {
|
||||
case "dot": {
|
||||
const c = svg("circle", {
|
||||
class: "chart-mark-dot",
|
||||
cx: mark.x,
|
||||
cy: mark.y,
|
||||
r: mark.radius,
|
||||
});
|
||||
g.appendChild(c);
|
||||
break;
|
||||
}
|
||||
case "diamond": {
|
||||
const r = mark.radius;
|
||||
g.appendChild(svg("polygon", {
|
||||
class: "chart-mark-diamond",
|
||||
points: `${mark.x},${mark.y - r} ${mark.x + r},${mark.y} ${mark.x},${mark.y + r} ${mark.x - r},${mark.y}`,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "hatched-dot": {
|
||||
g.appendChild(svg("circle", {
|
||||
class: "chart-mark-hatched",
|
||||
cx: mark.x,
|
||||
cy: mark.y,
|
||||
r: mark.radius,
|
||||
fill: "url(#chart-hatch)",
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "dashed-dot": {
|
||||
g.appendChild(svg("circle", {
|
||||
class: "chart-mark-dashed",
|
||||
cx: mark.x,
|
||||
cy: mark.y,
|
||||
r: mark.radius,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function markClassName(mark: Mark): string {
|
||||
const parts = ["chart-mark", `chart-mark--${mark.kind}`, `chart-mark--status-${mark.status}`];
|
||||
if (mark.undated) parts.push("chart-mark--undated");
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function markAriaLabel(mark: Mark, event: TimelineEvent): string {
|
||||
const dateStr = event.date ? event.date.slice(0, 10) : "Datum offen";
|
||||
return `${event.title} — ${event.kind} (${event.status}) — ${dateStr}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: mount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Palette presets from design §5.1. Each is a CSS-var override hung off
|
||||
* `.smart-timeline-chart[data-palette="<name>"]`; the renderer never
|
||||
* reads palette state directly. */
|
||||
export type Palette =
|
||||
| "default"
|
||||
| "kind-coded"
|
||||
| "track-coded"
|
||||
| "high-contrast"
|
||||
| "print";
|
||||
|
||||
export const ALL_PALETTES: ReadonlyArray<Palette> = [
|
||||
"default",
|
||||
"kind-coded",
|
||||
"track-coded",
|
||||
"high-contrast",
|
||||
"print",
|
||||
];
|
||||
|
||||
export const ALL_DENSITIES: ReadonlyArray<Density> = [
|
||||
"compact",
|
||||
"standard",
|
||||
"spacious",
|
||||
];
|
||||
|
||||
/** Range presets from design §10 + faraday-Q8 default. The chart caller
|
||||
* drives the active preset via setRange; "all" derives bounds from the
|
||||
* loaded events at repaint time so adding / completing a row reflows. */
|
||||
export type RangePreset = "1y" | "2y" | "all" | "custom";
|
||||
|
||||
export const ALL_RANGE_PRESETS: ReadonlyArray<RangePreset> = [
|
||||
"1y",
|
||||
"2y",
|
||||
"all",
|
||||
"custom",
|
||||
];
|
||||
|
||||
export interface ChartMountOpts {
|
||||
projectId: string;
|
||||
todayISO?: string;
|
||||
density?: Density;
|
||||
palette?: Palette;
|
||||
/** Initial range preset. Default "1y" (today-1y..today+1y) per design Q8. */
|
||||
rangePreset?: RangePreset;
|
||||
/** When rangePreset === "custom", these supply the bounds. Ignored for
|
||||
* preset values — those derive bounds from the preset + todayISO (or,
|
||||
* for "all", from the loaded events). */
|
||||
rangeFrom?: string;
|
||||
rangeTo?: string;
|
||||
/** Optional callback fired when the user clicks a mark with a known
|
||||
* deep-link target. Receives the underlying TimelineEvent. */
|
||||
onMarkClick?: (event: TimelineEvent) => void;
|
||||
/** Optional callback fired after every refresh() so the host can
|
||||
* re-render dynamic UI (e.g. lane filter chips). */
|
||||
onDataLoaded?: (data: { events: TimelineEvent[]; lanes: LaneInfo[] }) => void;
|
||||
/** Initial visible-lane allowlist. null = show all (default).
|
||||
* Lane ids not present in the response are silently dropped. */
|
||||
visibleLanes?: string[] | null;
|
||||
/** Pre-loaded data — used by Custom Views (Slice 4) where the rows
|
||||
* come from ViewService not /api/projects/{id}/timeline. When set,
|
||||
* mount() skips the initial fetch and paints from this data; the
|
||||
* handle's refresh() still hits the project endpoint (caller can
|
||||
* swap the chart back to project-mode via the standalone /chart URL). */
|
||||
staticData?: { events: TimelineEvent[]; lanes: LaneInfo[] };
|
||||
}
|
||||
|
||||
export interface ChartHandle {
|
||||
/** Re-fetches the timeline and re-paints. */
|
||||
refresh: () => Promise<void>;
|
||||
/** Removes event listeners + tears down the SVG. */
|
||||
dispose: () => void;
|
||||
/** Returns the last computed layout (useful for tests / debugging). */
|
||||
getLayout: () => ChartLayout | null;
|
||||
/** Swap palette via data-palette attribute. Pure CSS-var swap — no repaint. */
|
||||
setPalette: (palette: Palette) => void;
|
||||
/** Swap density. Re-runs layout() since lane height / mark radius change. */
|
||||
setDensity: (density: Density) => void;
|
||||
/** Switch range preset. "all" derives bounds from the loaded events;
|
||||
* "custom" expects customFrom + customTo (otherwise it falls back to
|
||||
* today-1y..today+1y). All others are time-shifted from todayISO. */
|
||||
setRange: (preset: RangePreset, customFrom?: string, customTo?: string) => void;
|
||||
/** Set the lane allowlist. null = show all lanes (default). Unknown
|
||||
* ids in the passed array are silently dropped on repaint. */
|
||||
setVisibleLanes: (lanes: string[] | null) => void;
|
||||
/** The raw SVG node — chart-export.ts reads this for SVG / PNG / print. */
|
||||
getSVGElement: () => SVGSVGElement;
|
||||
/** Last-loaded data — chart-export.ts reads this for CSV / JSON / iCal. */
|
||||
getData: () => { events: TimelineEvent[]; lanes: LaneInfo[] };
|
||||
}
|
||||
|
||||
interface TimelineEnvelope {
|
||||
events: TimelineEvent[];
|
||||
lanes: LaneInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* mount builds a chart inside the given host element. The host's
|
||||
* dimensions drive the SVG width; height grows from the lane row count.
|
||||
* Returns a handle for refresh / dispose.
|
||||
*/
|
||||
export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
|
||||
host.classList.add("smart-timeline-chart-host");
|
||||
|
||||
// Empty / error placeholders.
|
||||
const messageEl = document.createElement("div");
|
||||
messageEl.className = "smart-timeline-chart-message";
|
||||
messageEl.textContent = "";
|
||||
host.appendChild(messageEl);
|
||||
|
||||
// The SVG root we paint into.
|
||||
const svgEl = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
|
||||
svgEl.classList.add("smart-timeline-chart");
|
||||
svgEl.setAttribute("data-palette", opts.palette ?? "default");
|
||||
svgEl.setAttribute("data-density", opts.density ?? "standard");
|
||||
host.appendChild(svgEl);
|
||||
|
||||
let lastEvents: TimelineEvent[] = [];
|
||||
let lastLayout: ChartLayout | null = null;
|
||||
|
||||
const todayISO = opts.todayISO ?? today();
|
||||
let currentDensity: Density = opts.density ?? "standard";
|
||||
let currentRangePreset: RangePreset = opts.rangePreset ?? "1y";
|
||||
let customRangeFrom: string = opts.rangeFrom ?? shiftYears(todayISO, -1);
|
||||
let customRangeTo: string = opts.rangeTo ?? shiftYears(todayISO, 1);
|
||||
let currentVisibleLanes: Set<string> | null = opts.visibleLanes
|
||||
? new Set(opts.visibleLanes)
|
||||
: null;
|
||||
|
||||
function resolveRange(): { from: string; to: string } {
|
||||
switch (currentRangePreset) {
|
||||
case "1y":
|
||||
return { from: shiftYears(todayISO, -1), to: shiftYears(todayISO, 1) };
|
||||
case "2y":
|
||||
return { from: shiftYears(todayISO, -2), to: shiftYears(todayISO, 2) };
|
||||
case "all":
|
||||
return rangeFromEvents(lastEvents, todayISO);
|
||||
case "custom":
|
||||
return { from: customRangeFrom, to: customRangeTo };
|
||||
}
|
||||
}
|
||||
|
||||
function repaint(): void {
|
||||
const rect = host.getBoundingClientRect();
|
||||
// Minimum width keeps the canvas usable when the host is hidden /
|
||||
// about to be sized; resize listener will repaint on real layout.
|
||||
const width = Math.max(640, rect.width || 1000);
|
||||
const { from, to } = resolveRange();
|
||||
const viewport: ChartViewport = {
|
||||
width,
|
||||
height: 400,
|
||||
laneLabelWidth: 200,
|
||||
dateAxisHeight: 40,
|
||||
todayISO,
|
||||
rangeFrom: from,
|
||||
rangeTo: to,
|
||||
density: currentDensity,
|
||||
};
|
||||
// Lane allowlist filter. null = show all; otherwise drop both the
|
||||
// lane rows AND the events whose lane_id sits outside the allowlist.
|
||||
// (We don't fall back to "first lane" here — that's only sensible
|
||||
// when a stale id slips through; an explicit hide is a hide.)
|
||||
let renderLanes = [...currentLanes];
|
||||
let renderEvents: TimelineEvent[] = lastEvents;
|
||||
if (currentVisibleLanes !== null) {
|
||||
const allow = currentVisibleLanes;
|
||||
renderLanes = currentLanes.filter((l) => allow.has(l.id));
|
||||
renderEvents = lastEvents.filter((e) => {
|
||||
// Empty / missing lane_id is treated as "self" — included only
|
||||
// when the synthetic "self" lane is allowed.
|
||||
const id = e.lane_id || "self";
|
||||
return allow.has(id);
|
||||
});
|
||||
}
|
||||
const chart = layout(renderEvents, renderLanes, viewport);
|
||||
lastLayout = chart;
|
||||
paint(chart, svgEl, renderEvents);
|
||||
svgEl.setAttribute("width", String(width));
|
||||
svgEl.setAttribute("height", String(chart.chartTop + chart.chartHeight + 32));
|
||||
}
|
||||
|
||||
let currentLanes: LaneInfo[] = [];
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
messageEl.textContent = "Lädt …";
|
||||
messageEl.classList.remove("smart-timeline-chart-message--error");
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${encodeURIComponent(opts.projectId)}/timeline`,
|
||||
);
|
||||
if (!resp.ok) {
|
||||
messageEl.textContent = "Timeline konnte nicht geladen werden.";
|
||||
messageEl.classList.add("smart-timeline-chart-message--error");
|
||||
return;
|
||||
}
|
||||
const body = await resp.json();
|
||||
// Defensive: tolerate the legacy []TimelineEvent shape (pre-Slice-4)
|
||||
// even though the Slice-4 envelope is the contract today.
|
||||
if (Array.isArray(body)) {
|
||||
lastEvents = body as TimelineEvent[];
|
||||
currentLanes = [];
|
||||
} else {
|
||||
const env = body as TimelineEnvelope;
|
||||
lastEvents = env.events ?? [];
|
||||
currentLanes = env.lanes ?? [];
|
||||
}
|
||||
if (lastEvents.length === 0) {
|
||||
messageEl.textContent = "Keine Ereignisse im gewählten Zeitraum.";
|
||||
} else {
|
||||
messageEl.textContent = "";
|
||||
}
|
||||
// Drop stale lane ids from the allowlist — a deleted CCR / child
|
||||
// case shouldn't keep its lane id alive across re-fetches.
|
||||
if (currentVisibleLanes !== null) {
|
||||
const valid = new Set(currentLanes.map((l) => l.id));
|
||||
valid.add("self"); // synthetic lane always allowed
|
||||
const trimmed = new Set<string>();
|
||||
for (const id of currentVisibleLanes) {
|
||||
if (valid.has(id)) trimmed.add(id);
|
||||
}
|
||||
currentVisibleLanes = trimmed.size === 0 ? null : trimmed;
|
||||
}
|
||||
repaint();
|
||||
if (opts.onDataLoaded) {
|
||||
opts.onDataLoaded({ events: lastEvents, lanes: currentLanes });
|
||||
}
|
||||
} catch (err) {
|
||||
messageEl.textContent = "Netzwerkfehler beim Laden der Timeline.";
|
||||
messageEl.classList.add("smart-timeline-chart-message--error");
|
||||
}
|
||||
}
|
||||
|
||||
// Click delegation — read data-* attrs to deep-link.
|
||||
function handleClick(e: Event) {
|
||||
const target = e.target as Element | null;
|
||||
if (!target) return;
|
||||
const g = target.closest("g.chart-mark") as Element | null;
|
||||
if (!g) return;
|
||||
const indexAttr = g.getAttribute("data-event-index");
|
||||
if (!indexAttr) return;
|
||||
const idx = Number(indexAttr);
|
||||
const event = lastEvents[idx];
|
||||
if (!event) return;
|
||||
if (opts.onMarkClick) {
|
||||
opts.onMarkClick(event);
|
||||
return;
|
||||
}
|
||||
if (event.deadline_id) {
|
||||
window.location.href = `/deadlines/${encodeURIComponent(event.deadline_id)}`;
|
||||
} else if (event.appointment_id) {
|
||||
window.location.href = `/appointments/${encodeURIComponent(event.appointment_id)}`;
|
||||
}
|
||||
// Milestones + projected rows have no detail page today — no-op.
|
||||
}
|
||||
|
||||
// Resize handler — debounced.
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function handleResize() {
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
repaint();
|
||||
}, 120);
|
||||
}
|
||||
|
||||
svgEl.addEventListener("click", handleClick);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// If the caller supplied data up front (Custom Views host path), skip
|
||||
// the project-timeline fetch entirely — paint from the supplied rows.
|
||||
// Otherwise kick off the initial /api/projects/{id}/timeline load.
|
||||
if (opts.staticData) {
|
||||
lastEvents = opts.staticData.events;
|
||||
currentLanes = opts.staticData.lanes;
|
||||
if (lastEvents.length === 0) {
|
||||
messageEl.textContent = "Keine Ereignisse im gewählten Zeitraum.";
|
||||
} else {
|
||||
messageEl.textContent = "";
|
||||
}
|
||||
repaint();
|
||||
if (opts.onDataLoaded) {
|
||||
opts.onDataLoaded({ events: lastEvents, lanes: currentLanes });
|
||||
}
|
||||
} else {
|
||||
void refresh();
|
||||
}
|
||||
|
||||
return {
|
||||
refresh,
|
||||
getLayout: () => lastLayout,
|
||||
setPalette: (palette: Palette) => {
|
||||
svgEl.setAttribute("data-palette", palette);
|
||||
},
|
||||
setDensity: (density: Density) => {
|
||||
currentDensity = density;
|
||||
svgEl.setAttribute("data-density", density);
|
||||
repaint();
|
||||
},
|
||||
setRange: (preset: RangePreset, customFrom?: string, customTo?: string) => {
|
||||
currentRangePreset = preset;
|
||||
if (preset === "custom") {
|
||||
if (customFrom) customRangeFrom = customFrom;
|
||||
if (customTo) customRangeTo = customTo;
|
||||
}
|
||||
svgEl.setAttribute("data-range-preset", preset);
|
||||
repaint();
|
||||
},
|
||||
setVisibleLanes: (lanes: string[] | null) => {
|
||||
currentVisibleLanes = lanes ? new Set(lanes) : null;
|
||||
repaint();
|
||||
},
|
||||
getSVGElement: () => svgEl,
|
||||
getData: () => ({ events: lastEvents, lanes: currentLanes }),
|
||||
dispose: () => {
|
||||
svgEl.removeEventListener("click", handleClick);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
if (svgEl.parentNode) svgEl.parentNode.removeChild(svgEl);
|
||||
if (messageEl.parentNode) messageEl.parentNode.removeChild(messageEl);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve the "all" preset bounds from the loaded events. Empty data
|
||||
* falls back to the 1y default so the chart canvas isn't degenerate. */
|
||||
function rangeFromEvents(
|
||||
events: ReadonlyArray<TimelineEvent>,
|
||||
todayISO: string,
|
||||
): { from: string; to: string } {
|
||||
let minMs: number | null = null;
|
||||
let maxMs: number | null = null;
|
||||
for (const ev of events) {
|
||||
if (!ev.date) continue;
|
||||
const ms = parseISODay(ev.date);
|
||||
if (ms === null) continue;
|
||||
if (minMs === null || ms < minMs) minMs = ms;
|
||||
if (maxMs === null || ms > maxMs) maxMs = ms;
|
||||
}
|
||||
if (minMs === null || maxMs === null) {
|
||||
return { from: shiftYears(todayISO, -1), to: shiftYears(todayISO, 1) };
|
||||
}
|
||||
// Pad +30d at the right so the last event isn't flush against the edge.
|
||||
const fromDate = new Date(minMs);
|
||||
const toDate = new Date(maxMs + 30 * 86_400_000);
|
||||
return {
|
||||
from: toISO(fromDate),
|
||||
to: toISO(toDate),
|
||||
};
|
||||
}
|
||||
|
||||
function toISO(d: Date): string {
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function today(): string {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${dd}`;
|
||||
}
|
||||
|
||||
function shiftYears(iso: string, delta: number): string {
|
||||
const ms = parseISODay(iso);
|
||||
if (ms === null) return iso;
|
||||
const d = new Date(ms);
|
||||
return `${d.getUTCFullYear() + delta}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
}
|
||||
140
frontend/src/client/views/shape-timeline-cv.test.ts
Normal file
140
frontend/src/client/views/shape-timeline-cv.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { adapt } from "./shape-timeline-cv";
|
||||
import type { ViewRow } from "./types";
|
||||
|
||||
// t-paliad-177 Slice 4 — adapter contract tests for ViewRow →
|
||||
// TimelineEvent + LaneInfo. Pure function, no DOM access.
|
||||
// The actual chart-render math is pinned by shape-timeline-chart.test.ts;
|
||||
// this file pins the adapter's lossy translation rules from §13.4.
|
||||
|
||||
const baseRow = (overrides: Partial<ViewRow> = {}): ViewRow => ({
|
||||
kind: "deadline",
|
||||
id: "d1",
|
||||
title: "Test",
|
||||
event_date: "2026-06-15",
|
||||
detail: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("adapt — kind mapping", () => {
|
||||
test("deadline → kind='deadline' + deadline_id", () => {
|
||||
const out = adapt([baseRow({ kind: "deadline", id: "abc" })]);
|
||||
expect(out.events).toHaveLength(1);
|
||||
expect(out.events[0].kind).toBe("deadline");
|
||||
expect(out.events[0].deadline_id).toBe("abc");
|
||||
expect(out.events[0].appointment_id).toBeUndefined();
|
||||
expect(out.events[0].project_event_id).toBeUndefined();
|
||||
});
|
||||
|
||||
test("appointment → kind='appointment' + appointment_id", () => {
|
||||
const out = adapt([baseRow({ kind: "appointment", id: "x" })]);
|
||||
expect(out.events[0].kind).toBe("appointment");
|
||||
expect(out.events[0].appointment_id).toBe("x");
|
||||
});
|
||||
|
||||
test("project_event → kind='milestone' + project_event_id", () => {
|
||||
const out = adapt([baseRow({ kind: "project_event", id: "y" })]);
|
||||
expect(out.events[0].kind).toBe("milestone");
|
||||
expect(out.events[0].project_event_id).toBe("y");
|
||||
});
|
||||
|
||||
test("approval_request is skipped", () => {
|
||||
const out = adapt([
|
||||
baseRow({ kind: "deadline" }),
|
||||
baseRow({ kind: "approval_request" }),
|
||||
baseRow({ kind: "appointment" }),
|
||||
]);
|
||||
expect(out.events).toHaveLength(2);
|
||||
expect(out.events.map((e) => e.kind)).toEqual(["deadline", "appointment"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adapt — lane bucketing by project_id (cross-project chart)", () => {
|
||||
test("one lane per unique project_id, first-seen order", () => {
|
||||
const out = adapt([
|
||||
baseRow({ project_id: "p1", project_title: "Project 1" }),
|
||||
baseRow({ project_id: "p2", project_title: "Project 2" }),
|
||||
baseRow({ project_id: "p1", project_title: "Project 1" }),
|
||||
]);
|
||||
expect(out.lanes).toHaveLength(2);
|
||||
expect(out.lanes[0].id).toBe("p1");
|
||||
expect(out.lanes[0].label).toBe("Project 1");
|
||||
expect(out.lanes[1].id).toBe("p2");
|
||||
});
|
||||
|
||||
test("project_title preferred over project_reference for the label", () => {
|
||||
const out = adapt([
|
||||
baseRow({ project_id: "p1", project_title: "Nice Name", project_reference: "REF-1" }),
|
||||
]);
|
||||
expect(out.lanes[0].label).toBe("Nice Name");
|
||||
});
|
||||
|
||||
test("falls back to project_reference when title missing", () => {
|
||||
const out = adapt([
|
||||
baseRow({ project_id: "p1", project_reference: "REF-1" }),
|
||||
]);
|
||||
expect(out.lanes[0].label).toBe("REF-1");
|
||||
});
|
||||
|
||||
test("missing project_id collapses to synthetic 'self' lane", () => {
|
||||
const out = adapt([baseRow({ project_id: undefined })]);
|
||||
expect(out.lanes).toHaveLength(1);
|
||||
expect(out.lanes[0].id).toBe("self");
|
||||
expect(out.events[0].lane_id).toBe("self");
|
||||
expect(out.events[0].track).toBe("parent");
|
||||
});
|
||||
|
||||
test("event lane_id matches its lane row id", () => {
|
||||
const out = adapt([
|
||||
baseRow({ project_id: "p1", project_title: "A" }),
|
||||
baseRow({ project_id: "p2", project_title: "B" }),
|
||||
]);
|
||||
expect(out.events[0].lane_id).toBe("p1");
|
||||
expect(out.events[1].lane_id).toBe("p2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adapt — status extraction", () => {
|
||||
test("deadline status 'done' comes through from detail", () => {
|
||||
const out = adapt([
|
||||
baseRow({ kind: "deadline", detail: { status: "done" } }),
|
||||
]);
|
||||
expect(out.events[0].status).toBe("done");
|
||||
});
|
||||
|
||||
test("deadline status 'overdue' comes through", () => {
|
||||
const out = adapt([
|
||||
baseRow({ kind: "deadline", detail: { status: "overdue" } }),
|
||||
]);
|
||||
expect(out.events[0].status).toBe("overdue");
|
||||
});
|
||||
|
||||
test("unknown / missing detail.status defaults to 'open'", () => {
|
||||
const out = adapt([
|
||||
baseRow({ kind: "deadline", detail: { status: "weird-value" } }),
|
||||
baseRow({ kind: "appointment" }),
|
||||
baseRow({ kind: "project_event" }),
|
||||
]);
|
||||
expect(out.events.map((e) => e.status)).toEqual(["open", "open", "open"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adapt — date passthrough", () => {
|
||||
test("event_date is forwarded to TimelineEvent.date", () => {
|
||||
const out = adapt([baseRow({ event_date: "2026-08-15T00:00:00Z" })]);
|
||||
expect(out.events[0].date).toBe("2026-08-15T00:00:00Z");
|
||||
});
|
||||
|
||||
test("empty event_date becomes null (undated)", () => {
|
||||
const out = adapt([baseRow({ event_date: "" })]);
|
||||
expect(out.events[0].date).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("adapt — empty input", () => {
|
||||
test("empty rows array returns empty events + empty lanes", () => {
|
||||
const out = adapt([]);
|
||||
expect(out.events).toHaveLength(0);
|
||||
expect(out.lanes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
141
frontend/src/client/views/shape-timeline-cv.ts
Normal file
141
frontend/src/client/views/shape-timeline-cv.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
mount,
|
||||
type ChartHandle,
|
||||
type Density,
|
||||
type Palette,
|
||||
type RangePreset,
|
||||
} from "./shape-timeline-chart";
|
||||
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
|
||||
// shape-timeline-cv (t-paliad-177 Slice 4, faraday-Q7) — Custom Views
|
||||
// host for the chart renderer.
|
||||
//
|
||||
// Adapter contract: ViewRow → TimelineEvent + LaneInfo.
|
||||
// - deadline + appointment + project_event rows render as actual marks.
|
||||
// - approval_request rows are skipped (no chart-meaningful date).
|
||||
// - Lane axis = project_id; the cross-project chart use case (design
|
||||
// §10) groups events by their owning project. Rows without a
|
||||
// project_id collapse into a synthetic "self" lane.
|
||||
// - NO projected rows. ViewService doesn't run the fristenrechner
|
||||
// calculator, so the CV chart shows actuals only. The host page
|
||||
// ships a one-time caveat tooltip (see C3) explaining this.
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5 + §13.4.
|
||||
|
||||
export function renderTimelineShape(
|
||||
host: HTMLElement,
|
||||
rows: ReadonlyArray<ViewRow>,
|
||||
render: RenderSpec,
|
||||
): ChartHandle {
|
||||
// Tear down any previous mount so re-rendering the shape (e.g. shape
|
||||
// chip switch on /views/{slug}) doesn't stack SVGs.
|
||||
host.innerHTML = "";
|
||||
|
||||
const { events, lanes } = adapt(rows);
|
||||
const cfg = render.timeline ?? {};
|
||||
|
||||
// The CV adapter has no per-project "id" to fetch live timeline data
|
||||
// for — we hand mount() a placeholder projectId and the staticData
|
||||
// pre-loaded array so it skips the project endpoint entirely. If the
|
||||
// user clicks a mark, the renderer's default click handler still
|
||||
// resolves /deadlines/{id} / /appointments/{id} from the adapted
|
||||
// event's id field, so deep-links land on the correct entity page.
|
||||
return mount(host, {
|
||||
projectId: "cv",
|
||||
staticData: { events, lanes },
|
||||
palette: (cfg.palette as Palette | undefined) ?? "default",
|
||||
density: (cfg.density as Density | undefined) ?? "standard",
|
||||
rangePreset: (cfg.range_preset as RangePreset | undefined) ?? "1y",
|
||||
rangeFrom: cfg.range_from,
|
||||
rangeTo: cfg.range_to,
|
||||
});
|
||||
}
|
||||
|
||||
export interface AdapterResult {
|
||||
events: TimelineEvent[];
|
||||
lanes: LaneInfo[];
|
||||
}
|
||||
|
||||
/** Exported for tests (shape-timeline-cv.test.ts). Pure — no DOM. */
|
||||
export function adapt(rows: ReadonlyArray<ViewRow>): AdapterResult {
|
||||
const events: TimelineEvent[] = [];
|
||||
// Lane order = first-seen order of project_ids in rows, so the user
|
||||
// sees lanes in the order their data was returned (typically date-
|
||||
// sorted). Deterministic, no surprise re-ordering on re-renders.
|
||||
const laneIndex = new Map<string, LaneInfo>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.kind === "approval_request") {
|
||||
// Approval requests have no event_date in the chart sense; they
|
||||
// represent pending decisions, not scheduled work. Skip.
|
||||
continue;
|
||||
}
|
||||
const laneId = row.project_id || "self";
|
||||
if (!laneIndex.has(laneId)) {
|
||||
laneIndex.set(laneId, {
|
||||
id: laneId,
|
||||
label: row.project_title || row.project_reference || laneLabelFallback(laneId),
|
||||
project_id: row.project_id,
|
||||
});
|
||||
}
|
||||
|
||||
const event: TimelineEvent = {
|
||||
kind: toTimelineKind(row.kind),
|
||||
status: extractStatus(row),
|
||||
track: laneId === "self" ? "parent" : "child:" + laneId,
|
||||
date: row.event_date || null,
|
||||
title: row.title,
|
||||
description: row.subtitle,
|
||||
lane_id: laneId,
|
||||
};
|
||||
// Set the right provenance id so the renderer's click handler can
|
||||
// deep-link to /deadlines/{id} / /appointments/{id}.
|
||||
switch (row.kind) {
|
||||
case "deadline":
|
||||
event.deadline_id = row.id;
|
||||
break;
|
||||
case "appointment":
|
||||
event.appointment_id = row.id;
|
||||
break;
|
||||
case "project_event":
|
||||
event.project_event_id = row.id;
|
||||
break;
|
||||
}
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
return { events, lanes: [...laneIndex.values()] };
|
||||
}
|
||||
|
||||
function toTimelineKind(kind: ViewRow["kind"]): TimelineEvent["kind"] {
|
||||
// ViewRow "project_event" maps to chart "milestone" — they're the
|
||||
// same underlying paliad.project_events row, the chart just uses a
|
||||
// different name because milestones are the chart-meaningful subset.
|
||||
if (kind === "project_event") return "milestone";
|
||||
// Defensive: approval_request was filtered earlier, but TS doesn't
|
||||
// know that. Default to "milestone" for any unexpected kind.
|
||||
if (kind === "deadline" || kind === "appointment") return kind;
|
||||
return "milestone";
|
||||
}
|
||||
|
||||
/** Status defaults to "open" — ViewRow doesn't carry chart-status
|
||||
* semantics directly, and the underlying detail json shape varies per
|
||||
* kind. The chart's color saturation maps status → fill / ring style,
|
||||
* so "open" gives every mark a sensible default (filled, full color).
|
||||
* Detail-driven status lookup is a polish job for a future slice. */
|
||||
function extractStatus(row: ViewRow): TimelineEvent["status"] {
|
||||
if (row.kind === "deadline") {
|
||||
const d = row.detail as { status?: string };
|
||||
if (d.status === "done" || d.status === "overdue") {
|
||||
return d.status as TimelineEvent["status"];
|
||||
}
|
||||
}
|
||||
return "open";
|
||||
}
|
||||
|
||||
function laneLabelFallback(id: string): string {
|
||||
if (id === "self") return "(ohne Projekt)";
|
||||
// Truncated UUID is more useful than a bare 36-char string.
|
||||
return id.slice(0, 8);
|
||||
}
|
||||
966
frontend/src/client/views/shape-timeline.ts
Normal file
966
frontend/src/client/views/shape-timeline.ts
Normal file
@@ -0,0 +1,966 @@
|
||||
import { t, getLang } from "../i18n";
|
||||
|
||||
// shape-timeline (t-paliad-171 → t-paliad-175) — vertical timeline render
|
||||
// for the SmartTimeline. Two-column layout (date / event card), "Heute →"
|
||||
// rule separating past from future, status icon + kind chip per row.
|
||||
//
|
||||
// Slice 2 (t-paliad-173) adds:
|
||||
// - Kind="projected" rows in three flavours via Status:
|
||||
// "predicted" — fade-grey (future)
|
||||
// "court_set" — dashed border (court-determined)
|
||||
// "predicted_overdue" — amber-faded (past, no anchor yet)
|
||||
// - "[Datum setzen]" inline date editor → POST /timeline/anchor.
|
||||
// 200 → re-fetch + re-render. 409 → render the predecessor_missing
|
||||
// payload as inline error with a "Stattdessen <predecessor> erfassen"
|
||||
// link that pre-fills the editor for the parent rule.
|
||||
// - "Folgt aus: <Name> (<Date|„Datum offen“>)" footer on every row
|
||||
// with depends_on_rule_code, plus a "[Pfad anzeigen]" expander that
|
||||
// walks the parent chain back to the trigger.
|
||||
// - "[+ Mehr anzeigen]" / "[− Weniger]" lookahead toggle after the 7th
|
||||
// projected row, cap remembered in localStorage per project.
|
||||
//
|
||||
// Slice 4 (t-paliad-175) adds parent-node lane aggregation:
|
||||
// - When `lanes.length > 1` (Patent / Litigation / Client view), render
|
||||
// a horizontal lane-strip with one column per lane. Time axis stays
|
||||
// vertical within each lane; the lane sub-header names the child
|
||||
// project. CSS Grid handles the desktop side-by-side and collapses
|
||||
// to single-column on mobile (≤640px).
|
||||
// - Lane filter chip (multiselect) sits in the timeline header above
|
||||
// the strip; selecting a subset dims the others.
|
||||
// - Single-column flow stays the default at Case level (lanes mirror
|
||||
// tracks one-for-one).
|
||||
//
|
||||
// Wire shape: renderSmartTimeline(host, rows, opts). The TimelineEvent
|
||||
// shape is the wire contract from /api/projects/{id}/timeline.events;
|
||||
// LaneInfo[] from .lanes drives the lane-grouped layout.
|
||||
//
|
||||
// Design ref: docs/design-smart-timeline-2026-05-08.md §3 + §5 + §6 +
|
||||
// m/paliad#31 layered requirements.
|
||||
|
||||
export interface TimelineEvent {
|
||||
kind: "deadline" | "appointment" | "milestone" | "projected";
|
||||
status:
|
||||
| "done"
|
||||
| "open"
|
||||
| "overdue"
|
||||
| "court_set"
|
||||
| "predicted"
|
||||
| "predicted_overdue"
|
||||
| "off_script";
|
||||
track: string;
|
||||
date?: string | null;
|
||||
title: string;
|
||||
description?: string;
|
||||
rule_code?: string;
|
||||
|
||||
deadline_id?: string;
|
||||
appointment_id?: string;
|
||||
project_event_id?: string;
|
||||
|
||||
deadline_rule_id?: string;
|
||||
deadline_rule_party?: string;
|
||||
|
||||
sub_project_id?: string;
|
||||
sub_project_title?: string;
|
||||
|
||||
depends_on_rule_code?: string;
|
||||
depends_on_date?: string | null;
|
||||
depends_on_rule_name?: string;
|
||||
|
||||
// Slice 4 — parent-node aggregation (t-paliad-175). lane_id buckets
|
||||
// the row into one of the columns described by RenderOptions.lanes.
|
||||
// Empty / missing is treated as "self" (the legacy single-lane case).
|
||||
lane_id?: string;
|
||||
bubble_up?: boolean;
|
||||
|
||||
// t-paliad-176 — underlying paliad.project_events.event_type for
|
||||
// milestone rows. Empty for deadline / appointment / projected rows.
|
||||
// Powers the FilterBar's project_event_kind chip on the Verlauf tab
|
||||
// (matched against KnownProjectEventKinds in filter_spec.go).
|
||||
project_event_type?: string;
|
||||
}
|
||||
|
||||
export interface LaneInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
project_id?: string;
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
export interface PredecessorMissingPayload {
|
||||
error: "predecessor_missing";
|
||||
missing_rule_code: string;
|
||||
missing_rule_name_de: string;
|
||||
missing_rule_name_en: string;
|
||||
requested_rule_code: string;
|
||||
requested_rule_name_de: string;
|
||||
requested_rule_name_en: string;
|
||||
message_de: string;
|
||||
message_en: string;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
// Today's date as ISO YYYY-MM-DD; defaults to "now in browser TZ".
|
||||
today?: string;
|
||||
// The project the timeline belongs to. Required for anchor / skip
|
||||
// POSTs. When undefined, projected rows don't expose "Datum setzen".
|
||||
projectId?: string;
|
||||
// Language hint — falls back to getLang() when omitted.
|
||||
lang?: "de" | "en";
|
||||
// Called after a successful anchor write so the host can re-fetch
|
||||
// and re-render. Skipped when omitted.
|
||||
onChange?: () => void | Promise<void>;
|
||||
// Lookahead state for projected rows. Default 7 = backend default.
|
||||
lookahead?: number;
|
||||
// Total number of future predicted rows the backend knows about
|
||||
// (read from X-Projection-Total). When > visible projected count,
|
||||
// "Mehr anzeigen" is shown.
|
||||
projectedTotal?: number;
|
||||
// Called when the user toggles "Mehr / Weniger anzeigen". The host
|
||||
// updates state + re-fetches with the new ?lookahead=N.
|
||||
onLookaheadChange?: (next: number) => void | Promise<void>;
|
||||
|
||||
// Slice 3 — counterclaim parallel tracks. availableTracks lists every
|
||||
// track tag present in the response (parsed from X-Projection-Tracks).
|
||||
// When the list contains a non-"parent" entry, the [Track ▼] chip
|
||||
// surfaces. selectedTrack is the user's filter ("all" = render every
|
||||
// available track in parallel; otherwise render only the named tag).
|
||||
availableTracks?: string[];
|
||||
selectedTrack?: string;
|
||||
onTrackChange?: (next: string) => void | Promise<void>;
|
||||
|
||||
// Slice 4 — parent-node lane aggregation. When lanes.length > 1,
|
||||
// renderSmartTimeline renders a lane-strip layout (one column per
|
||||
// lane) instead of the single-column flow. selectedLanes is the
|
||||
// user's lane-filter chip; defaults to all lanes selected. Empty
|
||||
// array = nothing rendered (defensible for the user explicitly
|
||||
// unchecking every lane).
|
||||
lanes?: LaneInfo[];
|
||||
selectedLanes?: string[]; // ids; undefined = all lanes selected
|
||||
onLaneFilterChange?: (next: string[]) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function renderSmartTimeline(
|
||||
host: HTMLElement,
|
||||
rows: TimelineEvent[],
|
||||
opts: RenderOptions = {},
|
||||
): void {
|
||||
host.innerHTML = "";
|
||||
host.classList.add("smart-timeline");
|
||||
|
||||
// Slice 4 — lane-grouped rendering (t-paliad-175 §5). When the
|
||||
// backend reports more than one lane, every event already carries a
|
||||
// lane_id and the layout switches from single-column to lane strip.
|
||||
// Lane mode takes precedence over Track-mode (the two are different
|
||||
// axes — lanes group by *direct child project*, tracks group by
|
||||
// CCR-vs-parent on a single Case).
|
||||
const lanes = opts.lanes ?? [];
|
||||
const isLaneMode = lanes.length > 1;
|
||||
if (isLaneMode) {
|
||||
host.appendChild(renderLaneStrip(rows, lanes, opts));
|
||||
return;
|
||||
}
|
||||
|
||||
// Slice 3 — track filtering. The bar header carries the [Track ▼]
|
||||
// chip whenever the response advertised more than the default
|
||||
// "parent" track; the filter is applied here before any flow render.
|
||||
const availableTracks = (opts.availableTracks ?? []).filter((t) => !!t);
|
||||
const hasMultipleTracks = availableTracks.length > 1;
|
||||
const selectedTrack = opts.selectedTrack ?? "all";
|
||||
if (hasMultipleTracks) {
|
||||
host.appendChild(renderTrackChip(availableTracks, selectedTrack, opts));
|
||||
}
|
||||
|
||||
// Filter rows by the selected track. "all" leaves rows untouched
|
||||
// (parallel layout decides per-track partitioning below).
|
||||
const filteredRows =
|
||||
selectedTrack === "all"
|
||||
? rows
|
||||
: rows.filter((r) => (r.track ?? "parent") === selectedTrack);
|
||||
|
||||
if (filteredRows.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "smart-timeline-empty";
|
||||
empty.textContent = t("projects.detail.smarttimeline.empty");
|
||||
host.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
// When the user has selected "all" AND there are multiple tracks
|
||||
// present, render parallel columns side-by-side. Otherwise the
|
||||
// existing single-column flow serves both single-track projects and
|
||||
// an explicit "Nur Hauptverfahren / Nur Widerklage" filter.
|
||||
if (selectedTrack === "all" && hasMultipleTracks) {
|
||||
host.appendChild(renderParallelTracks(filteredRows, availableTracks, opts));
|
||||
return;
|
||||
}
|
||||
|
||||
// Single-column flow.
|
||||
host.appendChild(renderTimelineFlow(filteredRows, opts));
|
||||
}
|
||||
|
||||
// renderLaneStrip builds the parent-node aggregated layout (Slice 4).
|
||||
// One column per lane, each column shows the lane's own past/today/
|
||||
// future flow. Lane filter chip (multiselect) sits above the strip.
|
||||
// Lanes the user has unchecked render dimmed but still take up the
|
||||
// column slot — this preserves the time-axis alignment across lanes.
|
||||
function renderLaneStrip(
|
||||
rows: TimelineEvent[],
|
||||
lanes: LaneInfo[],
|
||||
opts: RenderOptions,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-lanes-wrap";
|
||||
|
||||
// Lane filter chip (Slice 4) — multiselect with "alle" / "keine".
|
||||
// Sits above the strip.
|
||||
wrap.appendChild(renderLaneFilterChip(lanes, opts));
|
||||
|
||||
const selected = new Set(opts.selectedLanes ?? lanes.map((l) => l.id));
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "smart-timeline-lanes";
|
||||
grid.style.setProperty("--smart-timeline-lane-count", String(lanes.length));
|
||||
|
||||
// Group rows by lane_id. Rows without a lane_id default to the first
|
||||
// lane id so they don't disappear. For lane mode the backend always
|
||||
// sets lane_id explicitly; this fallback is defensive.
|
||||
const byLane = new Map<string, TimelineEvent[]>();
|
||||
for (const l of lanes) byLane.set(l.id, []);
|
||||
for (const r of rows) {
|
||||
const id = r.lane_id || lanes[0].id;
|
||||
if (!byLane.has(id)) byLane.set(id, []);
|
||||
byLane.get(id)!.push(r);
|
||||
}
|
||||
|
||||
for (const lane of lanes) {
|
||||
const col = document.createElement("div");
|
||||
col.className = "smart-timeline-lane";
|
||||
if (!selected.has(lane.id)) {
|
||||
col.classList.add("smart-timeline-lane--dimmed");
|
||||
}
|
||||
if (lane.primary) {
|
||||
col.classList.add("smart-timeline-lane--primary");
|
||||
}
|
||||
|
||||
const header = document.createElement("h4");
|
||||
header.className = "smart-timeline-lane-header";
|
||||
if (lane.project_id) {
|
||||
const link = document.createElement("a");
|
||||
link.href = `/projects/${encodeURIComponent(lane.project_id)}`;
|
||||
link.textContent = lane.label;
|
||||
link.className = "smart-timeline-lane-header-link";
|
||||
header.appendChild(link);
|
||||
} else {
|
||||
header.textContent = lane.label;
|
||||
}
|
||||
col.appendChild(header);
|
||||
|
||||
const laneRows = byLane.get(lane.id) ?? [];
|
||||
if (laneRows.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "smart-timeline-lane-empty";
|
||||
empty.textContent = t("projects.detail.smarttimeline.lane.empty");
|
||||
col.appendChild(empty);
|
||||
} else {
|
||||
col.appendChild(renderTimelineFlow(laneRows, opts));
|
||||
}
|
||||
grid.appendChild(col);
|
||||
}
|
||||
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// renderLaneFilterChip — multiselect chip-row for the lane filter.
|
||||
// Defaults to all lanes selected; user toggles individual chips. The
|
||||
// "Alle" pseudo-chip resets to all selected.
|
||||
function renderLaneFilterChip(
|
||||
lanes: LaneInfo[],
|
||||
opts: RenderOptions,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-lane-filter";
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "smart-timeline-lane-filter-label";
|
||||
label.textContent = t("projects.detail.smarttimeline.lane.filter.label");
|
||||
wrap.appendChild(label);
|
||||
|
||||
const selected = new Set(opts.selectedLanes ?? lanes.map((l) => l.id));
|
||||
|
||||
const allBtn = document.createElement("button");
|
||||
allBtn.type = "button";
|
||||
allBtn.className = "smart-timeline-lane-chip smart-timeline-lane-chip--all";
|
||||
if (selected.size === lanes.length) {
|
||||
allBtn.classList.add("is-active");
|
||||
}
|
||||
allBtn.textContent = t("projects.detail.smarttimeline.lane.filter.all");
|
||||
allBtn.addEventListener("click", () => {
|
||||
if (opts.onLaneFilterChange) void opts.onLaneFilterChange(lanes.map((l) => l.id));
|
||||
});
|
||||
wrap.appendChild(allBtn);
|
||||
|
||||
for (const lane of lanes) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "smart-timeline-lane-chip";
|
||||
if (selected.has(lane.id)) chip.classList.add("is-active");
|
||||
chip.textContent = lane.label;
|
||||
chip.addEventListener("click", () => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(lane.id)) {
|
||||
next.delete(lane.id);
|
||||
} else {
|
||||
next.add(lane.id);
|
||||
}
|
||||
if (opts.onLaneFilterChange) void opts.onLaneFilterChange(Array.from(next));
|
||||
});
|
||||
wrap.appendChild(chip);
|
||||
}
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// renderParallelTracks builds a CSS-grid wrapper with one column per
|
||||
// track. Each column is a self-contained smart-timeline-flow with its
|
||||
// own past / today / future sections, plus a sub-header that names the
|
||||
// track ("Hauptverfahren" / "Widerklage — <CCR title>" / "Hauptverfahren
|
||||
// (Kontext)" for the parent_context view on a CCR child).
|
||||
//
|
||||
// Mobile collapse (≤640px) is owned by CSS via .smart-timeline-tracks
|
||||
// and a media query — the grid switches to a single column there with
|
||||
// each sub-header preserved so the user knows which track they're on.
|
||||
function renderParallelTracks(
|
||||
rows: TimelineEvent[],
|
||||
availableTracks: string[],
|
||||
opts: RenderOptions,
|
||||
): HTMLElement {
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "smart-timeline-tracks";
|
||||
grid.style.setProperty("--smart-timeline-track-count", String(availableTracks.length));
|
||||
|
||||
// Group rows by track. Rows with no track default to "parent".
|
||||
const byTrack = new Map<string, TimelineEvent[]>();
|
||||
for (const tr of availableTracks) byTrack.set(tr, []);
|
||||
for (const r of rows) {
|
||||
const key = r.track && byTrack.has(r.track) ? r.track : "parent";
|
||||
if (!byTrack.has(key)) byTrack.set(key, []);
|
||||
byTrack.get(key)!.push(r);
|
||||
}
|
||||
|
||||
for (const trackTag of availableTracks) {
|
||||
const trackRows = byTrack.get(trackTag) ?? [];
|
||||
const col = document.createElement("div");
|
||||
col.className = `smart-timeline-track ${trackClassFor(trackTag)}`;
|
||||
|
||||
const header = document.createElement("h4");
|
||||
header.className = "smart-timeline-track-header";
|
||||
header.textContent = trackHeaderLabel(trackTag, trackRows);
|
||||
col.appendChild(header);
|
||||
|
||||
if (trackRows.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "smart-timeline-track-empty";
|
||||
empty.textContent = t("projects.detail.smarttimeline.empty");
|
||||
col.appendChild(empty);
|
||||
} else {
|
||||
col.appendChild(renderTimelineFlow(trackRows, opts));
|
||||
}
|
||||
grid.appendChild(col);
|
||||
}
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
// renderTimelineFlow renders the past / today / future / undated flow
|
||||
// for the given row set into a fresh container. Extracted from the
|
||||
// pre-Slice-3 renderSmartTimeline so it can be reused as a per-track
|
||||
// column in the parallel layout.
|
||||
function renderTimelineFlow(rows: TimelineEvent[], opts: RenderOptions): HTMLElement {
|
||||
const todayISO = opts.today ?? todayLocalISO();
|
||||
const past: TimelineEvent[] = [];
|
||||
const todays: TimelineEvent[] = [];
|
||||
const future: TimelineEvent[] = [];
|
||||
const undated: TimelineEvent[] = [];
|
||||
for (const r of rows) {
|
||||
const iso = dateOnlyISO(r.date);
|
||||
if (!iso) {
|
||||
undated.push(r);
|
||||
continue;
|
||||
}
|
||||
if (iso < todayISO) past.push(r);
|
||||
else if (iso === todayISO) todays.push(r);
|
||||
else future.push(r);
|
||||
}
|
||||
past.sort(byDateAsc);
|
||||
todays.sort(byDateAsc);
|
||||
future.sort(byDateAsc);
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-flow";
|
||||
|
||||
if (past.length > 0) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "smart-timeline-section smart-timeline-section--past";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "smart-timeline-heading";
|
||||
heading.textContent = t("projects.detail.smarttimeline.section.past");
|
||||
section.appendChild(heading);
|
||||
for (const ev of past) section.appendChild(renderRow(ev, opts));
|
||||
wrap.appendChild(section);
|
||||
}
|
||||
|
||||
const todayRule = document.createElement("div");
|
||||
todayRule.className = "smart-timeline-today-rule";
|
||||
const todayLabel = document.createElement("span");
|
||||
todayLabel.className = "smart-timeline-today-label";
|
||||
todayLabel.textContent = `${t("projects.detail.smarttimeline.today")} (${formatDateOnly(todayISO)})`;
|
||||
todayRule.appendChild(todayLabel);
|
||||
wrap.appendChild(todayRule);
|
||||
|
||||
if (todays.length > 0) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "smart-timeline-section smart-timeline-section--today";
|
||||
for (const ev of todays) section.appendChild(renderRow(ev, opts));
|
||||
wrap.appendChild(section);
|
||||
}
|
||||
|
||||
if (future.length > 0) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "smart-timeline-section smart-timeline-section--future";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "smart-timeline-heading";
|
||||
heading.textContent = t("projects.detail.smarttimeline.section.future");
|
||||
section.appendChild(heading);
|
||||
for (const ev of future) section.appendChild(renderRow(ev, opts));
|
||||
section.appendChild(renderLookaheadToggle(future, opts));
|
||||
wrap.appendChild(section);
|
||||
} else {
|
||||
const lookaheadHost = renderLookaheadToggle(future, opts);
|
||||
if (lookaheadHost.childElementCount > 0) {
|
||||
wrap.appendChild(lookaheadHost);
|
||||
}
|
||||
}
|
||||
|
||||
if (undated.length > 0) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "smart-timeline-section smart-timeline-section--undated";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "smart-timeline-heading";
|
||||
heading.textContent = t("projects.detail.smarttimeline.section.undated");
|
||||
section.appendChild(heading);
|
||||
for (const ev of undated) section.appendChild(renderRow(ev, opts));
|
||||
wrap.appendChild(section);
|
||||
}
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// renderTrackChip builds the [Track ▼] selector. Options are derived
|
||||
// from the response's available_tracks header — i18n keys translate
|
||||
// each option label, with the sub-project title surfacing for CCR
|
||||
// tracks ("Widerklage — <title>"). Persists the user's selection via
|
||||
// the host through opts.onTrackChange.
|
||||
function renderTrackChip(
|
||||
availableTracks: string[],
|
||||
selected: string,
|
||||
opts: RenderOptions,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-track-chip";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "smart-timeline-track-chip-label";
|
||||
label.textContent = t("projects.detail.smarttimeline.track.label");
|
||||
wrap.appendChild(label);
|
||||
|
||||
const select = document.createElement("select");
|
||||
select.className = "smart-timeline-track-chip-select";
|
||||
|
||||
const allOpt = document.createElement("option");
|
||||
allOpt.value = "all";
|
||||
allOpt.textContent = t("projects.detail.smarttimeline.track.both");
|
||||
select.appendChild(allOpt);
|
||||
|
||||
for (const trackTag of availableTracks) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = trackTag;
|
||||
opt.textContent = trackOnlyLabel(trackTag);
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
select.value = selected;
|
||||
select.addEventListener("change", () => {
|
||||
if (opts.onTrackChange) void opts.onTrackChange(select.value);
|
||||
});
|
||||
wrap.appendChild(select);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// trackClassFor maps a track tag to its CSS modifier so the column
|
||||
// gets the appropriate visual treatment (lime for parent, light shade
|
||||
// for counterclaim, faded for parent_context).
|
||||
function trackClassFor(trackTag: string): string {
|
||||
if (trackTag === "parent") return "smart-timeline-track--parent";
|
||||
if (trackTag.startsWith("counterclaim:")) return "smart-timeline-track--counterclaim";
|
||||
if (trackTag.startsWith("parent_context:")) return "smart-timeline-track--parent-context";
|
||||
return "smart-timeline-track--other";
|
||||
}
|
||||
|
||||
// trackHeaderLabel picks the column sub-header. For CCR tracks pulls
|
||||
// the sub_project_title from the first row in the track so the user
|
||||
// sees "Widerklage — <child title>". Falls back to a generic label
|
||||
// when the title is empty.
|
||||
function trackHeaderLabel(trackTag: string, rows: TimelineEvent[]): string {
|
||||
if (trackTag === "parent") {
|
||||
return t("projects.detail.smarttimeline.track.header.parent");
|
||||
}
|
||||
const firstWithTitle = rows.find((r) => r.sub_project_title);
|
||||
const subTitle = firstWithTitle?.sub_project_title ?? "";
|
||||
if (trackTag.startsWith("counterclaim:")) {
|
||||
const base = t("projects.detail.smarttimeline.track.header.counterclaim");
|
||||
return subTitle ? `${base} — ${subTitle}` : base;
|
||||
}
|
||||
if (trackTag.startsWith("parent_context:")) {
|
||||
const base = t("projects.detail.smarttimeline.track.header.parent_context");
|
||||
return subTitle ? `${base} — ${subTitle}` : base;
|
||||
}
|
||||
return trackTag;
|
||||
}
|
||||
|
||||
// trackOnlyLabel is the chip dropdown label for "show only this track".
|
||||
function trackOnlyLabel(trackTag: string): string {
|
||||
if (trackTag === "parent") {
|
||||
return t("projects.detail.smarttimeline.track.only.parent");
|
||||
}
|
||||
if (trackTag.startsWith("counterclaim:")) {
|
||||
return t("projects.detail.smarttimeline.track.only.counterclaim");
|
||||
}
|
||||
if (trackTag.startsWith("parent_context:")) {
|
||||
return t("projects.detail.smarttimeline.track.only.parent_context");
|
||||
}
|
||||
return trackTag;
|
||||
}
|
||||
|
||||
function renderLookaheadToggle(
|
||||
futureRows: TimelineEvent[],
|
||||
opts: RenderOptions,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-lookahead";
|
||||
const total = opts.projectedTotal ?? 0;
|
||||
const projectedShown = futureRows.filter((r) => r.kind === "projected").length;
|
||||
const cur = opts.lookahead ?? 7;
|
||||
|
||||
if (total > projectedShown && opts.onLookaheadChange) {
|
||||
const more = document.createElement("button");
|
||||
more.type = "button";
|
||||
more.className = "smart-timeline-lookahead-btn";
|
||||
more.textContent = t("projects.detail.smarttimeline.lookahead.more");
|
||||
more.setAttribute(
|
||||
"aria-label",
|
||||
`${t("projects.detail.smarttimeline.lookahead.more")} (${total - projectedShown})`,
|
||||
);
|
||||
more.addEventListener("click", () => {
|
||||
const next = Math.min(50, cur + 7);
|
||||
void opts.onLookaheadChange?.(next);
|
||||
});
|
||||
wrap.appendChild(more);
|
||||
}
|
||||
if (cur > 7 && opts.onLookaheadChange) {
|
||||
const less = document.createElement("button");
|
||||
less.type = "button";
|
||||
less.className = "smart-timeline-lookahead-btn smart-timeline-lookahead-btn--less";
|
||||
less.textContent = t("projects.detail.smarttimeline.lookahead.less");
|
||||
less.addEventListener("click", () => {
|
||||
void opts.onLookaheadChange?.(7);
|
||||
});
|
||||
wrap.appendChild(less);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderRow(ev: TimelineEvent, opts: RenderOptions): HTMLElement {
|
||||
const li = document.createElement("article");
|
||||
li.className =
|
||||
`smart-timeline-row smart-timeline-row--${ev.kind} ` +
|
||||
`smart-timeline-row--${ev.status}`;
|
||||
if (ev.deadline_rule_party) {
|
||||
li.classList.add(`smart-timeline-row--party-${ev.deadline_rule_party}`);
|
||||
}
|
||||
|
||||
const dateCol = document.createElement("div");
|
||||
dateCol.className = "smart-timeline-date";
|
||||
dateCol.textContent = ev.date ? formatDateOnly(dateOnlyISO(ev.date) ?? "") : "—";
|
||||
li.appendChild(dateCol);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "smart-timeline-body";
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "smart-timeline-row-head";
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "smart-timeline-status-icon";
|
||||
icon.textContent = statusGlyph(ev.status);
|
||||
icon.setAttribute("aria-label", t(statusKey(ev.status)));
|
||||
head.appendChild(icon);
|
||||
|
||||
const titleEl = document.createElement("span");
|
||||
titleEl.className = "smart-timeline-title";
|
||||
const href = deepLinkHref(ev);
|
||||
if (href) {
|
||||
const a = document.createElement("a");
|
||||
a.className = "smart-timeline-link";
|
||||
a.href = href;
|
||||
a.textContent = ev.title;
|
||||
titleEl.appendChild(a);
|
||||
} else {
|
||||
titleEl.textContent = ev.title;
|
||||
}
|
||||
head.appendChild(titleEl);
|
||||
|
||||
const kindChip = document.createElement("span");
|
||||
kindChip.className = `smart-timeline-kind-chip smart-timeline-kind-chip--${ev.kind}`;
|
||||
kindChip.textContent = t(kindKey(ev.kind));
|
||||
head.appendChild(kindChip);
|
||||
|
||||
if (ev.rule_code) {
|
||||
const ruleChip = document.createElement("span");
|
||||
ruleChip.className = "smart-timeline-rule-chip";
|
||||
ruleChip.textContent = ev.rule_code;
|
||||
head.appendChild(ruleChip);
|
||||
}
|
||||
|
||||
// "voraussichtlich" / "vom Gericht" / "überfällig" status pill on
|
||||
// projected rows so the user reads the row's nature at a glance.
|
||||
if (ev.kind === "projected") {
|
||||
const statusPill = document.createElement("span");
|
||||
statusPill.className = `smart-timeline-status-pill smart-timeline-status-pill--${ev.status}`;
|
||||
statusPill.textContent = t(statusKey(ev.status));
|
||||
head.appendChild(statusPill);
|
||||
}
|
||||
|
||||
body.appendChild(head);
|
||||
|
||||
if (ev.description) {
|
||||
const desc = document.createElement("div");
|
||||
desc.className = "smart-timeline-desc";
|
||||
desc.textContent = ev.description;
|
||||
body.appendChild(desc);
|
||||
}
|
||||
|
||||
// Depends-on footer (#31 layer 2) — surface the parent rule + its
|
||||
// date right under the title so the user reads the dependency at a
|
||||
// glance. "[Pfad anzeigen]" expands the full chain on demand.
|
||||
if (ev.depends_on_rule_code) {
|
||||
body.appendChild(renderDependsOn(ev));
|
||||
}
|
||||
|
||||
// Click-to-anchor affordance (Slice 2 §6.2) — projected rows expose
|
||||
// "[Datum setzen]" inline editor; actuals from rules expose a
|
||||
// "[Datum ändern]" variant that PATCHes via the same endpoint.
|
||||
if (ev.kind === "projected" && ev.deadline_rule_id && opts.projectId) {
|
||||
body.appendChild(renderAnchorAction(ev, opts));
|
||||
}
|
||||
|
||||
li.appendChild(body);
|
||||
|
||||
// Row-level navigation — same pattern as .entity-event (t-paliad-103).
|
||||
if (href) {
|
||||
li.classList.add("smart-timeline-row--clickable");
|
||||
li.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("a") || target.closest("button") || target.closest("input")) return;
|
||||
window.location.href = href;
|
||||
});
|
||||
}
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderDependsOn(ev: TimelineEvent): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-depends-on";
|
||||
const code = ev.depends_on_rule_code ?? "";
|
||||
const name = ev.depends_on_rule_name || code;
|
||||
const dateText = ev.depends_on_date
|
||||
? formatDateOnly(dateOnlyISO(ev.depends_on_date) ?? "")
|
||||
: t("projects.detail.smarttimeline.depends_on.date_open");
|
||||
const prefix = t("projects.detail.smarttimeline.depends_on.prefix");
|
||||
const txt = document.createElement("span");
|
||||
txt.textContent = `${prefix}: ${name} (${code}, ${dateText})`;
|
||||
wrap.appendChild(txt);
|
||||
|
||||
const expand = document.createElement("button");
|
||||
expand.type = "button";
|
||||
expand.className = "smart-timeline-depends-on-expand";
|
||||
expand.textContent = t("projects.detail.smarttimeline.depends_on.show_path");
|
||||
expand.addEventListener("click", () => {
|
||||
if (wrap.classList.contains("smart-timeline-depends-on--expanded")) {
|
||||
wrap.classList.remove("smart-timeline-depends-on--expanded");
|
||||
const list = wrap.querySelector(".smart-timeline-depends-on-path");
|
||||
if (list) list.remove();
|
||||
expand.textContent = t("projects.detail.smarttimeline.depends_on.show_path");
|
||||
return;
|
||||
}
|
||||
wrap.classList.add("smart-timeline-depends-on--expanded");
|
||||
const list = document.createElement("div");
|
||||
list.className = "smart-timeline-depends-on-path";
|
||||
// The walked chain isn't pre-computed server-side beyond the
|
||||
// immediate parent; the backend annotation gives one hop. Future
|
||||
// slice can deepen this — for v1 we surface the immediate parent
|
||||
// (already in the prefix line) and a hint that the user can click
|
||||
// the parent's row to see its own dependency.
|
||||
const hint = document.createElement("span");
|
||||
hint.className = "smart-timeline-depends-on-hint";
|
||||
hint.textContent = t("projects.detail.smarttimeline.depends_on.path_hint");
|
||||
list.appendChild(hint);
|
||||
wrap.appendChild(list);
|
||||
expand.textContent = t("projects.detail.smarttimeline.depends_on.hide_path");
|
||||
});
|
||||
wrap.appendChild(expand);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderAnchorAction(ev: TimelineEvent, opts: RenderOptions): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-anchor";
|
||||
|
||||
const trigger = document.createElement("button");
|
||||
trigger.type = "button";
|
||||
trigger.className = "smart-timeline-anchor-btn";
|
||||
trigger.textContent = t("projects.detail.smarttimeline.anchor.set");
|
||||
wrap.appendChild(trigger);
|
||||
|
||||
trigger.addEventListener("click", () => {
|
||||
if (wrap.classList.contains("smart-timeline-anchor--editing")) return;
|
||||
wrap.classList.add("smart-timeline-anchor--editing");
|
||||
trigger.style.display = "none";
|
||||
wrap.appendChild(buildAnchorEditor(ev, opts, wrap));
|
||||
});
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function buildAnchorEditor(
|
||||
ev: TimelineEvent,
|
||||
opts: RenderOptions,
|
||||
wrap: HTMLElement,
|
||||
): HTMLElement {
|
||||
const editor = document.createElement("form");
|
||||
editor.className = "smart-timeline-anchor-form";
|
||||
editor.setAttribute("aria-label", t("projects.detail.smarttimeline.anchor.set"));
|
||||
editor.addEventListener("submit", (e) => e.preventDefault());
|
||||
|
||||
const dateInput = document.createElement("input");
|
||||
dateInput.type = "date";
|
||||
dateInput.className = "smart-timeline-anchor-date";
|
||||
dateInput.required = true;
|
||||
if (ev.date) dateInput.value = dateOnlyISO(ev.date) ?? "";
|
||||
editor.appendChild(dateInput);
|
||||
|
||||
const submit = document.createElement("button");
|
||||
submit.type = "submit";
|
||||
submit.className = "smart-timeline-anchor-submit";
|
||||
submit.textContent = t("projects.detail.smarttimeline.anchor.save");
|
||||
editor.appendChild(submit);
|
||||
|
||||
const cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.className = "smart-timeline-anchor-cancel";
|
||||
cancel.textContent = t("projects.detail.smarttimeline.anchor.cancel");
|
||||
cancel.addEventListener("click", () => {
|
||||
wrap.innerHTML = "";
|
||||
const trig = document.createElement("button");
|
||||
trig.type = "button";
|
||||
trig.className = "smart-timeline-anchor-btn";
|
||||
trig.textContent = t("projects.detail.smarttimeline.anchor.set");
|
||||
wrap.classList.remove("smart-timeline-anchor--editing");
|
||||
wrap.appendChild(trig);
|
||||
trig.addEventListener("click", () => {
|
||||
wrap.innerHTML = "";
|
||||
wrap.appendChild(buildAnchorEditor(ev, opts, wrap));
|
||||
wrap.classList.add("smart-timeline-anchor--editing");
|
||||
});
|
||||
});
|
||||
editor.appendChild(cancel);
|
||||
|
||||
const msg = document.createElement("div");
|
||||
msg.className = "smart-timeline-anchor-msg";
|
||||
editor.appendChild(msg);
|
||||
|
||||
editor.addEventListener("submit", async () => {
|
||||
if (!opts.projectId) return;
|
||||
if (!ev.rule_code) return;
|
||||
const date = dateInput.value;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
msg.textContent = t("projects.detail.smarttimeline.anchor.invalid_date");
|
||||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||||
return;
|
||||
}
|
||||
submit.disabled = true;
|
||||
cancel.disabled = true;
|
||||
msg.classList.remove("smart-timeline-anchor-msg--error");
|
||||
msg.textContent = t("projects.detail.smarttimeline.anchor.saving");
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${encodeURIComponent(opts.projectId)}/timeline/anchor`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
rule_code: ev.rule_code,
|
||||
actual_date: date,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
msg.textContent = t("projects.detail.smarttimeline.anchor.saved");
|
||||
if (opts.onChange) await opts.onChange();
|
||||
return;
|
||||
}
|
||||
if (resp.status === 409) {
|
||||
const payload = (await resp.json()) as PredecessorMissingPayload;
|
||||
renderPredecessorError(msg, payload, ev, opts, dateInput, submit, cancel);
|
||||
return;
|
||||
}
|
||||
msg.textContent = t("projects.detail.smarttimeline.anchor.error");
|
||||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||||
} catch {
|
||||
msg.textContent = t("projects.detail.smarttimeline.anchor.error");
|
||||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
cancel.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
return editor;
|
||||
}
|
||||
|
||||
function renderPredecessorError(
|
||||
msg: HTMLElement,
|
||||
payload: PredecessorMissingPayload,
|
||||
_ev: TimelineEvent,
|
||||
opts: RenderOptions,
|
||||
_dateInput: HTMLInputElement,
|
||||
_submit: HTMLButtonElement,
|
||||
_cancel: HTMLButtonElement,
|
||||
): void {
|
||||
msg.innerHTML = "";
|
||||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||||
msg.classList.add("smart-timeline-anchor-msg--predecessor");
|
||||
|
||||
const lang = (opts.lang ?? getLang()) === "en" ? "en" : "de";
|
||||
const message = lang === "en" ? payload.message_en : payload.message_de;
|
||||
const main = document.createElement("p");
|
||||
main.textContent = message;
|
||||
msg.appendChild(main);
|
||||
|
||||
// "Stattdessen <predecessor> erfassen" — pre-fills the editor for
|
||||
// the missing parent rule, scrolls to its row if present, falls back
|
||||
// to a fresh editor in-place.
|
||||
const link = document.createElement("button");
|
||||
link.type = "button";
|
||||
link.className = "smart-timeline-anchor-predecessor-link";
|
||||
const predName =
|
||||
lang === "en" ? payload.missing_rule_name_en : payload.missing_rule_name_de;
|
||||
link.textContent =
|
||||
lang === "en"
|
||||
? `Anchor „${predName}“ instead`
|
||||
: `Stattdessen „${predName}“ erfassen`;
|
||||
link.addEventListener("click", () => {
|
||||
// Find the projected row for missing_rule_code and scroll into view;
|
||||
// the row's own [Datum setzen] button takes it from there.
|
||||
const targetRow = findRowForRuleCode(payload.missing_rule_code);
|
||||
if (targetRow) {
|
||||
targetRow.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const btn = targetRow.querySelector<HTMLButtonElement>(
|
||||
".smart-timeline-anchor-btn",
|
||||
);
|
||||
if (btn) btn.click();
|
||||
}
|
||||
});
|
||||
msg.appendChild(link);
|
||||
}
|
||||
|
||||
function findRowForRuleCode(ruleCode: string): HTMLElement | null {
|
||||
const rows = document.querySelectorAll<HTMLElement>(".smart-timeline-row");
|
||||
for (const r of Array.from(rows)) {
|
||||
const chip = r.querySelector(".smart-timeline-rule-chip");
|
||||
if (chip && chip.textContent === ruleCode) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deepLinkHref(ev: TimelineEvent): string | null {
|
||||
if (ev.kind === "deadline" && ev.deadline_id) {
|
||||
return `/deadlines/${ev.deadline_id}`;
|
||||
}
|
||||
if (ev.kind === "appointment" && ev.appointment_id) {
|
||||
return `/appointments/${ev.appointment_id}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function statusGlyph(status: TimelineEvent["status"]): string {
|
||||
switch (status) {
|
||||
case "done": return "✓";
|
||||
case "open": return "…";
|
||||
case "overdue": return "!";
|
||||
case "court_set": return "▢";
|
||||
case "predicted": return "░";
|
||||
case "predicted_overdue": return "░!";
|
||||
case "off_script": return "⊕";
|
||||
default: return "·";
|
||||
}
|
||||
}
|
||||
|
||||
function statusKey(status: TimelineEvent["status"]) {
|
||||
return `projects.detail.smarttimeline.status.${status}` as const;
|
||||
}
|
||||
|
||||
function kindKey(kind: TimelineEvent["kind"]) {
|
||||
return `projects.detail.smarttimeline.kind.${kind}` as const;
|
||||
}
|
||||
|
||||
function dateOnlyISO(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
|
||||
const d = new Date(raw);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function todayLocalISO(): string {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function byDateAsc(a: TimelineEvent, b: TimelineEvent): number {
|
||||
const ai = dateOnlyISO(a.date) ?? "";
|
||||
const bi = dateOnlyISO(b.date) ?? "";
|
||||
if (ai === bi) return a.title.localeCompare(b.title);
|
||||
return ai < bi ? -1 : 1;
|
||||
}
|
||||
|
||||
function formatDateOnly(iso: string): string {
|
||||
if (!iso) return "—";
|
||||
const parts = iso.split("-");
|
||||
if (parts.length !== 3) return iso;
|
||||
const d = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export interface ScopeSpec {
|
||||
|
||||
export type TimeHorizon =
|
||||
| "next_7d" | "next_30d" | "next_90d"
|
||||
| "past_30d" | "past_90d"
|
||||
| "past_7d" | "past_30d" | "past_90d"
|
||||
| "any" | "all" | "custom";
|
||||
|
||||
export type TimeField = "auto" | "created_at";
|
||||
@@ -69,12 +69,23 @@ export interface FilterSpec {
|
||||
predicates?: Partial<Record<DataSource, Predicates>>;
|
||||
}
|
||||
|
||||
export type RenderShape = "list" | "cards" | "calendar";
|
||||
export type RenderShape = "list" | "cards" | "calendar" | "timeline";
|
||||
|
||||
export interface TimelineCVConfig {
|
||||
palette?: "default" | "kind-coded" | "track-coded" | "high-contrast" | "print";
|
||||
density?: "compact" | "standard" | "spacious";
|
||||
range_preset?: "1y" | "2y" | "all" | "custom";
|
||||
range_from?: string;
|
||||
range_to?: string;
|
||||
}
|
||||
|
||||
export type ListRowAction = "navigate" | "complete_toggle" | "approve" | "none";
|
||||
|
||||
export interface ListConfig {
|
||||
columns?: string[];
|
||||
sort?: "date_asc" | "date_desc";
|
||||
density?: "comfortable" | "compact";
|
||||
row_action?: ListRowAction;
|
||||
}
|
||||
|
||||
export interface CardsConfig {
|
||||
@@ -93,6 +104,7 @@ export interface RenderSpec {
|
||||
list?: ListConfig;
|
||||
cards?: CardsConfig;
|
||||
calendar?: CalendarConfig;
|
||||
timeline?: TimelineCVConfig;
|
||||
}
|
||||
|
||||
// ViewRow — the discriminated row shape from ViewService.RunSpec.
|
||||
|
||||
486
frontend/src/client/views/verfahrensablauf-core.ts
Normal file
486
frontend/src/client/views/verfahrensablauf-core.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
// Shared core for Fristenrechner-style proceeding-timeline rendering.
|
||||
//
|
||||
// Both /tools/fristenrechner (deadline determination) and
|
||||
// /tools/verfahrensablauf (abstract browse — t-paliad-179 Slice 1) call
|
||||
// POST /api/tools/fristenrechner and paint the result with the same
|
||||
// renderers. The module is pure-functional: no shared mutable state, all
|
||||
// language / overrides / editability flow in through args so the two
|
||||
// pages can wire their own per-page concerns (Akte save, anchor edits,
|
||||
// Pathway B etc. on fristenrechner; variant chips, compare etc. coming
|
||||
// to verfahrensablauf in later slices) without leaking into each other.
|
||||
|
||||
import { t, tDyn, getLang } from "../i18n";
|
||||
|
||||
export interface AdjustmentHoliday {
|
||||
Date: string;
|
||||
Name: string;
|
||||
IsVacation: boolean;
|
||||
IsClosure: boolean;
|
||||
}
|
||||
|
||||
export interface AdjustmentReason {
|
||||
kind: "weekend" | "public_holiday" | "vacation";
|
||||
holidays?: AdjustmentHoliday[];
|
||||
vacation_name?: string;
|
||||
vacation_start?: string;
|
||||
vacation_end?: string;
|
||||
original_weekday?: string;
|
||||
}
|
||||
|
||||
export interface CalculatedDeadline {
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
party: string;
|
||||
// Priority is the canonical 4-way enum (Slice 8 made it canonical;
|
||||
// Slice 9 dropped the legacy isMandatory / isOptional pair from the
|
||||
// wire). priorityRendering(d) below branches on it.
|
||||
priority: "mandatory" | "recommended" | "optional" | "informational";
|
||||
ruleRef: string;
|
||||
legalSource?: string;
|
||||
notes?: string;
|
||||
notesEN?: string;
|
||||
dueDate: string;
|
||||
originalDate: string;
|
||||
wasAdjusted: boolean;
|
||||
adjustmentReason?: AdjustmentReason;
|
||||
isRootEvent: boolean;
|
||||
isCourtSet: boolean;
|
||||
isCourtSetIndirect?: boolean;
|
||||
isOverridden?: boolean;
|
||||
// conditionExpr surfaces the jsonb gate predicate (design §2.4) so
|
||||
// the rule-editor + admin views can render the rule's gating shape.
|
||||
// Frontend save-modal logic doesn't read this; the rule editor
|
||||
// (Slice 11) is the consumer. Unknown shape on this side — pass-through.
|
||||
conditionExpr?: unknown;
|
||||
}
|
||||
|
||||
// priorityRendering returns the per-priority UX hints the save-modal
|
||||
// uses. Maps the unified Priority enum to:
|
||||
// - preChecked: whether the save-modal pre-checks the row
|
||||
// - hideSave: whether the row renders without a save button at all
|
||||
// (informational = notice card, no save action)
|
||||
//
|
||||
// Phase 3 Slice 9 (t-paliad-195) dropped the legacy
|
||||
// (isMandatory, isOptional) fallback that pre-Slice-8 backends
|
||||
// emitted. The backend now always populates `priority`; an unknown
|
||||
// value falls back to "render as mandatory" (safe default — never
|
||||
// silently drop a rule).
|
||||
export function priorityRendering(
|
||||
d: CalculatedDeadline,
|
||||
): { preChecked: boolean; hideSave: boolean } {
|
||||
switch (d.priority) {
|
||||
case "mandatory":
|
||||
case "recommended":
|
||||
return { preChecked: true, hideSave: false };
|
||||
case "optional":
|
||||
return { preChecked: false, hideSave: false };
|
||||
case "informational":
|
||||
return { preChecked: false, hideSave: true };
|
||||
}
|
||||
// Unknown priority value: pre-Slice-8 backend or a forward-compat
|
||||
// future value. Safe default: render as mandatory so the rule is
|
||||
// surfaced + saved. Never silently drop.
|
||||
return { preChecked: true, hideSave: false };
|
||||
}
|
||||
|
||||
export interface DeadlineResponse {
|
||||
proceedingType: string;
|
||||
proceedingName: string;
|
||||
triggerDate: string;
|
||||
deadlines: CalculatedDeadline[];
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
id: string;
|
||||
code: string;
|
||||
nameDE: string;
|
||||
nameEN: string;
|
||||
country: string;
|
||||
regime?: string;
|
||||
courtType: string;
|
||||
}
|
||||
|
||||
export interface CalcParams {
|
||||
proceedingType: string;
|
||||
triggerDate: string;
|
||||
priorityDate?: string;
|
||||
flags?: string[];
|
||||
anchorOverrides?: Record<string, string>;
|
||||
courtId?: string;
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
claimant: "party-claimant",
|
||||
defendant: "party-defendant",
|
||||
court: "party-court",
|
||||
both: "party-both",
|
||||
};
|
||||
|
||||
// ─── small helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
export function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
export function escHtml(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return "—";
|
||||
const d = new Date(dateStr + "T00:00:00");
|
||||
if (getLang() === "en") {
|
||||
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${weekday}, ${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
return d.toLocaleDateString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateSpan(startISO: string, endISO: string): string {
|
||||
const start = new Date(startISO + "T00:00:00");
|
||||
const end = new Date(endISO + "T00:00:00");
|
||||
if (getLang() === "en") {
|
||||
const fmt = (d: Date) => d.toLocaleDateString("en-US", { day: "numeric", month: "short" });
|
||||
return `${fmt(start)} – ${fmt(end)}`;
|
||||
}
|
||||
const fmt = (d: Date) => `${d.getDate()}.${d.getMonth() + 1}.`;
|
||||
return `${fmt(start)}–${fmt(end)}`;
|
||||
}
|
||||
|
||||
function localizeWeekday(en: string): string {
|
||||
if (en === "Saturday") return t("deadlines.adjusted.weekend.saturday");
|
||||
if (en === "Sunday") return t("deadlines.adjusted.weekend.sunday");
|
||||
return en;
|
||||
}
|
||||
|
||||
// Vacation names come straight from paliad.holidays (e.g. "UPC judicial
|
||||
// vacation"). Not translated — they're proper names of court-set closures.
|
||||
function localizeVacationName(name: string): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
function renderAdjustmentReason(r: AdjustmentReason): string {
|
||||
if (r.kind === "vacation" && r.vacation_name && r.vacation_start && r.vacation_end) {
|
||||
const span = formatDateSpan(r.vacation_start, r.vacation_end);
|
||||
return tDyn("deadlines.adjusted.vacation")
|
||||
.replace("{name}", localizeVacationName(r.vacation_name))
|
||||
.replace("{span}", span);
|
||||
}
|
||||
if (r.kind === "public_holiday" && r.holidays && r.holidays.length > 0) {
|
||||
return tDyn("deadlines.adjusted.holiday").replace("{name}", r.holidays[0].Name);
|
||||
}
|
||||
if (r.kind === "weekend" && r.original_weekday) {
|
||||
return localizeWeekday(r.original_weekday);
|
||||
}
|
||||
return t("deadlines.adjusted.weekend");
|
||||
}
|
||||
|
||||
function formatAdjustedNote(dl: CalculatedDeadline): string {
|
||||
const arrow = `${formatDate(dl.originalDate)} → ${formatDate(dl.dueDate)}`;
|
||||
const reason = dl.adjustmentReason
|
||||
? renderAdjustmentReason(dl.adjustmentReason)
|
||||
: t("deadlines.adjusted.reason");
|
||||
if (getLang() === "en") {
|
||||
return `${t("deadlines.adjusted")} (${reason}): ${arrow}`;
|
||||
}
|
||||
return `${t("deadlines.adjusted")} wegen ${reason}: ${arrow}`;
|
||||
}
|
||||
|
||||
export function partyBadge(party: string): string {
|
||||
const cls = PARTY_CLASS[party] || "party-both";
|
||||
return `<span class="party-badge ${cls}">${tDyn("deadlines.party." + party)}</span>`;
|
||||
}
|
||||
|
||||
// ─── card + body renderers ────────────────────────────────────────────────
|
||||
|
||||
export interface CardOpts {
|
||||
showParty: boolean;
|
||||
// editable=true wires the click-to-edit affordance: data-rule-code,
|
||||
// role=button, tabindex, hover hint. Fristenrechner enables it; the
|
||||
// verfahrensablauf abstract-browse surface keeps editable=false because
|
||||
// there's no anchor-override state on that page in Slice 1.
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
|
||||
const wantsEditable = !!opts.editable;
|
||||
const editable = wantsEditable && !dl.isRootEvent && dl.code !== "";
|
||||
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
|
||||
const editAttrs = editable
|
||||
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
|
||||
: "";
|
||||
const courtLabelKey = dl.isCourtSetIndirect
|
||||
? "deadlines.court.indirect"
|
||||
: "deadlines.court.set";
|
||||
const dateStr = dl.isCourtSet
|
||||
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
|
||||
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
||||
|
||||
// Slice 9 (t-paliad-195): the legacy boolean pair is gone — read
|
||||
// priority directly. Optional badge fires only on 'optional'
|
||||
// priority (RoP.151-style opt-in deadlines).
|
||||
const mandatoryBadge = dl.priority === "optional"
|
||||
? '<span class="optional-badge">optional</span>'
|
||||
: "";
|
||||
|
||||
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
|
||||
|
||||
const adjustedNote = dl.wasAdjusted
|
||||
? `<div class="timeline-adjusted">⚠ ${formatAdjustedNote(dl)}</div>`
|
||||
: "";
|
||||
|
||||
const ruleRef = dl.ruleRef
|
||||
? `<span class="timeline-rule">${dl.ruleRef}</span>`
|
||||
: "";
|
||||
|
||||
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
const notes = noteText
|
||||
? `<div class="timeline-notes">${noteText}</div>`
|
||||
: "";
|
||||
|
||||
const meta = (opts.showParty || ruleRef)
|
||||
? `<div class="timeline-meta">
|
||||
${opts.showParty ? partyBadge(dl.party) : ""}
|
||||
${ruleRef}
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
return `<div class="timeline-item-header">
|
||||
<span class="timeline-name">
|
||||
${dlName}
|
||||
${mandatoryBadge}
|
||||
</span>
|
||||
${dateStr}
|
||||
</div>
|
||||
${meta}
|
||||
${adjustedNote}
|
||||
${notes}`;
|
||||
}
|
||||
|
||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||
let html = '<div class="timeline">';
|
||||
for (const dl of data.deadlines) {
|
||||
html += `
|
||||
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
|
||||
<div class="timeline-dot-col">
|
||||
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
|
||||
<div class="timeline-line"></div>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
${deadlineCardHtml(dl, opts)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
}
|
||||
|
||||
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
|
||||
// (defendant). Each grid row shares a dueDate so same-day events line up
|
||||
// across columns; party=both renders in BOTH the Proactive and Reactive
|
||||
// cells of the row. Undated rows (Urteil etc.) trail the dated tail, each
|
||||
// keyed by sequence-order so e.g. Urteil precedes Berufungseinlegung.
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "showParty"> = {}): string {
|
||||
type Cell = CalculatedDeadline[];
|
||||
type Row = { proactive: Cell; court: Cell; reactive: Cell };
|
||||
|
||||
const UNSCHEDULED_PREFIX = "__unscheduled__";
|
||||
const rowsMap = new Map<string, Row>();
|
||||
const ensureRow = (key: string): Row => {
|
||||
let r = rowsMap.get(key);
|
||||
if (!r) {
|
||||
r = { proactive: [], court: [], reactive: [] };
|
||||
rowsMap.set(key, r);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
data.deadlines.forEach((dl, idx) => {
|
||||
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
|
||||
const row = ensureRow(key);
|
||||
switch (dl.party) {
|
||||
case "claimant":
|
||||
row.proactive.push(dl);
|
||||
break;
|
||||
case "defendant":
|
||||
row.reactive.push(dl);
|
||||
break;
|
||||
case "court":
|
||||
row.court.push(dl);
|
||||
break;
|
||||
case "both":
|
||||
row.proactive.push(dl);
|
||||
row.reactive.push(dl);
|
||||
break;
|
||||
default:
|
||||
row.court.push(dl);
|
||||
}
|
||||
});
|
||||
|
||||
const datedKeys: string[] = [];
|
||||
const unscheduledKeys: string[] = [];
|
||||
for (const k of rowsMap.keys()) {
|
||||
if (k.startsWith(UNSCHEDULED_PREFIX)) unscheduledKeys.push(k);
|
||||
else datedKeys.push(k);
|
||||
}
|
||||
datedKeys.sort();
|
||||
unscheduledKeys.sort();
|
||||
const keys = [...datedKeys, ...unscheduledKeys];
|
||||
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable };
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
|
||||
}
|
||||
const cards = items
|
||||
.map((dl) => {
|
||||
const mirrorTag = dl.party === "both"
|
||||
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
|
||||
: "";
|
||||
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
|
||||
${deadlineCardHtml(dl, cardOpts)}
|
||||
${mirrorTag}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="fr-col-cell">${cards}</div>`;
|
||||
};
|
||||
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
|
||||
|
||||
for (const key of keys) {
|
||||
const row = rowsMap.get(key)!;
|
||||
html += renderCell(row.proactive);
|
||||
html += renderCell(row.court);
|
||||
html += renderCell(row.reactive);
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
}
|
||||
|
||||
// ─── calculate fetch wrapper ──────────────────────────────────────────────
|
||||
|
||||
export async function calculateDeadlines(params: CalcParams): Promise<DeadlineResponse | null> {
|
||||
if (!params.proceedingType || !params.triggerDate) return null;
|
||||
try {
|
||||
const resp = await fetch("/api/tools/fristenrechner", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
proceedingType: params.proceedingType,
|
||||
triggerDate: params.triggerDate,
|
||||
priorityDate: params.priorityDate || undefined,
|
||||
flags: params.flags && params.flags.length > 0 ? params.flags : undefined,
|
||||
anchorOverrides: params.anchorOverrides && Object.keys(params.anchorOverrides).length > 0
|
||||
? params.anchorOverrides
|
||||
: undefined,
|
||||
courtId: params.courtId || undefined,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
console.error("API error:", err);
|
||||
return null;
|
||||
}
|
||||
return (await resp.json()) as DeadlineResponse;
|
||||
} catch (e) {
|
||||
console.error("Fetch error:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── court picker ─────────────────────────────────────────────────────────
|
||||
|
||||
const courtCache = new Map<string, CourtRow[]>();
|
||||
|
||||
export function courtTypesFor(proceedingType: string): string[] {
|
||||
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
|
||||
return ["UPC-CoA"];
|
||||
}
|
||||
if (proceedingType === "UPC_REV") {
|
||||
return ["UPC-CD", "UPC-LD"];
|
||||
}
|
||||
if (proceedingType.startsWith("UPC_")) {
|
||||
return ["UPC-LD"];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function defaultCourtFor(proceedingType: string): string {
|
||||
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
|
||||
return "upc-coa-luxembourg";
|
||||
}
|
||||
if (proceedingType === "UPC_REV") {
|
||||
return "upc-cd-paris";
|
||||
}
|
||||
return "upc-ld-muenchen";
|
||||
}
|
||||
|
||||
export async function fetchCourts(courtType: string): Promise<CourtRow[]> {
|
||||
if (courtCache.has(courtType)) return courtCache.get(courtType)!;
|
||||
try {
|
||||
const resp = await fetch(`/api/tools/courts?courtType=${encodeURIComponent(courtType)}`);
|
||||
if (!resp.ok) return [];
|
||||
const rows = (await resp.json()) as CourtRow[];
|
||||
courtCache.set(courtType, rows);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// populateCourtPicker fills the <select> for the proceeding's compatible
|
||||
// court types. The row + select IDs are passed in so each page can own
|
||||
// its own DOM scope. Visible only when the proceeding has ≥2 compatible
|
||||
// courts; otherwise hidden (server resolves the jurisdiction default).
|
||||
export async function populateCourtPicker(
|
||||
rowId: string,
|
||||
selectId: string,
|
||||
proceedingType: string,
|
||||
): Promise<void> {
|
||||
const row = document.getElementById(rowId);
|
||||
const select = document.getElementById(selectId) as HTMLSelectElement | null;
|
||||
if (!row || !select) return;
|
||||
|
||||
const types = courtTypesFor(proceedingType);
|
||||
if (types.length === 0) {
|
||||
row.style.display = "none";
|
||||
select.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const lists = await Promise.all(types.map((c) => fetchCourts(c)));
|
||||
const courts = lists.flat();
|
||||
if (courts.length <= 1) {
|
||||
row.style.display = "none";
|
||||
select.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const lang = getLang();
|
||||
const defaultID = defaultCourtFor(proceedingType);
|
||||
select.innerHTML = courts.map((c) => {
|
||||
const name = lang === "en" ? c.nameEN : c.nameDE;
|
||||
return `<option value="${escAttr(c.id)}"${c.id === defaultID ? " selected" : ""}>${escHtml(name)}</option>`;
|
||||
}).join("");
|
||||
row.style.display = "";
|
||||
}
|
||||
@@ -153,6 +153,20 @@ export function ProjectFormFields(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-our-side" data-i18n="projects.field.our_side">Wir vertreten</label>
|
||||
<select id="project-our-side">
|
||||
<option value="" data-i18n="projects.field.our_side.unset">Unbekannt / nicht gesetzt</option>
|
||||
<option value="claimant" data-i18n="projects.field.our_side.claimant">Klägerseite</option>
|
||||
<option value="defendant" data-i18n="projects.field.our_side.defendant">Beklagtenseite</option>
|
||||
<option value="court" data-i18n="projects.field.our_side.court">Gericht / Tribunal</option>
|
||||
<option value="both" data-i18n="projects.field.our_side.both">Beide Seiten</option>
|
||||
</select>
|
||||
<p className="form-hint" data-i18n="projects.field.our_side.hint">
|
||||
Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. Lässt sich dort jederzeit überschreiben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-description" data-i18n="projects.field.description">Notizen</label>
|
||||
<textarea id="project-description" rows={4} placeholder="Kurznotizen zum Projekt (optional)..." data-i18n-placeholder="projects.field.description.placeholder" />
|
||||
|
||||
@@ -7,6 +7,11 @@ const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" s
|
||||
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
const ICON_LINK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
|
||||
const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>';
|
||||
// Open-book icon for the /tools/verfahrensablauf "Verfahrensablauf"
|
||||
// nav entry (t-paliad-168 → t-paliad-179 Slice 1 split). Distinct from
|
||||
// ICON_BOOK (Glossar, closed) so the two affordances read as different
|
||||
// at a glance.
|
||||
const ICON_BOOK_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/><path d="M22 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>';
|
||||
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
|
||||
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
|
||||
const ICON_GLOBE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/></svg>';
|
||||
@@ -24,6 +29,10 @@ const ICON_SPARKLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
const ICON_USERS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
|
||||
const ICON_SHIELD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>';
|
||||
const ICON_AUDIT_LOG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="15" y2="17"/></svg>';
|
||||
// Newspaper icon for the /changelog "Neuigkeiten" entry. Sparkle is now
|
||||
// reserved for the Paliadin AI surface so the two affordances don't
|
||||
// share a glyph (m's 2026-05-08 21:11 dogfood).
|
||||
const ICON_NEWSPAPER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8z"/></svg>';
|
||||
// Bell icon for the /inbox entry (t-paliad-138 4-eye approval inbox).
|
||||
const ICON_BELL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
|
||||
// Theme-toggle icons. The button cycles auto → light → dark → auto, and
|
||||
@@ -131,20 +140,27 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
navItem("/team", ICON_USERS, "nav.team", "Team", currentPath),
|
||||
)}
|
||||
|
||||
{/* Ansichten \u2014 NEW group (t-paliad-162). Holds the time-window
|
||||
views over the visible Akten. Fristen + Termine moved here
|
||||
from the old "Arbeit" group; that group is now gone. */}
|
||||
{group("nav.group.ansichten", "Ansichten",
|
||||
navItem("/events?type=deadline", ICON_CLOCK, "nav.fristen", "Fristen", currentPath) +
|
||||
navItem("/events?type=appointment", ICON_CALENDAR, "nav.termine", "Termine", currentPath),
|
||||
)}
|
||||
{/* t-paliad-177 \u2014 contextual chart link, revealed by sidebar.ts
|
||||
when the user is on a /projects/{id}/* page (but NOT on the
|
||||
chart itself). The href is filled in client-side from the
|
||||
URL path so the same Sidebar TSX serves every page. */}
|
||||
<a href="#"
|
||||
className="sidebar-item sidebar-context-chart"
|
||||
id="sidebar-project-chart-link"
|
||||
style="display:none">
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_GAUGE }} />
|
||||
<span className="sidebar-label" data-i18n="nav.context.project_chart">Als Chart anzeigen</span>
|
||||
</a>
|
||||
|
||||
{/* t-paliad-144 Phase A2 — Meine Sichten group. Hydrated by
|
||||
client/sidebar.ts from /api/user-views on mount. The
|
||||
"+ Neue Sicht" entry is always present so first-time
|
||||
users have an obvious way in. */}
|
||||
{/* Ansichten \u2014 single consolidated group (m's 2026-05-08 20:32
|
||||
dogfood: "all views under one — not Ansichten and meine Ansichten").
|
||||
Holds the built-in Fristen + Termine, the user-defined views
|
||||
hydrated by client/sidebar.ts from /api/user-views, and the
|
||||
"+ Neue Sicht" entry. The previous "Meine Sichten" split is gone. */}
|
||||
<div className="sidebar-group sidebar-views-group" id="sidebar-views-group">
|
||||
<div className="sidebar-group-label" data-i18n="nav.group.user_views">Meine Sichten</div>
|
||||
<div className="sidebar-group-label" data-i18n="nav.group.ansichten">Ansichten</div>
|
||||
{navItem("/events?type=deadline", ICON_CLOCK, "nav.fristen", "Fristen", currentPath)}
|
||||
{navItem("/events?type=appointment", ICON_CALENDAR, "nav.termine", "Termine", currentPath)}
|
||||
<div className="sidebar-views-items" id="sidebar-views-items" />
|
||||
<a href="/views/new" className="sidebar-item sidebar-views-new">
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_FOLDER }} />
|
||||
@@ -158,6 +174,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
Gerichte / Glossar), then content (Links / Downloads). */}
|
||||
{group("nav.group.werkzeuge", "Werkzeuge",
|
||||
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
|
||||
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
|
||||
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
||||
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
|
||||
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +
|
||||
@@ -182,6 +199,8 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
|
||||
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
|
||||
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
|
||||
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
||||
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
|
||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
||||
<a href="/admin/paliadin" id="sidebar-admin-paliadin-link"
|
||||
@@ -198,7 +217,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
<div className="sidebar-bottom">
|
||||
{authenticated ? (
|
||||
<a href="/changelog" className={`sidebar-item sidebar-changelog${currentPath === "/changelog" ? " active" : ""}`} id="sidebar-changelog-link">
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_NEWSPAPER }} />
|
||||
<span className="sidebar-label" data-i18n="nav.neuigkeiten">Neuigkeiten</span>
|
||||
<span className="sidebar-badge" id="sidebar-changelog-badge" style="display:none" aria-hidden="true" />
|
||||
</a>
|
||||
|
||||
@@ -55,9 +55,50 @@ export function renderDeadlinesNew(): string {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<div className="form-field" id="deadline-event-type-field">
|
||||
<label data-i18n="deadlines.field.event_type">Typ (optional)</label>
|
||||
{/* t-paliad-165 follow-up — collapsed view: when a Regel
|
||||
is selected and a default event_type is known, the
|
||||
Typ chip is hidden and the type is rendered inline
|
||||
as a single read-only summary with an "Anderen Typ
|
||||
wählen" link that re-expands the picker. */}
|
||||
<div
|
||||
className="event-type-collapsed"
|
||||
id="deadline-event-type-collapsed"
|
||||
style="display:none"
|
||||
>
|
||||
<span
|
||||
className="event-type-collapsed-label"
|
||||
id="deadline-event-type-collapsed-label"
|
||||
/>
|
||||
<span
|
||||
className="event-type-collapsed-source"
|
||||
data-i18n="deadlines.field.rule.autofill_inline"
|
||||
>
|
||||
(vorgegeben durch Regel)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="event-type-collapsed-override"
|
||||
id="deadline-event-type-override-btn"
|
||||
data-i18n="deadlines.field.rule.override"
|
||||
>
|
||||
Anderen Typ wählen
|
||||
</button>
|
||||
</div>
|
||||
<div id="deadline-event-types" className="event-type-picker-host" />
|
||||
{/* Soft warning when the user is in expanded mode AND
|
||||
has picked an event_type that doesn't include the
|
||||
rule's canonical default. Reuses the existing
|
||||
yellow form-hint--warning style; never blocking. */}
|
||||
<p
|
||||
className="form-hint form-hint--warning"
|
||||
id="deadline-event-type-rule-mismatch"
|
||||
style="display:none"
|
||||
data-i18n="deadlines.field.rule.mismatch"
|
||||
>
|
||||
Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field-row">
|
||||
|
||||
@@ -207,6 +207,9 @@ export function renderFristenrechner(): string {
|
||||
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">
|
||||
@@ -273,6 +276,14 @@ export function renderFristenrechner(): string {
|
||||
<span data-i18n="deadlines.perspective.both.short">Beide</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* t-paliad-164 — predefined-from-Akte hint. Hidden by
|
||||
default; client/fristenrechner.ts shows it when the
|
||||
active perspective came from project.our_side. The
|
||||
user can still click another chip to override. */}
|
||||
<span className="fristen-perspective-hint" id="fristen-perspective-hint"
|
||||
data-i18n="deadlines.perspective.predefined_hint" hidden>
|
||||
vorgegeben durch Akte
|
||||
</span>
|
||||
</div>
|
||||
<div className="fristen-inbox-bar" id="fristen-inbox-bar" role="group" aria-label="Inbox channel">
|
||||
<span className="fristen-inbox-bar-label" data-i18n="deadlines.inbox.label">Wo kam es an?</span>
|
||||
|
||||
@@ -117,6 +117,8 @@ export type I18nKey =
|
||||
| "admin.card.feature_flags.title"
|
||||
| "admin.card.partner_units.desc"
|
||||
| "admin.card.partner_units.title"
|
||||
| "admin.card.rules.desc"
|
||||
| "admin.card.rules.title"
|
||||
| "admin.card.team.desc"
|
||||
| "admin.card.team.title"
|
||||
| "admin.coming_soon"
|
||||
@@ -266,6 +268,173 @@ export type I18nKey =
|
||||
| "admin.partner_units.new.heading"
|
||||
| "admin.partner_units.subtitle"
|
||||
| "admin.partner_units.title"
|
||||
| "admin.rules.col.code"
|
||||
| "admin.rules.col.lifecycle"
|
||||
| "admin.rules.col.modified"
|
||||
| "admin.rules.col.name"
|
||||
| "admin.rules.col.priority"
|
||||
| "admin.rules.col.proceeding"
|
||||
| "admin.rules.edit.action.archive"
|
||||
| "admin.rules.edit.action.archive.error"
|
||||
| "admin.rules.edit.action.archive.ok"
|
||||
| "admin.rules.edit.action.clone"
|
||||
| "admin.rules.edit.action.clone.error"
|
||||
| "admin.rules.edit.action.ok"
|
||||
| "admin.rules.edit.action.publish"
|
||||
| "admin.rules.edit.action.publish.error"
|
||||
| "admin.rules.edit.action.publish.ok"
|
||||
| "admin.rules.edit.action.restore"
|
||||
| "admin.rules.edit.action.restore.error"
|
||||
| "admin.rules.edit.action.restore.ok"
|
||||
| "admin.rules.edit.action.save_draft"
|
||||
| "admin.rules.edit.action.save_draft.error"
|
||||
| "admin.rules.edit.action.save_draft.ok"
|
||||
| "admin.rules.edit.audit.action.archive"
|
||||
| "admin.rules.edit.audit.action.create"
|
||||
| "admin.rules.edit.audit.action.delete"
|
||||
| "admin.rules.edit.audit.action.publish"
|
||||
| "admin.rules.edit.audit.action.restore"
|
||||
| "admin.rules.edit.audit.action.update"
|
||||
| "admin.rules.edit.audit.actor.system"
|
||||
| "admin.rules.edit.audit.empty"
|
||||
| "admin.rules.edit.audit.exported"
|
||||
| "admin.rules.edit.audit.heading"
|
||||
| "admin.rules.edit.audit.loading"
|
||||
| "admin.rules.edit.audit.loadmore"
|
||||
| "admin.rules.edit.breadcrumb"
|
||||
| "admin.rules.edit.error.bad_id"
|
||||
| "admin.rules.edit.error.load"
|
||||
| "admin.rules.edit.error.not_found"
|
||||
| "admin.rules.edit.field.alt_duration_unit"
|
||||
| "admin.rules.edit.field.alt_duration_value"
|
||||
| "admin.rules.edit.field.alt_rule_code"
|
||||
| "admin.rules.edit.field.anchor_alt"
|
||||
| "admin.rules.edit.field.code"
|
||||
| "admin.rules.edit.field.combine_op"
|
||||
| "admin.rules.edit.field.concept"
|
||||
| "admin.rules.edit.field.condition.valid"
|
||||
| "admin.rules.edit.field.condition_hint"
|
||||
| "admin.rules.edit.field.deadline_notes"
|
||||
| "admin.rules.edit.field.deadline_notes_en"
|
||||
| "admin.rules.edit.field.description"
|
||||
| "admin.rules.edit.field.duration_unit"
|
||||
| "admin.rules.edit.field.duration_value"
|
||||
| "admin.rules.edit.field.event_type"
|
||||
| "admin.rules.edit.field.is_court_set"
|
||||
| "admin.rules.edit.field.is_spawn"
|
||||
| "admin.rules.edit.field.legal_source"
|
||||
| "admin.rules.edit.field.name"
|
||||
| "admin.rules.edit.field.name_en"
|
||||
| "admin.rules.edit.field.parent"
|
||||
| "admin.rules.edit.field.primary_party"
|
||||
| "admin.rules.edit.field.priority"
|
||||
| "admin.rules.edit.field.proceeding"
|
||||
| "admin.rules.edit.field.proceeding.none"
|
||||
| "admin.rules.edit.field.rule_code"
|
||||
| "admin.rules.edit.field.sequence_order"
|
||||
| "admin.rules.edit.field.spawn_label"
|
||||
| "admin.rules.edit.field.spawn_proceeding"
|
||||
| "admin.rules.edit.field.spawn_proceeding.none"
|
||||
| "admin.rules.edit.field.timing"
|
||||
| "admin.rules.edit.field.trigger"
|
||||
| "admin.rules.edit.field.trigger.none"
|
||||
| "admin.rules.edit.heading.loading"
|
||||
| "admin.rules.edit.modal.archive.body"
|
||||
| "admin.rules.edit.modal.archive.title"
|
||||
| "admin.rules.edit.modal.clone.body"
|
||||
| "admin.rules.edit.modal.clone.title"
|
||||
| "admin.rules.edit.modal.publish.body"
|
||||
| "admin.rules.edit.modal.publish.title"
|
||||
| "admin.rules.edit.modal.restore.body"
|
||||
| "admin.rules.edit.modal.restore.title"
|
||||
| "admin.rules.edit.modal.save_draft.body"
|
||||
| "admin.rules.edit.modal.save_draft.title"
|
||||
| "admin.rules.edit.preview.empty"
|
||||
| "admin.rules.edit.preview.error"
|
||||
| "admin.rules.edit.preview.flags"
|
||||
| "admin.rules.edit.preview.heading"
|
||||
| "admin.rules.edit.preview.hint"
|
||||
| "admin.rules.edit.preview.only_drafts"
|
||||
| "admin.rules.edit.preview.run"
|
||||
| "admin.rules.edit.preview.running"
|
||||
| "admin.rules.edit.preview.trigger_date"
|
||||
| "admin.rules.edit.preview.trigger_required"
|
||||
| "admin.rules.edit.section.condition"
|
||||
| "admin.rules.edit.section.display"
|
||||
| "admin.rules.edit.section.identity"
|
||||
| "admin.rules.edit.section.lifecycle"
|
||||
| "admin.rules.edit.section.party"
|
||||
| "admin.rules.edit.section.proceeding"
|
||||
| "admin.rules.edit.section.timing"
|
||||
| "admin.rules.edit.title"
|
||||
| "admin.rules.empty"
|
||||
| "admin.rules.error.load"
|
||||
| "admin.rules.export.breadcrumb"
|
||||
| "admin.rules.export.copied"
|
||||
| "admin.rules.export.copy"
|
||||
| "admin.rules.export.copy_failed"
|
||||
| "admin.rules.export.count"
|
||||
| "admin.rules.export.download"
|
||||
| "admin.rules.export.error"
|
||||
| "admin.rules.export.field.since"
|
||||
| "admin.rules.export.heading"
|
||||
| "admin.rules.export.latest"
|
||||
| "admin.rules.export.no_pending"
|
||||
| "admin.rules.export.ok"
|
||||
| "admin.rules.export.run"
|
||||
| "admin.rules.export.running"
|
||||
| "admin.rules.export.subtitle"
|
||||
| "admin.rules.export.title"
|
||||
| "admin.rules.filter.lifecycle"
|
||||
| "admin.rules.filter.lifecycle.any"
|
||||
| "admin.rules.filter.proceeding"
|
||||
| "admin.rules.filter.proceeding.any"
|
||||
| "admin.rules.filter.search"
|
||||
| "admin.rules.filter.search.placeholder"
|
||||
| "admin.rules.filter.trigger"
|
||||
| "admin.rules.filter.trigger.any"
|
||||
| "admin.rules.lifecycle.archived"
|
||||
| "admin.rules.lifecycle.draft"
|
||||
| "admin.rules.lifecycle.published"
|
||||
| "admin.rules.list.export"
|
||||
| "admin.rules.list.heading"
|
||||
| "admin.rules.list.new"
|
||||
| "admin.rules.list.subtitle"
|
||||
| "admin.rules.list.title"
|
||||
| "admin.rules.loading"
|
||||
| "admin.rules.modal.confirm"
|
||||
| "admin.rules.modal.error.create"
|
||||
| "admin.rules.modal.error.name_required"
|
||||
| "admin.rules.modal.error.resolve"
|
||||
| "admin.rules.modal.field.duration"
|
||||
| "admin.rules.modal.field.name"
|
||||
| "admin.rules.modal.field.name_en"
|
||||
| "admin.rules.modal.new.body"
|
||||
| "admin.rules.modal.new.title"
|
||||
| "admin.rules.modal.reason"
|
||||
| "admin.rules.modal.reason.hint"
|
||||
| "admin.rules.modal.reason.placeholder"
|
||||
| "admin.rules.modal.reason.too_short"
|
||||
| "admin.rules.modal.resolve.body"
|
||||
| "admin.rules.modal.resolve.title"
|
||||
| "admin.rules.orphans.empty"
|
||||
| "admin.rules.orphans.field.proceeding"
|
||||
| "admin.rules.orphans.field.project"
|
||||
| "admin.rules.orphans.field.reason"
|
||||
| "admin.rules.orphans.loading"
|
||||
| "admin.rules.orphans.no_candidates"
|
||||
| "admin.rules.orphans.reason.ambiguous"
|
||||
| "admin.rules.orphans.reason.manual_unbound"
|
||||
| "admin.rules.orphans.reason.no_match"
|
||||
| "admin.rules.orphans.reason.no_project"
|
||||
| "admin.rules.orphans.resolved"
|
||||
| "admin.rules.orphans.subtitle"
|
||||
| "admin.rules.priority.informational"
|
||||
| "admin.rules.priority.mandatory"
|
||||
| "admin.rules.priority.optional"
|
||||
| "admin.rules.priority.recommended"
|
||||
| "admin.rules.tab.orphans"
|
||||
| "admin.rules.tab.rules"
|
||||
| "admin.section.available"
|
||||
| "admin.section.planned"
|
||||
| "admin.subtitle"
|
||||
@@ -652,6 +821,7 @@ export type I18nKey =
|
||||
| "dashboard.action.short.fristen_imported"
|
||||
| "dashboard.action.short.note_created"
|
||||
| "dashboard.action.short.notiz_created"
|
||||
| "dashboard.action.short.our_side_changed"
|
||||
| "dashboard.action.short.partei_added"
|
||||
| "dashboard.action.short.partei_removed"
|
||||
| "dashboard.action.short.project_archived"
|
||||
@@ -819,7 +989,11 @@ export type I18nKey =
|
||||
| "deadlines.field.notes"
|
||||
| "deadlines.field.notes.placeholder"
|
||||
| "deadlines.field.rule"
|
||||
| "deadlines.field.rule.autofill"
|
||||
| "deadlines.field.rule.autofill_inline"
|
||||
| "deadlines.field.rule.mismatch"
|
||||
| "deadlines.field.rule.none"
|
||||
| "deadlines.field.rule.override"
|
||||
| "deadlines.field.title"
|
||||
| "deadlines.field.title.placeholder"
|
||||
| "deadlines.filter.akte"
|
||||
@@ -905,8 +1079,14 @@ export type I18nKey =
|
||||
| "deadlines.perspective.defendant.short"
|
||||
| "deadlines.perspective.defendant.title"
|
||||
| "deadlines.perspective.label"
|
||||
| "deadlines.perspective.predefined_hint"
|
||||
| "deadlines.print"
|
||||
| "deadlines.priority.date"
|
||||
| "deadlines.priority.informational"
|
||||
| "deadlines.priority.informational.notice_label"
|
||||
| "deadlines.priority.mandatory"
|
||||
| "deadlines.priority.optional"
|
||||
| "deadlines.priority.recommended"
|
||||
| "deadlines.proceeding.reselect"
|
||||
| "deadlines.proceeding.selected"
|
||||
| "deadlines.reset"
|
||||
@@ -964,6 +1144,8 @@ export type I18nKey =
|
||||
| "deadlines.step1.selected"
|
||||
| "deadlines.step1.summary.adhoc.suffix"
|
||||
| "deadlines.step2"
|
||||
| "deadlines.step2.browse.desc"
|
||||
| "deadlines.step2.browse.title"
|
||||
| "deadlines.step2.file.desc"
|
||||
| "deadlines.step2.file.title"
|
||||
| "deadlines.step2.happened.desc"
|
||||
@@ -1108,6 +1290,7 @@ export type I18nKey =
|
||||
| "event.title.deadline_updated"
|
||||
| "event.title.deadlines_imported"
|
||||
| "event.title.note_created"
|
||||
| "event.title.our_side_changed"
|
||||
| "event.title.project_archived"
|
||||
| "event.title.project_created"
|
||||
| "event.title.project_reparented"
|
||||
@@ -1428,11 +1611,14 @@ export type I18nKey =
|
||||
| "nav.admin.event_types"
|
||||
| "nav.admin.paliadin"
|
||||
| "nav.admin.partner_units"
|
||||
| "nav.admin.rules"
|
||||
| "nav.admin.rules_export"
|
||||
| "nav.admin.team"
|
||||
| "nav.agenda"
|
||||
| "nav.akten"
|
||||
| "nav.caldav"
|
||||
| "nav.checklisten"
|
||||
| "nav.context.project_chart"
|
||||
| "nav.dashboard"
|
||||
| "nav.downloads"
|
||||
| "nav.einstellungen"
|
||||
@@ -1459,6 +1645,7 @@ export type I18nKey =
|
||||
| "nav.team"
|
||||
| "nav.termine"
|
||||
| "nav.user_views.new"
|
||||
| "nav.verfahrensablauf"
|
||||
| "notes.cancel"
|
||||
| "notes.delete"
|
||||
| "notes.delete.confirm"
|
||||
@@ -1538,6 +1725,8 @@ export type I18nKey =
|
||||
| "paliadin.error.upstream"
|
||||
| "paliadin.heading"
|
||||
| "paliadin.input.placeholder"
|
||||
| "paliadin.late.marker"
|
||||
| "paliadin.late.waiting"
|
||||
| "paliadin.reset"
|
||||
| "paliadin.send"
|
||||
| "paliadin.starter.concept"
|
||||
@@ -1561,6 +1750,10 @@ export type I18nKey =
|
||||
| "partner_unit.members_label"
|
||||
| "partner_unit.none"
|
||||
| "partner_unit.subtitle"
|
||||
| "project.instance_level.appeal"
|
||||
| "project.instance_level.cassation"
|
||||
| "project.instance_level.first"
|
||||
| "project.instance_level.unset"
|
||||
| "projects.cancel"
|
||||
| "projects.cards.deadline_open"
|
||||
| "projects.cards.deadline_overdue"
|
||||
@@ -1611,6 +1804,42 @@ export type I18nKey =
|
||||
| "projects.cards.show_all_levels"
|
||||
| "projects.cards.show_all_levels.hint"
|
||||
| "projects.cards.team"
|
||||
| "projects.chart.back"
|
||||
| "projects.chart.control.columns.auto"
|
||||
| "projects.chart.control.density.label"
|
||||
| "projects.chart.control.density.standard"
|
||||
| "projects.chart.control.export.soon"
|
||||
| "projects.chart.control.layout.horizontal"
|
||||
| "projects.chart.control.palette.default"
|
||||
| "projects.chart.control.palette.label"
|
||||
| "projects.chart.control.range.label"
|
||||
| "projects.chart.density.compact"
|
||||
| "projects.chart.density.spacious"
|
||||
| "projects.chart.density.standard"
|
||||
| "projects.chart.error.mount"
|
||||
| "projects.chart.export.csv"
|
||||
| "projects.chart.export.ics"
|
||||
| "projects.chart.export.json"
|
||||
| "projects.chart.export.menu"
|
||||
| "projects.chart.export.png"
|
||||
| "projects.chart.export.print"
|
||||
| "projects.chart.export.svg"
|
||||
| "projects.chart.loading"
|
||||
| "projects.chart.notfound"
|
||||
| "projects.chart.palette.default"
|
||||
| "projects.chart.palette.high_contrast"
|
||||
| "projects.chart.palette.kind_coded"
|
||||
| "projects.chart.palette.print"
|
||||
| "projects.chart.palette.track_coded"
|
||||
| "projects.chart.permalink.copy"
|
||||
| "projects.chart.permalink.title"
|
||||
| "projects.chart.range.1y"
|
||||
| "projects.chart.range.2y"
|
||||
| "projects.chart.range.all"
|
||||
| "projects.chart.range.custom"
|
||||
| "projects.chart.range.from"
|
||||
| "projects.chart.range.to"
|
||||
| "projects.chart.title"
|
||||
| "projects.chip.all"
|
||||
| "projects.chip.has_open_deadlines"
|
||||
| "projects.chip.mine"
|
||||
@@ -1683,6 +1912,79 @@ export type I18nKey =
|
||||
| "projects.detail.parteien.role.defendant"
|
||||
| "projects.detail.parteien.role.thirdparty"
|
||||
| "projects.detail.save"
|
||||
| "projects.detail.smarttimeline.add.cancel"
|
||||
| "projects.detail.smarttimeline.add.choice.amend"
|
||||
| "projects.detail.smarttimeline.add.choice.appointment"
|
||||
| "projects.detail.smarttimeline.add.choice.counterclaim"
|
||||
| "projects.detail.smarttimeline.add.choice.deadline"
|
||||
| "projects.detail.smarttimeline.add.choice.disabled"
|
||||
| "projects.detail.smarttimeline.add.choice.milestone"
|
||||
| "projects.detail.smarttimeline.add.cta"
|
||||
| "projects.detail.smarttimeline.add.modal.title"
|
||||
| "projects.detail.smarttimeline.add.submit"
|
||||
| "projects.detail.smarttimeline.anchor.cancel"
|
||||
| "projects.detail.smarttimeline.anchor.error"
|
||||
| "projects.detail.smarttimeline.anchor.invalid_date"
|
||||
| "projects.detail.smarttimeline.anchor.save"
|
||||
| "projects.detail.smarttimeline.anchor.saved"
|
||||
| "projects.detail.smarttimeline.anchor.saving"
|
||||
| "projects.detail.smarttimeline.anchor.set"
|
||||
| "projects.detail.smarttimeline.audit.toggle.hide"
|
||||
| "projects.detail.smarttimeline.audit.toggle.show"
|
||||
| "projects.detail.smarttimeline.client.matter_list.empty"
|
||||
| "projects.detail.smarttimeline.client.matter_list.heading"
|
||||
| "projects.detail.smarttimeline.client.matter_list.hint"
|
||||
| "projects.detail.smarttimeline.client.toggle.lanes"
|
||||
| "projects.detail.smarttimeline.client.toggle.matter_list"
|
||||
| "projects.detail.smarttimeline.counterclaim.case_number"
|
||||
| "projects.detail.smarttimeline.counterclaim.flip_hint"
|
||||
| "projects.detail.smarttimeline.counterclaim.flip_override"
|
||||
| "projects.detail.smarttimeline.counterclaim.procedure"
|
||||
| "projects.detail.smarttimeline.counterclaim.saving"
|
||||
| "projects.detail.smarttimeline.counterclaim.submit"
|
||||
| "projects.detail.smarttimeline.counterclaim.title"
|
||||
| "projects.detail.smarttimeline.depends_on.date_open"
|
||||
| "projects.detail.smarttimeline.depends_on.hide_path"
|
||||
| "projects.detail.smarttimeline.depends_on.path_hint"
|
||||
| "projects.detail.smarttimeline.depends_on.prefix"
|
||||
| "projects.detail.smarttimeline.depends_on.show_path"
|
||||
| "projects.detail.smarttimeline.empty"
|
||||
| "projects.detail.smarttimeline.error.generic"
|
||||
| "projects.detail.smarttimeline.error.title_required"
|
||||
| "projects.detail.smarttimeline.kind.appointment"
|
||||
| "projects.detail.smarttimeline.kind.deadline"
|
||||
| "projects.detail.smarttimeline.kind.milestone"
|
||||
| "projects.detail.smarttimeline.kind.projected"
|
||||
| "projects.detail.smarttimeline.lane.empty"
|
||||
| "projects.detail.smarttimeline.lane.filter.all"
|
||||
| "projects.detail.smarttimeline.lane.filter.label"
|
||||
| "projects.detail.smarttimeline.lookahead.less"
|
||||
| "projects.detail.smarttimeline.lookahead.more"
|
||||
| "projects.detail.smarttimeline.milestone.bubble_up"
|
||||
| "projects.detail.smarttimeline.milestone.bubble_up_hint"
|
||||
| "projects.detail.smarttimeline.milestone.date"
|
||||
| "projects.detail.smarttimeline.milestone.description"
|
||||
| "projects.detail.smarttimeline.milestone.title"
|
||||
| "projects.detail.smarttimeline.open_chart"
|
||||
| "projects.detail.smarttimeline.section.future"
|
||||
| "projects.detail.smarttimeline.section.past"
|
||||
| "projects.detail.smarttimeline.section.undated"
|
||||
| "projects.detail.smarttimeline.status.court_set"
|
||||
| "projects.detail.smarttimeline.status.done"
|
||||
| "projects.detail.smarttimeline.status.off_script"
|
||||
| "projects.detail.smarttimeline.status.open"
|
||||
| "projects.detail.smarttimeline.status.overdue"
|
||||
| "projects.detail.smarttimeline.status.predicted"
|
||||
| "projects.detail.smarttimeline.status.predicted_overdue"
|
||||
| "projects.detail.smarttimeline.today"
|
||||
| "projects.detail.smarttimeline.track.both"
|
||||
| "projects.detail.smarttimeline.track.header.counterclaim"
|
||||
| "projects.detail.smarttimeline.track.header.parent"
|
||||
| "projects.detail.smarttimeline.track.header.parent_context"
|
||||
| "projects.detail.smarttimeline.track.label"
|
||||
| "projects.detail.smarttimeline.track.only.counterclaim"
|
||||
| "projects.detail.smarttimeline.track.only.parent"
|
||||
| "projects.detail.smarttimeline.track.only.parent_context"
|
||||
| "projects.detail.tab.checklisten"
|
||||
| "projects.detail.tab.fristen"
|
||||
| "projects.detail.tab.kinder"
|
||||
@@ -1744,6 +2046,14 @@ export type I18nKey =
|
||||
| "projects.field.matter_number"
|
||||
| "projects.field.netdocuments_url"
|
||||
| "projects.field.office"
|
||||
| "projects.field.our_side"
|
||||
| "projects.field.our_side.both"
|
||||
| "projects.field.our_side.claimant"
|
||||
| "projects.field.our_side.court"
|
||||
| "projects.field.our_side.defendant"
|
||||
| "projects.field.our_side.hint"
|
||||
| "projects.field.our_side.none"
|
||||
| "projects.field.our_side.unset"
|
||||
| "projects.field.parent"
|
||||
| "projects.field.parent.hint"
|
||||
| "projects.field.parent.placeholder"
|
||||
@@ -1921,12 +2231,89 @@ export type I18nKey =
|
||||
| "theme.toggle.cycle.light"
|
||||
| "theme.toggle.dark"
|
||||
| "theme.toggle.light"
|
||||
| "tools.verfahrensablauf.heading"
|
||||
| "tools.verfahrensablauf.subtitle"
|
||||
| "tools.verfahrensablauf.title"
|
||||
| "unit_role.attorney"
|
||||
| "unit_role.lead"
|
||||
| "unit_role.pa"
|
||||
| "unit_role.paralegal"
|
||||
| "unit_role.senior_pa"
|
||||
| "verlauf.spawn.chip"
|
||||
| "verlauf.spawn.cycle_warning"
|
||||
| "views.action.edit"
|
||||
| "views.bar.action.reset"
|
||||
| "views.bar.action.save_as_view"
|
||||
| "views.bar.appointment_type.consultation"
|
||||
| "views.bar.appointment_type.deadline_hearing"
|
||||
| "views.bar.appointment_type.hearing"
|
||||
| "views.bar.appointment_type.meeting"
|
||||
| "views.bar.approval_entity.appointment"
|
||||
| "views.bar.approval_entity.deadline"
|
||||
| "views.bar.approval_role.any_visible"
|
||||
| "views.bar.approval_role.approver_eligible"
|
||||
| "views.bar.approval_role.self_requested"
|
||||
| "views.bar.approval_status.approved"
|
||||
| "views.bar.approval_status.pending"
|
||||
| "views.bar.approval_status.rejected"
|
||||
| "views.bar.approval_status.revoked"
|
||||
| "views.bar.common.all"
|
||||
| "views.bar.deadline_status.completed"
|
||||
| "views.bar.deadline_status.pending"
|
||||
| "views.bar.density.comfortable"
|
||||
| "views.bar.density.compact"
|
||||
| "views.bar.label.appointment_type"
|
||||
| "views.bar.label.approval_entity"
|
||||
| "views.bar.label.approval_role"
|
||||
| "views.bar.label.approval_status"
|
||||
| "views.bar.label.deadline_status"
|
||||
| "views.bar.label.density"
|
||||
| "views.bar.label.personal"
|
||||
| "views.bar.label.project_event_kind"
|
||||
| "views.bar.label.shape"
|
||||
| "views.bar.label.sort"
|
||||
| "views.bar.label.time"
|
||||
| "views.bar.label.timeline_status"
|
||||
| "views.bar.label.timeline_track"
|
||||
| "views.bar.personal.on"
|
||||
| "views.bar.save.cancel"
|
||||
| "views.bar.save.confirm"
|
||||
| "views.bar.save.error.name_required"
|
||||
| "views.bar.save.error.network"
|
||||
| "views.bar.save.error.slug_format"
|
||||
| "views.bar.save.error.slug_taken"
|
||||
| "views.bar.save.field.name"
|
||||
| "views.bar.save.field.show_count"
|
||||
| "views.bar.save.field.slug"
|
||||
| "views.bar.save.field.slug_hint"
|
||||
| "views.bar.save.heading"
|
||||
| "views.bar.shape.calendar"
|
||||
| "views.bar.shape.cards"
|
||||
| "views.bar.shape.list"
|
||||
| "views.bar.sort.date_asc"
|
||||
| "views.bar.sort.date_desc"
|
||||
| "views.bar.time.all"
|
||||
| "views.bar.time.any"
|
||||
| "views.bar.time.custom"
|
||||
| "views.bar.time.custom.coming_soon"
|
||||
| "views.bar.time.next_30d"
|
||||
| "views.bar.time.next_7d"
|
||||
| "views.bar.time.next_90d"
|
||||
| "views.bar.time.past_30d"
|
||||
| "views.bar.time.past_7d"
|
||||
| "views.bar.time.past_90d"
|
||||
| "views.bar.timeline_status.court_set"
|
||||
| "views.bar.timeline_status.done"
|
||||
| "views.bar.timeline_status.macro.future"
|
||||
| "views.bar.timeline_status.macro.past"
|
||||
| "views.bar.timeline_status.off_script"
|
||||
| "views.bar.timeline_status.open"
|
||||
| "views.bar.timeline_status.overdue"
|
||||
| "views.bar.timeline_status.predicted"
|
||||
| "views.bar.timeline_status.predicted_overdue"
|
||||
| "views.bar.timeline_track.counterclaim"
|
||||
| "views.bar.timeline_track.off_script"
|
||||
| "views.bar.timeline_track.parent"
|
||||
| "views.calendar.mobile_fallback"
|
||||
| "views.col.actor"
|
||||
| "views.col.appointment_type"
|
||||
@@ -2008,11 +2395,13 @@ export type I18nKey =
|
||||
| "views.shape.calendar"
|
||||
| "views.shape.cards"
|
||||
| "views.shape.list"
|
||||
| "views.shape.timeline"
|
||||
| "views.source.appointment"
|
||||
| "views.source.approval_request"
|
||||
| "views.source.deadline"
|
||||
| "views.source.project_event"
|
||||
| "views.subtitle"
|
||||
| "views.timeline.caveat.body"
|
||||
| "views.title"
|
||||
| "views.toast.inaccessible_n"
|
||||
| "views.toast.inaccessible_one";
|
||||
|
||||
@@ -5,13 +5,20 @@ import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Approval inbox page (t-paliad-138). Two-tab UI:
|
||||
// - "Zur Genehmigung": requests where the caller is qualified to approve
|
||||
// - "Meine Anfragen": requests submitted by the caller
|
||||
// /inbox — t-paliad-163 universal-filter migration.
|
||||
//
|
||||
// Hydrates lazily on load (no inline payload) — unlike the dashboard, the
|
||||
// inbox doesn't carry SSR state. The client bundle calls /api/inbox/* on
|
||||
// hydration and re-renders.
|
||||
// The page is a thin shell around two host divs: one for the
|
||||
// <FilterBar> primitive and one for the result list. The bar takes
|
||||
// care of every axis (approval_viewer_role chip cluster replaces the
|
||||
// two-tab UI; status / entity_type / time chips are new affordances).
|
||||
// Rows render via shape-list.ts with row_action="approve" — the
|
||||
// inbox-specific markup that produces the diff + approve/reject/revoke
|
||||
// buttons. Action handlers are wired in client/inbox.ts.
|
||||
//
|
||||
// The legacy `?tab=` URL is preserved by the client: ?tab=mine maps
|
||||
// to ?a_role=self_requested before the bar mounts so old bookmarks
|
||||
// (sidebar bell, Genehmigungen email links) keep landing on the
|
||||
// expected sub-view.
|
||||
|
||||
export function renderInbox(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -38,18 +45,11 @@ export function renderInbox(): string {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="agenda-controls">
|
||||
<div className="agenda-filter-group" role="group">
|
||||
<div className="agenda-chip-row" id="inbox-tab-row">
|
||||
<button type="button" className="agenda-chip active" data-tab="pending-mine" data-i18n="approvals.tab.pending_mine">Zur Genehmigung</button>
|
||||
<button type="button" className="agenda-chip" data-tab="mine" data-i18n="approvals.tab.mine">Meine Anfragen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="inbox-filter-bar" />
|
||||
|
||||
<div className="agenda-loading" id="inbox-loading" data-i18n="agenda.loading">Lädt …</div>
|
||||
<div className="entity-empty" id="inbox-empty" style="display:none" />
|
||||
<ul className="inbox-list" id="inbox-list" />
|
||||
<div id="inbox-results" />
|
||||
|
||||
{/* t-paliad-154 — admin-only nudge surfaced when:
|
||||
- the user is global_admin
|
||||
|
||||
172
frontend/src/projects-chart.tsx
Normal file
172
frontend/src/projects-chart.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-177 Slice 1 — Project Timeline / Chart standalone page.
|
||||
//
|
||||
// Pure shell: header / controls scaffold (inert chips for the
|
||||
// vertical-toggle, density and palette pickers, which Slice 3 wires
|
||||
// live) + a chart host that client/projects-chart.ts mounts the SVG
|
||||
// renderer into. Project metadata is loaded client-side so the same
|
||||
// dist/projects-chart.html serves every {id}.
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
|
||||
export function renderProjectsChart(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="projects.chart.title">Projekt-Chart — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/projects" />
|
||||
<BottomNav currentPath="/projects" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page smart-timeline-chart-page">
|
||||
<div className="container">
|
||||
<a
|
||||
id="projects-chart-back-link"
|
||||
href="/projects"
|
||||
className="back-link"
|
||||
data-i18n="projects.chart.back"
|
||||
>
|
||||
← Zurück zum Verlauf
|
||||
</a>
|
||||
|
||||
<div id="projects-chart-loading" className="entity-loading">
|
||||
<p data-i18n="projects.chart.loading">Lädt…</p>
|
||||
</div>
|
||||
|
||||
<div id="projects-chart-notfound" className="entity-empty" style="display:none">
|
||||
<p data-i18n="projects.chart.notfound">Projekt nicht gefunden oder keine Berechtigung.</p>
|
||||
</div>
|
||||
|
||||
<div id="projects-chart-body" style="display:none">
|
||||
<header className="smart-timeline-chart-header">
|
||||
<h1 id="projects-chart-title" />
|
||||
<span id="projects-chart-meta" className="smart-timeline-chart-meta" />
|
||||
</header>
|
||||
|
||||
<div className="smart-timeline-chart-controls" id="projects-chart-controls">
|
||||
{/* Slice 1: chips render inert. Slice 3 wires them to
|
||||
density / palette / zoom state. The presence keeps
|
||||
the surface visually stable when controls light up. */}
|
||||
<span className="chip-inert" data-i18n="projects.chart.control.layout.horizontal" title="Slice 3">
|
||||
Layout: Horizontal
|
||||
</span>
|
||||
<span className="smart-timeline-chart-picker">
|
||||
<label htmlFor="projects-chart-range" data-i18n="projects.chart.control.range.label">
|
||||
Zeitraum:
|
||||
</label>
|
||||
<select id="projects-chart-range">
|
||||
<option value="1y" data-i18n="projects.chart.range.1y">1 Jahr</option>
|
||||
<option value="2y" data-i18n="projects.chart.range.2y">2 Jahre</option>
|
||||
<option value="all" data-i18n="projects.chart.range.all">Alles anzeigen</option>
|
||||
<option value="custom" data-i18n="projects.chart.range.custom">Eigener Bereich…</option>
|
||||
</select>
|
||||
</span>
|
||||
<span className="smart-timeline-chart-picker smart-timeline-chart-range-custom" id="projects-chart-range-custom" style="display:none">
|
||||
<label htmlFor="projects-chart-range-from" data-i18n="projects.chart.range.from">Von:</label>
|
||||
<input type="date" id="projects-chart-range-from" />
|
||||
<label htmlFor="projects-chart-range-to" data-i18n="projects.chart.range.to">Bis:</label>
|
||||
<input type="date" id="projects-chart-range-to" />
|
||||
</span>
|
||||
<span className="smart-timeline-chart-picker">
|
||||
<label htmlFor="projects-chart-density" data-i18n="projects.chart.control.density.label">
|
||||
Dichte:
|
||||
</label>
|
||||
<select id="projects-chart-density">
|
||||
<option value="compact" data-i18n="projects.chart.density.compact">Kompakt</option>
|
||||
<option value="standard" data-i18n="projects.chart.density.standard">Standard</option>
|
||||
<option value="spacious" data-i18n="projects.chart.density.spacious">Großzügig</option>
|
||||
</select>
|
||||
</span>
|
||||
<span className="smart-timeline-chart-picker">
|
||||
<label htmlFor="projects-chart-palette" data-i18n="projects.chart.control.palette.label">
|
||||
Palette:
|
||||
</label>
|
||||
<select id="projects-chart-palette">
|
||||
<option value="default" data-i18n="projects.chart.palette.default">Standard</option>
|
||||
<option value="kind-coded" data-i18n="projects.chart.palette.kind_coded">Nach Ereignistyp</option>
|
||||
<option value="track-coded" data-i18n="projects.chart.palette.track_coded">Nach Spur</option>
|
||||
<option value="high-contrast" data-i18n="projects.chart.palette.high_contrast">Hoher Kontrast</option>
|
||||
<option value="print" data-i18n="projects.chart.palette.print">Druck (S/W)</option>
|
||||
</select>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
id="projects-chart-copylink"
|
||||
className="smart-timeline-chart-copylink"
|
||||
data-i18n="projects.chart.permalink.copy"
|
||||
data-i18n-title="projects.chart.permalink.title"
|
||||
title="URL mit allen Filtern in die Zwischenablage kopieren"
|
||||
>
|
||||
🔗 Link kopieren
|
||||
</button>
|
||||
<details className="smart-timeline-chart-export">
|
||||
<summary data-i18n="projects.chart.export.menu">
|
||||
⇓ Export
|
||||
</summary>
|
||||
<menu className="smart-timeline-chart-export-menu">
|
||||
<li>
|
||||
<button type="button" id="projects-chart-export-svg" data-i18n="projects.chart.export.svg">
|
||||
SVG (Vektorgrafik)
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" id="projects-chart-export-png" data-i18n="projects.chart.export.png">
|
||||
PNG (Bild, 2× HiDPI)
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" id="projects-chart-export-print" data-i18n="projects.chart.export.print">
|
||||
PDF (Drucken)
|
||||
</button>
|
||||
</li>
|
||||
<li className="smart-timeline-chart-export-divider" />
|
||||
<li>
|
||||
<button type="button" id="projects-chart-export-csv" data-i18n="projects.chart.export.csv">
|
||||
CSV (Excel-Tabelle)
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" id="projects-chart-export-json" data-i18n="projects.chart.export.json">
|
||||
JSON (Rohdaten)
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" id="projects-chart-export-ics" data-i18n="projects.chart.export.ics">
|
||||
iCal (.ics — Outlook / Apple)
|
||||
</button>
|
||||
</li>
|
||||
</menu>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div id="projects-chart-lanes-filter" className="smart-timeline-chart-lanes-filter" style="display:none" />
|
||||
|
||||
<div id="projects-chart-host" className="smart-timeline-chart-host" />
|
||||
|
||||
<p id="projects-chart-undated" className="smart-timeline-chart-undated-hint" style="display:none" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/projects-chart.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -82,21 +82,145 @@ export function renderProjectsDetail(): string {
|
||||
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
|
||||
</nav>
|
||||
|
||||
{/* History (Verlauf) */}
|
||||
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
|
||||
The legacy <ul.entity-events> + Mehr-laden controls are
|
||||
replaced by the vertical timeline (rendered by
|
||||
client/views/shape-timeline.ts). The bar from t-paliad-170
|
||||
keeps driving filter state via its customRunner. */}
|
||||
<section className="entity-tab-panel" id="tab-history">
|
||||
<div className="party-controls">
|
||||
<div className="smart-timeline-controls">
|
||||
<button type="button" className="btn-secondary btn-small subtree-toggle" aria-pressed="false">
|
||||
Inkl. Unterprojekte
|
||||
</button>
|
||||
</div>
|
||||
<ul className="entity-events" id="project-events-list" />
|
||||
<p className="entity-events-empty" id="project-events-empty" style="display:none" data-i18n="projects.detail.verlauf.empty">
|
||||
Noch keine Ereignisse aufgezeichnet.
|
||||
</p>
|
||||
<div className="entity-events-loadmore" id="project-events-loadmore-wrap" style="display:none">
|
||||
<button type="button" className="btn-secondary" id="project-events-loadmore" data-i18n="projects.detail.verlauf.loadMore">
|
||||
Mehr laden
|
||||
<button type="button" className="btn-secondary btn-small" id="smart-timeline-audit-toggle" aria-pressed="false" data-i18n="projects.detail.smarttimeline.audit.toggle.show">
|
||||
Audit-Log anzeigen
|
||||
</button>
|
||||
{/* Slice 4 — Client-level Timeline-Ansicht toggle.
|
||||
Hidden by default (display:none); the client TS
|
||||
flips it visible only when project.type === 'client'. */}
|
||||
<button type="button" className="btn-secondary btn-small" id="smart-timeline-client-toggle" aria-pressed="false" style="display:none" data-i18n="projects.detail.smarttimeline.client.toggle.lanes">
|
||||
Timeline-Ansicht
|
||||
</button>
|
||||
<button type="button" className="btn-primary btn-cta-lime btn-small" id="smart-timeline-add-btn" data-i18n="projects.detail.smarttimeline.add.cta">
|
||||
+ Eintrag
|
||||
</button>
|
||||
{/* t-paliad-177 — link to the standalone /chart page.
|
||||
Opens in a new tab per design §8.1; the Verlauf
|
||||
embed itself stays vertical-DOM-only. */}
|
||||
<a
|
||||
id="smart-timeline-open-chart"
|
||||
className="btn-secondary btn-small"
|
||||
href="#"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-i18n="projects.detail.smarttimeline.open_chart"
|
||||
>
|
||||
Als Chart anzeigen ↗
|
||||
</a>
|
||||
</div>
|
||||
<div id="project-events-filter-bar" />
|
||||
<div id="project-smart-timeline" className="smart-timeline" />
|
||||
|
||||
{/* "Eigener Meilenstein" modal. Hidden by default; opened
|
||||
by the "+ Eintrag" CTA above. The other modal options
|
||||
route to existing flows (see client wiring). */}
|
||||
<div id="smart-timeline-add-modal" className="smart-timeline-modal" style="display:none" role="dialog" aria-modal="true">
|
||||
<div className="smart-timeline-modal-card">
|
||||
<h3 data-i18n="projects.detail.smarttimeline.add.modal.title">
|
||||
Neuer Eintrag im SmartTimeline
|
||||
</h3>
|
||||
|
||||
<div className="smart-timeline-add-choices">
|
||||
<a id="smart-timeline-add-deadline" className="smart-timeline-add-choice" href="#" data-i18n="projects.detail.smarttimeline.add.choice.deadline">
|
||||
Frist anlegen
|
||||
</a>
|
||||
<a id="smart-timeline-add-appointment" className="smart-timeline-add-choice" href="#" data-i18n="projects.detail.smarttimeline.add.choice.appointment">
|
||||
Termin anlegen
|
||||
</a>
|
||||
<button type="button" id="smart-timeline-add-counterclaim" className="smart-timeline-add-choice" data-i18n="projects.detail.smarttimeline.add.choice.counterclaim">
|
||||
Widerklage (CCR)
|
||||
</button>
|
||||
<button type="button" className="smart-timeline-add-choice smart-timeline-add-choice--disabled" disabled title="Slice 3" data-i18n="projects.detail.smarttimeline.add.choice.amend">
|
||||
Antrag auf Änderung (R.30)
|
||||
</button>
|
||||
<button type="button" id="smart-timeline-add-milestone" className="smart-timeline-add-choice smart-timeline-add-choice--primary" data-i18n="projects.detail.smarttimeline.add.choice.milestone">
|
||||
Eigener Meilenstein
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="smart-timeline-milestone-form" className="entity-form" style="display:none" autocomplete="off">
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-milestone-title" data-i18n="projects.detail.smarttimeline.milestone.title">Titel</label>
|
||||
<input type="text" id="smart-timeline-milestone-title" required maxLength={200} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-milestone-date" data-i18n="projects.detail.smarttimeline.milestone.date">Datum (optional)</label>
|
||||
<input type="date" id="smart-timeline-milestone-date" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-milestone-desc" data-i18n="projects.detail.smarttimeline.milestone.description">Beschreibung (optional)</label>
|
||||
<textarea id="smart-timeline-milestone-desc" rows={3} />
|
||||
</div>
|
||||
{/* Slice 4 — bubble-up override (t-paliad-175 §7.2 Q5). */}
|
||||
<div className="form-field form-field--checkbox">
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" id="smart-timeline-milestone-bubble-up" />
|
||||
<span data-i18n="projects.detail.smarttimeline.milestone.bubble_up">In übergeordneten Akten anzeigen</span>
|
||||
</label>
|
||||
<small className="form-field-hint" data-i18n="projects.detail.smarttimeline.milestone.bubble_up_hint">
|
||||
Beim Aktivieren erscheint dieser Meilenstein auf Patent-, Verfahrens- und Mandantsicht.
|
||||
</small>
|
||||
</div>
|
||||
<div id="smart-timeline-milestone-msg" className="form-msg" />
|
||||
<div className="form-field-row">
|
||||
<button type="button" className="btn-secondary" id="smart-timeline-milestone-cancel" data-i18n="projects.detail.smarttimeline.add.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.detail.smarttimeline.add.submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* CCR sub-project create form (Slice 3, t-paliad-174). The
|
||||
proceeding-type select is populated by the client at
|
||||
runtime; our_side defaults to inverted with a
|
||||
"Stimmt nicht?" override toggle for the R.49.2.b
|
||||
edge case. Title is auto-suggested server-side and
|
||||
can be overridden inline. */}
|
||||
<form id="smart-timeline-counterclaim-form" className="entity-form" style="display:none" autocomplete="off">
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-counterclaim-procedure" data-i18n="projects.detail.smarttimeline.counterclaim.procedure">Verfahrenstyp</label>
|
||||
<select id="smart-timeline-counterclaim-procedure">
|
||||
{/* Options injected from client; defaults to UPC_REV */}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-counterclaim-title" data-i18n="projects.detail.smarttimeline.counterclaim.title">Titel (optional)</label>
|
||||
<input type="text" id="smart-timeline-counterclaim-title" maxLength={200} placeholder="Auto-Vorschlag aus Patentnummer" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-counterclaim-case-number" data-i18n="projects.detail.smarttimeline.counterclaim.case_number">CCR-Aktenzeichen (optional)</label>
|
||||
<input type="text" id="smart-timeline-counterclaim-case-number" maxLength={200} placeholder="ACT_xxx_2026" />
|
||||
</div>
|
||||
<div className="form-field form-field--checkbox">
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" id="smart-timeline-counterclaim-flip-toggle" />
|
||||
<span data-i18n="projects.detail.smarttimeline.counterclaim.flip_override">Unsere Seite NICHT umkehren (Stimmt nicht?)</span>
|
||||
</label>
|
||||
<small className="form-field-hint" data-i18n="projects.detail.smarttimeline.counterclaim.flip_hint">
|
||||
Im Standardfall (CCR-Nichtigkeit) kehrt sich unsere Seite um (Kläger ↔ Beklagter). Aktivieren bei R.49.2.b CCI.
|
||||
</small>
|
||||
</div>
|
||||
<div id="smart-timeline-counterclaim-msg" className="form-msg" />
|
||||
<div className="form-field-row">
|
||||
<button type="button" className="btn-secondary" id="smart-timeline-counterclaim-cancel" data-i18n="projects.detail.smarttimeline.add.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.detail.smarttimeline.counterclaim.submit">Widerklage anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="smart-timeline-modal-close-row">
|
||||
<button type="button" className="btn-secondary btn-small" id="smart-timeline-modal-close" data-i18n="projects.detail.smarttimeline.add.cancel">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
207
frontend/src/verfahrensablauf.tsx
Normal file
207
frontend/src/verfahrensablauf.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
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", i18nKey: "deadlines.upc_inf", name: "Verletzungsverfahren" },
|
||||
{ code: "UPC_REV", i18nKey: "deadlines.upc_rev", name: "Nichtigkeitsklage" },
|
||||
{ code: "UPC_PI", i18nKey: "deadlines.upc_pi", name: "Einstw. Maßnahmen" },
|
||||
{ code: "UPC_APP", i18nKey: "deadlines.upc_app", name: "Berufung" },
|
||||
{ code: "UPC_DAMAGES", i18nKey: "deadlines.upc_damages", name: "Schadensbemessung" },
|
||||
{ code: "UPC_DISCOVERY", i18nKey: "deadlines.upc_discovery", name: "Bucheinsicht" },
|
||||
{ code: "UPC_COST_APPEAL", i18nKey: "deadlines.upc_cost_appeal", name: "Berufung Kosten" },
|
||||
{ code: "UPC_APP_ORDERS", i18nKey: "deadlines.upc_app_orders", name: "Berufung Anordnungen" },
|
||||
];
|
||||
|
||||
const DE_TYPES: ProceedingDef[] = [
|
||||
{ code: "DE_INF", i18nKey: "deadlines.de_inf", name: "Verletzungsklage (LG)" },
|
||||
{ code: "DE_INF_OLG", i18nKey: "deadlines.de_inf_olg", name: "Berufung OLG" },
|
||||
{ code: "DE_INF_BGH", i18nKey: "deadlines.de_inf_bgh", name: "Revision/NZB BGH" },
|
||||
{ code: "DE_NULL", i18nKey: "deadlines.de_null", name: "Nichtigkeitsverfahren" },
|
||||
{ code: "DE_NULL_BGH", i18nKey: "deadlines.de_null_bgh", name: "Berufung BGH (Nichtigk.)" },
|
||||
];
|
||||
|
||||
const EPA_TYPES: ProceedingDef[] = [
|
||||
{ code: "EPA_OPP", i18nKey: "deadlines.epa_opp", name: "Einspruchsverfahren" },
|
||||
{ code: "EPA_APP", i18nKey: "deadlines.epa_app", name: "Beschwerdeverfahren" },
|
||||
{ code: "EP_GRANT", i18nKey: "deadlines.ep_grant", name: "EP-Erteilungsverfahren" },
|
||||
];
|
||||
|
||||
const DPMA_TYPES: ProceedingDef[] = [
|
||||
{ code: "DPMA_OPP", i18nKey: "deadlines.dpma_opp", name: "Einspruch DPMA" },
|
||||
{ code: "DPMA_BPATG_BESCHWERDE", i18nKey: "deadlines.dpma_bpatg_beschwerde", name: "Beschwerde BPatG (DPMA)" },
|
||||
{ code: "DPMA_BGH_RB", i18nKey: "deadlines.dpma_bgh_rb", name: "Rechtsbeschwerde BGH" },
|
||||
];
|
||||
|
||||
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">
|
||||
<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-btns">
|
||||
{DE_TYPES.map((p) => proceedingBtn(p))}
|
||||
</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">
|
||||
<label htmlFor="trigger-event" className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</label>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,7 @@ export function renderViews(): string {
|
||||
<button type="button" className="agenda-chip" data-shape="list" role="tab" data-i18n="views.shape.list">Liste</button>
|
||||
<button type="button" className="agenda-chip" data-shape="cards" role="tab" data-i18n="views.shape.cards">Karten</button>
|
||||
<button type="button" className="agenda-chip" data-shape="calendar" role="tab" data-i18n="views.shape.calendar">Kalender</button>
|
||||
<button type="button" className="agenda-chip" data-shape="timeline" role="tab" data-i18n="views.shape.timeline">Timeline</button>
|
||||
</div>
|
||||
<div className="views-toolbar-spacer" />
|
||||
<a href="#" className="btn-secondary btn-small" id="views-save-as" data-i18n="views.save_as" hidden>
|
||||
@@ -94,6 +95,24 @@ export function renderViews(): string {
|
||||
<div className="views-shape-host views-shape-list" id="views-shape-list" hidden />
|
||||
<div className="views-shape-host views-shape-cards" id="views-shape-cards" hidden />
|
||||
<div className="views-shape-host views-shape-calendar" id="views-shape-calendar" hidden />
|
||||
<div className="views-shape-host views-shape-timeline" id="views-shape-timeline" hidden>
|
||||
{/* CV-chart caveat banner — design §13.4: ViewService
|
||||
doesn't run the fristenrechner calculator, so Custom
|
||||
Views show actual events only. One-time-per-session
|
||||
dismissible (sessionStorage). */}
|
||||
<div className="views-timeline-caveat" id="views-timeline-caveat" hidden>
|
||||
<span data-i18n="views.timeline.caveat.body">
|
||||
Custom Views zeigen nur eingetretene Ereignisse. Für prognostizierte Fristen das Projekt-Chart öffnen.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="views-timeline-caveat-close"
|
||||
id="views-timeline-caveat-close"
|
||||
aria-label="Schließen"
|
||||
>×</button>
|
||||
</div>
|
||||
<div id="views-timeline-chart-host" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Footer />
|
||||
|
||||
4
internal/db/migrations/072_projects_our_side.down.sql
Normal file
4
internal/db/migrations/072_projects_our_side.down.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Reverse t-paliad-164: drop the our_side column + check constraint.
|
||||
|
||||
ALTER TABLE paliad.projects DROP CONSTRAINT IF EXISTS projects_our_side_check;
|
||||
ALTER TABLE paliad.projects DROP COLUMN IF EXISTS our_side;
|
||||
42
internal/db/migrations/072_projects_our_side.up.sql
Normal file
42
internal/db/migrations/072_projects_our_side.up.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- t-paliad-164 / m's 2026-05-08 21:42 dogfood feedback: when the user
|
||||
-- selects an Akte in the Determinator (Slice 3c perspective chip),
|
||||
-- the chip should already be locked to the firm's known side instead
|
||||
-- of asking the user to re-pick something the project already knows.
|
||||
--
|
||||
-- Add a project-level our_side text column. NULL = unknown / not set
|
||||
-- (default), so existing projects stay neutral and the Determinator
|
||||
-- falls back to free-pick. The chip values mirror event_categories.
|
||||
-- party so the Determinator can predefine the chip without mapping.
|
||||
--
|
||||
-- 'court' is allowed for completeness (paliad runs internal projects
|
||||
-- where the firm represents the court / a tribunal-side stakeholder
|
||||
-- — rare but real); the Determinator currently only acts on
|
||||
-- claimant / defendant.
|
||||
--
|
||||
-- Idempotent so re-runs against a partially-applied state stay safe
|
||||
-- (live tracker is at v71; paliad has been bitten by collisions
|
||||
-- twice this week, see m/paliad#15 commits and dirac's mig 070).
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN IF NOT EXISTS our_side text;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'projects_our_side_check'
|
||||
AND conrelid = 'paliad.projects'::regclass
|
||||
) THEN
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_our_side_check
|
||||
CHECK (our_side IS NULL
|
||||
OR our_side IN ('claimant', 'defendant', 'court', 'both'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.our_side IS
|
||||
'Which side the firm represents on this project. Used by the '
|
||||
'Fristenrechner Determinator (Slice 3c) to predefine the '
|
||||
'perspective chip from the project context. Allowed: claimant, '
|
||||
'defendant, court, both. NULL = unknown / not set; Determinator '
|
||||
'falls back to free-pick.';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- t-paliad-165 down: drop the concept→event_type junction.
|
||||
DROP TABLE IF EXISTS paliad.deadline_concept_event_types;
|
||||
113
internal/db/migrations/073_deadline_concept_event_types.up.sql
Normal file
113
internal/db/migrations/073_deadline_concept_event_types.up.sql
Normal file
@@ -0,0 +1,113 @@
|
||||
-- t-paliad-165: junction paliad.deadline_concept_event_types — maps each
|
||||
-- deadline_concept to the canonical paliad.event_types row(s) that
|
||||
-- represent it on the Typ chip cluster of the deadline create form.
|
||||
--
|
||||
-- Why this exists
|
||||
-- ---------------
|
||||
-- The deadline create form (/projects/{id}/deadlines/new and the global
|
||||
-- /deadlines/new) lets the user pick a Regel (paliad.deadline_rules) AND
|
||||
-- independently pick a Typ (paliad.event_types). They are decoupled, so a
|
||||
-- user can save a deadline whose Regel is `damages.rejoin — Duplik` but
|
||||
-- Typ is `Klageerwiderung` — two different legal events. m hit this
|
||||
-- contradiction during 2026-05-08 dogfooding (Gitea m/paliad#18).
|
||||
--
|
||||
-- Each rule already carries paliad.deadline_rules.concept_id (mig 040),
|
||||
-- so the rule knows what legal idea it represents. What was missing was
|
||||
-- the canonical event_type for that concept. Slug-pattern heuristics are
|
||||
-- unreliable (concept `notice-of-appeal` ↔ event_type
|
||||
-- `upc_statement_of_appeal_2201`) and many concepts have multiple
|
||||
-- candidate event_types (`statement-of-defence` ↔ base + with_ccr +
|
||||
-- no_ccr); this junction makes the mapping explicit and curated.
|
||||
--
|
||||
-- Shape
|
||||
-- -----
|
||||
-- Many-to-many, so concepts that genuinely have several candidate types
|
||||
-- (with_ccr / no_ccr / base; UPC + EPO + DPMA opposition) get one row
|
||||
-- per type. is_default picks the single row the create-form auto-fills
|
||||
-- when the user picks a Regel attached to this concept. The remaining
|
||||
-- rows are reserved for future surfaces (e.g. Determinator save flow
|
||||
-- might want to see all candidates) but the create-form only consumes
|
||||
-- is_default for now.
|
||||
--
|
||||
-- Idempotent against re-seeds: the seed below uses ON CONFLICT DO
|
||||
-- NOTHING so a second run after manual mapping additions doesn't blow
|
||||
-- them away. Down migration drops the table entirely.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadline_concept_event_types (
|
||||
concept_id uuid NOT NULL
|
||||
REFERENCES paliad.deadline_concepts(id) ON DELETE CASCADE,
|
||||
event_type_id uuid NOT NULL
|
||||
REFERENCES paliad.event_types(id) ON DELETE CASCADE,
|
||||
is_default bool NOT NULL DEFAULT false,
|
||||
sort_order int NOT NULL DEFAULT 100,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (concept_id, event_type_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_concept_event_types IS
|
||||
'Junction mapping paliad.deadline_concepts → paliad.event_types. '
|
||||
'Lets the deadline create form auto-populate the Typ chip when the '
|
||||
'user picks a Regel — the rule''s concept points here for its '
|
||||
'canonical event_type(s). Many-to-many for concepts with several '
|
||||
'natural variants (with_ccr / no_ccr / base, EPO + DPMA opposition).';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_concept_event_types.is_default IS
|
||||
'Exactly one row per concept_id should be marked default — that is '
|
||||
'the row the create-form chip cluster auto-fills with. Other rows '
|
||||
'remain selectable from the picker as alternatives.';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS deadline_concept_event_types_one_default
|
||||
ON paliad.deadline_concept_event_types (concept_id)
|
||||
WHERE is_default = true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_concept_event_types_event_type
|
||||
ON paliad.deadline_concept_event_types (event_type_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- Seed: curated mapping for active concepts that drive existing rules.
|
||||
--
|
||||
-- Concepts without an obvious event_type counterpart (filing, grant,
|
||||
-- decision, publication, communication-r71-3, search-report, the various
|
||||
-- DE-only Begründung concepts) stay unmapped — auto-fill silently
|
||||
-- skips them, leaving the user to pick a Typ manually as today.
|
||||
-- Future migrations can fill those gaps as event_types are added.
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order)
|
||||
SELECT dc.id, et.id, mapping.is_default, mapping.sort_order
|
||||
FROM (VALUES
|
||||
-- (concept_slug, event_type_slug, is_default, sort_order)
|
||||
('application-for-cost-decision', 'upc_application_for_cost_decision', true, 10),
|
||||
('application-for-determination-of-damages', 'upc_application_for_damages', true, 10),
|
||||
('application-for-revocation', 'upc_statement_for_revocation', true, 10),
|
||||
('application-for-provisional-measures', 'upc_protective_letter', true, 10),
|
||||
('cost-decision', 'upc_decision_on_costs', true, 10),
|
||||
('counterclaim-for-infringement', 'upc_counterclaim_for_infringement', true, 10),
|
||||
('counterclaim-for-revocation', 'upc_counterclaim_for_revocation', true, 10),
|
||||
('cross-appeal', 'upc_cross_appeal_2242a', true, 10),
|
||||
('defence-to-application-to-amend', 'upc_defence_to_amend_patent', true, 10),
|
||||
('defence-to-counterclaim-for-revocation', 'upc_defence_to_revocation', true, 10),
|
||||
('notice-of-appeal', 'upc_statement_of_appeal_2201', true, 10),
|
||||
('opposition', 'epo_opposition_filing', true, 10),
|
||||
('opposition', 'dpma_opposition', false, 20),
|
||||
('oral-hearing', 'upc_oral_hearing', true, 10),
|
||||
('order', 'upc_case_management_order', true, 10),
|
||||
('rejoinder', 'upc_rejoinder_to_reply', true, 10),
|
||||
('reply-to-cross-appeal', 'upc_cross_appeal_2242a', true, 10),
|
||||
('reply-to-defence', 'upc_reply_to_defence', true, 10),
|
||||
('reply-to-defence-to-application-to-amend', 'upc_reply_to_defence_to_amend_patent', true, 10),
|
||||
('reply-to-defence-to-counterclaim-for-revocation','upc_reply_to_defence_to_revocation', true, 10),
|
||||
('request-for-examination', 'dpma_examination_request', true, 10),
|
||||
('request-to-lay-open-books', 'upc_request_to_lay_open_books', true, 10),
|
||||
('response-to-appeal', 'upc_grounds_of_appeal_2242a', true, 10),
|
||||
('statement-of-claim', 'upc_statement_of_claim', true, 10),
|
||||
('statement-of-defence', 'upc_statement_of_defence', true, 10),
|
||||
('statement-of-defence', 'upc_statement_of_defence_with_ccr', false, 20),
|
||||
('statement-of-defence', 'upc_statement_of_defence_no_ccr', false, 30),
|
||||
('statement-of-grounds-of-appeal', 'upc_grounds_of_appeal_2242a', true, 10),
|
||||
('statement-of-grounds-of-appeal', 'epo_appeal_grounds', false, 20)
|
||||
) AS mapping(concept_slug, event_type_slug, is_default, sort_order)
|
||||
JOIN paliad.deadline_concepts dc ON dc.slug = mapping.concept_slug
|
||||
JOIN paliad.event_types et ON et.slug = mapping.event_type_slug
|
||||
AND et.archived_at IS NULL
|
||||
ON CONFLICT (concept_id, event_type_id) DO NOTHING;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- t-paliad-165 follow-up down: remove jurisdiction column + restore the
|
||||
-- old one-default-per-concept index. The added jurisdictional default
|
||||
-- rows are kept (harmless without the index), but this isn't an
|
||||
-- expected operation in production.
|
||||
|
||||
DROP INDEX IF EXISTS paliad.deadline_concept_event_types_one_default_per_jur;
|
||||
|
||||
ALTER TABLE paliad.deadline_concept_event_types
|
||||
DROP COLUMN IF EXISTS jurisdiction;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS deadline_concept_event_types_one_default
|
||||
ON paliad.deadline_concept_event_types (concept_id)
|
||||
WHERE is_default = true;
|
||||
@@ -0,0 +1,143 @@
|
||||
-- t-paliad-165 follow-up (m's 2026-05-08 22:08 dogfood): add jurisdiction
|
||||
-- to paliad.deadline_concept_event_types so DE rules don't auto-fill a
|
||||
-- UPC event_type and vice versa.
|
||||
--
|
||||
-- Bug being fixed
|
||||
-- ---------------
|
||||
-- m: rule '§ 276 Abs. 1 S. 2 ZPO — Klageerwiderung' (DE) auto-filled to
|
||||
-- 'Klageerwiderung' but the chosen event_type was upc_statement_of_defence
|
||||
-- (UPC). Both render as 'Klageerwiderung' in the UI, but they are
|
||||
-- different legal events in different jurisdictions — the auto-link is
|
||||
-- technically wrong even though the label looks right.
|
||||
--
|
||||
-- Root cause
|
||||
-- ----------
|
||||
-- Migration 073 made the junction one-default-per-concept. The same
|
||||
-- legal concept ('statement-of-defence' = Klageerwiderung) has several
|
||||
-- jurisdictional flavours (upc_statement_of_defence, de_klageerwiderung,
|
||||
-- DPMA Erwiderung, EPA Patentinhaber-Erwiderung). The default was
|
||||
-- jurisdiction-blind — it always picked the UPC variant.
|
||||
--
|
||||
-- Fix
|
||||
-- ---
|
||||
-- 1. Add a jurisdiction text column to the junction.
|
||||
-- 2. Backfill from each event_type's own jurisdiction.
|
||||
-- 3. Replace the unique-default index with a (concept_id, jurisdiction)
|
||||
-- pair so each concept can carry one default per jurisdiction.
|
||||
-- 4. Add jurisdictional defaults where a non-UPC event_type genuinely
|
||||
-- exists (DE Klageerwiderung, DPMA / EPO opposition + appeal).
|
||||
--
|
||||
-- Lookup contract (consumed by the rule-service hydrator)
|
||||
-- -------------------------------------------------------
|
||||
-- For a rule with proceeding_types.jurisdiction = J, the auto-fill
|
||||
-- looks up the row WHERE is_default AND jurisdiction = J. EPA→EPO
|
||||
-- canonicalisation lives in the Go service (proceeding_types use 'EPA'
|
||||
-- but event_types use 'EPO' — the two columns disagreed before this
|
||||
-- mapping table existed). When NO row matches the rule's jurisdiction,
|
||||
-- the auto-fill silently no-ops; better than a wrong default.
|
||||
|
||||
ALTER TABLE paliad.deadline_concept_event_types
|
||||
ADD COLUMN IF NOT EXISTS jurisdiction text;
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_concept_event_types.jurisdiction IS
|
||||
'Which jurisdiction this default applies to. Matches the rule''s '
|
||||
'proceeding_types.jurisdiction (UPC / DE / DPMA / EPO). EPA→EPO '
|
||||
'canonicalisation is done service-side. NULL = applies to any '
|
||||
'jurisdiction (the catch-all fallback — currently unused).';
|
||||
|
||||
-- Backfill jurisdiction from the event_type's own column.
|
||||
UPDATE paliad.deadline_concept_event_types j
|
||||
SET jurisdiction = et.jurisdiction
|
||||
FROM paliad.event_types et
|
||||
WHERE j.event_type_id = et.id
|
||||
AND j.jurisdiction IS NULL;
|
||||
|
||||
-- Replace the old unique-default index (one default per concept) with
|
||||
-- one default per (concept, jurisdiction). We DROP IF EXISTS so the
|
||||
-- migration is rerunnable against a freshly-rebuilt schema.
|
||||
DROP INDEX IF EXISTS paliad.deadline_concept_event_types_one_default;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS deadline_concept_event_types_one_default_per_jur
|
||||
ON paliad.deadline_concept_event_types (concept_id, jurisdiction)
|
||||
WHERE is_default = true;
|
||||
|
||||
-- ============================================================================
|
||||
-- Demote then re-elect defaults so each (concept, jurisdiction) pair is
|
||||
-- correctly anchored. Rows that never had jurisdiction picked stay as
|
||||
-- non-defaults until a curated row beats them.
|
||||
-- ============================================================================
|
||||
|
||||
-- statement-of-defence DE: de_klageerwiderung becomes the DE default.
|
||||
INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order, jurisdiction)
|
||||
SELECT dc.id, et.id, true, 10, 'DE'
|
||||
FROM paliad.deadline_concepts dc
|
||||
JOIN paliad.event_types et ON et.slug = 'de_klageerwiderung' AND et.archived_at IS NULL
|
||||
WHERE dc.slug = 'statement-of-defence'
|
||||
ON CONFLICT (concept_id, event_type_id) DO UPDATE
|
||||
SET is_default = true, jurisdiction = 'DE', sort_order = 10;
|
||||
|
||||
-- opposition: split per jurisdiction. EPO is the canonical EU-wide
|
||||
-- pre-grant Einspruch, DPMA is the German national variant.
|
||||
UPDATE paliad.deadline_concept_event_types j
|
||||
SET is_default = true, jurisdiction = 'EPO', sort_order = 10
|
||||
FROM paliad.deadline_concepts dc, paliad.event_types et
|
||||
WHERE j.concept_id = dc.id
|
||||
AND j.event_type_id = et.id
|
||||
AND dc.slug = 'opposition'
|
||||
AND et.slug = 'epo_opposition_filing';
|
||||
|
||||
UPDATE paliad.deadline_concept_event_types j
|
||||
SET is_default = true, jurisdiction = 'DPMA', sort_order = 20
|
||||
FROM paliad.deadline_concepts dc, paliad.event_types et
|
||||
WHERE j.concept_id = dc.id
|
||||
AND j.event_type_id = et.id
|
||||
AND dc.slug = 'opposition'
|
||||
AND et.slug = 'dpma_opposition';
|
||||
|
||||
-- request-for-examination: DPMA is the only jurisdiction with an
|
||||
-- event_type counterpart (EP-grant exam request has no event_type yet).
|
||||
UPDATE paliad.deadline_concept_event_types j
|
||||
SET is_default = true, jurisdiction = 'DPMA'
|
||||
FROM paliad.deadline_concepts dc, paliad.event_types et
|
||||
WHERE j.concept_id = dc.id
|
||||
AND j.event_type_id = et.id
|
||||
AND dc.slug = 'request-for-examination'
|
||||
AND et.slug = 'dpma_examination_request';
|
||||
|
||||
-- notice-of-appeal: keep the UPC default (already set by mig 073) AND
|
||||
-- add EPO + DPMA jurisdictional variants for non-UPC rules.
|
||||
INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order, jurisdiction)
|
||||
SELECT dc.id, et.id, true, 10, 'EPO'
|
||||
FROM paliad.deadline_concepts dc
|
||||
JOIN paliad.event_types et ON et.slug = 'epo_appeal_notice' AND et.archived_at IS NULL
|
||||
WHERE dc.slug = 'notice-of-appeal'
|
||||
ON CONFLICT (concept_id, event_type_id) DO UPDATE
|
||||
SET is_default = true, jurisdiction = 'EPO', sort_order = 10;
|
||||
|
||||
INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order, jurisdiction)
|
||||
SELECT dc.id, et.id, true, 10, 'DPMA'
|
||||
FROM paliad.deadline_concepts dc
|
||||
JOIN paliad.event_types et ON et.slug = 'dpma_appeal' AND et.archived_at IS NULL
|
||||
WHERE dc.slug = 'notice-of-appeal'
|
||||
ON CONFLICT (concept_id, event_type_id) DO UPDATE
|
||||
SET is_default = true, jurisdiction = 'DPMA', sort_order = 10;
|
||||
|
||||
-- statement-of-grounds-of-appeal: keep UPC default; add EPO variant.
|
||||
UPDATE paliad.deadline_concept_event_types j
|
||||
SET is_default = true, jurisdiction = 'EPO', sort_order = 10
|
||||
FROM paliad.deadline_concepts dc, paliad.event_types et
|
||||
WHERE j.concept_id = dc.id
|
||||
AND j.event_type_id = et.id
|
||||
AND dc.slug = 'statement-of-grounds-of-appeal'
|
||||
AND et.slug = 'epo_appeal_grounds';
|
||||
|
||||
-- ============================================================================
|
||||
-- Final pass: any junction row that still has NULL jurisdiction (none
|
||||
-- expected after the backfill, but defensive) gets its event_type's
|
||||
-- jurisdiction copied so the partial-unique index is well-defined.
|
||||
-- ============================================================================
|
||||
UPDATE paliad.deadline_concept_event_types j
|
||||
SET jurisdiction = et.jurisdiction
|
||||
FROM paliad.event_types et
|
||||
WHERE j.event_type_id = et.id
|
||||
AND j.jurisdiction IS NULL;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- t-paliad-171 down — drop the SmartTimeline opt-in column.
|
||||
|
||||
DROP INDEX IF EXISTS paliad.project_events_timeline_kind_idx;
|
||||
|
||||
ALTER TABLE paliad.project_events
|
||||
DROP COLUMN IF EXISTS timeline_kind;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- t-paliad-171 — SmartTimeline Slice 1.
|
||||
-- Add the `timeline_kind` opt-in column to paliad.project_events so a
|
||||
-- subset of audit rows can surface as timeline content. Existing rows
|
||||
-- stay NULL (audit-only) and are filtered out of the SmartTimeline
|
||||
-- read path; new write paths (custom milestone, counterclaim_created
|
||||
-- in later slices) set the column on insert.
|
||||
--
|
||||
-- Value space (enforced in code, not via CHECK — see
|
||||
-- internal/services/projection_service.go):
|
||||
-- 'milestone' — structural event worth pinning to the timeline
|
||||
-- (counterclaim_filed, third_party_intervened,
|
||||
-- party_amendment, our_side_changed, scope_change)
|
||||
-- 'custom_milestone' — free-text user-added event ("Eigener Meilenstein")
|
||||
-- NULL — audit only (default, all existing rows)
|
||||
--
|
||||
-- Design ref: docs/design-smart-timeline-2026-05-08.md §2.2.
|
||||
|
||||
ALTER TABLE paliad.project_events
|
||||
ADD COLUMN IF NOT EXISTS timeline_kind text NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.project_events.timeline_kind IS
|
||||
'When non-NULL, this audit event also surfaces as a SmartTimeline '
|
||||
'milestone. NULL keeps the row audit-only. See '
|
||||
'internal/services/projection_service.go for the value space.';
|
||||
|
||||
-- Partial index — the SmartTimeline read path filters on
|
||||
-- (project_id, timeline_kind IS NOT NULL); making the index partial
|
||||
-- keeps it tiny (most rows stay audit-only) while still serving the
|
||||
-- common lookup.
|
||||
CREATE INDEX IF NOT EXISTS project_events_timeline_kind_idx
|
||||
ON paliad.project_events (project_id, timeline_kind)
|
||||
WHERE timeline_kind IS NOT NULL;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- t-paliad-173 down — reverses 076_smart_timeline_slice_2.up.sql.
|
||||
|
||||
ALTER TABLE paliad.deadlines DROP CONSTRAINT IF EXISTS deadlines_source_check;
|
||||
|
||||
DROP INDEX IF EXISTS paliad.appointments_deadline_rule_id_idx;
|
||||
|
||||
ALTER TABLE paliad.appointments DROP COLUMN IF EXISTS deadline_rule_id;
|
||||
57
internal/db/migrations/076_smart_timeline_slice_2.up.sql
Normal file
57
internal/db/migrations/076_smart_timeline_slice_2.up.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- t-paliad-173 — SmartTimeline Slice 2.
|
||||
-- Two structural additions for click-to-anchor (§6 of
|
||||
-- docs/design-smart-timeline-2026-05-08.md) + the layered SoC→SoD
|
||||
-- sequence enforcement from m/paliad#31:
|
||||
--
|
||||
-- 1. paliad.appointments.deadline_rule_id — nullable FK to
|
||||
-- paliad.deadline_rules. Court-set rules (Hauptverhandlung,
|
||||
-- Decision, Order) anchor as appointments rather than deadlines
|
||||
-- and need to remember which rule they came from so downstream
|
||||
-- reflow has the parent_id chain.
|
||||
--
|
||||
-- 2. paliad.deadlines.source CHECK — adds 'anchor' alongside
|
||||
-- the existing 'manual' / 'fristenrechner' values + the two
|
||||
-- legacy values the design doc mentions ('rule', 'import') for
|
||||
-- forward-compat. 'anchor' separates a click-to-anchor write from
|
||||
-- a user-typed-it-in 'manual' write so analytics + a future
|
||||
-- Outlook-import path can tell them apart.
|
||||
--
|
||||
-- paliad.project_events.event_type is intentionally NOT constrained —
|
||||
-- the column is free-text in prod (every event_type today lives in
|
||||
-- code, not in a CHECK). Slice 2 needs to write 'rule_skipped' rows
|
||||
-- (§6.4); no schema change is required for that.
|
||||
--
|
||||
-- Idempotent: re-applying is a no-op. Tracker advances 75 → 76.
|
||||
|
||||
-- 1. paliad.appointments.deadline_rule_id ----------------------------------
|
||||
|
||||
ALTER TABLE paliad.appointments
|
||||
ADD COLUMN IF NOT EXISTS deadline_rule_id uuid NULL
|
||||
REFERENCES paliad.deadline_rules(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.appointments.deadline_rule_id IS
|
||||
'When non-NULL, this appointment is the actual occurrence of a '
|
||||
'standard-course rule (Hauptverhandlung, Decision, Order). '
|
||||
'Anchors downstream re-projection via FristenrechnerService '
|
||||
'AnchorOverrides. See docs/design-smart-timeline-2026-05-08.md §6.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS appointments_deadline_rule_id_idx
|
||||
ON paliad.appointments (deadline_rule_id)
|
||||
WHERE deadline_rule_id IS NOT NULL;
|
||||
|
||||
-- 2. paliad.deadlines.source CHECK -----------------------------------------
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'deadlines_source_check'
|
||||
AND conrelid = 'paliad.deadlines'::regclass
|
||||
) THEN
|
||||
ALTER TABLE paliad.deadlines DROP CONSTRAINT deadlines_source_check;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD CONSTRAINT deadlines_source_check
|
||||
CHECK (source IN ('manual', 'fristenrechner', 'rule', 'import', 'anchor'));
|
||||
@@ -0,0 +1,9 @@
|
||||
-- t-paliad-174 — revert SmartTimeline Slice 3 schema.
|
||||
|
||||
DROP TRIGGER IF EXISTS projects_no_two_level_ccr ON paliad.projects;
|
||||
DROP FUNCTION IF EXISTS paliad.projects_no_two_level_ccr();
|
||||
|
||||
DROP INDEX IF EXISTS paliad.projects_counterclaim_of_idx;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP COLUMN IF EXISTS counterclaim_of;
|
||||
89
internal/db/migrations/077_projects_counterclaim_of.up.sql
Normal file
89
internal/db/migrations/077_projects_counterclaim_of.up.sql
Normal file
@@ -0,0 +1,89 @@
|
||||
-- t-paliad-174 — SmartTimeline Slice 3.
|
||||
-- Two structural additions for the counterclaim sub-project shape
|
||||
-- (§4 of docs/design-smart-timeline-2026-05-08.md):
|
||||
--
|
||||
-- 1. paliad.projects.counterclaim_of — nullable FK referencing
|
||||
-- paliad.projects(id) ON DELETE SET NULL. When non-NULL the row
|
||||
-- represents the CCR (counterclaim) sub-project filed against the
|
||||
-- target row. Standard parent_id keeps governing the project tree;
|
||||
-- counterclaim_of is the *additional* relation describing the CCR
|
||||
-- link. parent_id of the CCR child is set to the target's parent
|
||||
-- (sibling-under-patent placement, §4.4) — that placement is owned
|
||||
-- by ProjectService.CreateCounterclaim, not the schema.
|
||||
--
|
||||
-- 2. Two-level-CCR rejection trigger — UPC practice does NOT have
|
||||
-- counterclaim-of-a-counterclaim chains. Reject the malformed shape
|
||||
-- at the schema level so the application can never write it. CHECK
|
||||
-- can't reference other rows; trigger function raises explicitly.
|
||||
--
|
||||
-- Idempotent: re-applying is a no-op. Tracker advances 76 → 77.
|
||||
|
||||
-- 1. paliad.projects.counterclaim_of ---------------------------------------
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN IF NOT EXISTS counterclaim_of uuid NULL
|
||||
REFERENCES paliad.projects(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.counterclaim_of IS
|
||||
'When non-NULL this project is the CCR (counterclaim) filed against '
|
||||
'the referenced parent project. parent_id continues to govern the '
|
||||
'project tree (CCR is placed as a sibling under the same patent — '
|
||||
'see ProjectService.CreateCounterclaim). ON DELETE SET NULL keeps '
|
||||
'the CCR row alive when the parent is hard-deleted (rare; default '
|
||||
'is archival) so the audit trail survives.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS projects_counterclaim_of_idx
|
||||
ON paliad.projects (counterclaim_of)
|
||||
WHERE counterclaim_of IS NOT NULL;
|
||||
|
||||
-- 2. Two-level-CCR rejection trigger ---------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.projects_no_two_level_ccr() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
-- A project that is itself a CCR may NOT be the target of another CCR.
|
||||
-- Two cases to reject:
|
||||
--
|
||||
-- (a) NEW row points at a parent that is itself a CCR:
|
||||
-- NEW.counterclaim_of -> some row with counterclaim_of NOT NULL.
|
||||
--
|
||||
-- (b) NEW row claims to be a CCR (NEW.counterclaim_of IS NOT NULL)
|
||||
-- but already has another CCR pointing AT it (NEW.id is the
|
||||
-- target of some other row's counterclaim_of). The cleaner
|
||||
-- phrasing: "no row may simultaneously have a CCR child AND
|
||||
-- a CCR parent".
|
||||
IF NEW.counterclaim_of IS NOT NULL THEN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM paliad.projects p
|
||||
WHERE p.id = NEW.counterclaim_of
|
||||
AND p.counterclaim_of IS NOT NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'two-level counterclaim chains are not allowed: parent project % is itself a counterclaim',
|
||||
NEW.counterclaim_of;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM paliad.projects p
|
||||
WHERE p.counterclaim_of = NEW.id
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'project % already has a counterclaim child and cannot itself be a counterclaim',
|
||||
NEW.id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.projects_no_two_level_ccr() IS
|
||||
'Rejects two-level counterclaim chains. UPC practice does not have '
|
||||
'CCR-of-a-CCR; reject the malformed shape at write time so the app '
|
||||
'layer never has to defend against it. See migration 077.';
|
||||
|
||||
DROP TRIGGER IF EXISTS projects_no_two_level_ccr ON paliad.projects;
|
||||
CREATE TRIGGER projects_no_two_level_ccr
|
||||
BEFORE INSERT OR UPDATE OF counterclaim_of ON paliad.projects
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.projects_no_two_level_ccr();
|
||||
27
internal/db/migrations/078_unified_rule_columns.down.sql
Normal file
27
internal/db/migrations/078_unified_rule_columns.down.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- t-paliad-182 down — reverses 078_unified_rule_columns.up.sql.
|
||||
--
|
||||
-- Drops in reverse dependency order: indexes → CHECK constraints →
|
||||
-- FKs → columns. Idempotent (IF EXISTS guards everywhere).
|
||||
|
||||
DROP INDEX IF EXISTS paliad.deadline_rules_lifecycle_state_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rules_spawn_proceeding_type_id_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rules_trigger_event_id_idx;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_lifecycle_state_check;
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_priority_check;
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_combine_op_check;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_draft_of_fkey;
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_spawn_proceeding_type_id_fkey;
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_trigger_event_id_fkey;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP COLUMN IF EXISTS published_at,
|
||||
DROP COLUMN IF EXISTS draft_of,
|
||||
DROP COLUMN IF EXISTS lifecycle_state,
|
||||
DROP COLUMN IF EXISTS is_court_set,
|
||||
DROP COLUMN IF EXISTS priority,
|
||||
DROP COLUMN IF EXISTS condition_expr,
|
||||
DROP COLUMN IF EXISTS combine_op,
|
||||
DROP COLUMN IF EXISTS spawn_proceeding_type_id,
|
||||
DROP COLUMN IF EXISTS trigger_event_id;
|
||||
173
internal/db/migrations/078_unified_rule_columns.up.sql
Normal file
173
internal/db/migrations/078_unified_rule_columns.up.sql
Normal file
@@ -0,0 +1,173 @@
|
||||
-- t-paliad-182 / Fristen Phase 3 Slice 1 (Step A of
|
||||
-- docs/design-fristen-phase2-2026-05-15.md §3.1).
|
||||
--
|
||||
-- Additive only: extends paliad.deadline_rules with the unified-rule
|
||||
-- columns the Phase 3 calculator + rule editor will use.
|
||||
--
|
||||
-- NO drops in this slice. Legacy columns (is_mandatory, is_optional,
|
||||
-- condition_flag, condition_rule_id) stay live until Slice 9. Compat-
|
||||
-- mode readers consume both shapes during the transition window
|
||||
-- (design §3.2 "Cutover ordering").
|
||||
--
|
||||
-- Column-by-column rationale:
|
||||
-- trigger_event_id — event-rooted dispatch (Pipeline C unification, §2.5).
|
||||
-- spawn_proceeding_type_id — cross-proceeding spawn resolution (Q7, §2.6).
|
||||
-- combine_op — composite-rule arithmetic 'max'/'min' (R.198/R.213).
|
||||
-- condition_expr — jsonb condition grammar replacing condition_flag (Q6, §2.4).
|
||||
-- priority — 4-way enum mandatory|recommended|optional|informational (Q3, §2.3).
|
||||
-- is_court_set — explicit replacement of the runtime heuristic (Q12).
|
||||
-- lifecycle_state — draft|published|archived for the rule editor (Q5, §4.2).
|
||||
-- draft_of — draft self-FK pointing at the published row it replaces.
|
||||
-- published_at — promotion timestamp, NULL while draft.
|
||||
--
|
||||
-- FK type notes:
|
||||
-- trigger_event_id is BIGINT (paliad.trigger_events.id is bigint, mig 028).
|
||||
-- spawn_proceeding_type_id is INTEGER (paliad.proceeding_types.id is
|
||||
-- serial = int4, mig 003).
|
||||
-- draft_of is UUID (self-FK on paliad.deadline_rules.id).
|
||||
-- The design doc (§2.1) calls them "int FK" loosely; the actual schemas
|
||||
-- demand the precise int width, hence bigint/integer here.
|
||||
--
|
||||
-- Indexes:
|
||||
-- FK lookups for trigger_event_id + spawn_proceeding_type_id (sparse,
|
||||
-- most rules have neither — partial WHERE NOT NULL keeps the index
|
||||
-- small).
|
||||
-- lifecycle_state is queried by the admin /admin/rules listing's
|
||||
-- default filter (state='published'); plain btree is fine, no
|
||||
-- WHERE clause so 'draft' / 'archived' rows index too.
|
||||
--
|
||||
-- Idempotent: every ADD COLUMN uses IF NOT EXISTS. Re-applying is a
|
||||
-- no-op. Tracker advances 77 → 78.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. New columns on paliad.deadline_rules
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN IF NOT EXISTS trigger_event_id bigint,
|
||||
ADD COLUMN IF NOT EXISTS spawn_proceeding_type_id integer,
|
||||
ADD COLUMN IF NOT EXISTS combine_op text,
|
||||
ADD COLUMN IF NOT EXISTS condition_expr jsonb,
|
||||
ADD COLUMN IF NOT EXISTS priority text NOT NULL DEFAULT 'mandatory',
|
||||
ADD COLUMN IF NOT EXISTS is_court_set boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS lifecycle_state text NOT NULL DEFAULT 'published',
|
||||
ADD COLUMN IF NOT EXISTS draft_of uuid,
|
||||
ADD COLUMN IF NOT EXISTS published_at timestamptz;
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.trigger_event_id IS
|
||||
'Optional FK to paliad.trigger_events. When non-NULL, this rule is '
|
||||
'event-rooted (Pipeline C unification, design §2.5). When NULL the '
|
||||
'rule is proceeding-rooted via proceeding_type_id. Exactly one of '
|
||||
'the two must be set after Slice 3 backfill (enforced by a CHECK '
|
||||
'constraint added in Slice 9 after legacy callers retire).';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.spawn_proceeding_type_id IS
|
||||
'When is_spawn=true, points at the target proceeding whose rule set '
|
||||
'the calculator follows when this rule fires (cross-proceeding '
|
||||
'spawn, design §2.6). Backfilled in Slice 7 for the 8 live spawn '
|
||||
'rules.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.combine_op IS
|
||||
'NULL = single-anchor arithmetic. ''max'' / ''min'' = composite-rule '
|
||||
'arithmetic combining (duration_value, duration_unit) with '
|
||||
'(alt_duration_value, alt_duration_unit). Used by R.198 / R.213 '
|
||||
'("31d OR 20 working_days, whichever is longer / shorter").';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.condition_expr IS
|
||||
'jsonb gating expression replacing condition_flag (Q6, design §2.4). '
|
||||
'Grammar: {"flag": "<name>"} | {"op":"and"|"or", "args":[...]} | '
|
||||
'{"op":"not", "args":[<node>]}. NULL or {} = unconditional. '
|
||||
'Backfilled in Slice 2 from condition_flag; new code reads this, '
|
||||
'falls back to condition_flag during the transition window.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.priority IS
|
||||
'Unified 4-way enum (Q3, design §2.3) replacing the is_mandatory + '
|
||||
'is_optional pair. Allowed: mandatory | recommended | optional | '
|
||||
'informational. Default ''mandatory'' on new rows; legacy rows get '
|
||||
'backfilled in Slice 2 from the (is_mandatory, is_optional) pair.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.is_court_set IS
|
||||
'Replaces the runtime heuristic (primary_party=''court'' OR '
|
||||
'event_type IN (...)) with an explicit column (Q12). Default false '
|
||||
'on new rows; Slice 2 backfills from the heuristic so behaviour is '
|
||||
'unchanged at first.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.lifecycle_state IS
|
||||
'Rule-editor lifecycle (Q5, design §4.2). draft = work-in-progress '
|
||||
'admin edit; published = live, calculator-visible; archived = '
|
||||
'historical (kept for audit). Default ''published'' so every '
|
||||
'existing row stays live without an UPDATE.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.draft_of IS
|
||||
'When lifecycle_state=''draft'', points at the published rule this '
|
||||
'draft will replace on publish. NULL on published or archived '
|
||||
'rows. NULL also on net-new drafts (no prior published peer).';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.published_at IS
|
||||
'Timestamp this row entered lifecycle_state=''published''. NULL '
|
||||
'while draft, populated on publish, retained through archive. '
|
||||
'Distinct from updated_at (which moves on every edit).';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Foreign keys
|
||||
-- =============================================================================
|
||||
--
|
||||
-- DEFERRABLE INITIALLY IMMEDIATE keeps normal-statement semantics
|
||||
-- intact while still letting backfill migrations defer until end-of-
|
||||
-- transaction if they need to (e.g. when Slice 3 inserts a rule row
|
||||
-- whose trigger_event_id references a row inserted in the same tx).
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_trigger_event_id_fkey
|
||||
FOREIGN KEY (trigger_event_id)
|
||||
REFERENCES paliad.trigger_events(id)
|
||||
ON DELETE SET NULL
|
||||
DEFERRABLE INITIALLY IMMEDIATE;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_spawn_proceeding_type_id_fkey
|
||||
FOREIGN KEY (spawn_proceeding_type_id)
|
||||
REFERENCES paliad.proceeding_types(id)
|
||||
ON DELETE SET NULL
|
||||
DEFERRABLE INITIALLY IMMEDIATE;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_draft_of_fkey
|
||||
FOREIGN KEY (draft_of)
|
||||
REFERENCES paliad.deadline_rules(id)
|
||||
ON DELETE SET NULL
|
||||
DEFERRABLE INITIALLY IMMEDIATE;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. CHECK constraints on enum-style columns
|
||||
-- =============================================================================
|
||||
--
|
||||
-- combine_op: NULL (unset) or one of two values.
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_combine_op_check
|
||||
CHECK (combine_op IS NULL OR combine_op IN ('max', 'min'));
|
||||
|
||||
-- priority: 4-way enum.
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_priority_check
|
||||
CHECK (priority IN ('mandatory', 'recommended', 'optional', 'informational'));
|
||||
|
||||
-- lifecycle_state: 3-way enum.
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_lifecycle_state_check
|
||||
CHECK (lifecycle_state IN ('draft', 'published', 'archived'));
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Indexes
|
||||
-- =============================================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rules_trigger_event_id_idx
|
||||
ON paliad.deadline_rules (trigger_event_id)
|
||||
WHERE trigger_event_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rules_spawn_proceeding_type_id_idx
|
||||
ON paliad.deadline_rules (spawn_proceeding_type_id)
|
||||
WHERE spawn_proceeding_type_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rules_lifecycle_state_idx
|
||||
ON paliad.deadline_rules (lifecycle_state);
|
||||
15
internal/db/migrations/079_deadline_rule_audit.down.sql
Normal file
15
internal/db/migrations/079_deadline_rule_audit.down.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- t-paliad-182 down — reverses 079_deadline_rule_audit.up.sql.
|
||||
--
|
||||
-- Order: trigger → function → policy → indexes → table.
|
||||
|
||||
DROP TRIGGER IF EXISTS deadline_rules_audit_aiud ON paliad.deadline_rules;
|
||||
DROP FUNCTION IF EXISTS paliad.deadline_rule_audit_trigger();
|
||||
|
||||
DROP POLICY IF EXISTS deadline_rule_audit_select ON paliad.deadline_rule_audit;
|
||||
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_audit_pending_export_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_audit_changed_by_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_audit_changed_at_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_audit_rule_id_idx;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.deadline_rule_audit;
|
||||
207
internal/db/migrations/079_deadline_rule_audit.up.sql
Normal file
207
internal/db/migrations/079_deadline_rule_audit.up.sql
Normal file
@@ -0,0 +1,207 @@
|
||||
-- t-paliad-182 / Fristen Phase 3 Slice 1 — audit log for the rule editor
|
||||
-- (design §2.8, §3.1 Step A.079).
|
||||
--
|
||||
-- The audit log lands BEFORE the rule editor (Slice 11) so every future
|
||||
-- write to paliad.deadline_rules is captured forever, including the
|
||||
-- Slice 2 backfill UPDATEs. Defence-in-depth: the rule-editor service
|
||||
-- writes Go-authored audit rows with semantic actions ('publish',
|
||||
-- 'archive', 'restore'); this trigger is the backstop for raw SQL.
|
||||
--
|
||||
-- Field-naming mirrors design §2.8 (`changed_by` / `changed_at` /
|
||||
-- `before_json` / `after_json` / `migration_exported`), not the
|
||||
-- audit_log shorthand used elsewhere in Paliad.
|
||||
--
|
||||
-- Schema deviations from design §2.8, documented for the head review:
|
||||
--
|
||||
-- 1. `changed_by` is nullable, not NOT NULL. Reason: the trigger reads
|
||||
-- auth.uid() which is NULL when the writer is `service_role`
|
||||
-- (migrations, server-side Go using the service key, direct DB
|
||||
-- maintenance). NOT NULL would block every Slice-2 backfill UPDATE
|
||||
-- and every migration-applied seed. The Go rule-editor service
|
||||
-- enforces non-NULL changed_by at the application layer when it
|
||||
-- writes its own audit rows.
|
||||
--
|
||||
-- 2. `action` values stored by the trigger are 'create' / 'update' /
|
||||
-- 'delete' (the raw TG_OP semantics). Go-authored audit rows can
|
||||
-- additionally store 'publish' / 'archive' / 'restore' — those are
|
||||
-- lifecycle_state flips at the SQL level and appear as 'update' in
|
||||
-- the trigger's view of the world. The Go layer writes the
|
||||
-- higher-level action *before* the UPDATE, so the human-readable
|
||||
-- action is captured even though the trigger fires a paired
|
||||
-- 'update' row. The audit UI in Slice 11 collapses paired rows.
|
||||
--
|
||||
-- Audit-reason enforcement: the trigger reads
|
||||
-- `current_setting('paliad.audit_reason', true)` (the `true` flag
|
||||
-- returns NULL when unset rather than raising). On UPDATE and DELETE
|
||||
-- the trigger requires a non-empty reason and raises EXCEPTION 'audit
|
||||
-- reason required' if missing. On INSERT the reason is optional
|
||||
-- (defaults to 'create' so seed migrations don't need to set it).
|
||||
--
|
||||
-- Idempotent: re-applying is a no-op. Tracker advances 78 → 79.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. paliad.deadline_rule_audit
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadline_rule_audit (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- The rule this delta concerns. ON DELETE CASCADE: when a rule row
|
||||
-- gets hard-deleted (rare; lifecycle_state='archived' is the normal
|
||||
-- path), drop its audit chain too — the trail otherwise survives in
|
||||
-- the migration history of the table itself.
|
||||
rule_id uuid NOT NULL
|
||||
REFERENCES paliad.deadline_rules(id) ON DELETE CASCADE,
|
||||
|
||||
-- See header comment §1: nullable so trigger writes from service_role
|
||||
-- contexts (migrations, backfills) don't fail.
|
||||
changed_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
|
||||
changed_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- See header comment §2 for the trigger vs Go-layer split.
|
||||
action text NOT NULL
|
||||
CHECK (action IN (
|
||||
'create', 'update', 'delete',
|
||||
'publish', 'archive', 'restore'
|
||||
)),
|
||||
|
||||
-- Row state pre/post change. NULL on create / delete respectively.
|
||||
before_json jsonb,
|
||||
after_json jsonb,
|
||||
|
||||
-- Justification required by the trigger on UPDATE / DELETE; optional
|
||||
-- on INSERT (defaults to 'create' when paliad.audit_reason is unset
|
||||
-- so seed migrations don't need to bother).
|
||||
reason text NOT NULL,
|
||||
|
||||
-- Flips to true when the migration-export endpoint (Slice 11b) folds
|
||||
-- this delta into a checked-in .up.sql. Lets the export endpoint
|
||||
-- skip already-exported rows.
|
||||
migration_exported boolean NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_audit_rule_id_idx
|
||||
ON paliad.deadline_rule_audit (rule_id, changed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_audit_changed_at_idx
|
||||
ON paliad.deadline_rule_audit (changed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_audit_changed_by_idx
|
||||
ON paliad.deadline_rule_audit (changed_by)
|
||||
WHERE changed_by IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_audit_pending_export_idx
|
||||
ON paliad.deadline_rule_audit (changed_at DESC)
|
||||
WHERE migration_exported = false;
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_rule_audit IS
|
||||
'Append-only audit log for paliad.deadline_rules. Written by the '
|
||||
'AFTER-trigger on the rules table (raw create/update/delete) and '
|
||||
'by the Go rule-editor service (semantic publish/archive/restore). '
|
||||
'Required reason field is the compliance hook for the rule-editor '
|
||||
'design (Q5, §4.7).';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Audit trigger
|
||||
-- =============================================================================
|
||||
--
|
||||
-- SECURITY DEFINER so the trigger function runs with the table-owner's
|
||||
-- privileges and bypasses RLS on the audit table. Otherwise an
|
||||
-- authenticated user's UPDATE on a rule would fail when the trigger
|
||||
-- tried to INSERT under their RLS context.
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.deadline_rule_audit_trigger()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = paliad, public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_reason text;
|
||||
v_action text;
|
||||
v_before jsonb;
|
||||
v_after jsonb;
|
||||
v_rule_id uuid;
|
||||
BEGIN
|
||||
v_reason := current_setting('paliad.audit_reason', true);
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
v_action := 'create';
|
||||
v_before := NULL;
|
||||
v_after := to_jsonb(NEW);
|
||||
v_rule_id := NEW.id;
|
||||
-- INSERT is allowed without an explicit reason; seed migrations
|
||||
-- and net-new drafts default to a synthetic reason.
|
||||
IF v_reason IS NULL OR v_reason = '' THEN
|
||||
v_reason := 'create';
|
||||
END IF;
|
||||
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
v_action := 'update';
|
||||
v_before := to_jsonb(OLD);
|
||||
v_after := to_jsonb(NEW);
|
||||
v_rule_id := NEW.id;
|
||||
IF v_reason IS NULL OR v_reason = '' THEN
|
||||
RAISE EXCEPTION 'paliad.deadline_rules: audit reason required for UPDATE — '
|
||||
'set paliad.audit_reason via SET LOCAL or set_config()';
|
||||
END IF;
|
||||
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
v_action := 'delete';
|
||||
v_before := to_jsonb(OLD);
|
||||
v_after := NULL;
|
||||
v_rule_id := OLD.id;
|
||||
IF v_reason IS NULL OR v_reason = '' THEN
|
||||
RAISE EXCEPTION 'paliad.deadline_rules: audit reason required for DELETE — '
|
||||
'set paliad.audit_reason via SET LOCAL or set_config()';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
INSERT INTO paliad.deadline_rule_audit
|
||||
(rule_id, changed_by, action, before_json, after_json, reason)
|
||||
VALUES
|
||||
(v_rule_id, auth.uid(), v_action, v_before, v_after, v_reason);
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.deadline_rule_audit_trigger() IS
|
||||
'AFTER-trigger backstop that writes paliad.deadline_rule_audit rows '
|
||||
'for every raw INSERT / UPDATE / DELETE on paliad.deadline_rules. '
|
||||
'UPDATE / DELETE require paliad.audit_reason to be set in the '
|
||||
'session (via SET LOCAL paliad.audit_reason = ...); INSERT defaults '
|
||||
'to ''create'' so seed migrations remain ergonomic.';
|
||||
|
||||
DROP TRIGGER IF EXISTS deadline_rules_audit_aiud ON paliad.deadline_rules;
|
||||
|
||||
CREATE TRIGGER deadline_rules_audit_aiud
|
||||
AFTER INSERT OR UPDATE OR DELETE ON paliad.deadline_rules
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.deadline_rule_audit_trigger();
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. RLS on the audit table
|
||||
-- =============================================================================
|
||||
--
|
||||
-- Read: global_admin only (mirrors mig 057 pattern). Service-layer code
|
||||
-- gates `/admin/rules/{id}/audit` separately; this RLS is defence-in-
|
||||
-- depth for any future auth-context query path.
|
||||
--
|
||||
-- Write: nobody via row-level paths. The trigger function is
|
||||
-- SECURITY DEFINER so it bypasses RLS entirely. Direct INSERTs by
|
||||
-- authenticated users are denied (no INSERT policy). service_role
|
||||
-- bypasses RLS as usual.
|
||||
|
||||
ALTER TABLE paliad.deadline_rule_audit ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY deadline_rule_audit_select
|
||||
ON paliad.deadline_rule_audit FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- t-paliad-182 down — reverses 080_projects_instance_level.up.sql.
|
||||
|
||||
ALTER TABLE paliad.projects DROP COLUMN IF EXISTS instance_level;
|
||||
30
internal/db/migrations/080_projects_instance_level.up.sql
Normal file
30
internal/db/migrations/080_projects_instance_level.up.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- t-paliad-182 / Fristen Phase 3 Slice 1 — paliad.projects.instance_level
|
||||
-- (design §2.7, §7).
|
||||
--
|
||||
-- Lets the SmartTimeline + calculator derive the effective proceeding
|
||||
-- code from (proceeding_code, instance_level) — e.g. DE_INF + 'appeal'
|
||||
-- resolves to DE_INF_OLG.
|
||||
--
|
||||
-- Nullable: NULL means "not asked / not relevant" (e.g. EP_GRANT, a
|
||||
-- non-litigation patent project). Allowed values:
|
||||
-- first — first instance (default once the picker UI lands)
|
||||
-- appeal — Berufung / EPA Beschwerde / appellate level
|
||||
-- cassation — BGH-Revision / EPA-EBA / final instance
|
||||
--
|
||||
-- No backfill in this slice. The picker UI (Slice 8) writes the column;
|
||||
-- legacy projects stay NULL and behave as if first instance via the
|
||||
-- calculator's fallback (`NULL OR 'first'` → use base proceeding code).
|
||||
--
|
||||
-- Idempotent: re-applying is a no-op. Tracker advances 79 → 80.
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN IF NOT EXISTS instance_level text
|
||||
CHECK (instance_level IS NULL
|
||||
OR instance_level IN ('first', 'appeal', 'cassation'));
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.instance_level IS
|
||||
'Procedural instance the project sits at: first | appeal | '
|
||||
'cassation. NULL = unset / not applicable. Combined with '
|
||||
'proceeding_type.code + jurisdiction by FristenrechnerService to '
|
||||
'pick the effective proceeding code (e.g. DE_INF + appeal → '
|
||||
'DE_INF_OLG). See design-fristen-phase2-2026-05-15.md §2.7, §7.';
|
||||
21
internal/db/migrations/082_backfill_is_court_set.down.sql
Normal file
21
internal/db/migrations/082_backfill_is_court_set.down.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- t-paliad-183 down — reverts the is_court_set flips written by
|
||||
-- 082_backfill_is_court_set.up.sql.
|
||||
--
|
||||
-- "Revert" here means: restore the post-Slice-1 default (false on every
|
||||
-- row). We don't know after the fact which rows were already true
|
||||
-- before the backfill (mig 078 created the column with DEFAULT false on
|
||||
-- every existing row, so post-Slice-1 every row was false — there is
|
||||
-- no pre-existing true population to preserve). Setting back to false
|
||||
-- is therefore equivalent to "undo the backfill".
|
||||
--
|
||||
-- Audit-reason set so the trigger doesn't raise on the down-side
|
||||
-- UPDATEs either.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 082: reset is_court_set to mig 078 default (false)',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = false
|
||||
WHERE is_court_set = true;
|
||||
68
internal/db/migrations/082_backfill_is_court_set.up.sql
Normal file
68
internal/db/migrations/082_backfill_is_court_set.up.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
-- t-paliad-183 / Fristen Phase 3 Slice 2 Step B-1 — backfill
|
||||
-- paliad.deadline_rules.is_court_set from the live runtime heuristic.
|
||||
--
|
||||
-- Heuristic source-of-truth: internal/services/fristenrechner.go
|
||||
-- isCourtDeterminedRule() — at the time of Slice 1 (commit c7fa0d6) the
|
||||
-- body is precisely:
|
||||
--
|
||||
-- primary_party = 'court'
|
||||
-- OR event_type IN ('hearing', 'decision', 'order')
|
||||
--
|
||||
-- The Slice 2 head instruction (msg 1746) suggested padding with
|
||||
-- 'name ILIKE %entscheidung% OR %urteil%'; head's clarification
|
||||
-- (msg 1750) rules that out: replicate the live code exactly. Padding
|
||||
-- would mis-flag party submissions like 'Antrag auf Kostenentscheidung'
|
||||
-- (RoP.151) and 'Stellungnahme zum Hinweisbeschluss' as court-set —
|
||||
-- they are not (the party files them; only their anchor is set by the
|
||||
-- court).
|
||||
--
|
||||
-- Audit footnote for the legal-review pass: ~8 'Zustellung…' rules
|
||||
-- (Zustellung BPatG-Entscheidung, Zustellung LG-Urteil, etc.) carry
|
||||
-- primary_party='both' + event_type='filing'. Semantically the
|
||||
-- Zustellung date IS court-set, but the live heuristic doesn't treat
|
||||
-- them as such and flagging them now would change calculator
|
||||
-- rendering without legal review. Leaving them is_court_set=false
|
||||
-- preserves current behaviour; the legal-review pass mentioned in
|
||||
-- design §2.3 ("flag them informational in a Phase 3 slice") can
|
||||
-- promote them later via a targeted UPDATE.
|
||||
--
|
||||
-- Audit-reason: set_config('paliad.audit_reason', …, true) scopes the
|
||||
-- value to golang-migrate's implicit per-file transaction. The audit
|
||||
-- trigger from mig 079 picks it up via current_setting() and writes
|
||||
-- one paliad.deadline_rule_audit row per flipped rule — the compliance
|
||||
-- trail for the backfill, persisted forever.
|
||||
--
|
||||
-- Idempotent: WHERE is_court_set = false guards re-runs against double-
|
||||
-- counting audit rows.
|
||||
--
|
||||
-- Expected delta on the production corpus (172 rules): 47 rows flipped
|
||||
-- false→true (every primary_party='court' rule also has a matching
|
||||
-- event_type in the current data — the two predicates fully overlap).
|
||||
--
|
||||
-- Tracker note: mig 081 was reserved for proceeding_types display_order
|
||||
-- verification per design §3.1; that was a no-op and not authored.
|
||||
-- Slice 1 shipped 078/079/080; Slice 2 starts at 082. golang-migrate
|
||||
-- only requires ascending order, not contiguity.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'backfill 082: is_court_set from isCourtDeterminedRule heuristic '
|
||||
|| '(primary_party=court OR event_type IN hearing/decision/order) '
|
||||
|| 'per design §2.3 / fristenrechner.go',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = true
|
||||
WHERE is_court_set = false
|
||||
AND (
|
||||
primary_party = 'court'
|
||||
OR event_type IN ('hearing', 'decision', 'order')
|
||||
);
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_set int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO n_set FROM paliad.deadline_rules WHERE is_court_set = true;
|
||||
RAISE NOTICE 'backfill 082: is_court_set=true on % rules', n_set;
|
||||
END $$;
|
||||
17
internal/db/migrations/083_backfill_priority.down.sql
Normal file
17
internal/db/migrations/083_backfill_priority.down.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- t-paliad-183 down — reverts the priority flips written by
|
||||
-- 083_backfill_priority.up.sql.
|
||||
--
|
||||
-- "Revert" here means: restore the post-Slice-1 column default
|
||||
-- ('mandatory' on every row). Mig 078 created the column with that
|
||||
-- default; post-Slice-1 every row was 'mandatory' regardless of its
|
||||
-- (is_mandatory, is_optional) pair. Resetting to 'mandatory' is
|
||||
-- therefore equivalent to "undo the backfill".
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 083: reset priority to mig 078 default (mandatory)',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET priority = 'mandatory'
|
||||
WHERE priority <> 'mandatory';
|
||||
110
internal/db/migrations/083_backfill_priority.up.sql
Normal file
110
internal/db/migrations/083_backfill_priority.up.sql
Normal file
@@ -0,0 +1,110 @@
|
||||
-- t-paliad-183 / Fristen Phase 3 Slice 2 Step B-2 — backfill
|
||||
-- paliad.deadline_rules.priority from the legacy (is_mandatory,
|
||||
-- is_optional) pair per DESIGN §2.3 (NOT the inverted mapping in
|
||||
-- head's msg 1746 — head's clarification msg 1750 rules in favour of
|
||||
-- the design doc).
|
||||
--
|
||||
-- Final mapping (design §2.3 + RoP.151 / mig 068 t-paliad-157 semantic):
|
||||
--
|
||||
-- is_mandatory=true, is_optional=false → 'mandatory' (statutory must,
|
||||
-- ☑ pre-checked in
|
||||
-- save modal)
|
||||
-- is_mandatory=true, is_optional=true → 'optional' (statutorily strict
|
||||
-- ONCE IT APPLIES,
|
||||
-- but applies only
|
||||
-- if a party files —
|
||||
-- RoP.151 is the
|
||||
-- canonical case;
|
||||
-- ☐ pre-unchecked)
|
||||
-- is_mandatory=false, is_optional=true → 'recommended' (no live data, but
|
||||
-- defensive default
|
||||
-- so the CHECK
|
||||
-- constraint stays
|
||||
-- satisfied if such
|
||||
-- a row ever lands)
|
||||
-- is_mandatory=false, is_optional=false → 'recommended' (situational filings
|
||||
-- — Berufungserwiderung,
|
||||
-- Replik, Duplik,
|
||||
-- R.19 Preliminary
|
||||
-- Objection, R.116
|
||||
-- EPÜ, Anschluss-
|
||||
-- berufung, etc.
|
||||
-- Default-save with
|
||||
-- override, not
|
||||
-- 'informational'
|
||||
-- which would make
|
||||
-- them never-saveable)
|
||||
--
|
||||
-- Live-data expected delta (172 rules total, mig 078 set every row to
|
||||
-- the default 'mandatory'):
|
||||
-- T/F (153 rows) → 'mandatory' — 153 no-op UPDATEs (already correct)
|
||||
-- T/T ( 1 row) → 'optional' — 1 row flips
|
||||
-- F/F ( 18 rows) → 'recommended' — 18 rows flip
|
||||
-- F/T ( 0 rows) → 'recommended' — 0 rows (no live data)
|
||||
--
|
||||
-- The UPDATE is split into branches with explicit WHERE clauses so the
|
||||
-- audit log records each branch as a distinct backfill action (separate
|
||||
-- audit row chains by (is_mandatory, is_optional) shape). It also keeps
|
||||
-- the migration idempotent: re-running only touches rows whose priority
|
||||
-- doesn't already match the target.
|
||||
--
|
||||
-- Audit-reason cites design §2.3 — that's the persistent rationale in
|
||||
-- the paliad.deadline_rule_audit log.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'backfill 083: priority from (is_mandatory, is_optional) per design §2.3 — '
|
||||
|| 'T/T→optional (RoP.151), F/F→recommended (situational filings)',
|
||||
true);
|
||||
|
||||
-- Branch 1: T/T → 'optional' (RoP.151).
|
||||
UPDATE paliad.deadline_rules
|
||||
SET priority = 'optional'
|
||||
WHERE is_mandatory = true
|
||||
AND is_optional = true
|
||||
AND priority <> 'optional';
|
||||
|
||||
-- Branch 2: F/F → 'recommended'.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET priority = 'recommended'
|
||||
WHERE is_mandatory = false
|
||||
AND is_optional = false
|
||||
AND priority <> 'recommended';
|
||||
|
||||
-- Branch 3: F/T → 'recommended' (defensive; no live rows today).
|
||||
UPDATE paliad.deadline_rules
|
||||
SET priority = 'recommended'
|
||||
WHERE is_mandatory = false
|
||||
AND is_optional = true
|
||||
AND priority <> 'recommended';
|
||||
|
||||
-- Branch 4: T/F → 'mandatory'. Skipped explicitly: the mig 078 column
|
||||
-- default is already 'mandatory', so every T/F row already has the
|
||||
-- correct value. A defensive UPDATE here would write 153 needless
|
||||
-- audit rows. Leave T/F untouched.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_mand int;
|
||||
n_opt int;
|
||||
n_reco int;
|
||||
n_info int;
|
||||
n_null int;
|
||||
BEGIN
|
||||
SELECT count(*) FILTER (WHERE priority = 'mandatory'),
|
||||
count(*) FILTER (WHERE priority = 'optional'),
|
||||
count(*) FILTER (WHERE priority = 'recommended'),
|
||||
count(*) FILTER (WHERE priority = 'informational'),
|
||||
count(*) FILTER (WHERE priority IS NULL)
|
||||
INTO n_mand, n_opt, n_reco, n_info, n_null
|
||||
FROM paliad.deadline_rules;
|
||||
RAISE NOTICE 'backfill 083: priority distribution — '
|
||||
'mandatory=%, optional=%, recommended=%, informational=%, NULL=%',
|
||||
n_mand, n_opt, n_reco, n_info, n_null;
|
||||
-- Hard assertion: priority is NOT NULL by schema (mig 078) and
|
||||
-- every value must lie in the CHECK enum. n_null must be 0.
|
||||
IF n_null > 0 THEN
|
||||
RAISE EXCEPTION 'backfill 083: % rows still have priority IS NULL — '
|
||||
'schema violation', n_null;
|
||||
END IF;
|
||||
END $$;
|
||||
14
internal/db/migrations/084_backfill_condition_expr.down.sql
Normal file
14
internal/db/migrations/084_backfill_condition_expr.down.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- t-paliad-183 down — reverts the condition_expr translations written
|
||||
-- by 084_backfill_condition_expr.up.sql. Mig 078 created the column
|
||||
-- with NULL on every row; resetting non-NULL values to NULL undoes the
|
||||
-- backfill cleanly (condition_flag is the source of truth for the
|
||||
-- legacy code path and stays untouched).
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 084: reset condition_expr to mig 078 default (NULL)',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET condition_expr = NULL
|
||||
WHERE condition_expr IS NOT NULL;
|
||||
111
internal/db/migrations/084_backfill_condition_expr.up.sql
Normal file
111
internal/db/migrations/084_backfill_condition_expr.up.sql
Normal file
@@ -0,0 +1,111 @@
|
||||
-- t-paliad-183 / Fristen Phase 3 Slice 2 Step B-3 — backfill
|
||||
-- paliad.deadline_rules.condition_expr from the legacy
|
||||
-- condition_flag text[] column per DESIGN §2.4 long form (NOT the
|
||||
-- short {"and":[...]} form sketched in head's msg 1746 — head's
|
||||
-- clarification msg 1750 rules in favour of the design doc).
|
||||
--
|
||||
-- Mapping (design §2.4):
|
||||
--
|
||||
-- condition_flag IS NULL OR array_length(_, 1) = 0
|
||||
-- → condition_expr stays NULL (unconditional, every rule renders)
|
||||
--
|
||||
-- array_length = 1, e.g. ['with_ccr']
|
||||
-- → condition_expr = jsonb '{"flag": "with_ccr"}'
|
||||
-- (single flag unwrapped — saves a layer of nesting that
|
||||
-- parses as the same boolean expression)
|
||||
--
|
||||
-- array_length >= 2, e.g. ['with_ccr', 'with_amend']
|
||||
-- → condition_expr = jsonb '{"op":"and","args":[
|
||||
-- {"flag":"with_ccr"},
|
||||
-- {"flag":"with_amend"}
|
||||
-- ]}'
|
||||
-- (long form — same shape the rule editor will emit for OR /
|
||||
-- NOT in future rules so the calculator's parser is uniform)
|
||||
--
|
||||
-- Why long form on >=2: the calculator (Slice 4) reads
|
||||
-- {"op":"<and|or|not>","args":[...]} as the canonical boolean node and
|
||||
-- {"flag":"<name>"} as the leaf. Single-flag unwrap is a parse-time
|
||||
-- shortcut equivalent to a 1-arg AND. The short {"and":[...]} form in
|
||||
-- msg 1746 would require a per-key parser that doesn't generalise to
|
||||
-- OR / NOT. Design §2.4 long form is the load-bearing decision.
|
||||
--
|
||||
-- Live-data expected delta (172 rules total):
|
||||
--
|
||||
-- ['with_ccr'] × 5 rows → {"flag":"with_ccr"}
|
||||
-- ['with_amend'] × 4 rows → {"flag":"with_amend"}
|
||||
-- ['with_cci'] × 4 rows → {"flag":"with_cci"}
|
||||
-- ['with_ccr', 'with_amend'] × 4 rows → {"op":"and","args":[
|
||||
-- {"flag":"with_ccr"},
|
||||
-- {"flag":"with_amend"}
|
||||
-- ]}
|
||||
-- NULL or {} × 155 rows → stays NULL
|
||||
--
|
||||
-- Total touched: 17 rows.
|
||||
--
|
||||
-- Idempotent: WHERE condition_expr IS NULL guards re-runs against
|
||||
-- double-writing audit rows for already-translated rules.
|
||||
--
|
||||
-- jsonb construction: jsonb_build_object + jsonb_agg + a CASE on
|
||||
-- array_length keeps the long-form / unwrapped-flag split inline in
|
||||
-- one UPDATE. Per-flag jsonb leaf is built by a LATERAL unnest over
|
||||
-- the flag array so the args[] order matches the source array.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'backfill 084: condition_expr from condition_flag text[] per design §2.4 — '
|
||||
|| 'single flag unwrapped, multi flag long form {op:and, args:[...]}',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET condition_expr = sub.expr
|
||||
FROM (
|
||||
SELECT dr_inner.id AS rule_id,
|
||||
CASE
|
||||
-- Single flag: unwrapped leaf.
|
||||
WHEN array_length(dr_inner.condition_flag, 1) = 1
|
||||
THEN jsonb_build_object('flag', dr_inner.condition_flag[1])
|
||||
|
||||
-- >=2 flags: long-form AND with args[] preserving order.
|
||||
WHEN array_length(dr_inner.condition_flag, 1) >= 2
|
||||
THEN jsonb_build_object(
|
||||
'op', 'and',
|
||||
'args', (
|
||||
SELECT jsonb_agg(jsonb_build_object('flag', f) ORDER BY ord)
|
||||
FROM unnest(dr_inner.condition_flag) WITH ORDINALITY AS u(f, ord)
|
||||
)
|
||||
)
|
||||
|
||||
-- Empty array (array_length=0) or NULL: leave NULL.
|
||||
ELSE NULL
|
||||
END AS expr
|
||||
FROM paliad.deadline_rules dr_inner
|
||||
WHERE dr_inner.condition_flag IS NOT NULL
|
||||
AND array_length(dr_inner.condition_flag, 1) > 0
|
||||
) AS sub
|
||||
WHERE dr.id = sub.rule_id
|
||||
AND dr.condition_expr IS NULL;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_total int;
|
||||
n_with_flag int;
|
||||
n_with_expr int;
|
||||
n_with_both int;
|
||||
BEGIN
|
||||
SELECT count(*),
|
||||
count(*) FILTER (WHERE condition_flag IS NOT NULL AND array_length(condition_flag, 1) > 0),
|
||||
count(*) FILTER (WHERE condition_expr IS NOT NULL),
|
||||
count(*) FILTER (WHERE condition_flag IS NOT NULL AND array_length(condition_flag, 1) > 0
|
||||
AND condition_expr IS NOT NULL)
|
||||
INTO n_total, n_with_flag, n_with_expr, n_with_both
|
||||
FROM paliad.deadline_rules;
|
||||
RAISE NOTICE 'backfill 084: total=%, with_condition_flag=%, with_condition_expr=%, both=%',
|
||||
n_total, n_with_flag, n_with_expr, n_with_both;
|
||||
-- Hard assertion: every rule with a non-empty condition_flag now
|
||||
-- has a non-NULL condition_expr (the inverse of the legacy column).
|
||||
IF n_with_flag <> n_with_both THEN
|
||||
RAISE EXCEPTION 'backfill 084: % rules carry condition_flag but no condition_expr — '
|
||||
'translation incomplete',
|
||||
n_with_flag - n_with_both;
|
||||
END IF;
|
||||
END $$;
|
||||
17
internal/db/migrations/085_pipeline_c_data_move.down.sql
Normal file
17
internal/db/migrations/085_pipeline_c_data_move.down.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- t-paliad-184 down — reverts the Pipeline-C data-move from
|
||||
-- 085_pipeline_c_data_move.up.sql. Deletes every paliad.deadline_rules
|
||||
-- row carrying a non-NULL trigger_event_id (those are exactly the rows
|
||||
-- the up-migration created — before mig 085 no Pipeline-A rule ever
|
||||
-- carried trigger_event_id, and Slice 9 hasn't dropped the source
|
||||
-- table yet so the rows can be regenerated).
|
||||
--
|
||||
-- Audit-reason set so the mig 079 trigger captures the rollback
|
||||
-- rationale and doesn't raise on DELETE.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 085: delete Pipeline-C unified rows (source preserved in event_deadlines)',
|
||||
true);
|
||||
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE trigger_event_id IS NOT NULL;
|
||||
184
internal/db/migrations/085_pipeline_c_data_move.up.sql
Normal file
184
internal/db/migrations/085_pipeline_c_data_move.up.sql
Normal file
@@ -0,0 +1,184 @@
|
||||
-- t-paliad-184 / Fristen Phase 3 Slice 3 Step C — data-move 77 rows
|
||||
-- from paliad.event_deadlines → paliad.deadline_rules so the Phase-3
|
||||
-- unified backend can serve both pipelines.
|
||||
--
|
||||
-- Source rows are PRESERVED (mig 086's read-only trigger blocks
|
||||
-- further writes; mig 090 in Slice 9 drops the table once every
|
||||
-- caller has cut over). The data-move is one-way; legacy callers
|
||||
-- continue reading event_deadlines via plain SELECTs until Slice 9.
|
||||
--
|
||||
-- Mapping (per design §3.C):
|
||||
--
|
||||
-- paliad.event_deadlines → paliad.deadline_rules
|
||||
-- ------------------------- ----------------------
|
||||
-- id (new gen_random_uuid())
|
||||
-- trigger_event_id trigger_event_id (Phase 3 column from mig 078)
|
||||
-- title (EN, NOT NULL) name_en (NOT NULL)
|
||||
-- title_de (DE, NOT NULL DEFAULT '') name (NOT NULL — every row has non-empty title_de in live data)
|
||||
-- duration_value duration_value
|
||||
-- duration_unit (days/weeks/months/working_days) duration_unit
|
||||
-- timing (before/after) timing
|
||||
-- notes (DE) deadline_notes (DE)
|
||||
-- notes_en (EN, nullable) deadline_notes_en (EN, nullable)
|
||||
-- alt_duration_value alt_duration_value
|
||||
-- alt_duration_unit alt_duration_unit
|
||||
-- combine_op (max/min, nullable) combine_op (Phase 3 column from mig 078)
|
||||
-- legal_source legal_source
|
||||
-- is_active is_active
|
||||
-- created_at published_at (preserves chronology — lifecycle_state='published' on every row)
|
||||
-- updated_at = now() (this is the publish event)
|
||||
--
|
||||
-- Pipeline-A-only fields default:
|
||||
-- proceeding_type_id = NULL (event-rooted, no proceeding)
|
||||
-- parent_id = NULL (Pipeline C is flat, no chain)
|
||||
-- spawn_proceeding_type_id = NULL (no spawn)
|
||||
-- code = NULL (no local rule code in Pipeline C)
|
||||
-- primary_party = NULL (event_deadlines has no party column)
|
||||
-- event_type = NULL (filing/hearing/decision is a
|
||||
-- Pipeline-A category)
|
||||
-- is_court_set = false (no court-set Pipeline-C rules
|
||||
-- in the corpus; legal-review
|
||||
-- pass can flip Zustellung-* if
|
||||
-- those ever land here)
|
||||
-- is_spawn = false
|
||||
-- is_mandatory = true (Pipeline C has no mandatory
|
||||
-- bool; design §2.3 says default
|
||||
-- 'mandatory' is correct for
|
||||
-- statutory event-driven deadlines)
|
||||
-- is_optional = false
|
||||
-- priority = 'mandatory'
|
||||
-- condition_expr = NULL (Pipeline C has no flag gating)
|
||||
-- condition_flag = NULL
|
||||
-- sequence_order = 1000 + event_deadlines.id
|
||||
-- (large offset so Pipeline-C
|
||||
-- rows sort AFTER any future
|
||||
-- hand-edited Pipeline-A
|
||||
-- sequence_orders without
|
||||
-- colliding with the
|
||||
-- existing 0–171 range)
|
||||
-- lifecycle_state = 'published'
|
||||
--
|
||||
-- Idempotency: WHERE NOT EXISTS guard on (trigger_event_id, name) skips
|
||||
-- rows that already exist in deadline_rules. Re-running the migration
|
||||
-- is a no-op.
|
||||
--
|
||||
-- Hard assertion at end: COUNT(deadline_rules WHERE trigger_event_id
|
||||
-- IS NOT NULL) == COUNT(event_deadlines WHERE is_active = true) (77 = 77).
|
||||
-- RAISE EXCEPTION on mismatch so a partial move fails the migration
|
||||
-- loudly instead of poisoning Slice 4.
|
||||
--
|
||||
-- Audit-reason cites design §3.C — the rationale persists in the
|
||||
-- paliad.deadline_rule_audit log forever via the mig 079 trigger.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'pipeline C migration 085: data-move event_deadlines → deadline_rules per design §3.C — '
|
||||
|| 'preserves source rows; mig 086 wraps the source table read-only',
|
||||
true);
|
||||
|
||||
INSERT INTO paliad.deadline_rules (
|
||||
id,
|
||||
proceeding_type_id,
|
||||
parent_id,
|
||||
trigger_event_id,
|
||||
spawn_proceeding_type_id,
|
||||
code,
|
||||
name,
|
||||
name_en,
|
||||
primary_party,
|
||||
event_type,
|
||||
is_mandatory,
|
||||
is_optional,
|
||||
is_court_set,
|
||||
is_spawn,
|
||||
duration_value,
|
||||
duration_unit,
|
||||
timing,
|
||||
alt_duration_value,
|
||||
alt_duration_unit,
|
||||
combine_op,
|
||||
rule_code,
|
||||
deadline_notes,
|
||||
deadline_notes_en,
|
||||
legal_source,
|
||||
condition_expr,
|
||||
condition_flag,
|
||||
sequence_order,
|
||||
is_active,
|
||||
priority,
|
||||
lifecycle_state,
|
||||
draft_of,
|
||||
published_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid() AS id,
|
||||
NULL::integer AS proceeding_type_id,
|
||||
NULL::uuid AS parent_id,
|
||||
ed.trigger_event_id AS trigger_event_id,
|
||||
NULL::integer AS spawn_proceeding_type_id,
|
||||
NULL::text AS code,
|
||||
ed.title_de AS name,
|
||||
ed.title AS name_en,
|
||||
NULL::text AS primary_party,
|
||||
NULL::text AS event_type,
|
||||
true AS is_mandatory,
|
||||
false AS is_optional,
|
||||
false AS is_court_set,
|
||||
false AS is_spawn,
|
||||
ed.duration_value AS duration_value,
|
||||
ed.duration_unit AS duration_unit,
|
||||
ed.timing AS timing,
|
||||
ed.alt_duration_value AS alt_duration_value,
|
||||
ed.alt_duration_unit AS alt_duration_unit,
|
||||
ed.combine_op AS combine_op,
|
||||
NULL::text AS rule_code,
|
||||
NULLIF(ed.notes, '') AS deadline_notes,
|
||||
ed.notes_en AS deadline_notes_en,
|
||||
ed.legal_source AS legal_source,
|
||||
NULL::jsonb AS condition_expr,
|
||||
NULL::text[] AS condition_flag,
|
||||
(1000 + ed.id)::integer AS sequence_order,
|
||||
ed.is_active AS is_active,
|
||||
'mandatory' AS priority,
|
||||
'published' AS lifecycle_state,
|
||||
NULL::uuid AS draft_of,
|
||||
ed.created_at AS published_at,
|
||||
ed.created_at AS created_at,
|
||||
now() AS updated_at
|
||||
FROM paliad.event_deadlines ed
|
||||
WHERE ed.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.deadline_rules dr
|
||||
WHERE dr.trigger_event_id = ed.trigger_event_id
|
||||
AND dr.name = ed.title_de
|
||||
);
|
||||
|
||||
-- Hard assertion: every active event_deadlines row must have a matching
|
||||
-- deadline_rules row by (trigger_event_id, name). If the counts diverge,
|
||||
-- something in the WHERE NOT EXISTS clause (likely a stale duplicate)
|
||||
-- prevented a real insert — fail the migration rather than ship a
|
||||
-- partial Pipeline-C corpus.
|
||||
DO $$
|
||||
DECLARE
|
||||
n_source int;
|
||||
n_target int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO n_source
|
||||
FROM paliad.event_deadlines WHERE is_active = true;
|
||||
|
||||
SELECT count(*) INTO n_target
|
||||
FROM paliad.deadline_rules WHERE trigger_event_id IS NOT NULL;
|
||||
|
||||
RAISE NOTICE 'mig 085: event_deadlines(active)=%, deadline_rules(trigger_event_id IS NOT NULL)=%',
|
||||
n_source, n_target;
|
||||
|
||||
IF n_target <> n_source THEN
|
||||
RAISE EXCEPTION 'mig 085: data-move incomplete — expected % unified rows, got %. '
|
||||
'Investigate event_deadlines (trigger_event_id, title_de) duplicates '
|
||||
'OR re-applied migration on dirtied target.',
|
||||
n_source, n_target;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- t-paliad-184 down — reverts the read-only wrapper from
|
||||
-- 086_event_deadlines_readonly.up.sql. Order: trigger → function.
|
||||
|
||||
DROP TRIGGER IF EXISTS event_deadlines_readonly ON paliad.event_deadlines;
|
||||
DROP FUNCTION IF EXISTS paliad.event_deadlines_readonly_trigger();
|
||||
58
internal/db/migrations/086_event_deadlines_readonly.up.sql
Normal file
58
internal/db/migrations/086_event_deadlines_readonly.up.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- t-paliad-184 / Fristen Phase 3 Slice 3 — wrap paliad.event_deadlines
|
||||
-- in a read-only trigger so nobody can edit either side mid-cutover.
|
||||
--
|
||||
-- Slice 3 just moved 77 rows from event_deadlines → deadline_rules (mig
|
||||
-- 085). Until Slice 4 cuts every reader over and Slice 9 drops the
|
||||
-- legacy table, event_deadlines stays in place as the audit anchor and
|
||||
-- (briefly) a compat-read source. We must not let any writer mutate it
|
||||
-- behind the unified backend's back — diverging the two sides would
|
||||
-- silently regress "Was kommt nach…" parity.
|
||||
--
|
||||
-- The trigger fires AFTER INSERT / UPDATE / DELETE and raises an
|
||||
-- EXCEPTION with a clear message pointing the writer at the unified
|
||||
-- table. SELECT is unaffected — the legacy EventDeadlineService's
|
||||
-- pre-Slice-3 SELECT path keeps working until Slice 4 swaps it.
|
||||
--
|
||||
-- The supabase service_role bypasses RLS but NOT triggers — so
|
||||
-- direct DB maintenance (psql, migration scripts) is also blocked.
|
||||
-- This is intentional: any further edit to event_deadlines is a
|
||||
-- mistake until Slice 9 drops the table.
|
||||
--
|
||||
-- Removed by Slice 9 (Step E, mig ~090) when paliad.event_deadlines is
|
||||
-- dropped. Until then the trigger is the only thing keeping the two
|
||||
-- tables in sync.
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.event_deadlines_readonly_trigger()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION
|
||||
'paliad.event_deadlines is read-only after Phase 3 Slice 3 — '
|
||||
'writes must go through paliad.deadline_rules (Pipeline C is '
|
||||
'unified; the source table is preserved as an audit anchor '
|
||||
'until Slice 9 drops it). Operation: %', TG_OP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.event_deadlines_readonly_trigger() IS
|
||||
'BEFORE INSERT/UPDATE/DELETE trigger function that raises on any '
|
||||
'write to paliad.event_deadlines. Lives only between Slice 3 and '
|
||||
'Slice 9 — removed when the source table is dropped.';
|
||||
|
||||
-- BEFORE-trigger so the write is blocked before any row image is
|
||||
-- captured. AFTER would still raise but the surrounding tx would
|
||||
-- have already taken row locks.
|
||||
DROP TRIGGER IF EXISTS event_deadlines_readonly ON paliad.event_deadlines;
|
||||
|
||||
CREATE TRIGGER event_deadlines_readonly
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON paliad.event_deadlines
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.event_deadlines_readonly_trigger();
|
||||
|
||||
-- Defensive INSERT-row-level trigger covers the COPY path too; same
|
||||
-- function, identical behaviour.
|
||||
|
||||
COMMENT ON TRIGGER event_deadlines_readonly ON paliad.event_deadlines IS
|
||||
'Phase 3 Slice 3 read-only wrapper. Blocks every INSERT/UPDATE/DELETE '
|
||||
'until Slice 9 drops the table. SELECT unaffected.';
|
||||
@@ -0,0 +1,28 @@
|
||||
-- t-paliad-186 down — reverses 087_project_proceeding_type_remap.up.sql.
|
||||
--
|
||||
-- "Revert" here means: NULL every project that the up-migration remapped
|
||||
-- AND drop the 'proceeding_type_remap_null' project_events rows it
|
||||
-- wrote. We cannot perfectly recover the litigation→fristenrechner
|
||||
-- remap because the up-migration moved INF→UPC_INF (etc.) without
|
||||
-- preserving the original code in a side column. Resetting to NULL is
|
||||
-- the safe rollback — the operator can hand-remap a project if needed.
|
||||
--
|
||||
-- Today this is a no-op on production data (0 live remaps).
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 087: NULL projects.proceeding_type_id remapped by mig 087',
|
||||
true);
|
||||
|
||||
DELETE FROM paliad.project_events
|
||||
WHERE event_type = 'proceeding_type_remap_null'
|
||||
AND metadata->>'migration' = '087';
|
||||
|
||||
UPDATE paliad.projects
|
||||
SET proceeding_type_id = NULL
|
||||
WHERE proceeding_type_id IS NOT NULL
|
||||
AND proceeding_type_id IN (
|
||||
SELECT id FROM paliad.proceeding_types
|
||||
WHERE category = 'fristenrechner'
|
||||
AND code IN ('UPC_INF', 'UPC_REV', 'UPC_APP')
|
||||
);
|
||||
148
internal/db/migrations/087_project_proceeding_type_remap.up.sql
Normal file
148
internal/db/migrations/087_project_proceeding_type_remap.up.sql
Normal file
@@ -0,0 +1,148 @@
|
||||
-- t-paliad-186 / Fristen Phase 3 Slice 5 Step F-1 — remap any project
|
||||
-- still pointing at a litigation-category proceeding_types row to the
|
||||
-- corresponding fristenrechner-category code (per design §3.F + m's
|
||||
-- Q2 ruling: "I dont even get 'litigation corpus'").
|
||||
--
|
||||
-- Live-data reality: 11/11 projects carry proceeding_type_id IS NULL
|
||||
-- today, so this migration is effectively a no-op on the production
|
||||
-- corpus. It still ships defensively for any future test / staging /
|
||||
-- imported data that might land with a litigation-category id before
|
||||
-- the CHECK trigger (mig 088) catches the next write.
|
||||
--
|
||||
-- Mapping (cross-checked against the live paliad.proceeding_types
|
||||
-- catalog — 19 fristenrechner codes, 7 litigation codes):
|
||||
--
|
||||
-- INF → UPC_INF (UPC infringement, canonical reading)
|
||||
-- REV → UPC_REV (UPC revocation)
|
||||
-- APP → UPC_APP (UPC appeal)
|
||||
-- CCR → NULL (no UPC_CCR in the fristenrechner catalog
|
||||
-- — flag for legal review per design §3.F)
|
||||
-- APM → NULL (no UPC_APM — flag for legal review)
|
||||
-- AMD → NULL (no UPC_AMD — flag for legal review)
|
||||
-- ZPO_CIVIL → NULL (no fristenrechner analogue, design §3.F:
|
||||
-- "litigation codes stay but become unused
|
||||
-- for project-binding")
|
||||
--
|
||||
-- Each NULL-remap leaves a paliad.project_events row with a
|
||||
-- 'proceeding_type_remap_null' event so legal review can spot the
|
||||
-- project + decide whether to pick a hand-mapped fristenrechner code.
|
||||
-- Today no live project hits this branch — the events table stays
|
||||
-- clean — but the audit hook is there for the day a litigation-coded
|
||||
-- project lands.
|
||||
--
|
||||
-- Idempotent: only rows still pointing at a litigation-category code
|
||||
-- are touched. Re-running on a clean target is a no-op.
|
||||
--
|
||||
-- Hard assertion at end: no paliad.projects row points at a
|
||||
-- non-fristenrechner-category proceeding_types row post-mig. RAISE
|
||||
-- EXCEPTION if violated — fails the migration loudly rather than
|
||||
-- relying on mig 088's runtime trigger to catch the next write.
|
||||
--
|
||||
-- Audit-reason wrapper: required by the mig 079 trigger when this
|
||||
-- migration UPDATEs deadline_rules tangentially (it doesn't, but
|
||||
-- set_config is harmless if no audited row mutates).
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 087: remap projects.proceeding_type_id from litigation→fristenrechner per design §3.F + Q2',
|
||||
true);
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. Remap rows that point at litigation codes with a known UPC analogue.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.projects p
|
||||
SET proceeding_type_id = pt_new.id
|
||||
FROM paliad.proceeding_types pt_old
|
||||
JOIN paliad.proceeding_types pt_new
|
||||
ON pt_new.code = CASE pt_old.code
|
||||
WHEN 'INF' THEN 'UPC_INF'
|
||||
WHEN 'REV' THEN 'UPC_REV'
|
||||
WHEN 'APP' THEN 'UPC_APP'
|
||||
END
|
||||
AND pt_new.is_active = true
|
||||
AND pt_new.category = 'fristenrechner'
|
||||
WHERE p.proceeding_type_id = pt_old.id
|
||||
AND pt_old.category = 'litigation'
|
||||
AND pt_old.code IN ('INF', 'REV', 'APP');
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. NULL-remap rows pointing at litigation codes with no fristenrechner
|
||||
-- analogue. Record a paliad.project_events row so legal review can
|
||||
-- follow up.
|
||||
-- ============================================================================
|
||||
|
||||
-- Capture the projects we're about to NULL-remap into a temp table so
|
||||
-- we can both UPDATE and INSERT events from the same set (without a
|
||||
-- second SELECT that might race with the UPDATE).
|
||||
|
||||
CREATE TEMP TABLE _mig_087_null_remaps ON COMMIT DROP AS
|
||||
SELECT p.id AS project_id,
|
||||
p.created_by AS actor,
|
||||
pt_old.code AS old_code
|
||||
FROM paliad.projects p
|
||||
JOIN paliad.proceeding_types pt_old ON pt_old.id = p.proceeding_type_id
|
||||
WHERE pt_old.category = 'litigation'
|
||||
AND pt_old.code IN ('CCR', 'APM', 'AMD', 'ZPO_CIVIL');
|
||||
|
||||
UPDATE paliad.projects p
|
||||
SET proceeding_type_id = NULL
|
||||
FROM _mig_087_null_remaps r
|
||||
WHERE p.id = r.project_id;
|
||||
|
||||
INSERT INTO paliad.project_events
|
||||
(id, project_id, event_type, title, description, event_date, created_by, metadata, created_at, updated_at)
|
||||
SELECT gen_random_uuid(),
|
||||
r.project_id,
|
||||
'proceeding_type_remap_null',
|
||||
'Verfahrenstyp zurückgesetzt (Soft-Merge Phase 3)',
|
||||
'proceeding_type_id wurde auf NULL gesetzt — '
|
||||
|| r.old_code
|
||||
|| ' hat kein Fristenrechner-Pendant. Bitte manuell einen passenden Code wählen.',
|
||||
now(),
|
||||
r.actor,
|
||||
jsonb_build_object(
|
||||
'migration', '087',
|
||||
'old_code', r.old_code,
|
||||
'reason', 'project soft-merge: no fristenrechner analogue'
|
||||
),
|
||||
now(),
|
||||
now()
|
||||
FROM _mig_087_null_remaps r;
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. Hard assertion: every non-NULL proceeding_type_id on projects now
|
||||
-- references a fristenrechner-category row.
|
||||
-- ============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_total int;
|
||||
n_null int;
|
||||
n_fristen int;
|
||||
n_non_fristen int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO n_total FROM paliad.projects;
|
||||
SELECT count(*) FILTER (WHERE proceeding_type_id IS NULL)
|
||||
INTO n_null FROM paliad.projects;
|
||||
SELECT count(*)
|
||||
INTO n_fristen
|
||||
FROM paliad.projects p
|
||||
JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
|
||||
WHERE pt.category = 'fristenrechner';
|
||||
SELECT count(*)
|
||||
INTO n_non_fristen
|
||||
FROM paliad.projects p
|
||||
JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
|
||||
WHERE pt.category <> 'fristenrechner';
|
||||
|
||||
RAISE NOTICE 'mig 087: projects total=%, NULL=%, fristenrechner=%, other=%',
|
||||
n_total, n_null, n_fristen, n_non_fristen;
|
||||
|
||||
IF n_non_fristen > 0 THEN
|
||||
RAISE EXCEPTION 'mig 087: % projects still point at non-fristenrechner-category '
|
||||
'proceeding_type_ids — soft-merge incomplete. Investigate '
|
||||
'and either extend the remap or add a hand-mapped code.',
|
||||
n_non_fristen;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- t-paliad-186 down — reverses 088_project_proceeding_type_check.up.sql.
|
||||
|
||||
DROP TRIGGER IF EXISTS projects_proceeding_type_category_check
|
||||
ON paliad.projects;
|
||||
DROP FUNCTION IF EXISTS paliad.projects_proceeding_type_category_check();
|
||||
@@ -0,0 +1,90 @@
|
||||
-- t-paliad-186 / Fristen Phase 3 Slice 5 Step F-2 — enforce
|
||||
-- "fristenrechner-category only" on paliad.projects.proceeding_type_id
|
||||
-- via a BEFORE INSERT/UPDATE trigger. PostgreSQL CHECK constraints
|
||||
-- can't reference other tables, so a trigger is the only way to
|
||||
-- evaluate the (proceeding_types.category = 'fristenrechner')
|
||||
-- predicate per row.
|
||||
--
|
||||
-- Why trigger over deferrable-FK-to-partial-index: a partial unique
|
||||
-- index on proceeding_types where category='fristenrechner' would
|
||||
-- let us reference it from a separate FK column, but the existing
|
||||
-- FK on projects.proceeding_type_id → proceeding_types.id is
|
||||
-- broad-category. Replacing it with a narrower FK would invalidate
|
||||
-- the existing schema reference in mig 027. A trigger keeps the FK
|
||||
-- in place and just adds the category predicate on top.
|
||||
--
|
||||
-- Behaviour:
|
||||
-- - INSERT/UPDATE with proceeding_type_id IS NULL: pass (NULL is allowed).
|
||||
-- - INSERT/UPDATE with proceeding_type_id pointing at a
|
||||
-- fristenrechner-category row: pass.
|
||||
-- - INSERT/UPDATE with proceeding_type_id pointing at any other
|
||||
-- category: RAISE EXCEPTION with a German + English message so the
|
||||
-- handler / frontend can surface a friendly error.
|
||||
-- - INSERT/UPDATE with proceeding_type_id pointing at a missing row:
|
||||
-- the existing FK on the column rejects it before this trigger
|
||||
-- even fires; nothing to do here.
|
||||
--
|
||||
-- Removed when the litigation category is fully retired (Slice 9 or
|
||||
-- later). Until then this is the runtime guard for any writer that
|
||||
-- bypasses the Go service-layer validation.
|
||||
--
|
||||
-- Idempotent: re-applying the migration drops + recreates the trigger.
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.projects_proceeding_type_category_check()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_category text;
|
||||
BEGIN
|
||||
IF NEW.proceeding_type_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT category INTO v_category
|
||||
FROM paliad.proceeding_types
|
||||
WHERE id = NEW.proceeding_type_id;
|
||||
|
||||
-- The FK on the column guarantees v_category is non-NULL when the
|
||||
-- id resolves — but defensive against a future FK relax-and-replace.
|
||||
IF v_category IS NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'paliad.projects.proceeding_type_id = % does not resolve to a '
|
||||
'proceeding_types row — FK constraint should have caught this.',
|
||||
NEW.proceeding_type_id;
|
||||
END IF;
|
||||
|
||||
IF v_category <> 'fristenrechner' THEN
|
||||
RAISE EXCEPTION
|
||||
'paliad.projects.proceeding_type_id must reference a '
|
||||
'fristenrechner-category proceeding_types row (got category=''%''). '
|
||||
'Verfahrenstyp muss ein Fristenrechner-Typ sein (Kategorie=''%''). '
|
||||
'Slice 5 (Phase 3 soft-merge per design §3.F) retires the '
|
||||
'''litigation'' category for project-binding; pick a UPC_*, '
|
||||
'DE_*, EPA_*, DPMA_* or EP_GRANT code instead.',
|
||||
v_category, v_category;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.projects_proceeding_type_category_check() IS
|
||||
'BEFORE INSERT/UPDATE trigger function enforcing the Phase 3 Slice 5 '
|
||||
'invariant: paliad.projects.proceeding_type_id may only reference '
|
||||
'fristenrechner-category proceeding_types rows. NULL is allowed.';
|
||||
|
||||
DROP TRIGGER IF EXISTS projects_proceeding_type_category_check
|
||||
ON paliad.projects;
|
||||
|
||||
CREATE TRIGGER projects_proceeding_type_category_check
|
||||
BEFORE INSERT OR UPDATE OF proceeding_type_id ON paliad.projects
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.projects_proceeding_type_category_check();
|
||||
|
||||
COMMENT ON TRIGGER projects_proceeding_type_category_check ON paliad.projects IS
|
||||
'Phase 3 Slice 5 (t-paliad-186) runtime guard for the projects '
|
||||
'soft-merge — rejects any INSERT/UPDATE that would bind a project '
|
||||
'to a non-fristenrechner-category proceeding_type. The Go service '
|
||||
'layer also enforces this with a typed error; this trigger is the '
|
||||
'defence-in-depth backstop.';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- t-paliad-190 down — reverses 089_deadline_rule_backfill_orphans.up.sql.
|
||||
-- Drops the staging table; mig 090's down-migration MUST run first
|
||||
-- (it depends on this table for its INSERT — running them in reverse
|
||||
-- order satisfies that).
|
||||
|
||||
DROP POLICY IF EXISTS deadline_rule_backfill_orphans_select ON paliad.deadline_rule_backfill_orphans;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_backfill_orphans_unresolved_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_backfill_orphans_deadline_id_idx;
|
||||
DROP TABLE IF EXISTS paliad.deadline_rule_backfill_orphans;
|
||||
@@ -0,0 +1,82 @@
|
||||
-- t-paliad-190 / Fristen Phase 3 Slice 10 — staging table for the
|
||||
-- fuzzy-match orphans produced by mig 090. Per design §3.I + m's Q10
|
||||
-- ruling: legacy paliad.deadlines rows whose title can't be uniquely
|
||||
-- bound to a deadline_rule via fuzzy matching are NOT silently left
|
||||
-- NULL — they're logged here so a legal-review pass can hand-link
|
||||
-- the ambiguous tail.
|
||||
--
|
||||
-- Mig 089 ships the table; mig 090 does the actual backfill +
|
||||
-- populates this table. Numbering reflects the dependency order
|
||||
-- (the backfill SELECTs into this table, so the table must exist
|
||||
-- first).
|
||||
--
|
||||
-- Schema notes:
|
||||
-- - deadline_id is the FK to paliad.deadlines.id with ON DELETE
|
||||
-- CASCADE so a hand-deletion of an orphan deadline cleans up
|
||||
-- its staging row too. (Deadlines are normally archived, not
|
||||
-- deleted; the cascade is defensive.)
|
||||
-- - project_id stays denormalised so the admin orphan-review UI
|
||||
-- can group orphans by project without re-joining deadlines.
|
||||
-- - reason is a free-text discriminator: 'no_match' | 'ambiguous'
|
||||
-- today; the editor in Slice 11 may add 'manual_unbound' or
|
||||
-- similar in the future.
|
||||
-- - resolved_at + resolved_rule_id are NULL on insert; the admin
|
||||
-- orphan-review UI sets them when an editor hand-links the row,
|
||||
-- so the table doubles as an audit trail of the legal-review
|
||||
-- pass. The matching paliad.deadlines.rule_id is updated at the
|
||||
-- same time (the UPDATE on deadlines fires its own audit row
|
||||
-- once an audit trigger lives on that table; today no trigger,
|
||||
-- so the staging row is the audit artefact).
|
||||
--
|
||||
-- RLS: admin-only read. The orphan list contains real deadline titles
|
||||
-- + project ids, so non-admins should not see it. The Slice 11 rule
|
||||
-- editor surface gates this further.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadline_rule_backfill_orphans (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
deadline_id uuid NOT NULL
|
||||
REFERENCES paliad.deadlines(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
project_id uuid,
|
||||
proceeding_code text,
|
||||
reason text NOT NULL
|
||||
CHECK (reason IN ('no_match', 'ambiguous', 'no_project', 'manual_unbound')),
|
||||
candidate_count int NOT NULL DEFAULT 0,
|
||||
candidate_rule_ids uuid[] NOT NULL DEFAULT '{}',
|
||||
resolved_at timestamptz,
|
||||
resolved_rule_id uuid
|
||||
REFERENCES paliad.deadline_rules(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_backfill_orphans_deadline_id_idx
|
||||
ON paliad.deadline_rule_backfill_orphans (deadline_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_backfill_orphans_unresolved_idx
|
||||
ON paliad.deadline_rule_backfill_orphans (created_at DESC)
|
||||
WHERE resolved_at IS NULL;
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_rule_backfill_orphans IS
|
||||
'Slice 10 (mig 089/090, t-paliad-190): staging for legacy '
|
||||
'paliad.deadlines rows that the fuzzy-match backfill could not '
|
||||
'uniquely bind to a deadline_rule. Each row holds the deadline '
|
||||
'context + the candidate rule IDs the matcher found (0 → '
|
||||
'''no_match''; ≥2 → ''ambiguous'') so a legal-review pass can '
|
||||
'hand-link without rerunning the match. resolved_at + '
|
||||
'resolved_rule_id flip when the admin orphan-review UI binds the '
|
||||
'row.';
|
||||
|
||||
-- RLS: admin-only read.
|
||||
ALTER TABLE paliad.deadline_rule_backfill_orphans ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS deadline_rule_backfill_orphans_select ON paliad.deadline_rule_backfill_orphans;
|
||||
|
||||
CREATE POLICY deadline_rule_backfill_orphans_select
|
||||
ON paliad.deadline_rule_backfill_orphans FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
-- t-paliad-190 down — reverses 090_backfill_deadline_rule_id.up.sql.
|
||||
--
|
||||
-- Restores rule_id values from the pre-mig snapshot (every deadline
|
||||
-- that mig 090 touched had rule_id IS NULL originally, so restoring
|
||||
-- means setting rule_id back to NULL on every row that survived the
|
||||
-- backfill). Drops the orphan rows mig 090 wrote (resolved rows stay
|
||||
-- — those represent legal-review work that shouldn't disappear on
|
||||
-- a code rollback) and drops the backup table.
|
||||
--
|
||||
-- This is a defensive rollback path; the migration itself is one-time
|
||||
-- + idempotent, so re-running 090 after a down + up is safe.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 090: NULL rule_id on deadlines mig 090 touched + drop pre-089 backup',
|
||||
true);
|
||||
|
||||
-- Restore rule_id = NULL on every deadline mig 090 may have written.
|
||||
-- We use the backup table as the authoritative "before" snapshot.
|
||||
UPDATE paliad.deadlines d
|
||||
SET rule_id = b.rule_id
|
||||
FROM paliad.deadlines_pre_089 b
|
||||
WHERE d.id = b.id;
|
||||
|
||||
-- Drop the unresolved orphan rows mig 090 wrote. Resolved rows stay —
|
||||
-- a legal-review hand-link is real work that survives a code rollback.
|
||||
DELETE FROM paliad.deadline_rule_backfill_orphans
|
||||
WHERE resolved_at IS NULL;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.deadlines_pre_089;
|
||||
320
internal/db/migrations/090_backfill_deadline_rule_id.up.sql
Normal file
320
internal/db/migrations/090_backfill_deadline_rule_id.up.sql
Normal file
@@ -0,0 +1,320 @@
|
||||
-- t-paliad-190 / Fristen Phase 3 Slice 10 — one-time fuzzy-match
|
||||
-- backfill of paliad.deadlines.rule_id per design §3.I + m's Q10
|
||||
-- ruling. Restores SmartTimeline's "anchor real deadlines into
|
||||
-- projection" affordance on legacy data (1 of 26 deadlines currently
|
||||
-- has rule_id populated; the SmartTimeline anchor flow needs the FK
|
||||
-- to thread predicted dates off actuals).
|
||||
--
|
||||
-- Matching strategies (in priority order; first unique hit wins):
|
||||
--
|
||||
-- 1. rule_code-prefix extraction from title. Titles like
|
||||
-- "RoP.023 — Klageerwiderung" carry the rule citation in the
|
||||
-- prefix; we extract the leading citation token and JOIN on
|
||||
-- deadline_rules.rule_code = extracted. When the rule_code
|
||||
-- resolves to multiple rules (e.g. RoP.023 → 2 rules — DE
|
||||
-- Klageerwiderung + EN Statement of Defence), the remaining
|
||||
-- title fragment narrows by name ILIKE.
|
||||
--
|
||||
-- 2. exact title match against rule.name OR rule.name_en (LOWER).
|
||||
-- Mostly hits common Pipeline-A names ("Antrag auf
|
||||
-- Schadensbemessung" → 1 unique rule); ambiguous for shared
|
||||
-- names like "Klageerwiderung" (8 rules across proceedings).
|
||||
--
|
||||
-- 3. deadline_concepts.aliases match. Each concept carries a
|
||||
-- text[] of canonical aliases; if LOWER(d.title) is in the
|
||||
-- aliases array, we pick the rules with that concept_id. Today
|
||||
-- the alias coverage is thin (no aliases for "Schutzschrift"
|
||||
-- etc.), but the strategy is shaped so a future seed lights
|
||||
-- it up.
|
||||
--
|
||||
-- For each deadline, we collect all candidates across the three
|
||||
-- strategies, dedupe by rule.id, and:
|
||||
-- - exactly 1 candidate → UPDATE rule_id (matched).
|
||||
-- - 0 candidates → orphan with reason='no_match'.
|
||||
-- - ≥2 candidates → orphan with reason='ambiguous', candidate_rule_ids
|
||||
-- populated so a legal-review pass can hand-pick.
|
||||
--
|
||||
-- Per-project narrowing by proceeding_type_id is the design's primary
|
||||
-- discriminator. In the live corpus today all 11 projects have
|
||||
-- proceeding_type_id IS NULL (Slice 5 retired litigation codes from
|
||||
-- project-binding; the fristenrechner-side rebinding hasn't happened),
|
||||
-- so this slice can't use proceeding-narrowing on production data.
|
||||
-- The CTE still includes the predicate so the migration self-tunes
|
||||
-- the moment proceeding_type_id starts getting populated.
|
||||
--
|
||||
-- Defensive backup: paliad.deadlines is snapshotted to
|
||||
-- paliad.deadlines_pre_089 before the UPDATE so an operator can
|
||||
-- restore individual rule_id values if a hand-link goes wrong post
|
||||
-- mig. The table is dropped in the down-migration; Slice 11 (rule
|
||||
-- editor) can drop it once orphan resolution finishes in prod.
|
||||
--
|
||||
-- Idempotency: WHERE d.rule_id IS NULL on the UPDATE; the orphan
|
||||
-- INSERT uses ON CONFLICT DO NOTHING via a NOT EXISTS guard (no
|
||||
-- unique constraint on deadline_id alone — a deadline may legitimately
|
||||
-- get re-orphaned after a resolution rollback; but re-running 090 on
|
||||
-- the same corpus must not duplicate orphan rows for unresolved
|
||||
-- deadlines).
|
||||
--
|
||||
-- Hard assertion at end: SUM(matched) + SUM(orphans for current
|
||||
-- unresolved deadlines) ≥ COUNT(deadlines processed). Strict equality
|
||||
-- doesn't hold cleanly on a re-run (the orphan table may already
|
||||
-- carry prior rows from a partial run), so the assertion is "at
|
||||
-- least one row exists per unresolved deadline".
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 090: one-time fuzzy-match backfill of deadlines.rule_id per design §3.I / Q10',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Defensive backup before any UPDATE.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadlines_pre_089 AS
|
||||
SELECT id, project_id, title, rule_id, rule_code, status, due_date,
|
||||
completed_at, created_at, updated_at
|
||||
FROM paliad.deadlines
|
||||
WHERE rule_id IS NULL
|
||||
AND project_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE paliad.deadlines_pre_089 IS
|
||||
'Snapshot of paliad.deadlines (id, rule_id-relevant columns) taken '
|
||||
'before mig 090 ran the fuzzy-match backfill. Lets an operator '
|
||||
'restore individual rule_id values if a hand-link goes wrong. '
|
||||
'Slice 11 (rule editor) drops this once orphan resolution finishes.';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Build the candidate set in a temp table so the per-deadline
|
||||
-- aggregation + UPDATE + orphan INSERT can share the work without
|
||||
-- re-evaluating the matchers.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TEMP TABLE _mig_090_candidates ON COMMIT DROP AS
|
||||
WITH targets AS (
|
||||
-- Every NULL-rule_id deadline still bound to a project. project_id
|
||||
-- is required because we want at least the SmartTimeline anchor
|
||||
-- flow to work; un-bound deadlines (rare) are out of scope.
|
||||
SELECT d.id AS deadline_id,
|
||||
d.title AS title,
|
||||
d.project_id,
|
||||
p.proceeding_type_id,
|
||||
-- Extract a leading citation token like "RoP.023" or
|
||||
-- "R.49" from the title. Captures the rule_code prefix
|
||||
-- on titles that carry one ("RoP.023 — Klageerwiderung");
|
||||
-- NULL on plain titles.
|
||||
NULLIF(regexp_replace(d.title, '^\s*((?:RoP|R|Art|§)\.?\s*[0-9]+(?:\.[a-z0-9]+)*)\s*(?:[—–-].*)?$', '\1'), d.title) AS code_token,
|
||||
-- Strip the leading citation + separator to surface the
|
||||
-- title's name fragment. "RoP.023 — Klageerwiderung" →
|
||||
-- "Klageerwiderung"; "RoP.029.a" (no suffix) → ""; plain
|
||||
-- "Klageerwiderung" → "Klageerwiderung" unchanged.
|
||||
NULLIF(trim(regexp_replace(d.title, '^\s*(?:RoP|R|Art|§)\.?\s*[0-9]+(?:\.[a-z0-9]+)*\s*[—–-]?\s*', '')), '') AS title_tail
|
||||
FROM paliad.deadlines d
|
||||
LEFT JOIN paliad.projects p ON p.id = d.project_id
|
||||
WHERE d.rule_id IS NULL
|
||||
AND d.project_id IS NOT NULL
|
||||
),
|
||||
by_code_and_tail AS (
|
||||
-- Strategy 1a (narrowest): rule_code AND name (DE or EN) matches
|
||||
-- the title's tail fragment. Handles "RoP.023 — Klageerwiderung"
|
||||
-- where the bare code matches 2 rules (DE Klageerwiderung +
|
||||
-- EN Statement of Defence); the tail picks the DE one.
|
||||
SELECT t.deadline_id, dr.id AS rule_id, 'rule_code_and_tail' AS strategy
|
||||
FROM targets t
|
||||
JOIN paliad.deadline_rules dr
|
||||
ON dr.rule_code = trim(t.code_token)
|
||||
AND dr.is_active = true
|
||||
AND (LOWER(dr.name) = LOWER(t.title_tail)
|
||||
OR LOWER(dr.name_en) = LOWER(t.title_tail))
|
||||
WHERE t.code_token IS NOT NULL
|
||||
AND t.title_tail IS NOT NULL
|
||||
),
|
||||
by_code AS (
|
||||
-- Strategy 1b: rule_code prefix only. Handles bare-code titles
|
||||
-- ("RoP.029.a" maps to 1 unique rule regardless of suffix) and
|
||||
-- the fallback when by_code_and_tail returns 0 (suffix doesn't
|
||||
-- match — e.g. "RoP.029.a — Replik" where the suffix "Replik"
|
||||
-- doesn't appear in any RoP.029.a rule's name; pick the
|
||||
-- code-only match anyway).
|
||||
SELECT t.deadline_id, dr.id AS rule_id, 'rule_code' AS strategy
|
||||
FROM targets t
|
||||
JOIN paliad.deadline_rules dr
|
||||
ON dr.rule_code = trim(t.code_token)
|
||||
AND dr.is_active = true
|
||||
WHERE t.code_token IS NOT NULL
|
||||
),
|
||||
by_name AS (
|
||||
-- Strategy 2: exact title match against rule.name or rule.name_en.
|
||||
-- The widest matcher; for shared names like "Klageerwiderung"
|
||||
-- (8 rules across proceedings) this is ambiguous, but for
|
||||
-- unique titles like "Antrag auf Schadensbemessung" (1 rule) it
|
||||
-- nails the match.
|
||||
SELECT t.deadline_id, dr.id AS rule_id, 'name_exact' AS strategy
|
||||
FROM targets t
|
||||
JOIN paliad.deadline_rules dr
|
||||
ON (LOWER(dr.name) = LOWER(t.title)
|
||||
OR LOWER(dr.name_en) = LOWER(t.title))
|
||||
AND dr.is_active = true
|
||||
),
|
||||
by_alias AS (
|
||||
-- Strategy 3: concept aliases. deadline_concepts.aliases is a
|
||||
-- text[] of canonical synonyms; if the deadline title appears
|
||||
-- in that array, every active rule on the concept is a candidate.
|
||||
-- Today's alias coverage is thin (the seed for Slice 12 is the
|
||||
-- expected source of new aliases), but the strategy is in place
|
||||
-- so future seeds light it up without a migration.
|
||||
SELECT t.deadline_id, dr.id AS rule_id, 'concept_alias' AS strategy
|
||||
FROM targets t
|
||||
JOIN paliad.deadline_concepts dc
|
||||
ON LOWER(t.title) = ANY(SELECT LOWER(a) FROM unnest(dc.aliases) a)
|
||||
JOIN paliad.deadline_rules dr
|
||||
ON dr.concept_id = dc.id
|
||||
AND dr.is_active = true
|
||||
)
|
||||
SELECT deadline_id, rule_id, strategy
|
||||
FROM by_code_and_tail
|
||||
UNION
|
||||
SELECT deadline_id, rule_id, strategy
|
||||
FROM by_code
|
||||
UNION
|
||||
SELECT deadline_id, rule_id, strategy
|
||||
FROM by_name
|
||||
UNION
|
||||
SELECT deadline_id, rule_id, strategy
|
||||
FROM by_alias;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Aggregate per-deadline candidate counts by strategy + pick the
|
||||
-- narrowest-unique-match per deadline. Strategy priority (narrowest
|
||||
-- first): rule_code_and_tail > rule_code > name_exact > concept_alias.
|
||||
-- A deadline's "chosen" rule comes from the highest-priority strategy
|
||||
-- that yields exactly 1 distinct candidate.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TEMP TABLE _mig_090_strategy_counts ON COMMIT DROP AS
|
||||
SELECT deadline_id,
|
||||
strategy,
|
||||
count(DISTINCT rule_id) AS n,
|
||||
MIN(rule_id::text) AS first_rule_text
|
||||
FROM _mig_090_candidates
|
||||
GROUP BY deadline_id, strategy;
|
||||
|
||||
CREATE TEMP TABLE _mig_090_chosen ON COMMIT DROP AS
|
||||
SELECT DISTINCT ON (deadline_id)
|
||||
deadline_id,
|
||||
first_rule_text::uuid AS rule_id,
|
||||
strategy AS chosen_strategy
|
||||
FROM _mig_090_strategy_counts
|
||||
WHERE n = 1
|
||||
ORDER BY deadline_id,
|
||||
CASE strategy
|
||||
WHEN 'rule_code_and_tail' THEN 1
|
||||
WHEN 'rule_code' THEN 2
|
||||
WHEN 'name_exact' THEN 3
|
||||
WHEN 'concept_alias' THEN 4
|
||||
ELSE 5
|
||||
END;
|
||||
|
||||
-- "Aggregated" carries the widest candidate set for orphan logging
|
||||
-- (an editor reviewing an orphan wants to see EVERY plausible rule,
|
||||
-- not just the narrowest-strategy result).
|
||||
CREATE TEMP TABLE _mig_090_aggregated ON COMMIT DROP AS
|
||||
SELECT c.deadline_id,
|
||||
count(DISTINCT c.rule_id) AS n_candidates,
|
||||
array_agg(DISTINCT c.rule_id) AS all_rule_ids
|
||||
FROM _mig_090_candidates c
|
||||
GROUP BY c.deadline_id;
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. UPDATE deadlines.rule_id for the chosen set (narrowest-unique match).
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE paliad.deadlines d
|
||||
SET rule_id = c.rule_id
|
||||
FROM _mig_090_chosen c
|
||||
WHERE d.id = c.deadline_id
|
||||
AND d.rule_id IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. Log every deadline that didn't get a unique match as an orphan.
|
||||
-- Skip rows that already have a non-resolved orphan entry (re-run
|
||||
-- guard) — the existing entry is the source-of-truth until the
|
||||
-- admin UI flips resolved_at.
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO paliad.deadline_rule_backfill_orphans
|
||||
(deadline_id, title, project_id, proceeding_code, reason,
|
||||
candidate_count, candidate_rule_ids)
|
||||
SELECT t.deadline_id,
|
||||
t.title,
|
||||
t.project_id,
|
||||
pt.code AS proceeding_code,
|
||||
CASE
|
||||
WHEN a.n_candidates IS NULL OR a.n_candidates = 0 THEN 'no_match'
|
||||
WHEN a.n_candidates > 1 THEN 'ambiguous'
|
||||
END AS reason,
|
||||
COALESCE(a.n_candidates, 0),
|
||||
COALESCE(a.all_rule_ids, ARRAY[]::uuid[])
|
||||
FROM (
|
||||
SELECT d.id AS deadline_id, d.title, d.project_id, p.proceeding_type_id
|
||||
FROM paliad.deadlines d
|
||||
LEFT JOIN paliad.projects p ON p.id = d.project_id
|
||||
WHERE d.rule_id IS NULL
|
||||
AND d.project_id IS NOT NULL
|
||||
) t
|
||||
LEFT JOIN _mig_090_aggregated a ON a.deadline_id = t.deadline_id
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = t.proceeding_type_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.deadline_rule_backfill_orphans o
|
||||
WHERE o.deadline_id = t.deadline_id
|
||||
AND o.resolved_at IS NULL
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. Hard assertion: every NULL-rule_id deadline (with project) is
|
||||
-- either resolved (rule_id IS NOT NULL post-mig) or carries an
|
||||
-- unresolved orphan row.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_processed int;
|
||||
n_matched int;
|
||||
n_orphaned int;
|
||||
n_unaccounted int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO n_processed
|
||||
FROM paliad.deadlines
|
||||
WHERE project_id IS NOT NULL
|
||||
AND (rule_id IS NOT NULL OR EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rule_backfill_orphans o
|
||||
WHERE o.deadline_id = paliad.deadlines.id
|
||||
));
|
||||
|
||||
SELECT count(*) INTO n_matched
|
||||
FROM paliad.deadlines d
|
||||
JOIN paliad.deadlines_pre_089 b ON b.id = d.id
|
||||
WHERE d.rule_id IS NOT NULL;
|
||||
|
||||
SELECT count(DISTINCT deadline_id) INTO n_orphaned
|
||||
FROM paliad.deadline_rule_backfill_orphans
|
||||
WHERE resolved_at IS NULL;
|
||||
|
||||
SELECT count(*) INTO n_unaccounted
|
||||
FROM paliad.deadlines d
|
||||
WHERE d.rule_id IS NULL
|
||||
AND d.project_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rule_backfill_orphans o
|
||||
WHERE o.deadline_id = d.id
|
||||
);
|
||||
|
||||
RAISE NOTICE 'mig 090: processed=% matched=% orphaned=% unaccounted=%',
|
||||
n_processed, n_matched, n_orphaned, n_unaccounted;
|
||||
|
||||
IF n_unaccounted > 0 THEN
|
||||
RAISE EXCEPTION 'mig 090: % deadlines have rule_id IS NULL and no orphan row — '
|
||||
'matcher missed them. Investigate the candidate query.',
|
||||
n_unaccounted;
|
||||
END IF;
|
||||
END $$;
|
||||
32
internal/db/migrations/091_drop_legacy_rule_columns.down.sql
Normal file
32
internal/db/migrations/091_drop_legacy_rule_columns.down.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- t-paliad-195 down — reverses 091_drop_legacy_rule_columns.up.sql.
|
||||
--
|
||||
-- Restores the four columns and re-populates them from the
|
||||
-- paliad.deadline_rules_pre_091 snapshot. Rules created AFTER the
|
||||
-- mig 091 cutover (via the rule editor's POST /admin/api/rules)
|
||||
-- won't have a snapshot entry — they get NULL on the restored
|
||||
-- columns, which matches their original "never had these legacy
|
||||
-- fields" state.
|
||||
--
|
||||
-- The snapshot table itself stays (it's a permanent audit artefact);
|
||||
-- a focused follow-up slice / Slice 12 cleanup drops it once the
|
||||
-- rule editor's migration-export flow has been used to roll any
|
||||
-- post-drop edits back into version control.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 091: restore legacy columns from pre-drop snapshot',
|
||||
true);
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN IF NOT EXISTS is_mandatory boolean NOT NULL DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS is_optional boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS condition_flag text[],
|
||||
ADD COLUMN IF NOT EXISTS condition_rule_id uuid;
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET is_mandatory = b.is_mandatory,
|
||||
is_optional = b.is_optional,
|
||||
condition_flag = b.condition_flag,
|
||||
condition_rule_id = b.condition_rule_id
|
||||
FROM paliad.deadline_rules_pre_091 b
|
||||
WHERE dr.id = b.id;
|
||||
116
internal/db/migrations/091_drop_legacy_rule_columns.up.sql
Normal file
116
internal/db/migrations/091_drop_legacy_rule_columns.up.sql
Normal file
@@ -0,0 +1,116 @@
|
||||
-- t-paliad-195 / Fristen Phase 3 Slice 9 Step E (design §3.E, §9.1).
|
||||
-- m approved the downtime window 2026-05-15 ("paliad ist nicht in use
|
||||
-- heute, downtime ist egal") so the destructive drops can land.
|
||||
--
|
||||
-- This migration drops the four legacy columns on
|
||||
-- paliad.deadline_rules that the unified Phase 3 calculator no longer
|
||||
-- reads. The replacements have been backfilled (Slice 2 mig 082/083/
|
||||
-- 084), wired into the calculator (Slice 4), and on the wire (Slice 8):
|
||||
--
|
||||
-- is_mandatory → priority='mandatory' | (recommended | optional | informational)
|
||||
-- is_optional → priority='optional' (the RoP.151 T/T case)
|
||||
-- condition_flag → condition_expr (jsonb long form)
|
||||
-- condition_rule_id → DEAD (no live rows, Q13 m's approved drop)
|
||||
--
|
||||
-- Sibling drops (event_deadlines/trigger_events tables, retire of
|
||||
-- litigation category) are deferred from this slice per the live-data
|
||||
-- audit (see head ping). This file is the legacy-column-drop only.
|
||||
--
|
||||
-- Backup: paliad.deadline_rules_pre_091 snapshot of the four columns +
|
||||
-- id BEFORE the drop, so the down-migration can restore individual
|
||||
-- values if a deploy needs to roll back. The backup uses CREATE TABLE
|
||||
-- IF NOT EXISTS so a re-applied migration is a no-op.
|
||||
--
|
||||
-- Audit-reason set at the top: the mig 079 trigger fires on every
|
||||
-- UPDATE/DELETE on paliad.deadline_rules; ALTER TABLE DROP COLUMN
|
||||
-- doesn't fire the row-level trigger but the wrapper is the standard
|
||||
-- Phase 3 pattern. The reason persists in the audit log only for
|
||||
-- write paths.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 091: drop legacy rule columns per design §3.E + m''s 2026-05-15 approval',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Snapshot of the four columns + id, so the down-migration can
|
||||
-- restore values to existing rows. Skipping the snapshot table
|
||||
-- would mean a rollback adds the columns back but with NULL data;
|
||||
-- the snapshot preserves the legacy values for any downstream
|
||||
-- consumer the audit might surface.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_091 AS
|
||||
SELECT id,
|
||||
is_mandatory,
|
||||
is_optional,
|
||||
condition_flag,
|
||||
condition_rule_id,
|
||||
now() AS snapshotted_at
|
||||
FROM paliad.deadline_rules;
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_rules_pre_091 IS
|
||||
'Snapshot of paliad.deadline_rules.(is_mandatory, is_optional, '
|
||||
'condition_flag, condition_rule_id) before mig 091''s drop. Lets '
|
||||
'a rollback restore the legacy values for the 172 rules that '
|
||||
'existed at drop time. Drop this table after Slice 9 is verified '
|
||||
'in prod (a focused follow-up slice or part of Slice 12 cleanup).';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Drop the columns. Order doesn't matter — none of them reference
|
||||
-- each other or other tables (condition_rule_id was a dead self-FK
|
||||
-- that no live row uses, Q13).
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP COLUMN IF EXISTS is_mandatory,
|
||||
DROP COLUMN IF EXISTS is_optional,
|
||||
DROP COLUMN IF EXISTS condition_flag,
|
||||
DROP COLUMN IF EXISTS condition_rule_id;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Hard assertion: every remaining row carries a valid priority +
|
||||
-- has condition_expr populated when its legacy condition_flag was
|
||||
-- non-empty pre-mig. Belt-and-braces — Slice 2 backfilled both
|
||||
-- paths and Slice 4 unified the calculator, but a stale row would
|
||||
-- light up here BEFORE we hand the schema to the unified code.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_total int;
|
||||
n_null_prio int;
|
||||
n_lost int;
|
||||
BEGIN
|
||||
SELECT count(*), count(*) FILTER (WHERE priority IS NULL)
|
||||
INTO n_total, n_null_prio
|
||||
FROM paliad.deadline_rules;
|
||||
|
||||
-- Cross-check against the snapshot: every pre-mig row with a
|
||||
-- non-empty condition_flag must have a non-NULL condition_expr
|
||||
-- post-mig. If any row lost its gate, the calculator's gate
|
||||
-- behaviour would silently change — surface it loudly.
|
||||
SELECT count(*)
|
||||
INTO n_lost
|
||||
FROM paliad.deadline_rules_pre_091 b
|
||||
JOIN paliad.deadline_rules dr ON dr.id = b.id
|
||||
WHERE b.condition_flag IS NOT NULL
|
||||
AND array_length(b.condition_flag, 1) > 0
|
||||
AND dr.condition_expr IS NULL;
|
||||
|
||||
RAISE NOTICE 'mig 091: % rules, % with NULL priority, % lost condition_expr',
|
||||
n_total, n_null_prio, n_lost;
|
||||
|
||||
IF n_null_prio > 0 THEN
|
||||
RAISE EXCEPTION 'mig 091: % rules have priority IS NULL post-drop — '
|
||||
'the priority column must be backfilled (Slice 2 mig 083) '
|
||||
'before legacy columns are dropped',
|
||||
n_null_prio;
|
||||
END IF;
|
||||
|
||||
IF n_lost > 0 THEN
|
||||
RAISE EXCEPTION 'mig 091: % rules had a condition_flag pre-drop but no '
|
||||
'condition_expr post-drop — Slice 2 mig 084 missed them',
|
||||
n_lost;
|
||||
END IF;
|
||||
END $$;
|
||||
440
internal/handlers/admin_rules.go
Normal file
440
internal/handlers/admin_rules.go
Normal file
@@ -0,0 +1,440 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// Admin rule-editor endpoints — Phase 3 Slice 11a (t-paliad-191).
|
||||
// Every handler in this file is wired through auth.RequireAdminFunc
|
||||
// in handlers.go, so the handlers themselves assume the caller is a
|
||||
// global_admin and only validate request shape.
|
||||
//
|
||||
// Every write endpoint takes an audit_reason field on the request
|
||||
// body. The service layer sets paliad.audit_reason in the same tx
|
||||
// before the UPDATE so mig 079's audit trigger captures the rationale
|
||||
// forever. Missing reason → 400 (ErrAuditReasonRequired).
|
||||
//
|
||||
// Lifecycle invariants live in the service layer: ErrInvalidLifecycleState
|
||||
// is mapped to 409 Conflict so the editor UI can show a clear "must
|
||||
// clone first" hint.
|
||||
|
||||
// GET /admin/api/rules — paginated list with filters.
|
||||
func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
f := services.ListRulesFilter{
|
||||
LifecycleState: q.Get("lifecycle_state"),
|
||||
Query: q.Get("q"),
|
||||
}
|
||||
if v := q.Get("proceeding_type_id"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid proceeding_type_id"})
|
||||
return
|
||||
}
|
||||
f.ProceedingTypeID = &n
|
||||
}
|
||||
if v := q.Get("trigger_event_id"); v != "" {
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid trigger_event_id"})
|
||||
return
|
||||
}
|
||||
f.TriggerEventID = &n
|
||||
}
|
||||
if v := q.Get("offset"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid offset"})
|
||||
return
|
||||
}
|
||||
f.Offset = n
|
||||
}
|
||||
if v := q.Get("limit"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid limit"})
|
||||
return
|
||||
}
|
||||
f.Limit = n
|
||||
}
|
||||
rows, err := dbSvc.ruleEditor.ListRules(r.Context(), f)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// GET /admin/api/rules/{id}
|
||||
func handleAdminGetRule(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
id, ok := parseRuleID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.ruleEditor.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// POST /admin/api/rules — create draft.
|
||||
func handleAdminCreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
services.CreateRuleInput
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.ruleEditor.Create(r.Context(), body.CreateRuleInput, body.Reason)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, row)
|
||||
}
|
||||
|
||||
// PATCH /admin/api/rules/{id} — partial update of a draft.
|
||||
func handleAdminPatchRule(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
id, ok := parseRuleID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
services.RulePatch
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.ruleEditor.UpdateDraft(r.Context(), id, body.RulePatch, body.Reason)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// POST /admin/api/rules/{id}/clone-as-draft
|
||||
func handleAdminCloneAsDraft(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
id, ok := parseRuleID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
reason, ok := decodeReason(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.ruleEditor.CloneAsDraft(r.Context(), id, reason)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, row)
|
||||
}
|
||||
|
||||
// POST /admin/api/rules/{id}/publish
|
||||
func handleAdminPublishRule(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
id, ok := parseRuleID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
reason, ok := decodeReason(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.ruleEditor.Publish(r.Context(), id, reason)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// POST /admin/api/rules/{id}/archive
|
||||
func handleAdminArchiveRule(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
id, ok := parseRuleID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
reason, ok := decodeReason(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.ruleEditor.Archive(r.Context(), id, reason)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// POST /admin/api/rules/{id}/restore
|
||||
func handleAdminRestoreRule(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
id, ok := parseRuleID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
reason, ok := decodeReason(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.ruleEditor.Restore(r.Context(), id, reason)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// GET /admin/api/rules/{id}/audit?offset=N&limit=M
|
||||
func handleAdminGetRuleAudit(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
id, ok := parseRuleID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
offset, limit := 0, 0
|
||||
q := r.URL.Query()
|
||||
if v := q.Get("offset"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid offset"})
|
||||
return
|
||||
}
|
||||
offset = n
|
||||
}
|
||||
if v := q.Get("limit"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid limit"})
|
||||
return
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
rows, err := dbSvc.ruleEditor.ListAudit(r.Context(), id, offset, limit)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// GET /admin/api/rules/{id}/preview?trigger_date=YYYY-MM-DD&flags=a,b&court_id=...
|
||||
func handleAdminPreviewRule(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil || dbSvc.fristenrechner == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
id, ok := parseRuleID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
triggerDate := q.Get("trigger_date")
|
||||
if triggerDate == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "trigger_date required"})
|
||||
return
|
||||
}
|
||||
var flags []string
|
||||
if v := q.Get("flags"); v != "" {
|
||||
for _, f := range splitCSV(v) {
|
||||
if f != "" {
|
||||
flags = append(flags, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
courtID := q.Get("court_id")
|
||||
resp, err := dbSvc.ruleEditor.Preview(r.Context(), dbSvc.fristenrechner, id, triggerDate, flags, courtID)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /admin/api/rules/export-migrations?since=<audit_id>
|
||||
func handleAdminExportRuleMigrations(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
since := r.URL.Query().Get("since")
|
||||
out, err := dbSvc.ruleEditor.ExportMigrationsSince(r.Context(), since)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Page handlers — serve the static SPA shells. Auth + admin gate live
|
||||
// at the route registration in handlers.go.
|
||||
// =============================================================================
|
||||
|
||||
func handleAdminRulesListPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-rules-list.html")
|
||||
}
|
||||
|
||||
func handleAdminRulesEditPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-rules-edit.html")
|
||||
}
|
||||
|
||||
func handleAdminRulesExportPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-rules-export.html")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// helpers
|
||||
// =============================================================================
|
||||
|
||||
func parseRuleID(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func decodeReason(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
var body struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return body.Reason, true
|
||||
}
|
||||
|
||||
// writeRuleEditorError maps the service-level typed errors to HTTP statuses.
|
||||
// Distinct from writeServiceError (projects path) because the rule
|
||||
// editor's lifecycle errors map to 409 Conflict, which the project
|
||||
// service doesn't use.
|
||||
func writeRuleEditorError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrRuleNotFound):
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "rule not found"})
|
||||
case errors.Is(err, services.ErrAuditReasonRequired):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "audit_reason required",
|
||||
"message": "Every rule-editor write must include a non-empty `reason` body field.",
|
||||
})
|
||||
case errors.Is(err, services.ErrInvalidLifecycleState):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrCyclicSpawn):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrOrphanAlreadyResolved):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrOrphanCandidateMismatch):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrInvalidInput):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
default:
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Orphan-resolution handlers — Slice 11b admin add-on.
|
||||
// Lists the unresolved rows from paliad.deadline_rule_backfill_orphans
|
||||
// (mig 089) and lets an admin hand-bind each to one of the matcher's
|
||||
// candidate rule_ids. The resolve write lands in a single tx via the
|
||||
// rule editor service so the deadline row + the staging row stay in
|
||||
// sync; admin-only at the route layer.
|
||||
// =============================================================================
|
||||
|
||||
// GET /admin/api/orphans
|
||||
func handleAdminListOrphans(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.ruleEditor.ListOrphans(r.Context())
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// POST /admin/api/orphans/{id}/resolve body: {"rule_id": "...", "reason": "..."}
|
||||
func handleAdminResolveOrphan(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
ruleID, err := uuid.Parse(body.RuleID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid rule_id"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.ruleEditor.ResolveOrphan(r.Context(), id, ruleID, body.Reason); err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user