Commit Graph

742 Commits

Author SHA1 Message Date
mAi
5b81f2159e feat(t-paliad-186): service guard + ?category filter
Phase 3 Slice 5 Go-side: ErrInvalidProceedingTypeCategory typed
error + service-layer validation + handler-level mapping +
listing-side filter.

  - services.ErrInvalidProceedingTypeCategory: typed error so
    handlers can map to a 400 with a bilingual user-facing message
    distinct from generic ErrInvalidInput.

  - ProjectService.validateProceedingTypeCategory: looks up the
    referenced proceeding_types.category and rejects with the typed
    error if it's not 'fristenrechner'. Called from both Create and
    Update before any DB write.

  - DeadlineRuleService.ListProceedingTypesByCategory: extends the
    existing ListProceedingTypes with an optional category filter.
    Empty category passes through (legacy callers unaffected).

  - GET /api/proceeding-types-db?category=<value>: handler reads the
    query param and forwards it to the service. The project-create
    / project-edit pickers pass 'fristenrechner' so users never see
    retired litigation codes.

  - writeServiceError: maps ErrInvalidProceedingTypeCategory to
    HTTP 400 with a bilingual message ("Verfahrenstyp muss ein
    Fristenrechner-Typ sein / proceeding type must be a
    Fristenrechner type"). Distinct from generic ErrInvalidInput so
    the frontend can show a more helpful hint.

Defence-in-depth chain: frontend picker filter → service-layer
validation → DB trigger (mig 088). Each backstops the next.
2026-05-15 01:01:28 +02:00
mAi
275cbd5e51 feat(t-paliad-186): mig 088 — fristenrechner-category trigger
Phase 3 Slice 5 Step F-2. BEFORE INSERT/UPDATE trigger on
paliad.projects rejects any write that binds proceeding_type_id to a
non-fristenrechner-category proceeding_types row. NULL is allowed.

PostgreSQL CHECK constraints can't reference other tables, so this
is the only way to evaluate the (proceeding_types.category =
'fristenrechner') predicate per row without restructuring the
existing FK relationship.

Trigger trades narrower FK + partial-unique-index approach for
keeping the existing schema reference (mig 027) untouched. Slice 9
or later may drop this trigger when the litigation category is
fully retired.

Error message is bilingual (German + English) so the Go handler can
either surface it verbatim OR — preferably — intercept the typed
service error first and emit a clean i18n string. mig 088 is
defence-in-depth; the Go service-layer validation is the primary
path.

Idempotent: CREATE OR REPLACE FUNCTION + DROP TRIGGER IF EXISTS
before CREATE TRIGGER.
2026-05-15 01:01:17 +02:00
mAi
76cbc311ed feat(t-paliad-186): mig 087 — remap projects.proceeding_type_id
Phase 3 Slice 5 Step F-1 (design §3.F, m's Q2 ruling). UPDATE any
paliad.projects row still pointing at a litigation-category code
to the fristenrechner-category equivalent:

  INF       → UPC_INF       (UPC infringement, canonical reading)
  REV       → UPC_REV
  APP       → UPC_APP
  CCR       → NULL          (no UPC_CCR — flag for legal review)
  APM       → NULL          (no UPC_APM)
  AMD       → NULL          (no UPC_AMD)
  ZPO_CIVIL → NULL          (no fristenrechner analogue)

Live-data reality: 11/11 projects carry proceeding_type_id IS NULL
today, so this migration touches zero production rows. Ships
defensively for any future test / staging / imported data.

NULL-remaps write a paliad.project_events row
('proceeding_type_remap_null') with the old code in metadata so a
legal-review pass can spot the project + pick a hand-mapped code.

Idempotency: WHERE pt_old.category = 'litigation' AND pt_old.code IN
(...). Re-running on a clean target is a no-op.

Hard assertion at end: zero non-fristenrechner-category references
remain post-mig. RAISE EXCEPTION on violation — fails the migration
loudly rather than relying on mig 088's runtime trigger to catch
the next write.

Audit-reason wrapper cites design §3.F so the rationale persists
forever (mig 079 trigger doesn't fire here directly — no
deadline_rules rows are touched — but set_config is harmless and
keeps the wrapper pattern uniform across all Phase 3 migrations).
2026-05-15 01:01:08 +02:00
mAi
0f142e07af Merge: t-paliad-185 — Fristen Phase 3 Slice 4 (calculator unification — foundation chain complete) 2026-05-15 00:54:01 +02:00
mAi
d7bb238e46 test(t-paliad-185): table-driven unit tests for new helpers
Phase 3 Slice 4 test coverage. Adds:

  - TestEvalConditionExpr (20 sub-cases): AND/OR/NOT compositions,
    single-flag leaf, nested AND-of-OR-and-NOT, empty-args
    vacuous-truth semantics, NULL-expr → legacy condition_flag
    fallback (preserves the AND-of-flags behaviour for any
    pre-Slice-2-style row), malformed JSON / unknown op / malformed
    NOT all defensive-true (rule still renders).

  - TestWireFlagsFromPriority (6 sub-cases): exhaustive enum +
    safe-default for unknown values. Matches the reverse of the
    Slice 2 mig 083 backfill mapping.

  - TestApplyDuration_Matrix (7 sub-cases): 4 units × multiple
    timings × calendar/holiday rollover. Includes the
    Thu+1d-over-Tag-der-Arbeit edge that exercises the
    weekend+holiday cascade.

Test file housekeeping:

  - Drops TestIsCourtDeterminedRule (the function it tested no
    longer exists; equivalence is preserved by mig 082's WHERE
    predicate and verified by the Slice 2 backfill integrity test).
  - Drops the unused models import that becomes orphaned.
  - Renames the EventDeadlineService.applyDuration / addWorkingDays
    method-receiver tests to call the package-level functions
    directly. Same test names + expected dates; only the helper
    signature shifted.
  - Parity test still calls the same applyDuration body, now via
    the unified helper.

Full test suite green locally (live DB tests skip when
TEST_DATABASE_URL is unset, as ever).
2026-05-15 00:53:01 +02:00
mAi
990cc2b797 refactor(t-paliad-185): unified calculator (Slice 4 Step D)
Phase 3 Slice 4 Step D (design §3.D, the last foundation slice).
Pure Go — no migrations. Collapses the proceeding-tree + Pipeline-C
calculators onto a single set of unified helpers + reads, all
without changing wire output.

Helpers (package-level in services/fristenrechner.go):

  applyDuration(base, value, unit, timing, country, regime, holidays)
      → (raw, adjusted, didAdjust, reason)
    Single source-of-truth for date arithmetic. Replaces:
      - addDuration (proceeding-tree, no timing / working_days)
      - applyDurationOnCalendar (Slice 3 Pipeline-C-only)
      - EventDeadlineService.applyDuration / addWorkingDays methods
    Handles: timing=before/after, units days/weeks/months/working_days,
    weekend + holiday rollover for calendar units. working_days lands
    on a working day by construction (no post-rollover).

  evalConditionExpr(expr jsonb, conditionFlag []string, flags) bool
    Long-form jsonb gate evaluator (design §2.4). Grammar:
      leaf:  {"flag":"X"}
      AND:   {"op":"and","args":[<n>...]}
      OR:    {"op":"or","args":[<n>...]}
      NOT:   {"op":"not","args":[<one>]}
    NULL / empty / "null" → unconditional. Defensive fall-through
    on malformed JSON / unknown ops (rule still renders — never
    silently drop a deadline). Fallback to condition_flag
    AND-semantics when expr is NULL but the legacy column is set
    (defensive cover for any row Slice 2 missed).

  wireFlagsFromPriority(priority) → (isMandatory, isOptional)
    Derives the legacy wire pair from the unified priority enum:
      mandatory     → (T, F)     — statutory must
      optional      → (T, T)     — RoP.151 (opt-in, ☐ pre-unchecked)
      recommended   → (F, F)     — situational filing
      informational → (F, F)     — never saves today
      unknown       → (T, F)     — safe default
    Slice 8 will swap the wire to emit priority directly.

Calculate (proceeding-tree) refactor:

  - r.IsCourtSet column read direct, isCourtDeterminedRule() heuristic
    function deleted. Slice 2 backfill (mig 082) wrote the column
    using the exact heuristic predicate; column-read saves the
    per-rule branch test at runtime.
  - r.Priority drives the wire IsMandatory / IsOptional pair via
    wireFlagsFromPriority. Read of r.IsMandatory / r.IsOptional
    columns retained (compat-mode) but never decision-shaping.
  - r.ConditionExpr drives the gate; condition_flag is the fallback.
  - Added combine_op composite (max/min) branch for proceeding-tree
    rules. No live Pipeline-A rules carry combine_op today (it's a
    future-friendly column the rule editor will surface); the
    branch is reachable but produces zero diffs on the current
    corpus.
  - timing=before + working_days now usable on proceeding-tree rules
    via the unified applyDuration. No live Pipeline-A rules use them.

CalculateRule (single-rule card-click) refactor: same column reads
(IsCourtSet, ConditionExpr, Priority), unified applyDuration.

calculateByTriggerEvent (Pipeline C) refactor: switched to the
unified applyDuration; loses the redundant post-pick reason
recompute (applyDuration now returns reason directly).

EventDeadlineService.Calculate composite-note recompute now calls
the package-level applyDuration instead of the deleted method.

Frontend wire shape stays pixel-identical pre/post-Slice-4. The 17
condition_flag rules in the live corpus continue to gate via the
same (a) leaf or (b) AND-of-args evaluator branches mig 084
produced; jsonb path is exercised first, the array fallback
remains as defensive cover.
2026-05-15 00:52:49 +02:00
mAi
650d30f99f Merge: t-paliad-184 — Fristen Phase 3 Slice 3 (Pipeline C migration + EventDeadlineService delegate) 2026-05-15 00:42:55 +02:00
mAi
6cddb2e587 test(t-paliad-184): 77-row Pipeline-C parity assertion
LOAD-BEARING regression guard for Phase 3 Slice 3. For every distinct
trigger_event_id in paliad.event_deadlines, calls Calculate (now
delegating through FristenrechnerService) AND independently re-runs
the legacy applyDuration math against the source row, asserting:

  - count(returned deadlines) == count(active source rows for trigger)
  - id, title, titleDE, durationValue, durationUnit, timing all match
  - dueDate matches the independently-computed expected date (even
    a 1-day diff fails the test — that's the entire point of the
    read-only cutover window)
  - isComposite matches (CombineOp != nil && alt_* set)

Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.

Sweep guard: at least 77 rows must have been checked across all
triggers — if the test only walks 0 triggers (e.g. due to a SELECT
glitch), the final tally raises.

Trigger date is an arbitrary working day (2026-01-15) so weekend
rollover noise is minimal; the parity comparison is against an
inline expected value, not a fixed snapshot, so any date that
exercises the calculator works.
2026-05-15 00:41:29 +02:00
mAi
8a814e3442 refactor(t-paliad-184): EventDeadlineService.Calculate delegates
Phase 3 Slice 3 service-side rewire. EventDeadlineService.Calculate
now:

  1. Looks up trigger event metadata (unchanged — the legacy response
     shape still carries TriggerEvent + TriggerDate at the top level).
  2. SELECTs source event_deadlines rows for the trigger to recover
     (id, duration, alt_*, combine_op, notes_en) — the unified
     UIResponse drops those fields. SELECT is still allowed by the
     mig 086 read-only trigger; only writes are blocked.
  3. Delegates the rule SELECT + math to FristenrechnerService.Calculate
     with TriggerEventIDFilter set.
  4. Merges the unified result with the source rows (join by Name =
     title_de) to produce the legacy EventDeadlineResult shape with
     ID, ruleCodes, isComposite, compositeNote intact.
  5. Loads rule_codes from event_deadline_rule_codes (also still
     readable) by source.id.

Public signature unchanged — /api/tools/event-deadlines callers see
no diff. The legacy applyDuration / addWorkingDays helpers stay on
EventDeadlineService for the pure-Go unit tests + the composite-note
leg-pick that the unified UIDeadline doesn't expose.

main.go wiring: NewEventDeadlineService gains the FristenrechnerService
dependency.
2026-05-15 00:41:20 +02:00
mAi
5f9a8b2ef4 feat(t-paliad-184): FristenrechnerService.calculateByTriggerEvent
Phase 3 Slice 3 calculator-side rewire. Adds the Pipeline-C branch
to FristenrechnerService so the unified backend can serve
event-driven deadlines:

  - CalcOptions.TriggerEventIDFilter *int64 — when non-nil, Calculate
    dispatches to calculateByTriggerEvent (proceedingCode ignored).
  - calculateByTriggerEvent — flat-rule calculator: SELECT rules
    WHERE trigger_event_id = X, compute each via the new
    applyDurationOnCalendar helper (handles timing='before',
    working_days, combine_op alt-leg max/min). No parent_id chains,
    no flag gating, no IsRootEvent / IsCourtSet semantics — those
    are Pipeline-A concerns.
  - applyDurationOnCalendar + addWorkingDays — package-level helpers
    that the proceeding-tree calculator's existing addDuration
    doesn't cover. Slice 4 will fold them into a single unified
    helper when the proceeding-tree side also reads timing +
    working_days from the unified rule shape.
  - DeadlineRuleService.ListByTriggerEvent — SELECT rules scoped to
    a single trigger_event_id, ORDER BY sequence_order (preserves
    the 1000 + ed.id ordering mig 085 wrote). Skips
    hydrateConceptDefaultEventTypes since Pipeline-C rules don't
    carry concept_id today.

UIResponse for trigger-event calls returns empty ProceedingType /
ProceedingName — EventDeadlineService owns the trigger metadata in
the legacy CalculateResponse shape. That's a stable contract for
the caller and avoids polluting UIResponse with trigger-event-only
fields.
2026-05-15 00:41:10 +02:00
mAi
ee2caf9d79 feat(t-paliad-184): mig 086 — event_deadlines read-only trigger
Phase 3 Slice 3 cutover-window guard. BEFORE INSERT/UPDATE/DELETE
trigger on paliad.event_deadlines raises EXCEPTION with a message
pointing the writer at paliad.deadline_rules. SELECT remains
unaffected.

Why: Slice 3 just moved 77 rows into the unified backend (mig 085).
Until Slice 4 cuts every reader over and Slice 9 drops the legacy
table, the two sides must not diverge. Letting any write through
event_deadlines would silently regress "Was kommt nach…" parity.

Supabase service_role bypasses RLS but NOT triggers — direct DB
maintenance (psql, migration scripts, MCP) is also blocked. That's
intentional: every further edit to event_deadlines pre-Slice-9 is a
mistake. Slice 9's mig ~090 will drop the table + this trigger
together as part of the legacy cleanup.

Function is plain (not SECURITY DEFINER): the trigger function only
RAISE EXCEPTIONs, no INSERTs anywhere, so it doesn't need elevated
privileges. Caller's RLS / role context doesn't matter — the raise
fires unconditionally before any tuple lock is taken.
2026-05-15 00:40:59 +02:00
mAi
88d5656a35 feat(t-paliad-184): mig 085 — Pipeline C data-move (77 rows)
Phase 3 Slice 3 Step C (design §3.C). INSERT 77 active rows from
paliad.event_deadlines into paliad.deadline_rules so the unified
backend can serve both pipelines. Source rows preserved (mig 086
wraps the source table in a read-only trigger; Slice 9 drops it).

Mapping:
  trigger_event_id              ← event_deadlines.trigger_event_id (bigint, mig 028)
  name (DE, NOT NULL)           ← event_deadlines.title_de         (NOT NULL DEFAULT '')
  name_en (NOT NULL)            ← event_deadlines.title            (EN, NOT NULL)
  duration_value / unit         ← event_deadlines.duration_value / unit
  timing                        ← event_deadlines.timing           (before / after)
  alt_duration_value / unit     ← event_deadlines.alt_duration_*
  combine_op                    ← event_deadlines.combine_op       (mig 078 column)
  deadline_notes (DE)           ← event_deadlines.notes  (DE; NULLIF '' so empty
                                                          stays NULL on dr side)
  deadline_notes_en             ← event_deadlines.notes_en (mig 036)
  legal_source                  ← event_deadlines.legal_source
  published_at                  ← event_deadlines.created_at        (chronological audit)
  sequence_order = 1000 + ed.id (large offset so Pipeline-C rules
                                  sort after any hand-authored
                                  Pipeline-A sequence_orders; preserves
                                  source ordering within Pipeline C)
  lifecycle_state = 'published' / priority = 'mandatory' / is_active = ed.is_active

Pipeline-A-only fields stay NULL on the new rows: proceeding_type_id,
parent_id, spawn_proceeding_type_id, code, primary_party, event_type,
condition_expr, condition_flag. is_court_set = false (no court-set
rules in the Pipeline-C corpus today; legal-review pass can flip
Zustellung-* later via a separate slice).

Idempotency: WHERE NOT EXISTS guard on (trigger_event_id, name).
Re-running the migration is a no-op.

Hard assertion at end: COUNT(deadline_rules WHERE trigger_event_id
IS NOT NULL) must equal COUNT(event_deadlines WHERE is_active=true)
post-mig. RAISE EXCEPTION on mismatch — better to fail the migration
loudly than to ship a partial Pipeline-C corpus and poison Slice 4.

Audit-reason set via set_config so the mig 079 trigger writes 77
paliad.deadline_rule_audit rows with the design §3.C citation
preserved as the rationale. That's the persistent compliance trail
for the data-move.

No mandatory bool on event_deadlines (the head instruction sketch
suggested mapping it; the schema doesn't have one) — Pipeline-C
rules default priority='mandatory', consistent with the statutory
nature of the corpus.
2026-05-15 00:40:50 +02:00
mAi
238c4d7cf0 Merge: t-paliad-183 — Fristen Phase 3 Slice 2 (backfill is_court_set / priority / condition_expr) 2026-05-15 00:29:56 +02:00
mAi
32a620b788 test(t-paliad-183): assert backfill integrity for Slice 2
Live-DB test (TEST_DATABASE_URL-gated, mirrors Slice 1 pattern)
validating mig 082/083/084 landed correctly:

  1. is_court_set matches isCourtDeterminedRule() exactly. Counts
     rows where is_court_set != (primary_party='court' OR
     event_type IN ('hearing','decision','order')); must be zero.

  2. priority is non-NULL everywhere (CHECK guards the schema —
     this is belt-and-braces). Buckets by (is_mandatory,
     is_optional) and asserts the design §2.3 mapping:
       T/F → mandatory; T/T → optional; F/* → recommended.

  3. condition_expr translation is complete + non-spurious:
       - every non-empty condition_flag has non-NULL condition_expr
       - every NULL/empty condition_flag has NULL condition_expr
       - single-flag rows: condition_expr ->> 'flag' = condition_flag[1]
       - multi-flag rows: condition_expr ->> 'op' = 'and' AND
         jsonb_array_length(args) = array_length(condition_flag, 1)

The Slice 1 test's "every row priority='mandatory' && !is_court_set"
assertion is loosened to "priority in enum" + "lifecycle_state='published'"
since Slice 2 backfills now mutate those defaults.

Build clean, full test suite green (live DB tests skip locally).
2026-05-15 00:29:10 +02:00
mAi
9d73b91e05 feat(t-paliad-183): mig 084 — backfill condition_expr per design §2.4
Phase 3 Slice 2 Step B-3. Convert condition_flag text[] →
condition_expr jsonb per DESIGN §2.4 long form (NOT msg 1746's
short {"and":[...]} form — head clarified in msg 1750 that
design §2.4 wins because long form parses uniformly across
and/or/not, matching what the Slice-4 calculator + Slice-11 rule
editor will emit).

Mapping:
  ['with_ccr']                  →  {"flag":"with_ccr"}              (5 rows)
  ['with_amend']                →  {"flag":"with_amend"}            (4 rows)
  ['with_cci']                  →  {"flag":"with_cci"}              (4 rows)
  ['with_ccr', 'with_amend']    →  {"op":"and","args":[
                                       {"flag":"with_ccr"},
                                       {"flag":"with_amend"}
                                   ]}                                (4 rows)
  NULL or {}                    →  NULL                             (155 rows)

Total translated: 17 rows.

Single-flag is unwrapped (no AND wrapper) per design §2.4 — a
shortcut equivalent to a 1-arg AND that saves a layer of nesting
without losing semantics. The calculator's parser treats
{"flag":"<name>"} as the leaf and {"op":"<and|or|not>","args":[…]}
as the canonical boolean node.

jsonb construction uses jsonb_build_object + a LATERAL unnest…WITH
ORDINALITY over the flag array so args[] order matches the source
array exactly (load-bearing if a future migration adds order-
sensitive ops).

Idempotent via WHERE condition_expr IS NULL — re-running doesn't
double-write audit rows for already-translated rules. Migration
ends with a DO block that RAISE EXCEPTION if any non-empty
condition_flag row still has NULL condition_expr (catches a
broken translation path before it reaches Slice 4).
2026-05-15 00:29:00 +02:00
mAi
b966d7c8cd feat(t-paliad-183): mig 083 — backfill priority per design §2.3
Phase 3 Slice 2 Step B-2. UPDATE paliad.deadline_rules.priority
from the legacy (is_mandatory, is_optional) pair per DESIGN §2.3
(NOT msg 1746's inverted mapping — head clarified in msg 1750
that design §2.3 is the load-bearing spec).

Mapping:
  T/F (153 rows) → 'mandatory'   (statutory must, ☑ pre-checked)
  T/T (  1 row)  → 'optional'    (RoP.151 — opt-in deadline,
                                  ☐ pre-unchecked per mig 068)
  F/T (  0 rows) → 'recommended' (defensive; no live data)
  F/F ( 18 rows) → 'recommended' (situational filings —
                                  Berufungserwiderung, Replik,
                                  Duplik, R.19 Preliminary
                                  Objection, R.116 EPÜ, etc.)

Why NOT msg 1746's mapping:
  - T/T → 'recommended' would PRE-CHECK RoP.151 in the save modal
    and auto-create a Kostenentscheidung deadline the user didn't
    ask for. That's the regression we'd ship.
  - F/F → 'informational' would render 18 real filing deadlines
    NEVER-SAVEABLE per design §2.3 ("informational … NEVER saves
    as a deadline"). They'd disappear from save flows entirely.

T/F branch is intentionally skipped — mig 078 already defaults
priority='mandatory', so all 153 T/F rows are already correct.
Writing 153 needless audit rows would dilute the backfill trail.

Audit-reason cites design §2.3 — that's the persistent rationale
captured in paliad.deadline_rule_audit. Migration enforces NOT NULL
post-run via a DO block that RAISE EXCEPTION on stragglers.
2026-05-15 00:28:49 +02:00
mAi
755a1042ff feat(t-paliad-183): mig 082 — backfill is_court_set from heuristic
Phase 3 Slice 2 Step B-1 (design §3.B). UPDATE paliad.deadline_rules
to set is_court_set=true where the live isCourtDeterminedRule()
heuristic returns true:

  primary_party = 'court'
    OR event_type IN ('hearing', 'decision', 'order')

Expected delta on the production corpus: 47 rows flipped false→true
(every primary_party='court' rule overlaps with a court event_type
in the current data, so the two predicates fully overlap at 47).

Replicates the live fristenrechner.go body EXACTLY, not the
ILIKE-padded sketch in msg 1746. Per head's ruling msg 1750:
padding with '%entscheidung%' / '%urteil%' would mis-flag party
filings like RoP.151 (Antrag auf Kostenentscheidung) and § 83 PatG
(Stellungnahme zum Hinweisbeschluss) as court-set. They aren't —
only their anchors are.

Audit footnote: ~8 'Zustellung…' rules (LG-Urteil, OLG-Urteil,
BPatG-Entscheidung, Beschwerdeentscheidung, DPMA-Entscheidung)
carry primary_party='both' + event_type='filing'. Semantically the
Zustellung date IS court-set; flagging them is left to the legal-
review pass mentioned in design §2.3, not this slice.

Idempotent via WHERE is_court_set = false. Audit-reason is set via
set_config('paliad.audit_reason', …, true) so the mig 079 trigger
captures one paliad.deadline_rule_audit row per flipped rule —
the persistent backfill trail.

Mig 081 was reserved for proceeding_types display_order verification
in design §3.1; it was a no-op and was not authored. Tracker
skips 081, advances 80 → 82. golang-migrate handles non-contiguous
numbers fine as long as the order ascends.
2026-05-15 00:28:38 +02:00
mAi
c7fa0d6542 Merge: t-paliad-182 — Fristen Phase 3 Slice 1 (unified rule columns + audit table + instance_level) 2026-05-15 00:20:52 +02:00
mAi
1f8230b264 feat(t-paliad-182): models + service compat-read for unified rules
Phase 3 Slice 1 Go-side of mig 078–080. Compat-mode reads: the
service selects BOTH the legacy shape (is_mandatory, is_optional,
condition_flag, condition_rule_id) and the new shape (priority,
condition_expr, is_court_set, trigger_event_id,
spawn_proceeding_type_id, combine_op, lifecycle_state, draft_of,
published_at). Existing callers stay on the legacy fields until
Slice 4 cuts the calculator over.

Adds:
  - DeadlineRule field block for the nine Phase 3 columns. NULLable
    jsonb (condition_expr) uses NullableJSON to dodge the
    json.RawMessage NULL-scan trap (see Project.Metadata note from
    t-paliad-138 dogfood).
  - Project.InstanceLevel *string.
  - DeadlineRuleAudit row struct (id, rule_id, changed_by,
    changed_at, action, before_json, after_json, reason,
    migration_exported).
  - ruleColumns const extended to project every new column.

Test (TEST_DATABASE_URL-gated, mirrors audit_service_test.go):
  1. ruleColumns SELECT scans cleanly — every new column populates
     its Go field.
  2. Migration defaults land: priority='mandatory',
     is_court_set=false, lifecycle_state='published' on every
     pre-Slice-1 row.
  3. Audit trigger writes one row on UPDATE WITH paliad.audit_reason
     set, captures before+after JSON + reason.
  4. Audit trigger RAISES on UPDATE WITHOUT paliad.audit_reason —
     Slice 2 backfills fail loudly if they forget to set it.
  5. paliad.projects.instance_level accepts NULL + first/appeal/
     cassation, rejects 'final'.

Build clean, full test suite green (live DB test skipped locally).
2026-05-15 00:19:49 +02:00
mAi
bd8ec42b80 feat(t-paliad-182): mig 080 — projects.instance_level
Phase 3 Slice 1, design §2.7 + §7. Adds a nullable text column
gated by a CHECK to 'first' | 'appeal' | 'cassation'. Combined
with proceeding_code + jurisdiction, the FristenrechnerService
(Slice 8) will derive the effective proceeding code — e.g.
DE_INF + appeal → DE_INF_OLG.

No backfill in this slice. The project-detail picker UI (Slice 8)
writes the column; pre-Slice-1 rows stay NULL and behave as
implicit 'first' in the calculator's fallback.
2026-05-15 00:19:37 +02:00
mAi
ec0ec32271 feat(t-paliad-182): mig 079 — deadline_rule_audit table + trigger
Phase 3 Slice 1 audit-log foundation (design §2.8). The audit log
lands BEFORE the rule editor (Slice 11) so every future write to
paliad.deadline_rules is captured — including the Slice 2
backfill UPDATEs.

paliad.deadline_rule_audit columns mirror design §2.8 (changed_by,
changed_at, before_json / after_json, reason, migration_exported).
Two intentional deviations, documented inline:

  1. changed_by is nullable, not NOT NULL. Trigger reads auth.uid()
     which is NULL under service_role (migrations, server-side Go
     using the service key). NOT NULL would block Slice 2 backfills
     and every seed insert.

  2. action values written by the trigger are 'create'|'update'|
     'delete' (raw TG_OP). Go-authored audit rows additionally
     write 'publish'|'archive'|'restore' (lifecycle_state flips
     that the trigger sees as plain UPDATEs). The audit UI in
     Slice 11 collapses the paired rows.

Trigger is SECURITY DEFINER so its INSERT into the audit table
bypasses the audit table's RLS — otherwise an authenticated
user's UPDATE on a rule would fail when the trigger tried to write
under their RLS context.

Audit-reason enforcement: trigger reads paliad.audit_reason via
current_setting(..., true) and raises EXCEPTION on UPDATE/DELETE
when unset. INSERT defaults to 'create' so seed migrations stay
ergonomic.

RLS: SELECT for global_admin only (mirrors mig 057 pattern). No
INSERT policy — the SECURITY DEFINER trigger and service_role are
the only writers.
2026-05-15 00:19:31 +02:00
mAi
251f5a250f feat(t-paliad-182): mig 078 — unified rule columns
Phase 3 Slice 1 Step A (design §3.1). Additive only; no drops, no
data change. Adds nine columns to paliad.deadline_rules so the
calculator + rule editor can converge on a single rule shape over
the following slices:

  trigger_event_id          (bigint, FK trigger_events.id)
  spawn_proceeding_type_id  (int,    FK proceeding_types.id)
  combine_op                (text, CHECK 'max'|'min')
  condition_expr            (jsonb)
  priority                  (text, DEFAULT 'mandatory', 4-way CHECK)
  is_court_set              (bool, DEFAULT false)
  lifecycle_state           (text, DEFAULT 'published', 3-way CHECK)
  draft_of                  (uuid, self-FK)
  published_at              (timestamptz)

FK types follow the actual referenced columns (bigint on
trigger_events, int4 serial on proceeding_types) — the design doc's
"int FK" shorthand is widened to the precise widths.

FKs are DEFERRABLE INITIALLY IMMEDIATE so Slice 3's data-move can
defer FK checks within a single transaction without disturbing
normal-statement semantics.

Indexes: partial WHERE NOT NULL on the two FK columns (sparse;
most rules have neither); plain btree on lifecycle_state so the
admin filter on 'published' is O(log n).
2026-05-15 00:19:19 +02:00
mAi
58a1abc6d8 Merge: t-paliad-181 — Fristen Phase 2 design (unified rule model + 12 slices, DESIGN READY FOR REVIEW) 2026-05-15 00:11:28 +02:00
mAi
7159443dcb Merge: t-paliad-177 Slice 4 (FINAL) — Custom Views shape=timeline + cross-project lane aggregation 2026-05-15 00:10:43 +02:00
mAi
119b06dcff design(t-paliad-181): Fristen Phase 2 — unified rule model + 12-slice plan
Phase 2 design pass operationalising all 7 m-locked + 8 head-default
picks from audit §9.

Headline architecture:
- ONE unified deadline_rules table (evolved, not replaced) absorbing
  Pipeline A + Pipeline C. Adds trigger_event_id, spawn_proceeding_type_id,
  combine_op, condition_expr (jsonb AND/OR/NOT), priority (4-way enum),
  is_court_set (real column, drops heuristic), lifecycle_state +
  draft_of + published_at (rule-editor draft → published lifecycle).
  Drops condition_flag, condition_rule_id, is_mandatory, is_optional.
  Net +5 columns, 32 → 37.
- paliad.deadline_rule_audit table + DB trigger + RLS for admin-only
  rule editing (Q5C). Mandatory reason field. Migration-export
  endpoint keeps rules in version control after-the-fact.
- paliad.projects.instance_level column (first/appeal/cassation)
  enables DE_INF → DE_INF_OLG → DE_INF_BGH ladder without proceeding_type
  re-pick.
- Cross-proceeding spawn wired via spawn_proceeding_type_id FK +
  global rule index in the calculator + cycle guard.
- POST /api/tools/event-trigger preserves Pipeline C contract on
  unified backend.

Migration path (Steps A-I, ~17 migrations 078-094):
- Step A additive schema → Step B backfill → Step C Pipeline C
  data-move → Step D calculator unification (service refactor) →
  Step E destructive drops (gated) → Step F project soft-merge
  (Q2) → Step G spawn → Step H instance-level → Step I rule_id
  backfill on legacy deadlines.
- Read-only trigger on paliad.event_deadlines during the cutover
  window prevents drift.
- Backup snapshots before destructive drops.

12 prioritized slices (§10) for Phase 3:
- Slices 1-4 sequential: schema, backfill, Pipeline C migration,
  calculator unification.
- Slices 5-8 parallel: project soft-merge, event-trigger endpoint,
  spawn wiring, instance level.
- Slices 9-10 cleanup: destructive drops, rule_id fuzzy-match
  backfill.
- Slices 11a + 11b: rule-editor backend + frontend (HEAVIEST,
  lands last on stable schema).
- Slice 12: orphan concept seed (wiedereinsetzung first), through
  the editor as its real-world workout.

§9 risk surface: destructive migrations, audit-log compliance gap
during cutover (mitigated by SET LOCAL audit_reason in migration
tooling), cross-corpus drift window (mitigated by read-only
trigger), condition_expr jsonb perf (trivial at 172-row scale),
migration-export manual step.

§12 has 12 open questions for HEAD (not m) — sub-decisions head
resolves at slice-start: migration window, draft lifecycle for
v1, audit retention, preview implementation, export format, slice
ordering, cycle-guard strictness, picker placement, testing scope,
ambiguity-tail handling, seed-vs-editor ordering, telemetry.

§0 drift since 2026-05-13 audit: 1 fristenrechner code deactivated
(20→19 active); mig 075-077 are SmartTimeline, NOT Fristen-logic;
new concept (56→57); new event_types (40→45). All audit findings
hold.

NOT self-merged. Head gates Phase 3 transition (no m-gate).
NOT cronus per memory directive 2026-05-06.
2026-05-15 00:10:07 +02:00
mAi
1c915639b9 feat(t-paliad-177): Custom Views timeline-shape host (frontend)
Slice 4 step 2 (faraday-Q7). Wires shape="timeline" into the /views
shape switcher and the dispatch in client/views.ts.

New file shape-timeline-cv.ts holds the adapter:
- ViewRow.kind="deadline" → TimelineEvent kind="deadline" + deadline_id
- ViewRow.kind="appointment" → kind="appointment" + appointment_id
- ViewRow.kind="project_event" → kind="milestone" + project_event_id
- ViewRow.kind="approval_request" → SKIPPED (no chart-meaningful date)
- Lane axis = project_id (design §10 cross-project chart use case);
  first-seen order keeps lanes deterministic across re-renders.
- Rows without project_id collapse to a synthetic "self" lane.
- Status comes from row.detail.status for deadlines (done/overdue),
  defaults to "open" everywhere else.

shape-timeline-chart.ts gets a new ChartMountOpts.staticData escape
hatch: when supplied, mount() skips the /api/projects/{id}/timeline
fetch and paints from the supplied events + lanes directly. This is
what lets the CV adapter feed pre-loaded ViewRows into the same
renderer that powers /projects/{id}/chart — Slice 1-3 features
(palette, density, range chips, lane filter, permalink) all carry
over for free.

views.ts switches the active shape host and disposes the chart handle
on shape flips so resize listeners don't leak between mounts.

Tests (13 new): pin the kind mapping, lane bucketing by project_id,
status extraction precedence, date passthrough, empty-input safety.

Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5.
2026-05-15 00:09:23 +02:00
mAi
83a3d27fe0 feat(t-paliad-177): ShapeTimeline enum + render_spec wiring
Slice 4 step 1 (faraday-Q7). RenderShape gets a fourth member
ShapeTimeline, AllShapes extends, Validate accepts it. The
companion TimelineConfig struct stores the saved palette / density /
range-preset for a CV-timeline view so re-opening the view restores
the same visual settings — same vocabulary as the standalone
/projects/{id}/chart URL state, just persisted in user_views.render_spec
instead of the URL.

Validator mirrors the frontend's enum guards:
- known palettes (default | kind-coded | track-coded | high-contrast | print)
- known densities (compact | standard | spacious)
- known range presets (1y | 2y | all | custom)
- ISO-date strings length-bounded to 32 chars so a hostile editor
  can't bloat the jsonb column.

Tests pin every accept/reject path in TestRenderSpec_TimelineConfigValidates.

Design ref: docs/design-project-chart-2026-05-09.md §11.5 + §14 Q7.
2026-05-15 00:06:37 +02:00
mAi
79f6be3fc9 Merge: t-paliad-157 — Fristen-Logik-Audit (AUDIT READY FOR REVIEW) 2026-05-15 00:02:31 +02:00
mAi
b455df265e audit(t-paliad-157): Fristen logic — rules, triggers, conditionals
Phase 1 audit (AUDIT ONLY, no implementation). 799 lines, mai/pauli/fristen-logic-audit.

Headline findings:

- THREE parallel deadline-generation systems coexist with overlapping
  intent:
  - Pipeline A (proceeding-driven) — paliad.deadline_rules (172 rows),
    FristenrechnerService.Calculate, drives /tools/fristenrechner +
    SmartTimeline.
  - Pipeline B (single-rule subset of A) — Pathway B cascade click.
  - Pipeline C (event-driven, youpc legacy) — paliad.trigger_events
    (110) + paliad.event_deadlines (77), EventDeadlineService.Calculate,
    drives "Was kommt nach…" tab. Disjoint corpus from A.

- Rule corpus is RICHER than the brief implied: 32 columns, 172 rules
  across 27 proceeding_types (132 fristenrechner + 40 litigation). The
  dual-corpus is a latent footgun: paliad.projects.proceeding_type_id
  accepts both categories with no CHECK constraint, so a project's
  SmartTimeline depends on which code lands first.

- Data model already encodes most of m's mental model:
  multi-deadline triggers via parent_id chains (deepest live: 3
  levels in UPC_INF), conditional via condition_flag (AND-only),
  flag-swap via alt_duration_value / alt_rule_code, court-set via
  heuristic + 4-bucket classification, holiday adjustment via
  HolidayService+CourtService.

- Real gaps (§6, 13 of them):
  - Pipeline A/C redundancy (different capabilities, disjoint data).
  - Litigation vs fristenrechner corpus drift (no contract).
  - is_mandatory + is_optional overlap.
  - deadline_concept_event_types is config layer, NOT trigger model.
  - No real event-driven trigger endpoint.
  - AND-only condition_flag (no OR/NOT/compound).
  - Cross-proceeding spawn half-wired.
  - 9 orphan concepts with rule_count=0 (incl wiedereinsetzung,
    schriftsatznachreichung, weiterbehandlung).
  - condition_rule_id dead column.
  - Instance dimension (LG/OLG/BGH) not on paliad.projects.
  - 1/26 deadlines linked to rule_id (anchor-from-actuals barely
    used).
  - Court-set is heuristic, not first-class column.
  - Pipeline A lacks before / working_days / combine_op.

- The big m's-question: "all in the Rules so we should be able to
  manage" is FALSE today. Rules edits = SQL migrations only. §8
  proposes a 3-step ladder: status-quo / read-only admin / full
  editor with audit log.

- §7 has concrete extension proposal for each §6 gap (migration size
  costed).

- §9 has 15 open questions for m to call before Phase 2 starts.

- Live data sparse: 11/11 projects NULL proceeding_type_id, 1/26
  deadlines with rule_id — demand-side mostly empty even though
  supply-side (rules) is rich.

NOT cronus per memory directive 2026-05-06. NOT self-merged. Awaiting
m's go/no-go.
2026-05-13 21:33:38 +02:00
mAi
7d9935de60 Merge: t-paliad-177 Slice 3 — chart range chips + lane filter + permalink + sidebar entry 2026-05-13 11:54:29 +02:00
mAi
e9bcf3a7b6 feat(t-paliad-177): chart reciprocal "Zurück zum Verlauf" link
Slice 3 step 5 (optional). The back-link on the chart page now points
explicitly at /projects/{id}/history (Verlauf sub-path) instead of
the bare /projects/{id}. Today's projects-detail.ts treats both the
same — bare and /history land on the Verlauf tab — but /history is
the explicit form, so the link keeps working if Verlauf ever stops
being the default tab.

Label flips from "Zurück zum Projekt" → "Zurück zum Verlauf" so
users see exactly where they're heading. Pairs naturally with the
Slice 1 "Als Chart anzeigen ↗" affordance: the trip is round.

Design ref: docs/design-project-chart-2026-05-09.md §8.1.
2026-05-13 11:53:46 +02:00
mAi
1ad78918bc feat(t-paliad-177): chart sidebar contextual entry (option a)
Slice 3 step 4 (head Slice-2 deferral). Implements head's option (a):
sidebar.ts walks the URL pathname on init and reveals a contextual
"Als Chart anzeigen" entry when it sits on a /projects/{uuid}/* page
that ISN'T already the chart itself.

Sidebar TSX gets a new hidden slot id="sidebar-project-chart-link"
right under the Übersicht group. The page never has to touch the
sidebar — initProjectContextChartLink owns the path-match and the
href population. Clean separation: pages don't know about the slot;
sidebar.ts doesn't know about pages.

UUID-shape regex prevents the chip from appearing on /projects (list)
or /projects/new. Rest-path check excludes /chart and /chart/ — the
chart page already has its own "Zurück zum Verlauf" path (Slice 1
link goes the other direction, a reciprocal can land in the next
commit).

i18n: 1 new key DE+EN under nav.context.project_chart.

Design ref: docs/design-project-chart-2026-05-09.md §8.1 +
Slice-2 head deferral resolution.
2026-05-13 11:53:13 +02:00
mAi
5e1f1fecf6 feat(t-paliad-177): chart permalink copy-link + URL params consolidation
Slice 3 step 3 (faraday-Q10). The URL already aggregates every chip's
state via the individual writeParamToURL writers we built in Slice 2
and Slice 3 C1-C2 — palette + density + range + lanes. The copy
button just reads window.location.href and writes it to the clipboard.

Two-tier clipboard strategy:
1. navigator.clipboard.writeText in secure contexts (modern browsers,
   localhost, paliad.de over TLS).
2. document.execCommand("copy") fallback for older / non-secure
   contexts (file://, some iframes).

Visual feedback flashes green/amber on the button for 1.8s after the
click — no toast component needed, the button IS the affordance.

Permalink contract: reload an identical URL → visually identical
chart. Tested by hand on every chip combination; URL stays canonical
(default values omit their param) so shared links don't accumulate
defaults that drift if defaults change.

Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §14 Q10.
2026-05-13 11:51:47 +02:00
mAi
731e762919 feat(t-paliad-177): chart lane visibility filter + URL state
Slice 3 step 2. The chip group is rendered dynamically by the boot
client after refresh() reports lanes via the new onDataLoaded
callback — the lane labels and ids only exist after the server
responds, so static TSX can't render the chips. Hidden when the
projection has 0-1 lanes (filter has no value on a single-track
render).

setVisibleLanes(allowlist | null) on the chart handle filters BOTH
lanes and events in repaint() before passing to layout() — drops
unselected entirely (doesn't fall back to first-lane the way an
unknown stale id does). null = show all.

Stale lane ids are dropped from the URL-restored allowlist after
every refresh: deleted CCRs / child cases can't keep their lane id
alive across re-fetches.

URL state in ?lanes=id1,id2; absent / empty = show all. Hostile or
oversized ids are filtered (length cap 200) at parse time; the
allowlist intersection in repaint() defends again. Toggling every
chip back on collapses to null so the URL stays canonical.

Design ref: docs/design-project-chart-2026-05-09.md §3.2 + §8.2.
2026-05-13 11:51:08 +02:00
mAi
581fbe7d92 feat(t-paliad-177): chart range chips + custom-range URL state
Slice 3 step 1. Four range presets per design §10 + faraday-Q8 default:
1y (today-1y..today+1y, default), 2y, all (derives bounds from loaded
events with a +30d right pad), and custom (date-pair inputs).

mount() grows currentRangePreset + customRangeFrom + customRangeTo so
the layout-time viewport is computed from the live preset, not the
constructor-time opts. resolveRange() handles the four cases; "all"
calls rangeFromEvents() over the last fetched timeline so completing
or adding a row reflows on next repaint.

URL state in ?range=1y|2y|all|custom (omit when 1y); custom adds
?from=&to=. ISO_DATE_RE guards malformed input. Custom date-pair
shows / hides based on the preset.

i18n: 7 new keys DE+EN under projects.chart.range.*.

Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §10 + §14 Q8.
2026-05-13 11:49:24 +02:00
mAi
8f5b83ec93 Merge: t-paliad-166 — Determinator row-by-row cascade design doc (DESIGN READY FOR REVIEW) 2026-05-13 11:43:31 +02:00
mAi
7c4bc39115 design(t-paliad-166): Determinator B1 row-by-row cascade
- §0 premises verified live: 4-layer Pathway B mess (radio + 2 chip-strips
  + breadcrumb-cascade), 91/103 leaves carry forum tag, 16 leaves carry
  party tag, 11/11 live projects have NULL proceeding_type_id (graceful
  degrade), 4 distinct condition_flag value-sets on UPC_INF + UPC_REV
  only, project.court is free-text not FK, verfahrensablauf-core.ts
  carries zero cascade leakage post-t-paliad-179 Slice 1.
- §1 three intertwined pillars: project-driven narrowing / visual
  hierarchy overhaul / row-by-row persistent cascade.
- §2-3 single .fristen-row primitive (active / answered / prefilled /
  hidden) replaces radio + chip-strips + breadcrumb-cards.
- §4 data mapping: forum derivation already shipped; new
  litigation_code x jurisdiction -> fristenrechner_code helper
  (shared with t-paliad-178 Slice 2).
- §5 per-row pre-fill / hide / skipped-but-shown matrix across UPC /
  DE / EPA / DPMA / ad-hoc / zero-context flows with two compact
  ASCII diagrams.
- §6 Filter / Suche mode = escape-hatch icon-button (inventor's pick).
- §7-9 mobile breakpoints, three reset flavours, search affordance
  placement.
- §10 three slices: visual-only (Slice 1), narrowing depth +
  proceeding_mapping.go helper (Slice 2), mobile + search polish
  (Slice 3).
- §11 seven trade-offs flagged (row-stack height, aus-Akte noise,
  auto-walk magic, radio removal, NULL proceeding_type_id reality,
  mapping ambiguities, ändern descendant invalidation).
- §12 file-touch map for Slice 1 only.
- §13 fifteen open questions for m to call before coder shift.

NOT self-merged. Awaiting m's go/no-go.
2026-05-13 11:27:06 +02:00
mAi
adf377c2ca Merge: t-paliad-179 Slice 1 — Tools surface split (route + shell + code-lift) 2026-05-13 00:20:44 +02:00
mAi
f5eb84718a chore(t-paliad-179): sidebar maps Verfahrensablauf 1:1 to its own URL
Sidebar.tsx href flips from /tools/fristenrechner?path=a to
/tools/verfahrensablauf. The two Werkzeuge entries now resolve to
distinct pathnames, so the SSR navItem helper picks the right active
class on its own — fixVerfahrensablaufActive (which compared search
params client-side to disambiguate) is deleted along with its call
in initSidebar.
2026-05-13 00:19:16 +02:00
mAi
1255ee049f feat(t-paliad-179): /tools/verfahrensablauf page (TSX + client + build)
The new abstract-browse surface. TSX shell hosts:

  - header (h1 + subtitle)
  - jurisdiction-tabbed proceeding-tile picker (UPC / DE / EPA / DPMA)
  - trigger date input
  - court picker (visible only for proceedings with multiple
    compatible courts — UPC_REV across CD + LD seats etc.)
  - view toggle (Spalten / Zeitstrahl)
  - result container

client/verfahrensablauf.ts wires picker click → calculateDeadlines →
renderColumnsBody/renderTimelineBody via the shared core. Pre-selects
the first proceeding tile on load so users see a timeline immediately,
matching /tools/fristenrechner's auto-render behaviour. No Akte
picker, no Pathway B cascade, no save modal, no anchor-override edit
— Slice 1 is the structural foundation; variant chips + lane view
(Slice 3) and compare (Slice 4) layer on top in later commits.

build.ts wires the new entrypoint + write step. i18n adds
tools.verfahrensablauf.title / .heading / .subtitle in DE + EN; the
existing nav.verfahrensablauf reused.
2026-05-13 00:19:10 +02:00
mAi
0105d35f0c refactor(t-paliad-179): fristenrechner consumes shared renderer module
client/fristenrechner.ts imports renderTimelineBody / renderColumnsBody
/ deadlineCardHtml / formatDate / partyBadge / escAttr / escHtml /
calculateDeadlines / populateCourtPicker from views/verfahrensablauf-
core, deleting the local copies (~480 lines out). The click-to-edit
anchor-override path stays wired by passing { editable: true } to the
shared renderers; the local anchor-override Map / openInlineDateEditor
/ render-on-override path are unchanged.

The "Verfahrensablauf einsehen" Step 2 card (t-paliad-168) is retired
— TSX markup gone, click handler gone. The abstract-browse intent
lives at /tools/verfahrensablauf now (Slice 1 design §9, §10).
2026-05-13 00:19:00 +02:00
mAi
0531e5dbf6 feat(t-paliad-179): lift Fristenrechner renderers into shared core module
frontend/src/client/views/verfahrensablauf-core.ts — pure-functional
module with the proceeding-timeline rendering surface:

  - DeadlineResponse / CalculatedDeadline / CourtRow types
  - escAttr / escHtml / formatDate / partyBadge helpers
  - deadlineCardHtml(dl, { showParty, editable })
  - renderTimelineBody(data, opts)
  - renderColumnsBody(data, opts)
  - calculateDeadlines(params) — POST /api/tools/fristenrechner wrapper
  - courtTypesFor / defaultCourtFor / fetchCourts (cache)
  - populateCourtPicker(rowId, selectId, proceedingType)

Both /tools/fristenrechner and /tools/verfahrensablauf import from
here. No module-level mutable state — the per-page concerns
(anchorOverrides, lastResponse, Akte save) stay in the consumers.

The deadlineCardHtml signature carries an editable flag so the click-
to-edit anchor-override affordance is opt-in per page: fristenrechner
enables it, verfahrensablauf (Slice 1 scope) doesn't.
2026-05-13 00:18:52 +02:00
mAi
0099e2f28c feat(t-paliad-179): register /tools/verfahrensablauf + 302 legacy ?path=a
Backend half of Slice 1: a new dedicated route owns the abstract-browse
intent that was previously emulated by /tools/fristenrechner?path=a +
client-side fix-up. The page handler is a 1-liner that serves
dist/verfahrensablauf.html (no DB dependency).

A naked ?path=a on /tools/fristenrechner now 302s to the new URL so
bookmarked legacy links survive. ?project=<uuid>&path=a still serves
the fristenrechner shell because that's wizard state set by client-
side history.replaceState during Akte-mode Pathway A — refreshing
mid-wizard must not bounce away.

Test covers all four query shapes: naked path=a → redirect, path=a
with project → no redirect, no params → no redirect, path=b → no
redirect.
2026-05-13 00:18:42 +02:00
mAi
3ba5727deb Merge: t-paliad-177 Slice 2 — visibility-leak fix + palette/density + exports (SVG/PNG/CSV/JSON/iCal/print) 2026-05-13 00:11:58 +02:00
mAi
d8f7745f86 feat(t-paliad-177): chart export — iCal feed (deadlines+appointments only)
Server-side endpoint GET /api/projects/{id}/timeline.ics returns a
VCALENDAR + one VEVENT per actual deadline (VALUE=DATE all-day) and
appointment (UTC timestamp). Projected / milestone / off_script rows
are deliberately skipped — faraday-Q6 / m's pick: a calendar feed
must never carry predicted dates the user never confirmed, otherwise
Outlook fills with rule_code-derived events that erode trust.

FormatTimelineICS reuses the existing caldav_ical.go escape helpers
and writes through the same canonical UIDs (paliad-deadline-<id> +
paliad-appointment-<id>) so a re-subscribe updates entries instead
of duplicating them. Stable across re-exports = lawyer-safe.

Visibility piggybacks on ProjectionService.For + ProjectService.GetByID
(same gates as the chart page handler). Content-Disposition filename
slugged for portable ASCII so Outlook + Apple Calendar agree.

4 tests pin the contract: only deadline/appointment kinds emit
VEVENTs; undated rows skip cleanly; RFC 5545 §3.3.11 escaping for
; , \ \\n; empty input still produces a valid VCALENDAR.

i18n: 1 new key DE+EN.

Design ref: docs/design-project-chart-2026-05-09.md §7.8.
2026-05-13 00:11:14 +02:00
mAi
98a51faa66 feat(t-paliad-177): chart exports — SVG/PNG/CSV/JSON + browser-print CSS
Five client-side export paths per design §7 (faraday-Q4: rule out
chromedp, browser-print is good enough).

- SVG: XMLSerializer over a clone of the live SVGSVGElement, with
  --chart-* tokens inlined so the standalone file paints the same way
  when opened in an image viewer (no document.css context).
- PNG: SVG → Image → Canvas at 2× DPR, toBlob("image/png"). White
  background painted first so transparent SVG stays printable.
- PDF: window.print() → @media print stylesheet hides chrome, forces
  the print palette tokens, locks A4 landscape via @page. User picks
  "Save as PDF" in the browser print dialog. No chromedp dep.
- CSV: 20-column flat schema mirroring TimelineEvent, UTF-8 BOM for
  Excel-DE, RFC 4180 escaping.
- JSON: events + lanes envelope + export-metadata header (project_id,
  project_title, exported_at).

Export menu uses native <details>/<summary> so it's keyboard-accessible
without JS. The chart handle exposes getSVGElement() + getData() so
chart-export.ts stays pure: it never reads DOM state outside the SVG
it's handed.

Filenames are sanitised + dated: paliad-{title}-{yyyy-mm-dd}.{ext}.

i18n: 7 new keys DE+EN under projects.chart.export.*.

Design ref: docs/design-project-chart-2026-05-09.md §7.
2026-05-13 00:08:28 +02:00
mAi
b24063bee1 feat(t-paliad-177): density toggle — compact/standard/spacious + URL state
Density flips lane height (24/40/64) and mark radius (5/7/10) via the
existing LANE_HEIGHT / MARK_RADIUS tables in shape-timeline-chart.ts.
Unlike palette (pure CSS swap), density needs a repaint because it
changes layout() output — setDensity() on the handle re-runs the
layout pure function with the new viewport.density.

URL state in ?density=<compact|standard|spacious>, default omitted.
The writeParamToURL helper is now shared between palette + density to
keep the canonical URL short (omit when value equals the default).

i18n: 4 new keys DE+EN under projects.chart.density.*.

Design ref: docs/design-project-chart-2026-05-09.md §6.1.
2026-05-13 00:06:32 +02:00
mAi
d1314a46f9 feat(t-paliad-177): palette picker — 5 CSS-token sets + URL state
Slice 2 ships all 5 palettes from design §5.1 (m's pick on faraday-Q5):
default / kind-coded / track-coded / high-contrast / print.

Each palette is a pure data-attribute swap of the --chart-* tokens on
.smart-timeline-chart[data-palette="..."]. The renderer never reads
palette state — it stamps classed SVG nodes and the tokens flow in
via CSS variable cascade. setPalette() on the chart handle is a
one-line attribute write; no repaint.

URL state lives in ?palette=<name>; default omits the param so the
canonical URL stays clean. Initial paint reads the URL, every change
writes via history.replaceState — bookmarkable per design §8.2.
Unknown values silently fall back to default (defence against stale /
hostile URLs).

i18n: 6 new keys DE+EN under projects.chart.palette.*.

Design ref: docs/design-project-chart-2026-05-09.md §5 + §8.2.
2026-05-13 00:05:38 +02:00
mAi
968b0bc2da feat(t-paliad-177): close visibility leak on /projects/{id}/chart handler
Slice 1 served dist/projects-chart.html unconditionally, leaking a 200
for any well-formed UUID guesser. Slice 2 resolves the project via
ProjectService.GetByID before serving — ErrNotVisible (and any other
visibility error) collapses to 404 + the standard notfound chrome,
matching the JSON-API contract that already lives in writeServiceError.

A genuine DB error logs through writeServiceError's existing path but
still renders 404 chrome to the user (httpDevNullJSON wrapper discards
the JSON body writeServiceError would otherwise emit, keeping the log
side-effect intact).

Test pins serveChartNotFound: 404 + non-empty body, degrading
gracefully when dist/notfound.html is absent (test env).

Closes Slice 1 edge case #2 flagged at m/paliad#35 issuecomment-7710.
Design ref: docs/design-project-chart-2026-05-09.md §8.2.
2026-05-13 00:03:45 +02:00
mAi
cd1a70d08c Merge: t-paliad-178 — Tools surface cleanup design doc (DESIGN READY FOR REVIEW) 2026-05-13 00:00:41 +02:00